Signature verification
Every delivery is signed with the receiving endpoint's signing secret. Verifying that signature is what separates "a POST arrived at my URL" from "NimbusNexus sent me this event" — a webhook URL is not a secret, and anything on the internet can find it.
This page is the contract. If you use one of the SDKs, its verify helper already implements all of it and you can skip to what to do after verifying.
The headers
| Header | Contents |
|---|---|
X-Webhook-Signature | sha256=<hex> — the HMAC you verify. Several comma-separated tokens during a secret rotation; see rules |
X-Webhook-Timestamp | Unix seconds; part of the signature and the replay window |
X-Webhook-Event-Id | The event id, matching id in the body — use it to deduplicate |
X-Webhook-Event-Type | The event type |
X-Webhook-Delivery-Id | This delivery's id; quote it in support requests |
These are not the headers used by platform webhooks, which sign infrastructure notifications with an
NN-prefix. The two products are separate systems. A handler written against the wrong header names will find nothing to verify and reject every delivery.
The construction
signature = "sha256=" + hex( HMAC_SHA256( secret, "<timestamp>." + raw_body ) )
The timestamp, a literal ., then the raw request body — concatenated, HMAC'd under your endpoint's signing secret, hex-encoded, and prefixed with sha256=.
Binding the timestamp into the signed material is what makes the replay window enforceable. If the timestamp were merely a header alongside the signature, an attacker replaying a captured delivery could rewrite it freely; signing it means any edit invalidates the HMAC.
The body itself is compact, sorted-key JSON, serialized once at publish time and stored. Every retry of a delivery ships identical bytes — so a delivery that finally succeeds on attempt four verifies exactly as attempt one would have. Each attempt is re-signed with a fresh timestamp, which is why a long-delayed retry still falls inside a tight replay window.
The three rules
1. Sign the raw bytes. Read the body once, as bytes, and verify that. Parsing the JSON and re-serializing it changes key order and whitespace, and the signature is over what we sent, not over an equivalent object. This is the single most common cause of "the signature never matches" — the payload is fine and the comparison is against different bytes.
Frameworks that eagerly parse JSON usually expose the original separately: request.body in Flask, req.rawBody with an Express verify hook, await request.body() in FastAPI.
2. Compare in constant time — against every token. Use hmac.compare_digest, crypto.timingSafeEqual, hmac.Equal — not ==. A byte-by-byte comparison that returns early leaks, through timing, how much of a guessed signature was correct, which turns forgery into a tractable search. The cost of doing it right is nothing; the cost of doing it wrong is not obvious from testing, because a wrong implementation passes every functional test.
Split the header on , first and accept if any token matches. Normally there is exactly one token, so this looks redundant — but during a signing-secret rotation we sign with both the new and the previous secret and send sha256=<a>,sha256=<b>. A verifier that compares the whole header value against a single expected token matches neither, and rejects every delivery for the length of the overlap. The official SDKs all split; a hand-rolled verifier is the one that gets this wrong.
3. Reject stale timestamps. If X-Webhook-Timestamp is more than 300 seconds from your clock, reject the delivery regardless of whether the HMAC checks out. Without this, a captured delivery stays replayable forever. With it, the exposure is bounded to five minutes even if a payload is intercepted.
Rule 3 depends on your server's clock being roughly correct. If deliveries start failing verification in bulk after an infrastructure change, check NTP before checking your code.
A complete handler
import hashlib, hmac, time
WINDOW_SECONDS = 300
def verify(secret: str, raw_body: bytes, signature: str, timestamp: str) -> bool:
# Rule 3 — replay window, checked first because it is the cheapest rejection.
try:
if abs(int(time.time()) - int(timestamp)) > WINDOW_SECONDS:
return False
except (TypeError, ValueError):
return False # absent or non-numeric header
# Rule 1 — the raw bytes, exactly as received.
expected = "sha256=" + hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
# Rule 2 — constant-time comparison against EVERY token. One token normally; several,
# comma-separated, while a rotation overlap is in effect.
return any(
hmac.compare_digest(expected, token.strip())
for token in signature.split(",")
)
Responding
Return a 2xx and we consider the delivery made. Anything else — or no response within the timeout — counts as a failure and enters the retry schedule.
Acknowledge fast and do the work afterwards. A handler that finishes a slow job before returning holds the delivery open, and if it exceeds the timeout the work happens and the delivery retries, giving you the duplicate you were trying to avoid. Queue it, return 200, process out of band.
Be idempotent regardless. Delivery is at-least-once, so plan on seeing the same event twice: deduplicate on X-Webhook-Event-Id, which is stable across every retry of a delivery.
Rotating a secret
curl -sX POST https://webhooks.nimbusnexus.net/v1/endpoints/ep_3f9a/rotate-secret \
-H "Authorization: Bearer $ADMIN_KEY"
The new secret is returned once. You do not need to run two secrets in your handler: for roughly 24 hours after a rotation we sign each delivery with both the new and the previous secret and send both tokens in one header, sha256=<new>,sha256=<previous>. A verifier that follows rule 2 and checks every token keeps working throughout, whichever secret it holds, so you can switch to the new one immediately.
The corollary is the trap: a verifier that compares the whole header value works fine right up until the first rotation, then rejects everything for a day. If you rotate and deliveries start failing verification in bulk, that comparison is the first thing to check.
What's next
- SDKs —
verifyhelpers that implement this page. - Quickstart — the end-to-end flow this fits into.
- Webhooks (product) — delivery semantics and the dead-letter queue.
- Platform webhooks — the separate,
NN--signed infrastructure notifications.