Guide to Notifuse Webhooks: Features and Best Practices
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
POSTrequests 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 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:
{
"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: limitslist.*events to those lists. All lists by default.segment_ids: limitssegment.*events to those segments. All segments by default.custom_event_filters:{ "goal_fortypes": [...], "event_ names": [...] } 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_. 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_idsand 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
410from the webhook route. Use503for 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.