README
webhooks
Verify and sign webhook payloads the way the common providers do: GitHub (X-Hub-Signature-256), Stripe (Stripe-Signature with a timestamp and a tolerance), Slack (v0 signatures), Standard Webhooks (HMAC v1 and Ed25519 v1a), Shopify (X-Shopify-Hmac-Sha256), and any custom HMAC scheme you describe with a record. Every scheme has both halves: the receiver's *_verify and the sender's *_signature / *_headers.
Signatures are compared with crypto.equal (constant time), every verify takes a list of keys so secrets can be rotated without downtime, timestamps are checked in both directions (too old and too far in the future), and replays are refused with an in-memory set or a SQLite table that many handlers and processes can share. Verification is pure: the current time is a parameter (webhooks.unix_now() reads the clock), so tests are deterministic.
Install
$ sspur add webhooks
Example
fn request(headers: List[(Str, Str)], body: Str) -> Request
= Request{method: "POST", url: "/webhooks/stripe", headers, body, timeout_ms: 0, retries: 0, redirects: 0}
fn main() -> Unit ! log
= do
key = webhooks.text_key(secret("whsec_example"))
body = "\{\"id\":\"evt_1\",\"type\":\"invoice.paid\"}"
now = 1700000000
header = webhooks.stripe_test_header(key, body, now)
log(header)
ok_req = request([("Stripe-Signature", header)], body)
log("fresh: {webhooks.stripe_verify_request([key], ok_req, now + 10, 300)}")
log("late: {webhooks.stripe_verify_request([key], ok_req, now + 900, 300)}")
log("tampered: {webhooks.stripe_verify_request([key], request([("Stripe-Signature", header)], body + " "), now, 300)}")
gh = webhooks.text_key(secret("It's a Secret to Everybody"))
log(webhooks.github_signature(gh, "Hello, World!"))
(first, seen) = webhooks.seen_claim(webhooks.seen_empty(), "evt_1", now, 600)
(second, _) = webhooks.seen_claim(seen, "evt_1", now + 5, 600)
log("replay: {first} then {second}")
$ sspur run main.ssp
t=1700000000,v1=5f95cbcceca23d58613a8cf4da2f4ef4708a2b8b7ce10e76f96cb354dd7cd55b
fresh: ok(())
late: err(TooOld{age: 900, tolerance: 300})
tampered: err(SignatureMismatch)
sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17
replay: ok(()) then err(Replayed{id: "evt_1"})
In an http.serve handler, check the raw request before decoding its body (App, Event and handle_event stand for your own definitions):
fn stripe_hook(app: App, req: Request) -> Response ! db, fail[DbErr], time
= do
now = webhooks.unix_now()
match webhooks.stripe_verify_request(app.stripe_keys, req, now, webhooks.default_tolerance())
| err(e) => http.text(400, "{e}")
| ok(_) => match webhooks.replay_claim(app.db, req.json[Event].map(e => e.id).or(""), now, 3600)
| err(_) => http.text(200, "already processed")
| ok(_) => handle_event(app, req.body)
API
Errors and keys
| Definition | What it is |
|---|
WebhookErr | MissingHeader{name}, MalformedHeader{name, why}, SignatureMismatch, TooOld{age, tolerance}, TooNew{ahead, tolerance}, Replayed{id}, InvalidKey{why}. No variant carries the expected signature |
Algorithm | Sha256 or Sha512 (HMAC) |
Encoding | Hex, Base64 (padded), Base64Url (unpadded) |
text_key(s) | A text secret (Secret[Str], as dashboards and config.load give it) as an HMAC key Secret[Bytes] |
hmac(alg, key, data) | The HMAC of data |
encode(e, b), decode(e, s) | Signature text from bytes and back (decode is none for invalid text; hex accepts either case) |
matches(alg, e, keys, data, given) | Whether given is the HMAC of data under any key, in constant time, trying every key |
Timestamps
| Definition | What it is |
|---|
unix_now() | The clock in Unix seconds (! time) |
parse_timestamp(s) | 1 to 12 ASCII digits as an Int, nothing else (no sign, spaces or exponent) |
check_timestamp(ts, now, tolerance) | TooOld or TooNew outside the window; tolerance 0 disables it |
default_tolerance() | 300 seconds, the providers' default |
GitHub
| Definition | What it is |
|---|
github_signature(key, body) | sha256=<hex> |
github_headers(key, body) | [("x-hub-signature-256", ...)] for senders |
github_verify(keys, body, header) | Checks the header value; the SHA-1 X-Hub-Signature is not accepted |
github_verify_request(keys, req) | Checks a received request |
Stripe
| Definition | What it is |
|---|
StripeHeader | {timestamp, v1} |
stripe_parse(header) | Parses t=...,v1=...[,v1=...], ignoring v0 and unknown schemes; strict about t and the element shape |
stripe_signature(key, body, timestamp) | The hex v1 signature of "{timestamp}.{body}" |
stripe_test_header(key, body, timestamp) | A whole header value, like stripe-node's generateTestHeaderString |
stripe_verify(keys, body, header, now, tolerance) | Any v1 must match, then the timestamp must be in the window |
stripe_verify_request(keys, req, now, tolerance) | Checks a received request |
Stripe endpoint secrets (whsec_...) are HMAC keys as text, prefix included: text_key(secret("whsec_...")).
Slack
| Definition | What it is |
|---|
slack_signature(key, timestamp, body) | v0=<hex> of "v0:{timestamp}:{body}" |
slack_headers(key, timestamp, body) | The timestamp and signature headers |
slack_verify(keys, body, timestamp, signature, now, tolerance) | Checks the timestamp first, as Slack recommends, then the signature |
slack_verify_request(keys, req, now) | Checks a received request with the 5 minute window |
Standard Webhooks
| Definition | What it is |
|---|
StandardHeaders | {id, timestamp, signature} as received |
standard_key(s) | Parses a whsec_<base64> secret (24 to 64 bytes) |
standard_public_key(s) | Parses a whpk_<base64> Ed25519 public key (32 bytes) |
standard_content(id, timestamp, body) | "{id}.{timestamp}.{body}", what every signature covers |
standard_signature(key, id, timestamp, body) | v1,<base64> |
standard_signature_ed25519(sk, id, timestamp, body) | v1a,<base64> |
standard_headers(key, id, timestamp, body) | The three headers for senders |
standard_signatures(header, v) | The signatures of version v in a space-separated list |
standard_verify(keys, h, body, now, tolerance) | Timestamp, then any v1 under any key |
standard_verify_ed25519(pks, h, body, now, tolerance) | Timestamp, then any v1a under any public key |
standard_headers_of(req) | The three headers of a request |
standard_verify_request(keys, req, now, tolerance) | Checks a received request |
Shopify
| Definition | What it is |
|---|
shopify_signature(key, body) | Base64 HMAC-SHA-256 |
shopify_verify(keys, body, header), shopify_verify_request(keys, req) | Check a value or a received request |
Custom schemes
| Definition | What it is |
|---|
Signed | BodyOnly, or Timestamped{header, lead, sep, tolerance}: the content is lead + timestamp + sep + body |
Scheme | {header, prefix, algorithm, encoding, separator, signed}; separator splits several signatures in one header ("" for one) |
hmac_scheme(header) | HMAC-SHA-256 of the body in hex, no prefix; change fields with with |
scheme_content(s, timestamp, body) | The signed content |
scheme_signature(s, key, timestamp, body), scheme_headers(...) | For senders |
scheme_verify(s, keys, headers, body, now), scheme_verify_request(s, keys, req, now) | For receivers |
Slack's scheme, for instance, is hmac_scheme("x-slack-signature") with prefix := "v0=", signed := Timestamped{header: "x-slack-request-timestamp", lead: "v0:", sep: ":", tolerance: 300}.
Replay protection
| Definition | What it is |
|---|
Seen, seen_empty(), seen_claim(s, id, now, ttl), seen_purge(s, now) | An immutable set of recent delivery ids with expiry |
replay_setup(c) | Creates the webhooks_seen table (! db) |
replay_claim(c, id, now, ttl) | Claims an id in one atomic statement: of concurrent claims exactly one gets ok, the rest Replayed |
replay_purge(c, now) | Deletes expired claims |
Use a ttl at least as long as the timestamp tolerance, so a replayed delivery can't outlive its claim and still pass the timestamp check.
Security notes
- Verify the raw body exactly as received, before any JSON decoding or re-encoding.
- Comparisons are constant time per key, and every key is tried, so timing says nothing about which key or which bytes matched. Lengths are not secret.
- A timestamp only limits replays to the window; claim the delivery id (
webhook-id, X-GitHub-Delivery, the Stripe event id) for exactly-once handling. - Header values are bounded: at most 20 signatures per header, and timestamps of at most 12 digits.
- Test vectors in the tests: GitHub's documentation example, Slack's documented request, the Standard Webhooks reference vector, RFC 8032 test 1 for the Ed25519 key, and HMAC-SHA-256/512 known answers.
Out of scope
- Twilio (
X-Twilio-Signature) and the legacy GitHub X-Hub-Signature: both use HMAC-SHA1, and the standard library has no SHA-1. - Providers that sign with RSA or ECDSA (for example some PayPal and AWS SNS flows): the standard library has no RSA or ECDSA verification.
- Fetching provider certificates or public keys over the network; pass the keys in.
License
MIT. See LICENSE.
API
pub type WebhookErr = MissingHeader{name: Str} | MalformedHeader{name: Str, why: Str} | SignatureMismatch | TooOld{age: Int, tolerance: Int} | TooNew{ahead: Int, tolerance: Int} | Replayed{id: Str} | InvalidKey{why: Str} derive Eq, Show
Why a webhook was refused. No variant carries the expected signature, so an error can be logged or returned without helping an attacker.
pub type Algorithm = Sha256 | Sha512 derive Eq, Show
The HMAC hash function of a scheme.
pub type Encoding = Hex | Base64 | Base64Url derive Eq, Show
How a signature is written as text: lower-case hex, padded base64 or unpadded base64url.
fn text_key(s: Secret[Str]) -> Secret[Bytes]
A shared secret given as text (the usual form in provider dashboards), as an HMAC key.
fn hmac(alg: Algorithm, key: Secret[Bytes], data: Str) -> Bytes
The HMAC of data under key with alg.
fn encode(e: Encoding, b: Bytes) -> Str
b written with encoding e.
fn decode(e: Encoding, s: Str) -> Opt[Bytes]
The bytes s encodes with e, or none when it is not valid in that encoding. Hex accepts either case.
fn matches(alg: Algorithm, e: Encoding, keys: List[Secret[Bytes]], data: Str, given: Str) -> Bool
Whether the encoded signature given is the HMAC of data under one of keys. Every key is tried and each comparison is constant time, so the time taken doesn't say which bytes or which key matched.
fn unix_now() -> Int ! time
The current Unix time in seconds, for the now parameters.
fn parse_timestamp(s: Str) -> Opt[Int]
A Unix timestamp in seconds as a header carries it: 1 to 12 ASCII digits, nothing else.
fn check_timestamp(ts: Int, now: Int, tolerance: Int where _ >= 0) -> Res[Unit, WebhookErr]
Whether ts is within tolerance seconds of now, in either direction. A tolerance of 0 disables the check.
fn default_tolerance() -> Int
The default tolerance of Stripe, Slack and Standard Webhooks: 5 minutes.
fn github_signature(key: Secret[Bytes], body: Str) -> Str
The value of X-Hub-Signature-256 for body: sha256= and the hex HMAC-SHA-256.
fn github_verify(keys: List[Secret[Bytes]] where _.len > 0, body: Str, header: Str) -> Res[Unit, WebhookErr]
Checks an X-Hub-Signature-256 value against body and any of keys. The legacy SHA-1 X-Hub-Signature header is not accepted.
fn github_verify_request(keys: List[Secret[Bytes]] where _.len > 0, req: Request) -> Res[Unit, WebhookErr]
Checks a GitHub delivery received by an http.serve handler: its raw body and its X-Hub-Signature-256 header.
fn stripe_parse(header: Str) -> Res[StripeHeader, WebhookErr]
Parses t=<seconds>,v1=<hex>[,v1=<hex>...]. A header without exactly one valid t, without a v1, with an element that isn't key=value, or with more than 20 elements is malformed.
fn stripe_signature(key: Secret[Bytes], body: Str, timestamp: Int where _ >= 0) -> Str
The v1 signature of body sent at timestamp: hex HMAC-SHA-256 of "{timestamp}.{body}". Stripe's endpoint secrets (whsec_...) are used as text, prefix included: text_key.
fn stripe_verify(keys: List[Secret[Bytes]] where _.len > 0, body: Str, header: Str, now: Int, tolerance: Int where _ >= 0) -> Res[Unit, WebhookErr]
Checks a Stripe-Signature value: one of its v1 signatures must match under one of keys, and its timestamp must be within tolerance seconds of now (0 disables the check).
fn stripe_verify_request(keys: List[Secret[Bytes]] where _.len > 0, req: Request, now: Int, tolerance: Int where _ >= 0) -> Res[Unit, WebhookErr]
Checks a Stripe event received by an http.serve handler.
fn slack_signature(key: Secret[Bytes], timestamp: Int where _ >= 0, body: Str) -> Str
The X-Slack-Signature value for body sent at timestamp: v0= and the hex HMAC-SHA-256 of "v0:{timestamp}:{body}".
fn slack_verify(keys: List[Secret[Bytes]] where _.len > 0, body: Str, timestamp: Str, signature: Str, now: Int, tolerance: Int where _ >= 0) -> Res[Unit, WebhookErr]
Checks a Slack request given its X-Slack-Request-Timestamp and X-Slack-Signature values. The timestamp is checked first, as Slack recommends, against tolerance seconds.
fn slack_verify_request(keys: List[Secret[Bytes]] where _.len > 0, req: Request, now: Int) -> Res[Unit, WebhookErr]
Checks a Slack request received by an http.serve handler, with Slack's 5 minute window.
pub type StandardHeaders = {id: Str, timestamp: Str, signature: Str} derive Eq, Show
The three headers of the Standard Webhooks specification, as received.
fn standard_key(s: Secret[Str]) -> Res[Secret[Bytes], WebhookErr]
A symmetric Standard Webhooks secret, whsec_ and base64 (the prefix is optional), as an HMAC key. The specification requires 24 to 64 bytes.
fn standard_public_key(s: Str) -> Res[Bytes, WebhookErr]
A Standard Webhooks Ed25519 public key, whpk_ and the base64 of 32 bytes (prefix optional).
fn standard_content(id: Str, timestamp: Int, body: Str) -> Str
The content every Standard Webhooks signature covers: "{id}.{timestamp}.{body}".
fn standard_signature(key: Secret[Bytes], id: Str, timestamp: Int where _ >= 0, body: Str) -> Str
A v1,<base64> symmetric signature of a message.
fn standard_signature_ed25519(sk: Secret[Bytes], id: Str, timestamp: Int where _ >= 0, body: Str) -> Str
A v1a,<base64> Ed25519 signature of a message, under the 32-byte secret seed sk.
fn standard_headers(key: Secret[Bytes], id: Str where _.len > 0, timestamp: Int where _ >= 0, body: Str) -> List[(Str, Str)]
The three headers a sender adds, with one v1 signature.
fn standard_signatures(header: Str, v: Str) -> Res[List[Str], WebhookErr]
The signatures of a webhook-signature value with version v: the list is space separated, each entry version,base64, and other versions are skipped.
fn standard_verify(keys: List[Secret[Bytes]] where _.len > 0, h: StandardHeaders, body: Str, now: Int, tolerance: Int where _ >= 0) -> Res[Unit, WebhookErr]
Checks a message signed with symmetric keys: the timestamp within tolerance seconds of now, then a v1 signature under one of keys.
fn standard_verify_ed25519(pks: List[Bytes] where _.len > 0, h: StandardHeaders, body: Str, now: Int, tolerance: Int where _ >= 0) -> Res[Unit, WebhookErr]
Checks a message signed with Ed25519 (v1a) against any of the public keys pks.
fn standard_headers_of(req: Request) -> Res[StandardHeaders, WebhookErr]
The Standard Webhooks headers of a request, or the first one missing.
fn standard_verify_request(keys: List[Secret[Bytes]] where _.len > 0, req: Request, now: Int, tolerance: Int where _ >= 0) -> Res[Unit, WebhookErr]
Checks a symmetric Standard Webhooks message received by an http.serve handler.
fn shopify_signature(key: Secret[Bytes], body: Str) -> Str
The X-Shopify-Hmac-Sha256 value for body: the base64 HMAC-SHA-256.
fn shopify_verify(keys: List[Secret[Bytes]] where _.len > 0, body: Str, header: Str) -> Res[Unit, WebhookErr]
Checks an X-Shopify-Hmac-Sha256 value against body and any of keys.
fn shopify_verify_request(keys: List[Secret[Bytes]] where _.len > 0, req: Request) -> Res[Unit, WebhookErr]
Checks a Shopify webhook received by an http.serve handler.
pub type Signed = BodyOnly | Timestamped{header: Str where _.len > 0, lead: Str, sep: Str, tolerance: Int where _ >= 0} derive Eq, Show
What a scheme signs: the body alone, or lead + timestamp + sep + body with the timestamp (Unix seconds) taken from the header header and checked against tolerance seconds (0 disables the check). Slack's scheme is Timestamped{header: "x-slack-request-timestamp", lead: "v0:", sep: ":", tolerance: 300}.
pub type Scheme = {header: Str where _.len > 0, prefix: Str, algorithm: Algorithm, encoding: Encoding, separator: Str, signed: Signed} derive Eq, Show
A custom HMAC scheme: the signature is in header as prefix and the encoded HMAC of the signed content. Several signatures may be sent separated by separator ("" for one).
fn hmac_scheme(header: Str where _.len > 0) -> Scheme
A scheme with the common defaults: HMAC-SHA-256 of the body, hex, no prefix, one signature in header. Change fields with with.
fn scheme_content(s: Scheme, timestamp: Int, body: Str) -> Str
The content s signs for body sent at timestamp (ignored for BodyOnly).
fn scheme_signature(s: Scheme, key: Secret[Bytes], timestamp: Int where _ >= 0, body: Str) -> Str
The signature header value under s for body sent at timestamp.
fn scheme_verify(s: Scheme, keys: List[Secret[Bytes]] where _.len > 0, headers: List[(Str, Str)], body: Str, now: Int) -> Res[Unit, WebhookErr]
Checks a message under s, given the headers it came with (names compared without regard to case) and its raw body.
fn scheme_verify_request(s: Scheme, keys: List[Secret[Bytes]] where _.len > 0, req: Request, now: Int) -> Res[Unit, WebhookErr]
Checks a request received by an http.serve handler under s.
pub type Seen = {until: Map[Str, Int]} derive Eq, Show
Delivery ids seen recently, each with the Unix second it may be forgotten at. An immutable value for one thread or one process loop; use the SQLite store to share it between handlers.
fn seen_empty() -> Seen
fn seen_purge(s: Seen, now: Int) -> Seen
s without the ids whose time ran out by now.
fn seen_claim(s: Seen, id: Str where _.len > 0, now: Int, ttl: Int where _ > 0) -> (Res[Unit, WebhookErr], Seen)
Claims id at now for ttl seconds: ok and the updated set the first time, Replayed while an earlier claim is live. Expired ids are dropped as it goes. Use a ttl at least the timestamp tolerance, so a replay can't outlive its claim and still pass the timestamp check.
fn replay_setup(c: Db) -> Unit ! db, fail[DbErr]
Creates the SQLite table of claimed ids, webhooks_seen, if it isn't there.
fn replay_claim(c: Db, id: Str where _.len > 0, now: Int, ttl: Int where _ > 0) -> Res[Unit, WebhookErr] ! db, fail[DbErr]
Claims id in the database at now for ttl seconds, atomically: of any number of handlers or processes claiming one id at once, exactly one gets ok. An expired claim can be taken again.
fn replay_purge(c: Db, now: Int) -> Int ! db, fail[DbErr]
Deletes the claims that ran out by now, and gives how many went.