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.