Skip to content

Schemes

Schemes

A scheme is one provider's convention. Most compute an HMAC over a shared secret; what differs is the canonical string, the digest, the encoding, and where the pieces live on the wire. One — Postal — is asymmetric, and verifies an RSA signature against a public key instead.

Name Header(s) Signed bytes Digest Encoding Window
github X-Hub-Signature-256 body SHA-256 hex none
stripe Stripe-Signature {t}.{body} SHA-256 hex 300s
slack X-Slack-Signature, X-Slack-Request-Timestamp v0:{t}:{body} SHA-256 hex 300s
shopify X-Shopify-Hmac-Sha256 body SHA-256 base64 none
standard-webhooks webhook-id, webhook-timestamp, webhook-signature {id}.{t}.{body} SHA-256 base64 300s
twilio X-Twilio-Signature URL + sorted params SHA-1 base64 none
mailgun (in the body) {t}{token} SHA-256 hex 300s
postal X-Postal-Signature-256, X-Postal-Signature body (RSA, public key) SHA-256 / SHA-1 base64 none
cbox X-Cbox-Signature, X-Cbox-Timestamp {t}.{body} SHA-256 hex 300s

"Window" is the scheme's default tolerance. A scheme that binds no timestamp reports null, and no amount of configuration can give it one — there is nothing signed to check against.

GitHub

Only X-Hub-Signature-256 is accepted. The legacy X-Hub-Signature (HMAC-SHA1) is refused, because honouring both would let a caller choose which algorithm to be verified under by sending the other header — the shape of an algorithm-confusion attack.

X-GitHub-Delivery is surfaced as the event id, which is what makes single-use enforcement possible for a scheme with no timestamp.

Stripe

The header can carry several v1 values. That is not redundancy: it is how a rotation is performed from the sending side, with the same body signed under both secrets while the receiver catches up. A verifier that reads only the first v1 rejects half the traffic during a rotation. Every presented signature is checked against every configured secret.

Unknown versions (a future v2) are ignored rather than trusted.

Slack

The v0: prefix is part of the signed string, not decoration. Note that Slack bodies are usually form-encoded rather than JSON — another reason the raw body has to survive to verification untouched, since re-encoding a parsed form body reorders and re-escapes it.

Shopify

Base64, not hex. This is the single most common mistake when porting a GitHub-shaped verifier: the algorithm is right, the encoding is wrong, and it fails every time with no obvious cause.

X-Shopify-Webhook-Id stays stable across Shopify's own retries, so it is the right key for both the replay guard and your idempotency.

Standard Webhooks

An open specification (standardwebhooks.com) with implementations in several languages — the one to choose for a new product, because your receivers are not obliged to write a verifier by hand.

Two details that a naive implementation misses:

  • The secret arrives with a whsec_ prefix and the remainder is base64. The HMAC key is the decoded bytes. Using the printable string produces a stable, wrong signature.
  • The signature header is a space-separated list, because a sender rotating its secret sends the same message under both.

Binding the message id into the signature is what makes that id non-forgeable, and therefore usable as an idempotency key rather than merely a hint. The legacy svix-* header names are accepted as aliases.

Twilio

The odd one out: Twilio does not sign the body at all. It signs the full request URL with every POST parameter appended as keyvalue, sorted byte-wise, HMAC-SHA1, base64.

Two things to know before deploying it.

The URL must be exactly the one Twilio called. Behind a proxy or load balancer, the URL your application reconstructs is what the app server saw, not what Twilio dialled — so Laravel's trusted-proxy configuration must be correct or every signature fails. This is the usual cause of a Twilio verifier that works locally and fails in production.

The digest is SHA-1 because Twilio specifies it. HMAC-SHA1 has no known practical break, but it is not a choice this package makes anywhere it has a say.

Twilio's JSON-body webhooks additionally carry a bodySHA256 query parameter that must be checked separately. That variant is out of scope here and is not silently treated as verified.

Mailgun

Verify-only. Mailgun carries its signature inside the JSON body rather than in a header, so producing one would mean rewriting the payload rather than attaching headers — which is why signing is a separate interface that this scheme does not implement.

What the signature proves is that Mailgun issued this token at this time. The payload itself is not signed. The token is therefore the only meaningful replay key, and it is what the scheme surfaces as the event id.

Postal

The odd one in a different way from Twilio: Postal signs the raw body with its server's private key, and you verify with the matching public key published by that installation. It is why this package is named for signatures rather than for HMAC.

The value you configure under secrets is therefore a public key, not a secret. It is not confidential, and holding it does not let you forge a delivery — which is the point, and also why this scheme cannot sign.

Postal sends two headers: X-Postal-Signature-256 (RSA-SHA256) and the older X-Postal-Signature (RSA-SHA1). When the SHA-256 header is present it is the only one checked. Falling back to SHA-1 after a failed SHA-256 check would let an attacker who can strip or corrupt one header pick the weaker algorithm — the downgrade the newer header exists to close. SHA-1 is honoured only when no SHA-256 header arrives at all, so installations that have not upgraded still work.

No timestamp is bound and Postal sends no delivery id, so there is no window and single-use enforcement falls back to a digest of the signature.

Cbox

The scheme Cbox products use for their own outbound webhooks, wire-compatible with the signing already deployed in the Cbox billing and identity services.

X-Cbox-Timestamp is informational and this scheme ignores it. The authoritative timestamp is the t= value inside X-Cbox-Signature, because that is the one bound into the signed bytes — change it and the MAC no longer matches. Nothing signs the standalone header, so anyone in the path can set it to anything. If you write a receiver for this format by hand, read t=; reading the separate header would hand an attacker control of the freshness check, which is the single thing the timestamp is there to prevent.

The same caution does not apply to Slack's X-Slack-Request-Timestamp, which is read directly — there it is part of the signed string, so tampering with it breaks the MAC.

If you are choosing a scheme for a new product rather than integrating with an existing one, prefer standard-webhooks.

Something not listed here

Describe it in configuration with the generic HMAC driver — see custom schemes. If the canonical string is not expressible as a template over {body}, {timestamp} and {id}, it needs a real class, which is exactly why Twilio and Mailgun have one.