Signing and verifying Tool Webhook payloads with X-Novu-Signature (HMAC-SHA256)
Turn on signing, recompute the HMAC over the raw body, and reject anything that does not match. Plus what the signature does and does not protect.

Key takeaways
- The header is
X-Novu-Signature. It carries a lowercase hex HMAC-SHA256 digest, nothing else. - Novu signs the exact UTF-8 request body. No timestamp, no
sha256=prefix. - Verify against the raw body bytes, before any JSON parsing or re-serialization.
- Compare in constant time, not with
===. - The signature proves the request is authentic and unaltered. On its own it does not prevent a replayed valid request, so add your own replay guard.
- Signing applies to Tool Webhook only, not the native PagerDuty, Opsgenie, or Grafana providers.
A webhook endpoint that skips signature verification trusts anyone who learns the URL. If your handler acts on whatever POSTs to it, a stranger with the right path can page your on-call, flip a flag, or kick off a job, and your server will happily oblige.
Signing closes that door. When you set a signing secret on a Novu Tool Webhook, Novu hashes the exact body it sends with a secret only the two of you share and puts the result in the X-Novu-Signature header. Your job is to recompute that hash over the raw body and reject the request if it does not match.
This post covers the whole path: turning on signing, verifying X-Novu-Signature with HMAC-SHA256 in a few languages, and lastly, what the signature protects and what it does not.
What X-Novu-Signature is
X-Novu-Signature is an HMAC-SHA256 signature of the request body, sent as a lowercase hex string. When you configure a signing secret on the Tool Webhook integration, every outbound request carries this header, and its value is HMAC-SHA256(signing_secret, raw_body) rendered as hex.
That is the entire scheme. There is no timestamp bolted on, no version tag, no sha256= prefix in front of the digest. If you have integrated Stripe or Svix before, this is simpler than what you are used to, and the difference matters when you write the verifier: you hash the body and only the body.

Turn on signing
Signing is a single field on the integration. Open the Tool Webhook integration in the Integrations Store and set a Signing Secret.

