# Novu Webhooks

Novu delivers outbound webhooks through Svix, which implements
Standard Webhooks. Every delivery is an HTTP `POST` with a JSON body and three signature
headers: `webhook-id`, `webhook-timestamp`, and `webhook-signature`. The body is an envelope
`{ id, type, object, timestamp, environmentId, data }`. The event name is the top-level `type`
(for example `message.sent`). The top-level `object` is only the resource kind (`message`,
`workflow`, `preference` or `email_inbound`), and the resource itself is `data.object`.

## Plan requirement

Hookdeck's configuration alert for this source reads: "Novu outbound webhooks are available on
the Team and Enterprise plans." Without one of those plans you can't create a webhook endpoint
in Novu. See [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/novu-webhooks/references/setup.md).

## When to Use This Skill

* How do I receive Novu webhooks?
* How do I verify Novu webhook signatures (`webhook-signature`)?
* How do I handle `message.sent`, `message.failed` or `message.delivered` events?
* How do I react to `workflow.published`, `preference.updated` or `email.received`?
* Why is my Novu webhook signature verification failing, or rejecting Hookdeck retries?

## Verification (core)

Signed content is `{webhook-id}.{webhook-timestamp}.{raw body}`. Svix signs it with HMAC-SHA256
using the base64-decoded secret (the part after `whsec_`), base64-encodes the result, and sends
one or more space-separated `v1,<signature>` entries. Use the official `svix` library, which
compares in constant time. Read the `webhook-*` headers, and when `webhook-id` is absent fall
back to the `svix-*` headers (`svix-id`, `svix-timestamp`, `svix-signature`), as Hookdeck does.

```javascript
import { Webhook } from 'svix'; // svix 2.x is ESM-only

const prefix = req.headers['webhook-id'] ? 'webhook' : 'svix';
const id = req.headers[`${prefix}-id`];
const timestamp = req.headers[`${prefix}-timestamp`];
const signature = req.headers[`${prefix}-signature`];
// rawBody: the raw Buffer/string, never re-serialized JSON
new Webhook(process.env.NOVU_WEBHOOK_SECRET).verify(rawBody, {
  'webhook-id': id, 'webhook-timestamp': timestamp, 'webhook-signature': signature,
  'svix-id': id, 'svix-timestamp': timestamp, 'svix-signature': signature,
}); // throws on a bad signature or a timestamp outside the window
const event = JSON.parse(rawBody.toString('utf8')); // parse only after verify passes

```

```python
from svix.webhooks import Webhook, WebhookVerificationError

wh = Webhook(os.environ["NOVU_WEBHOOK_SECRET"])  # "whsec_..."
wh.verify(raw_body, {"webhook-id": id, "webhook-timestamp": ts, "webhook-signature": sig,
                     "svix-id": id, "svix-timestamp": ts, "svix-signature": sig})

```

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/novu-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/novu-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/novu-webhooks/examples/fastapi/).

Each endpoint has its own signing secret on the Webhooks page of the Novu dashboard. Novu's docs
only show a placeholder (`YOUR_WEBHOOK_SECRET_KEY`) and don't print the prefix. The page is a
Svix portal, so expect Svix's `whsec_<base64>` form. Keep the full value in
`NOVU_WEBHOOK_SECRET`. Handlers fail closed: with the variable unset (or not a `whsec_` value)
they return 500 instead of accepting anything. A missing header or bad signature gets 400.

With Express, read the body with `express.raw({ type: '*/*' })` so any content type reaches the
verifier as the exact bytes Novu signed, and parse JSON only after the check.

### Hookdeck retries and replays hit the 5-minute window

The `svix` and `standardwebhooks` libraries always enforce a fixed 5-minute timestamp window, and
it can't be configured. Hookdeck retries and replays resend the **original** `webhook-timestamp`,
so the library rejects any retry or replay that happens more than 5 minutes after the original
delivery. If you verify behind Hookdeck (or replay old events), use the manual verifier in
[references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/novu-webhooks/references/verification.md), where the window can be dropped or
widened. Deduplicate on `webhook-id` instead of relying on the timestamp.

## Common Event Types

17 events are documented (the list may not be exhaustive). The name is `type` in the JSON body.
The examples handle all of these:

| Event | Triggered When |
| --- | --- |
| `message.sent` | Novu sends the message to the delivery provider |
| `message.delivered` | The provider acknowledged delivery to the end client |
| `message.failed` | Sending to the provider failed (`data.error` may carry details) |
| `message.seen` | A subscriber opens a message |
| `message.read` | A subscriber reads a message (also `message.unread`) |
| `message.archived` | A subscriber archives a message (also `message.unarchived`) |
| `message.snoozed` | A subscriber snoozes a message (also `message.unsnoozed`) |
| `message.deleted` | A message is deleted |
| `email.received` | Inbound mail matches a Webhook route on a verified domain |
| `preference.updated` | A subscriber preference changes (`data.subscriberId`) |
| `workflow.created` | A workflow is created (also `workflow.updated`, `workflow.deleted`) |
| `workflow.published` | A workflow is synced from dev to prod |

See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/novu-webhooks/references/overview.md) for payload shapes and the retry schedule.

## Environment Variables

```bash
# Signing secret of the webhook endpoint (Svix format, starts with whsec_)
NOVU_WEBHOOK_SECRET=whsec_xxxxx

```

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 novu --path /webhooks/novu

```

Register the tunnel URL as a **Webhook** endpoint on the Webhooks page of the Novu dashboard,
then use the endpoint's Testing tab to send an example event.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/novu-webhooks/references/overview.md) - Novu webhook concepts, envelope, events, retries
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/novu-webhooks/references/setup.md) - Register an endpoint, get the secret, plan requirement
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/novu-webhooks/references/verification.md) - Svix verification details, header fallback, manual verifier, timestamp window