A reusable verifier for Standard Webhooks (https://www.standardwebhooks.com), the signing scheme Increase uses. A delivery carries three headers:

  • webhook-id: the message id (Increase sends the Event id)
  • webhook-timestamp: Unix seconds when the delivery was sent
  • webhook-signature: space-separated signatures, each v1,<base64 HMAC-SHA256>; several are sent while a secret rotates

The signed content is {id}.{timestamp}.{raw body}. verify accepts the delivery when any v1 signature matches (each compared in constant time) and the timestamp is within the tolerance (five minutes by default) in either direction. It fails closed: a missing header, a non-numeric timestamp, an empty key or an unknown signature version is a rejection.

Keys: Standard Webhooks secrets look like whsec_<base64>; turn one into the HMAC key with decode-secret. Increase signs with the raw bytes of the event subscription's shared secret instead (see ::increase::webhooks).

Values

BASE64_ALPHABET

BASE64_ALPHABET: Str "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"

DEFAULT_TOLERANCE_SECONDS

DEFAULT_TOLERANCE_SECONDS: Int 300

SECRET_PREFIX

SECRET_PREFIX: Str "whsec_"

Functions

decode-secret

fn (secret: Str): Bytes

The HMAC key for a Standard Webhooks secret: drop an optional whsec_ prefix and base64-decode the rest (padding optional). Errs on an empty or non-base64 secret.

Example

key ::increase::standard-webhooks/decode-secret("whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw")

endpoint-verification-response

fn (key: Str | Bytes, challenge: Str): Str

The webhook-challenge-response header value for a Standard Webhooks endpoint-verification challenge: v1, + base64 HMAC-SHA256 of the challenge under HMAC-SHA256(key, "wev-v1"). Increase doesn't send challenges; this is here for other Standard Webhooks senders.

fn (headers: Map, name: Str): Str

A header value by name, ignoring case, trimmed; "" when absent. Exposed for testing.

key-is-empty

fn (key: Str | Bytes): Bool

pad-base64

fn (text: Str): Str

Canonical, padded base64 for a secret that may be shared unpadded: strip any =, clear the unused low bits of the last character (the reference libraries decode leniently and ignore them; Hot's decoder is strict), and pad to a multiple of four. Exposed for testing.

sign

fn (key: Str | Bytes, msg-id: Str, timestamp: Str, body: Str | Bytes): Str

The v1,<base64> signature for a message: HMAC-SHA256 of signed-content under key (the decoded secret, as Bytes or Str). Use it to produce test deliveries.

Example

key ::increase::standard-webhooks/decode-secret("whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw")
::increase::standard-webhooks/sign(key, "msg_p5jXN8AQM9LWM0D4loKWxJek", "1614265330", "{\\"test\\": 2432232314}")
// => "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE="

signatures

fn (value: Str): Vec<Str>

The space-separated entries of a webhook-signature header. Exposed for testing.

signed-content

fn (msg-id: Str, timestamp: Str, body: Str | Bytes): Str | Bytes

The bytes a Standard Webhooks signature covers: {msg-id}.{timestamp}. followed by the body exactly as received. A Bytes body stays bytes, so a body that isn't valid UTF-8 still verifies. Exposed for testing.

timestamp-within

fn (timestamp: Int, now: Int, tolerance-seconds: Int): Bool

verify

fn (headers: Map, body: Str | Bytes, key: Str | Bytes): Bool
fn (headers: Map, body: Str | Bytes, key: Str | Bytes, tolerance-seconds: Int): Bool

Verify a delivery's headers and raw body against key (the decoded secret) at the current time, with a five-minute tolerance by default. Pass the body exactly as received (::hot::http/raw-body), never a re-serialized request.body.

Example

key ::increase::standard-webhooks/decode-secret(secret)
ok ::increase::standard-webhooks/verify(request.headers, ::hot::http/raw-body(request), key)

verify-at

fn (headers: Map, body: Str | Bytes, key: Str | Bytes, tolerance-seconds: Int, now: Int): Bool

Verify a delivery against key as of now (Unix seconds): true when webhook-id, webhook-timestamp and webhook-signature are present, the timestamp is an integer within tolerance-seconds of now (a tolerance of 0 or less rejects everything), and one of the signatures equals sign(key, id, timestamp, body). Every candidate is compared in constant time. Exposed for testing with fixed vectors; use verify in handlers.

verify-request

fn (request: Map, key: Str | Bytes): Bool
fn (request: Map, key: Str | Bytes, tolerance-seconds: Int): Bool

verify for an inbound webhook request: its headers and ::hot::http/raw-body.