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.failedormessage.deliveredevents? - How do I react to
workflow.published,preference.updatedoremail.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:
| 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 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
- references/overview.md - Novu webhook concepts, envelope, events, retries
- references/setup.md - Register an endpoint, get the secret, plan requirement
- references/verification.md - Svix verification details, header fallback, manual verifier, timestamp window