# Guide to Novu Webhooks: Features and Best Practices

[Novu](https://novu.co) 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](https://github.com/hookdeck/webhook-samples/blob/main/providers/novu/latest/message.failed.json), a push message to a device token FCM no longer recognises:

```json
{
  "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](https://dashboard.novu.co/webhooks) 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:

```bash
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](/docs/cli) to forward it to your machine:

```bash
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`:

```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);
  });
}

```

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:

```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;
  }
}

// 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](/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](/docs/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](/docs/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](/webhooks/guides/implement-webhook-idempotency) 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](https://hookdeck.com) filters Novu events by type, absorbs bursts and keeps them through outages. [Register for Hookdeck](https://dashboard.hookdeck.com/signup).