Gareth Wilson Gareth Wilson

Guide to Notifuse Webhooks: Features and Best Practices

Published


Notifuse 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

FeatureDetails
ConfigurationConsole, or webhookSubscriptions.create
FiltersEvent types, list_ids, segment_ids, custom-event goal types and names
SignatureStandard Webhooks HMAC-SHA256
Signature headerwebhook-signature
SecretPer subscription, whsec_..., visible to the workspace owner only
BodyJSON
Event routingBody field type
Response window2xx within 30 seconds
RetriesUp to 10 over about 34 hours
Auto-disable20 consecutive failures spanning more than 2 hours
Test deliverywebhookSubscriptions.test

Example applications in the Notifuse skill route all 23 event types.

Events and payloads

EventTriggered when
contact.createdA new contact was added to the workspace
contact.updatedContact data was modified
contact.deletedA contact was permanently deleted
list.subscribedContact subscribed to a list
list.unsubscribedContact unsubscribed from a list
list.confirmedContact confirmed a double opt-in subscription
list.resubscribedA previously unsubscribed contact resubscribed
list.bouncedEmail to the contact bounced
list.complainedContact marked email as spam
list.pendingSubscription is awaiting confirmation
list.removedContact was removed from the list
segment.joinedContact entered a segment
segment.leftContact left a segment
email.sentEmail was sent to the contact
email.deliveredDelivery confirmed by the recipient's server
email.openedContact opened the email
email.clickedContact clicked a link in the email
email.bouncedEmail delivery failed
email.complainedContact marked the email as spam
email.unsubscribedContact unsubscribed via an email link
custom_event.createdA custom event was recorded
custom_event.updatedA custom event was updated
custom_event.deletedA custom event was soft-deleted

Every delivery has the same envelope:

FieldDescription
idIdentifier of the webhook delivery (delivery_...)
typeThe event type
timestampISO 8601 timestamp when the webhook was sent
workspace_idThe workspace where the event occurred
dataEvent-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:

{
  "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:

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:

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:

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:

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 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 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.

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.

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 smooths Notifuse's bursts and keeps events safe while your service recovers. Start using Hookdeck free.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.