# Guide to Notifuse Webhooks: Features and Best Practices

[Notifuse](https://www.notifuse.com) is an open-source email platform for marketing and transactional mail that you can use as a cloud service or host yourself. It keeps contacts, lists and segments, sends broadcasts and transactional messages, and tracks what recipients do. Its outgoing webhooks push those changes to your systems: a contact is added, someone confirms a double opt-in, a contact joins a segment, an email bounces or gets clicked.

## What are Notifuse webhooks?

Notifuse has two kinds of webhook, and only one is covered here:

* **Incoming** webhooks are callbacks from your email provider, such as Amazon SES, Mailgun or Postmark, to Notifuse. They're set up automatically with the email integration.
* **Outgoing** webhooks are `POST` requests from Notifuse to your server. This guide is about these.

An outgoing **webhook subscription** has a URL, a set of event types, optional filters and its own signing secret, and deliveries are signed to the Standard Webhooks specification.

## Notifuse webhook features

| Feature | Details |
| --- | --- |
| Configuration | Console, or `webhookSubscriptions.create` |
| Filters | Event types, `list_ids`, `segment_ids`, custom-event goal types and names |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | `webhook-signature` |
| Secret | Per subscription, `whsec_...`, visible to the workspace owner only |
| Body | JSON |
| Event routing | Body field `type` |
| Response window | `2xx` within 30 seconds |
| Retries | Up to 10 over about 34 hours |
| Auto-disable | 20 consecutive failures spanning more than 2 hours |
| Test delivery | `webhookSubscriptions.test` |

[Example applications](https://github.com/hookdeck/webhook-skills/tree/main/skills/notifuse-webhooks/examples) in the Notifuse skill route all 23 event types.

## Events and payloads

| Event | Triggered when |
| --- | --- |
| `contact.created` | A new contact was added to the workspace |
| `contact.updated` | Contact data was modified |
| `contact.deleted` | A contact was permanently deleted |
| `list.subscribed` | Contact subscribed to a list |
| `list.unsubscribed` | Contact unsubscribed from a list |
| `list.confirmed` | Contact confirmed a double opt-in subscription |
| `list.resubscribed` | A previously unsubscribed contact resubscribed |
| `list.bounced` | Email to the contact bounced |
| `list.complained` | Contact marked email as spam |
| `list.pending` | Subscription is awaiting confirmation |
| `list.removed` | Contact was removed from the list |
| `segment.joined` | Contact entered a segment |
| `segment.left` | Contact left a segment |
| `email.sent` | Email was sent to the contact |
| `email.delivered` | Delivery confirmed by the recipient's server |
| `email.opened` | Contact opened the email |
| `email.clicked` | Contact clicked a link in the email |
| `email.bounced` | Email delivery failed |
| `email.complained` | Contact marked the email as spam |
| `email.unsubscribed` | Contact unsubscribed via an email link |
| `custom_event.created` | A custom event was recorded |
| `custom_event.updated` | A custom event was updated |
| `custom_event.deleted` | A custom event was soft-deleted |

Every delivery has the same envelope:

| Field | Description |
| --- | --- |
| `id` | Identifier of the webhook delivery (`delivery_...`) |
| `type` | The event type |
| `timestamp` | ISO 8601 timestamp when the webhook was sent |
| `workspace_id` | The workspace where the event occurred |
| `data` | Event-specific data |

`data` follows the kind of object:

* **Contact:** `id`, `email`, `external_id`, `first_name`, `last_name`, `tags`, `created_at`, `updated_at`
* **Email:** `message_id`, `email`, `subject`, `broadcast_id`, `template_id`, `created_at`
* **Segment:** `email`, `contact_id`, `segment_id`, `segment_name`, `created_at`

Notifuse publishes examples for `contact.created`, `email.sent`, `email.opened` and `segment.joined` only. The `list.*` and `custom_event.*` shapes aren't shown, so use `webhookSubscriptions.test` or a real delivery to see them. This is the `contact.created` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/notifuse/latest/contact.created.json):

```json
{
  "id": "delivery_abc123",
  "type": "contact.created",
  "timestamp": "2024-01-15T10:30:00Z",
  "workspace_id": "ws_1234567890",
  "data": {
    "email": "user@example.com",
    "id": "contact_12345",
    "external_id": "ext_67890",
    "first_name": "John",
    "last_name": "Doe",
    "tags": [
      "newsletter",
      "customer"
    ],
    "created_at": "2024-01-15T10:30:00Z",
    "updated_at": "2024-01-15T10:30:00Z"
  }
}

```

## Setting up Notifuse webhooks

You need a workspace and a user or API key with **Webhook Subscriptions** write permission. Add a subscription on the console's outgoing webhooks page, or call the API on your instance:

```bash
curl -X POST "https://your-instance.com/api/webhookSubscriptions.create" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "workspace_id": "your_workspace",
    "name": "Production Webhook",
    "url": "https://api.example.com/webhooks/notifuse",
    "event_types": ["contact.created", "contact.updated", "email.sent"]
  }'

```

Narrow it with filters where you can, because list events fire once per contact per list:

* `event_types`: any of the 23 events above.
* `list_ids`: limits `list.*` events to those lists. All lists by default.
* `segment_ids`: limits `segment.*` events to those segments. All segments by default.
* `custom_event_filters`: `{ "goal_types": [...], "event_names": [...] }` for `custom_event.*`.

Notifuse generates a `whsec_` secret when it creates the subscription. Only the workspace owner can see it, so ask them to copy it into `NOTIFUSE_WEBHOOK_SECRET`.

Notifuse refuses private, loopback, link-local and cloud-metadata addresses, including through redirects. A self-hosted instance can allow private hosts with `WEBHOOK_DELIVERY_ALLOW_PRIVATE_HOSTS=true`. For development it's simpler to use a public Hookdeck source: create one with the **Notifuse** type, paste the secret, register the source URL, and forward it locally with the [Hookdeck CLI](/docs/cli):

```bash
npx hookdeck-cli listen 3000 notifuse --path /webhooks/notifuse

```

Then call `webhookSubscriptions.test` to send a test delivery through it.

## Securing Notifuse webhooks

Notifuse documents the Standard Webhooks construction: `{webhook-id}.{webhook-timestamp}.{body}`, HMAC-SHA256 with the base64-decoded part of the secret after `whsec_`, then `v1,` plus the base64 result. `webhook-signature` can hold several space-separated entries, and the function below accepts any matching `v1,` one:

```javascript
const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

Notifuse's samples use the Standard Webhooks library, which rejects timestamps more than five minutes from your clock. Hookdeck has no timestamp tolerance and keeps the original `webhook-timestamp` on retries and replays, so behind Hookdeck drop that window, as this verifier does.

The receiver stores each delivery and returns well inside the 30-second limit:

```javascript
const express = require('express');

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

// audienceEvents.save(deliveryId, event) stores the event and resolves once it is durable.
function createNotifuseReceiver(audienceEvents) {
  const app = express();
  app.post('/webhooks/notifuse', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.NOTIFUSE_WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    const event = parseJsonObject(req.body);
    if (!event) return res.sendStatus(400); // signed, but not a JSON object
    try {
      await audienceEvents.save(req.headers['webhook-id'], event);
    } catch {
      return res.sendStatus(503); // never 410, which retires the subscription
    }
    return res.sendStatus(200);
  });
  return app;
}

```

A Hookdeck Notifuse source sends `200` back to Notifuse for every request. If the signature is wrong, Hookdeck marks the request as failed verification and doesn't forward it.

> **Make Notifuse webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) absorbs bursts of list and email events and keeps your subscription clear of Notifuse's auto-disable rule.

## Notifuse webhook limitations and pain points

### Disabled subscriptions drop events

**The Problem:** After an outage, events from the time the subscription was disabled never arrive.

**Why It Happens:** Notifuse disables a subscription after 20 consecutive failures spanning more than two hours. Events that happen while it's disabled are dropped, not replayed when you re-enable it, and re-enabling clears the failure history.

**Workarounds:**

* After re-enabling, read what you missed from the Notifuse API.
* Disable rather than delete when you take an endpoint down on purpose; deleting discards queued deliveries.

**How Hookdeck Can Help:** Hookdeck accepts Notifuse's deliveries while your service is down, so the subscription stays enabled, and [retries](/docs/retries) your service once it's back.

### Rotation breaks delivery immediately

**The Problem:** Deliveries fail right after someone regenerates the secret.

**Why It Happens:** `webhookSubscriptions.regenerateSecret` invalidates the old secret at once, and only the workspace owner can see the new one.

**Workarounds:**

* Rotate when the owner can deploy the new secret straight away, or accept a short window of failed deliveries.

**How Hookdeck Can Help:** Requests that fail verification during the switch are kept in Hookdeck, so you can see exactly which ones to resend.

### List events fan out per contact

**The Problem:** A list import produces a flood of `list.subscribed` deliveries.

**Why It Happens:** Notifuse sends one delivery per contact per list event.

**Workarounds:**

* Filter with `list_ids` and subscribe only to the list events you use.

**How Hookdeck Can Help:** Hookdeck queues the burst and delivers it to your service at a [rate you set](/docs/destinations).

### `410` retires the subscription

**The Problem:** A subscription vanishes after your framework returns `410 Gone`.

**Why It Happens:** Notifuse treats `410` as a request to retire the subscription at once.

**Workarounds:**

* Never return `410` from the webhook route. Use `503` for temporary problems.

**How Hookdeck Can Help:** Behind Hookdeck, Notifuse only ever sees `200`, whatever your service returns.

## Best practices

### Leave `source`-tagged subscriptions alone

A subscription with a `source`, such as `zapier`, was created by another tool. Manage it from that tool, not from Notifuse.

### Throttle with `429`

Notifuse never counts a `429` as a failure. If you rate-limit the webhook route, return `429` rather than another error so throttling can't push the subscription towards auto-disable.

### Deduplicate on `webhook-id`

Notifuse says deliveries may rarely repeat and recommends `webhook-id` as the key. For storage patterns, see [how to make webhook processing idempotent](/webhooks/guides/implement-webhook-idempotency).

### Keep contact data out of logs

Contact payloads include names, emails and tags. Log the delivery ID and event type instead.

## Conclusion

Notifuse follows Standard Webhooks closely, with fine-grained filters and a 34-hour retry schedule. Watch the auto-disable, which drops events, and expect list events to fan out per contact.

[Hookdeck Event Gateway](https://hookdeck.com) smooths Notifuse's bursts and keeps events safe while your service recovers. [Start using Hookdeck free](https://dashboard.hookdeck.com/signup).