It works the same in both static and dynamic routing modes, so whether you deliver to one shared URL or to per-subscriber endpoints, every request from that integration gets signed with the secret you set.
Store that same secret on your receiver, out of source control, as an environment variable. It is the one value both sides need to agree on, and it is the only thing standing between your handler and a forged request.
Verify the signature
Verifying is three steps:
- 1
Read the raw body
- 2
Recompute the HMAC-SHA256 digest with your secret
- 3
Compare it to the header in constant time.
Here is the canonical TypeScript version.
Two details do real work here. The length guard runs before timingSafeEqual because that function throws if the two buffers differ in length, and a forged or truncated header often will. And the comparison is timingSafeEqual, not ===, so an attacker cannot learn the correct signature one byte at a time by measuring how long your check takes to fail. A plain string comparison bails at the first wrong character, and the timing of that leaks information.
Wire it into your server
Novu signs the exact body string it sent, so you have to hash the exact bytes you received. Re-stringifying a parsed object usually produces different bytes, key order, whitespace, number formatting, and the digests will not match even when the request is genuine.
TypeScript / Express
In Express, register the raw body parser on the webhook route before express.json() runs:
Note the header comes through as x-novu-signature in lowercase, since Node lowercases header names.
Verify first, parse second, and only touch payload once the signature checks out.
Python / Flask
The scheme is the same in any language: HMAC-SHA256 over the raw body, hex output, constant-time compare. In Python with Flask:
Go
hmac.compare_digest and hmac.Equal are the constant-time comparisons for Python and Go. Use them rather than a normal equality check, for the same timing reason as above.
What signing does and does not protect
Authenticity and integrity
Here is the honest part, and it is the part that keeps a receiver from being safe in name only. A valid X-Novu-Signature tells you two things: the request came from someone holding your signing secret, and the body was not changed in transit. That is authenticity and integrity, and it is most of what you want.
A valid signature does not prove freshness
What it does not tell you is that the request is fresh. Novu signs the body only, with no timestamp inside the signature, so a request that was valid an hour ago is still a valid-looking request now. If someone captured a genuine signed delivery, they could send it to your endpoint again and the signature would still verify. HTTPS makes capturing it hard, but "hard" is not "impossible," and defense in depth is the whole point of verifying in the first place.
So add a replay guard yourself. The simplest one is idempotency: put a stable id in the Tool step body, record the ids you have processed, and drop repeats.
Return a 2xx for the duplicate so Novu does not treat it as a failed delivery and retry it. Idempotency also covers Novu’s own retries, which resend the same payload after a non-2xx response, so you want this even if you are not worried about an attacker. Beyond that, keep the endpoint on HTTPS, and if you want a second factor, set a per-endpoint Authorization header on the channel endpoint (Novu stores header values encrypted at rest) so requests also have to carry a bearer token your server checks. Signature, transport, and a token: three cheap layers that each fail independently.
How Novu’s scheme compares
If you have wired up Stripe or Svix, you will notice Novu asks less of you, and gives you less. Stripe signs a timestamp joined to the body and ships both in a structured Stripe-Signature: t=…,v1=… header, and it expects you to reject requests whose timestamp is outside a tolerance window, which is replay protection built into the verification step. Svix does something similar with a message id, a timestamp, and versioned signatures.
Novu’s X-Novu-Signature is a plain hex digest of the body. That is easier to verify, there is no header to parse and no clock skew to reason about, and it moves the freshness question to you. Neither approach is wrong. It is a trade between a scheme that hands you replay protection and one that stays out of your way and lets you add exactly the replay guard your system needs. Knowing which one you are holding is what keeps the receiver honest.
When verification fails
Almost every failed check comes down to one of a handful of causes. Run through these before assuming the signature itself is broken.
- Raw body. If a JSON parser ran before your verifier, you are hashing a re-serialized object, not the bytes Novu signed. Make sure the raw body reaches your verifier untouched.
- Signing secret. A wrong or missing secret causes verification to fail. The receiver’s secret must match the one on the integration exactly.
- Header name. Check that you are reading the correct header under the name and case exposed by your server framework.
- Encoding. Check for an encoding mismatch when you build the buffers to compare.
- Header value. Make sure you have not accidentally trimmed or lowercased the value you are comparing.
- Missing header. Confirm you set a Signing Secret on the integration. Novu only sends the header when one is configured.
Ship it
Verification is a small amount of code that changes what your endpoint is: from a URL that trusts the internet to one that only acts on requests Novu actually sent. Set a Signing Secret on your Tool Webhook integration, drop in the verifier for your language, and add one idempotency check so a replayed or retried request is a no-op. That is a receiver you can trust.
The full request shape and signing details live in the Tool Webhook integration docs, and if you are registering per-subscriber destinations, the channel endpoints API reference covers where those Authorization headers go. For the wider picture of the Tool channel, start there.
Frequently asked questions
What is the X-Novu-Signature header?
It is an HMAC-SHA256 signature of the webhook body, sent as a lowercase hex string. Novu adds it to every Tool Webhook request when you set a signing secret on the integration, so your server can confirm the request came from Novu and the body was not altered. There is no timestamp or prefix in the value, just the hex digest.
How do I verify a Novu Tool Webhook signature?
Read the raw request body, compute HMAC-SHA256(signing_secret, rawBody) as a hex digest, and compare it to the X-Novu-Signature header using a constant-time comparison like crypto.timingSafeEqual. If it matches, the request is authentic; if not, reject it with a 401. Verify before you parse the JSON.
Why does my signature verification fail?
The most common reason is hashing a parsed-and-re-serialized body instead of the raw bytes Novu signed. Re-stringifying JSON changes whitespace and key order, which changes the digest. Read the raw body before any JSON middleware runs. Other causes are a mismatched signing secret, reading the header under the wrong name, or an encoding difference when comparing.
Does Novu include a timestamp or replay protection in the signature?
No. Novu signs the request body only, with no timestamp, so the signature proves authenticity and integrity but not freshness. A captured valid request would still verify if replayed. Add your own replay guard, such as idempotency on a stable id in the payload, and keep the endpoint on HTTPS.
Which Novu providers send X-Novu-Signature?
Only Tool Webhook. The native on-call providers, PagerDuty, Opsgenie, and Grafana, deliver to their own APIs and do not send this header. Grafana can carry a bearer token on its endpoint, which authenticates the request but is not the same as an HMAC signature.
Do I still need signing if I already use HTTPS or a bearer token?
They cover different risks, so use them together. HTTPS encrypts the request in transit but does not prove who sent it. A bearer token proves the caller holds a token but does not prove the body is unaltered. The signature proves both origin and integrity, and it costs a few lines to check, so there is no reason to skip it.
