Designing HMAC signing schemes for outbound webhook delivery
Most HMAC guidance is written for receivers verifying signatures. The sender side — especially when you're a multi-tenant SaaS — has its own set of decisions. A practical guide to schemes that survive versioning, key rotation, and in-flight retries.
Search "verify webhook signature" and almost every result is written from the receiver's seat: how to validate an incoming HMAC so your handler can trust that the request came from the right sender. That's a real problem, and it's well-covered — including by the HMAC signature tool on Meshes for verifying signatures on the receiver side.
The sender's problem is less covered, and arguably more consequential. Once a SaaS commits to signing every outbound webhook — which any product sending events to customer endpoints should — the emitter owns a set of decisions that every receiver implementation downstream will depend on for years. Get them wrong on day one, and you can't change them later without breaking every customer at once.
This post is about those decisions. It covers what the sender actually has to commit to, why signature versioning is a field you don't have until you wish you did, how to handle per-tenant secrets in a multi-tenant SaaS, and how to rotate a signing key without breaking the retries already in your queue.
The contract
The signing scheme isn't a feature. It's a contract. Six pieces:
Algorithm. HMAC-SHA256 is the standard. HMAC-SHA512 if there's a specific reason.
Canonical form. Which exact bytes get hashed.
Encoding. Hex or base64 for the resulting digest.
Header format. Where the signature lives in the request and how it's parsed.
Replay window. How long receivers should accept a signed payload before treating it as stale.
Version field. How receivers know which scheme produced the signature.
Receivers consume this contract by writing verification code. Once the first customer ships verification against version 1 of the scheme, that version is frozen for them — short of every customer simultaneously updating their integration, which never happens.
Version 1 of the scheme is the production interface, not a draft. Plan it accordingly.
What to sign
The most consequential decision, and the one most likely to bite later. The question: which bytes go into the HMAC computation?
Three established patterns:
GitHub signs the raw request body, nothing else. Replay protection — if needed — comes from a separate timestamp header, a body-internal timestamp field, or delivery-ID idempotency on the receiver side.
Stripe signs {timestamp}.{body} with a literal period separator. The timestamp is included in the canonical form, so the signature covers it directly.
Slack signs v0:{timestamp}:{body} with colons and a version prefix. Both the timestamp and the scheme version live inside the canonical form.
Each pattern represents a different tradeoff.
Raw-body signing keeps the contract minimal. The canonical form is the literal bytes on the wire, and any standard HMAC library can verify without provider-specific reconstruction logic. Receivers don't have to know about separator characters, version prefixes, or timestamp formatting — they hash what they received. The cost is that replay protection has to live somewhere else. Either the body itself contains a timestamp field that the receiver validates, or a separate Webhook-Timestamp header travels alongside the signature and the receiver enforces a freshness window. Both work; both ask the receiver to do a small amount of additional work outside the HMAC step itself.
Wrapped signing — the Stripe and Slack patterns — bakes more into the signature. Replay protection is structural rather than dependent on a separate header. A version prefix inside the canonical form gives the scheme room to evolve without coordinating new headers. The cost is that the canonical form is no longer just "the bytes that arrive." It's a derived form receivers must reconstruct exactly — including separators, version prefixes, and timestamp formatting. Mistakes in reconstruction are a common source of "the signature doesn't match" support tickets, especially in languages where string concatenation has subtle whitespace or encoding edge cases.
There is no universally right answer. The choice depends on whether the operational simplicity of raw-body signing or the structural guarantees of wrapped signing matters more for a given product and audience.
Whatever the choice, document the canonical form as the literal bytes that get hashed — not "the JSON payload," but the actual byte sequence, including any version prefix, separator, or timestamp formatting. Receivers reproduce these bytes character by character. Anything ambiguous becomes a support ticket.
The body is bytes, not an object
Worth its own section because it's the most common emitter bug, regardless of which canonical form is chosen.
When the application emits a webhook, it usually starts with an object: { id: 'evt_...', type: 'user.created', data: { ... } }. Somewhere between that object and the network, JSON serialization happens. Whichever process does that serialization is the source of truth for what bytes go on the wire.
Sign the bytes, not the object. If signing happens against the parsed object and serialization runs separately, the receiver verifying against the wire bytes fails every time the serializer makes a different choice — key ordering, whitespace, unicode escaping. Cross-runtime drift is real, and any middleware that re-serializes the payload (logging, instrumentation, encryption-at-rest of the queue) silently breaks the signature.
import { createHmac } from 'node:crypto';export function signEvent( event: object, secret: string): { body: string; signature: string } { // Serialize once. These are the bytes both sides will hash. const body = JSON.stringify(event); const signature = createHmac('sha256', secret) .update(body, 'utf8') .digest('hex'); return { body, signature };}
The contract isn't event — it's body. Whatever sends the request must send those exact bytes, not the parsed object re-serialized later in the pipeline. If the queue stores the parsed object and re-serializes on dequeue, the signature is computed against bytes that don't exist on the wire. Sign and serialize together, then carry both as a unit.
The same rule applies to wrapped canonical forms — sign whatever derived bytes the scheme requires, then transmit those exact source bytes. The serialization is part of the contract.
Versioning: the field you don't have until you wish you did
The version field is a tag on the signature that tells the receiver which scheme produced it. Stripe puts it inside the signature header (Webhook-Signature: v1=...). Slack puts it inside the signed payload (v0:). A third option is to put the version in its own header — Webhook-Signature-Version: 1 — separate from the signature itself. All three work.
Versioning matters at three moments:
Algorithm migration. HMAC-SHA256 is the right answer today, but the right answer ten years from now is unknown. The history of cryptographic primitives — MD5 deprecated, SHA-1 deprecated, RC4 deprecated — suggests planning for migration is cheaper than executing one without preparation. A version field lets receivers accept old and new signatures simultaneously while migration happens.
Canonical form change. "We should have included the URL path in the signed payload" is a real bug that can be fixed only with versioning. The new version signs different bytes; receivers route based on the version field.
Multi-signature support during rotation. When two signing keys are active at once, a versioned header lets the sender emit both signatures and let the receiver accept either. (More on this in the rotation section.)
Most production webhook schemes ship without an explicit version field, and never need one. Versioning is dead weight until it isn't — every request carrying a v1= prefix that only matters if a migration eventually becomes necessary. For most products, that day never comes, or comes only after years.
When it does come, the new scheme can be introduced alongside the old. Ship it on a different header name, or with a different shape that receivers can recognize. Keep the original scheme active for a long deprecation window, announce the change in release notes, and let customers migrate at their own pace. Most API versioning works this way — additive, not destructive. GitHub introduced X-Hub-Signature-256 alongside X-Hub-Signature when SHA-1 became unsafe; the original kept working for years.
Stripe and Slack carry version prefixes specifically because they reached the scale where any change to the signing scheme would require coordinating with thousands of customer integrations across hundreds of frameworks. At that scale, versioning is real insurance. At smaller scale — or for any product whose canonical form is stable enough not to need iteration — it's a feature waiting for a problem.
If versioning is added later, it can take any of three shapes: inside the signature header (Webhook-Signature: v1=abc123...), inside the signed payload, or in its own header (Webhook-Signature-Version: 1).
Per-tenant secrets in a multi-tenant SaaS
The shape of the problem changes when the sender is a SaaS delivering webhooks to thousands of customers, each with their own endpoint. Every customer needs a unique signing secret. A global signing key would mean any customer can verify any other customer's signatures, and the secret can't be rotated without breaking everyone.
Three operational properties matter.
Generation. 32 bytes from a cryptographic RNG. Not a UUID, not a hash of the workspace ID, not a re-encoded API key. NIST SP 800-107 specifies a key length equal to the hash output size — 32 bytes for SHA-256, 64 bytes for SHA-512.
Distribution. Reveal-once on creation, rotate-on-demand from the dashboard, never logged, never round-tripped through a chat support tool. The mental model is the same as a deploy key or a private API key. The dashboard surface that reveals the secret runs through the same access controls as billing.
Retrieval at signing time. The signing pipeline runs hot. A database round-trip per event is the wrong design — it adds latency to every delivery and creates a hard dependency on the credentials store being available for any event to flow. The right design is a short-TTL in-process cache (60 seconds is plenty), fed from an encrypted at-rest store, invalidated on rotation via pub/sub or a version bump on the cache key.
The credentials store is a security boundary. Separate from the event log, separate from the application database, audited independently. A leak of customer event payloads is bad. A leak of customer signing secrets is the kind of incident that's hard to recover from.
Key rotation without breaking in-flight retries
The hard one.
A retry queue holds requests that haven't successfully delivered yet. Those retries were signed with key version N at the moment they were enqueued. If a customer rotates their signing secret to version N+1 mid-flight, the queued retries are signed with a key the receiver no longer accepts. They fail. The retry policy backs off, retries again, fails again, and eventually dead-letters — all without the customer noticing, because the rotation happened cleanly from their dashboard.
Three patterns handle this.
Two-key acceptance window
The receiver accepts either the old or the new key for a grace period. The sender continues to use whichever key signed each event originally — events in the queue keep their old signature; new events use the new key. The window must exceed the maximum retry horizon. If retries can run for 72 hours, the grace window is at least 72 hours.
This is the simplest model conceptually but pushes complexity to the receiver, who has to track two keys and try verification with both.
Multi-signature header
The sender signs every outbound event with both keys during the rotation window and emits both signatures. They can ride in the same header as comma-separated values:
Webhook-Signature: v1=abc123...,v1-prev=def456...
Or in separate headers, which is cleaner if the convention is for each header to do exactly one job:
Receivers attempt verification with their current key against the primary signature first; if that fails, against the previous. This is more bandwidth and more compute on the sender side, but it's simpler for receivers — they verify with their current secret regardless of which signature actually matches.
This is the pattern most SaaS pick because it concentrates complexity on the sender, where there's full control.
Key fingerprint in header
The header includes a short fingerprint of the signing key — typically the first eight bytes of a hash of the secret:
Webhook-Signature: v1=abc123...,kid=fp_a4b1c9d2
Receivers store both keys during rotation and look up the right one by fingerprint. Cleanest from a verification standpoint, but the fingerprint scheme itself becomes part of the contract. A change to how fingerprints are computed is now another versioned migration.
What ties them together
Whichever pattern is chosen, rotation is a deliberate operation. Surface a rotate-key control in the dashboard. Communicate the rotation window in the customer-facing release notes or a webhook event announcing the change. Provide a way for customers to test verification with the new key before the old one retires. Treat the rotation flow as a feature with a UI, not a database migration.
Replay window from the sender's seat
Receivers reject signed payloads whose timestamp is older than some tolerance window. The sender chooses what that window should be — implicitly, by documenting it. Wherever the timestamp travels — inside the canonical form, in a separate header, or in the body itself — the receiver enforces a freshness check against it.
Too short, and legitimate retries get rejected. A delivery that backs off for 30 minutes and lands at the receiver with a timestamp from 30 minutes ago fails verification, even though every other property of the request is fine. The signature is valid; the timestamp is "stale" by the receiver's policy.
Too long, and the replay window is wider than necessary. A captured request can be replayed hours later and look authentic.
Stripe's five minutes is the de facto industry default, but it's not universal. The right window is bounded below by the retry policy and above by the security tolerance. If retries can run for 60 minutes, the receiver tolerance window must be at least 60 minutes; otherwise half the retries fail verification with no useful error. If retries are capped at 10 minutes, a tighter window is reasonable.
This is one of those decisions that gets noticed only when retries start failing. Pick the window with the retry policy in mind, and document both numbers in the same place.
Headers
Once everything else is decided, the header format is the visible artifact. Three rules:
Use a structured format. Comma-separated key=value pairs are robust to extension. A bare hex string in a header named X-Signature is not.
Be unambiguous about where receivers look. Whether timestamp, version, and signature all live in one header or are split across two (Webhook-Signature and Webhook-Timestamp), document the layout once and stick to it. Receivers shouldn't have to guess which header carries which field.
Pick a name with room to grow.X-{Brand}-Signature for legacy compatibility; Webhook-Signature for forward compatibility with the IETF HTTP Message Signatures work.
A real-world header during a key rotation, fully decomposed into separate headers:
All three are parseable, extensible, and support multi-signature during rotation. The choice between them is partly stylistic and partly tied to the canonical form decision: if the timestamp is part of what gets signed, it usually rides along inside the signature header; if not, it can live anywhere as long as the layout is documented.
Closing
Receiver-side HMAC verification is documented exhaustively. The code is a small handful of lines, the failure modes are well-known, and the HMAC signature tool covers the receiver-side fundamentals end to end — timing-safe compare, signing raw bytes, replay tolerance, encoding pitfalls.
Sender-side signing is different. The sender writes the contract. Every choice — algorithm, canonical form, encoding, header format, version field, replay window, rotation pattern — gets baked into thousands of customer integrations and can't be changed without coordination. Most of those choices look invisible until something forces them to change.
Designing the scheme deliberately on day one costs a few extra hours. Designing it as you go costs a coordinated migration every time something needs to evolve.
Want signed outbound webhooks with per-workspace secrets, key rotation, and retry-aware signing without rebuilding the transport?Join Meshes — emit one event, and we handle outbound delivery and signing to every destination.