Gareth Wilson Gareth Wilson

Guide to Novu Webhooks: Features and Best Practices

Published


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

FeatureDetails
PlansTeam and Enterprise
ConfigurationDashboard Webhooks page (Svix portal)
Endpoint optionsEvent types, rate limit, transformation, custom headers
SignatureStandard Webhooks HMAC-SHA256
Signature headerwebhook-signature
SecretPer endpoint. Novu's docs show only a placeholder
BodyJSON
Event routingBody field type
Response window2xx within 15 seconds
RetriesEight attempts over about 28 hours
RecoveryResend, recover by time window or replay from the dashboard
Source IPsNot published

Events and payloads

Every event uses the same envelope:

FieldDescription
idUnique event ID, prefixed evt_
typeEvent type, for example message.sent
objectResource kind: message, workflow, preference or email_inbound
timestampISO timestamp of when the event occurred
environmentIdThe Novu environment the event belongs to
dataAlways 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:

EventTriggered Whendata.object
message.sentNovu hands the message to the delivery providerMessage (_id, subscriberId, channel, providerId, status)
message.deliveredThe provider acknowledged delivery to the end clientMessage
message.failedSending to the provider failedMessage; data.error may add message and push.reason
message.seenA subscriber opens a messageMessage
message.readA subscriber reads a messageMessage
message.unreadA message is marked unreadMessage
message.archivedA subscriber archives a messageMessage
message.unarchivedAn archived message is unarchivedMessage
message.snoozedA subscriber snoozes a messageMessage (snoozedUntil)
message.unsnoozedA subscriber unsnoozes a messageMessage
message.deletedA message is deletedMessage
email.receivedInbound email matches a Webhook route on a verified domaindomain, route, mail (from, to, subject, text, html, attachments)
preference.updatedA subscriber preference changesPreference (level, enabled, channels); the subscriber is data.subscriberId
workflow.createdA workflow is createdWorkflow response (workflowId, name, steps, ...)
workflow.updatedA workflow is updatedWorkflow response; previousObject is the stored workflow
workflow.publishedA workflow is synced from dev to prodWorkflow response; previousObject is the one before the sync
workflow.deletedA workflow is deletedStored 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

  1. Open the Webhooks page in the Novu dashboard, select Endpoints and click Add Endpoint.
  2. Choose Webhook. Polling Endpoint, FIFO Endpoint and connectors to warehouses, object stores or queues are alternatives this guide doesn't cover.
  3. Enter your HTTPS URL, such as https://example.com/webhooks/novu, and a description.
  4. Select the event types: message delivery, workflow changes, preference updates and inbound email.
  5. 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.


Gareth Wilson

Gareth Wilson

Product Marketing

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