Guide to Novu Webhooks: Features and Best Practices
Novu is open-source notification infrastructure: you define workflows once, and Novu sends them as in-app, email, SMS, push and chat messages through the providers you connect. Its outbound webhooks tell your systems what happened next: whether a message was sent, delivered or failed, whether a subscriber read, archived or snoozed it, how preferences changed, when someone edited or published a workflow, and when inbound email arrived on a verified domain.
What are Novu webhooks?
Outbound webhooks run on Svix, so the dashboard's Webhooks page is a Svix portal and every request carries webhook-id, webhook-timestamp and webhook-signature headers. Each endpoint subscribes to the event types you choose and can have its own rate limit and transformation.
Novu outbound webhooks are available on the Team and Enterprise plans.
Novu webhook features
| Feature | Details |
|---|---|
| Plans | Team and Enterprise |
| Configuration | Dashboard Webhooks page (Svix portal) |
| Endpoint options | Event types, rate limit, transformation, custom headers |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | webhook-signature |
| Secret | Per endpoint. Novu's docs show only a placeholder |
| Body | JSON |
| Event routing | Body field type |
| Response window | 2xx within 15 seconds |
| Retries | Eight attempts over about 28 hours |
| Recovery | Resend, recover by time window or replay from the dashboard |
| Source IPs | Not published |
Events and payloads
Every event uses the same envelope:
| Field | Description |
|---|---|
id | Unique event ID, prefixed evt_ |
type | Event type, for example message.sent |
object | Resource kind: message, workflow, preference or email_inbound |
timestamp | ISO timestamp of when the event occurred |
environmentId | The Novu environment the event belongs to |
data | Always has object (the resource itself); update events add previousObject |
type is the event name; object says what kind of resource data.object is. Update events also carry data.previousObject, so you can see what changed.
Novu documents 17 event types. Return 200 for any you don't recognise:
| Event | Triggered When | data.object |
|---|---|---|
message.sent | Novu hands the message to the delivery provider | Message (_id, subscriberId, channel, providerId, status) |
message.delivered | The provider acknowledged delivery to the end client | Message |
message.failed | Sending to the provider failed | Message; data.error may add message and push.reason |
message.seen | A subscriber opens a message | Message |
message.read | A subscriber reads a message | Message |
message.unread | A message is marked unread | Message |
message.archived | A subscriber archives a message | Message |
message.unarchived | An archived message is unarchived | Message |
message.snoozed | A subscriber snoozes a message | Message (snoozedUntil) |
message.unsnoozed | A subscriber unsnoozes a message | Message |
message.deleted | A message is deleted | Message |
email.received | Inbound email matches a Webhook route on a verified domain | domain, route, mail (from, to, subject, text, html, attachments) |
preference.updated | A subscriber preference changes | Preference (level, enabled, channels); the subscriber is data.subscriberId |
workflow.created | A workflow is created | Workflow response (workflowId, name, steps, ...) |
workflow.updated | A workflow is updated | Workflow response; previousObject is the stored workflow |
workflow.published | A workflow is synced from dev to prod | Workflow response; previousObject is the one before the sync |
workflow.deleted | A workflow is deleted | Stored workflow (_id, internal field names) |
Message payloads use Mongo-style field names such as _id, _templateId and _notificationId, and Novu redacts secrets in channelData, such as push tokens, before sending. This is the message.failed sample from the webhook samples library, a push message to a device token FCM no longer recognises:
{
"id": "evt_67f8a1b2c3d4e5f6a7b8c9d1",
"type": "message.failed",
"object": "message",
"timestamp": "2026-06-30T12:00:01.000Z",
"environmentId": "development",
"data": {
"object": {
"_id": "message_id",
"_templateId": "workflow_internal_id",
"_environmentId": "environment_id",
"_organizationId": "organization_id",
"_notificationId": "notification_id",
"subscriberId": "subscriber_123",
"templateIdentifier": "order-shipped",
"workflowId": "order-shipped",
"stepId": "send-push",
"transactionId": "transaction_id",
"channel": "push",
"providerId": "fcm",
"status": "error",
"seen": false,
"read": false,
"archived": false,
"createdAt": "2026-06-30T12:00:00.000Z",
"updatedAt": "2026-06-30T12:00:01.000Z",
"errorText": "Requested entity was not found."
},
"error": {
"message": "Requested entity was not found.",
"push": {
"reason": "token_invalid",
"deviceToken": "device_token"
}
}
}
}
Novu publishes complete bodies only for message.failed, preference.updated and workflow.updated. Testing > Send Example in the portal sends an example of any event type to your endpoint.
Setting up Novu webhooks
- Open the Webhooks page in the Novu dashboard, select Endpoints and click Add Endpoint.
- Choose Webhook. Polling Endpoint, FIFO Endpoint and connectors to warehouses, object stores or queues are alternatives this guide doesn't cover.
- Enter your HTTPS URL, such as
https://example.com/webhooks/novu, and a description. - Select the event types: message delivery, workflow changes, preference updates and inbound email.
- Optionally set a rate limit and a transformation under advanced settings, then click Create. Custom headers can be added once the endpoint exists.
Copy the endpoint's signing secret into NOVU_WEBHOOK_SECRET. Novu's docs only show YOUR_WEBHOOK_SECRET_KEY, but Svix secrets take the form whsec_<base64>, so check that the dashboard value starts with whsec_ and store all of it:
NOVU_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
Secrets are per endpoint and per environment, so a development endpoint's secret won't verify production traffic.
To receive Novu through Hookdeck, create a source with the Novu type, add the secret, and use the source URL as the endpoint. Use the Hookdeck CLI to forward it to your machine:
npx hookdeck-cli listen 3000 novu --path /webhooks/novu
Use Send Example to check that a message.sent reaches your local server and verifies.
Securing Novu webhooks
Novu's sample uses the svix library. The manual equivalent: base64-decode the part of the secret after whsec_, compute HMAC-SHA256 over <webhook-id>.<webhook-timestamp>.<raw body>, base64-encode, and compare with each v1, entry in webhook-signature:
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);
});
}
Novu sets no timestamp tolerance of its own, but the svix library enforces five minutes. Hookdeck applies none and replays or retries with the original webhook-timestamp, so a receiver behind Hookdeck should rely on the signature alone, as this one does.
A workflow sent to many subscribers produces many events at once, so the receiver stores each one and returns:
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;
}
}
// notificationEvents.push(webhookId, event) stores the event and resolves once it is durable.
function createNovuReceiver(notificationEvents) {
const app = express();
app.post('/webhooks/novu', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.NOVU_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 notificationEvents.push(req.headers['webhook-id'], event);
} catch {
return res.sendStatus(503);
}
return res.sendStatus(200); // unknown types too, or Novu keeps retrying them
});
return app;
}
Exempt this route from CSRF protection; the signature is its authentication. When Novu posts to a Hookdeck source, Hookdeck returns 200 regardless of the signature, records failures as failed verification, and forwards only verified requests.
Make Novu webhooks production-ready. Hookdeck Event Gateway verifies every Novu event and can send delivery failures, engagement and workflow changes to the services that own them.
Novu webhook limitations and pain points
Plan-gated
The Problem: You can't find outbound webhooks in your Novu dashboard.
Why It Happens: Outbound webhooks are only on the Team and Enterprise plans.
Workarounds:
- Check your plan before designing around webhooks.
How Hookdeck Can Help: Once webhooks are available, a Hookdeck source gives you a request log and replay from the first event.
Endpoints are disabled after five days
The Problem: An endpoint goes quiet after a long outage, even once the service is fixed.
Why It Happens: Novu retries for about 28 hours per message, and disables an endpoint whose deliveries have all failed for five consecutive days.
Workarounds:
- Re-enable it from the (...) menu on the Endpoints tab, then recover failed messages for the outage window.
How Hookdeck Can Help: Hookdeck accepts Novu's deliveries while your service is down, so the endpoint never fails, and retries your service when it returns.
Engagement events are high volume
The Problem: message.seen and message.read events swamp the service that only cares about failures.
Why It Happens: Every subscriber interaction with an in-app message is an event, and one endpoint receives everything it subscribes to.
Workarounds:
- Use separate endpoints for engagement and for delivery outcomes, and use the portal's rate limit.
How Hookdeck Can Help: Hookdeck filters on type send each kind of event to the right destination from one Novu endpoint.
Secret format isn't documented
The Problem: The verifier rejects every request after you paste the secret.
Why It Happens: Novu's docs show only a placeholder. Svix secrets are whsec_<base64>, and a missing prefix or the wrong environment's secret breaks verification.
Workarounds:
- Copy the secret from the endpoint in the right environment, and keep the
whsec_prefix.
How Hookdeck Can Help: Hookdeck's log marks each request that fails verification, which makes a wrong secret obvious on the first delivery.
Best practices
Act on message.failed reasons
data.error.push.reason explains push failures. A token_invalid reason points to a device token that no longer works, so stop sending to it.
Use previousObject for diffs
workflow.updated and workflow.published include the previous version. Compare the two to audit changes.
Keep preference changes in sync
preference.updated gives you the subscriber and the new channel settings. Mirror them if other systems also contact your users.
Deduplicate on webhook-id
Retries reuse it. The idempotency guide shows how to record it alongside your changes.
Conclusion
Novu's outbound webhooks report delivery, engagement, workflow and preference changes on Svix's signing and retry model. Engagement events add up quickly, so size the receiver for them.
Hookdeck Event Gateway filters Novu events by type, absorbs bursts and keeps them through outages. Register for Hookdeck.