Package

webhooks

Verify and sign webhooks: GitHub, Stripe, Slack, Standard Webhooks, Shopify and custom HMAC schemes, with timestamp tolerance and replay protection

0.1.0 MIT db

Install

sspur add webhooks@^0.1.0

or in sspur.toml:

webhooks = { version = "^0.1.0" }

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

DefinitionWhat it is
WebhookErrMissingHeader{name}, MalformedHeader{name, why}, SignatureMismatch, TooOld{age, tolerance}, TooNew{ahead, tolerance}, Replayed{id}, InvalidKey{why}. No variant carries the expected signature
AlgorithmSha256 or Sha512 (HMAC)
EncodingHex, 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

DefinitionWhat 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

DefinitionWhat 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

DefinitionWhat 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

DefinitionWhat 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

DefinitionWhat 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

DefinitionWhat 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

DefinitionWhat it is
SignedBodyOnly, 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

DefinitionWhat 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

WebhookErr type

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.

Algorithm type

pub type Algorithm = Sha256 | Sha512 derive Eq, Show

The HMAC hash function of a scheme.

Encoding type

pub type Encoding = Hex | Base64 | Base64Url derive Eq, Show

How a signature is written as text: lower-case hex, padded base64 or unpadded base64url.

text_key fn

fn text_key(s: Secret[Str]) -> Secret[Bytes]

A shared secret given as text (the usual form in provider dashboards), as an HMAC key.

hmac fn

fn hmac(alg: Algorithm, key: Secret[Bytes], data: Str) -> Bytes

The HMAC of data under key with alg.

encode fn

fn encode(e: Encoding, b: Bytes) -> Str

b written with encoding e.

decode fn

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.

matches fn

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.

unix_now fn

fn unix_now() -> Int ! time

The current Unix time in seconds, for the now parameters.

parse_timestamp fn

fn parse_timestamp(s: Str) -> Opt[Int]

A Unix timestamp in seconds as a header carries it: 1 to 12 ASCII digits, nothing else.

check_timestamp fn

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.

default_tolerance fn

fn default_tolerance() -> Int

The default tolerance of Stripe, Slack and Standard Webhooks: 5 minutes.

github_signature fn

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.

github_headers fn

fn github_headers(key: Secret[Bytes], body: Str) -> List[(Str, Str)]

The headers a sender adds to a GitHub-style delivery of body.

github_verify fn

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.

github_verify_request fn

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.

StripeHeader type

pub type StripeHeader = {timestamp: Int, v1: List[Str]} derive Eq, Show

A parsed Stripe-Signature header: the timestamp and every v1 signature. Other schemes, such as the test-mode v0, are ignored.

stripe_parse fn

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.

stripe_signature fn

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.

stripe_test_header fn

fn stripe_test_header(key: Secret[Bytes], body: Str, timestamp: Int where _ >= 0) -> Str

A Stripe-Signature value for body at timestamp, as stripe-node's generateTestHeaderString makes, for tests and for senders.

stripe_verify fn

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).

stripe_verify_request fn

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.

slack_signature fn

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}".

slack_headers fn

fn slack_headers(key: Secret[Bytes], timestamp: Int where _ >= 0, body: Str) -> List[(Str, Str)]

The headers Slack sends with a request: the timestamp and the signature.

slack_verify fn

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.

slack_verify_request fn

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.

StandardHeaders type

pub type StandardHeaders = {id: Str, timestamp: Str, signature: Str} derive Eq, Show

The three headers of the Standard Webhooks specification, as received.

standard_key fn

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.

standard_public_key fn

fn standard_public_key(s: Str) -> Res[Bytes, WebhookErr]

A Standard Webhooks Ed25519 public key, whpk_ and the base64 of 32 bytes (prefix optional).

standard_content fn

fn standard_content(id: Str, timestamp: Int, body: Str) -> Str

The content every Standard Webhooks signature covers: "{id}.{timestamp}.{body}".

standard_signature fn

fn standard_signature(key: Secret[Bytes], id: Str, timestamp: Int where _ >= 0, body: Str) -> Str

A v1,<base64> symmetric signature of a message.

standard_signature_ed25519 fn

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.

standard_headers fn

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.

standard_signatures fn

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.

standard_verify fn

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.

standard_verify_ed25519 fn

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.

standard_headers_of fn

fn standard_headers_of(req: Request) -> Res[StandardHeaders, WebhookErr]

The Standard Webhooks headers of a request, or the first one missing.

standard_verify_request fn

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.

shopify_signature fn

fn shopify_signature(key: Secret[Bytes], body: Str) -> Str

The X-Shopify-Hmac-Sha256 value for body: the base64 HMAC-SHA-256.

shopify_verify fn

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.

shopify_verify_request fn

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.

Signed type

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}.

Scheme type

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).

hmac_scheme fn

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.

scheme_content fn

fn scheme_content(s: Scheme, timestamp: Int, body: Str) -> Str

The content s signs for body sent at timestamp (ignored for BodyOnly).

scheme_signature fn

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.

scheme_headers fn

fn scheme_headers(s: Scheme, key: Secret[Bytes], timestamp: Int where _ >= 0, body: Str) -> List[(Str, Str)]

The headers a sender adds under s: the signature, and the timestamp when the scheme signs one.

scheme_verify fn

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.

scheme_verify_request fn

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.

Seen type

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.

seen_purge fn

fn seen_purge(s: Seen, now: Int) -> Seen

s without the ids whose time ran out by now.

seen_claim fn

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.

replay_setup fn db

fn replay_setup(c: Db) -> Unit ! db, fail[DbErr]

Creates the SQLite table of claimed ids, webhooks_seen, if it isn't there.

replay_claim fn db

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.

replay_purge fn db

fn replay_purge(c: Db, now: Int) -> Int ! db, fail[DbErr]

Deletes the claims that ran out by now, and gives how many went.

Versions

VersionPublishedCommitSourceEffects
0.1.0 2026-10-16b0b844d8c2snapshotdb