Agent skill

Novu Webhooks Skill

Receive and verify Novu webhooks (open-source notification infrastructure, delivered via Svix with Standard Webhooks headers). Use when setting up Novu webhook handlers, debugging webhook-signature verification, or handling events like message.sent, message.failed, message.read, preference.updated, workflow.published, and email.received.

Install this skill

npx skills add hookdeck/webhook-skills --skill 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.

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.

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
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/, examples/nextjs/, 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, 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:

EventTriggered When
message.sentNovu sends the message to the delivery provider
message.deliveredThe provider acknowledged delivery to the end client
message.failedSending to the provider failed (data.error may carry details)
message.seenA subscriber opens a message
message.readA subscriber reads a message (also message.unread)
message.archivedA subscriber archives a message (also message.unarchived)
message.snoozedA subscriber snoozes a message (also message.unsnoozed)
message.deletedA message is deleted
email.receivedInbound mail matches a Webhook route on a verified domain
preference.updatedA subscriber preference changes (data.subscriberId)
workflow.createdA workflow is created (also workflow.updated, workflow.deleted)
workflow.publishedA workflow is synced from dev to prod

See references/overview.md for payload shapes and the retry schedule.

Environment Variables

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

Local Development

# 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 12, 2026

View on GitHub →