# Signing schemes



A signed webhook is an HTTP request carrying three extra headers. The receiver's job is to reject any request that doesn't match a small, well-defined set of conditions — and to do so without leaking timing information. This page is the deep dive on those checks.

## The three headers [#the-three-headers]

| Header              | Purpose                                                                                                                                       |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `webhook-id`        | Stable, unique message identifier. Used as the dedup key.                                                                                     |
| `webhook-timestamp` | Unix epoch seconds, as a string. Rejected outside a configurable window (default ±5 minutes).                                                 |
| `webhook-signature` | One or more space-separated `<version>,<base64>` tokens. A request is accepted if **any** token verifies against **any** configured verifier. |

Producers signing with a keyset also stamp a fourth, optional header — `webhook-key-id` — carrying the RFC 7638 thumbprint of the signing key. JWKS-mode receivers use it for `kid` lookup.

The signed content string is:

```
${webhook-id}.${webhook-timestamp}.${body-bytes}
```

`${body-bytes}` is the **raw request body** — byte-identical to what the producer sent. If anything between the network and `verify` mutates those bytes (a JSON middleware re-serializes, a proxy adds a trailing newline), the signature won't match. See [Raw bytes](/docs/concepts/raw-bytes) — it's the single most common silent failure.

## What `verify` checks, in order [#what-verify-checks-in-order]

```ts
import { Postel, Secret } from "@postel/core";
import { config } from "./config.js";

const postel = Postel({
  inbound: {
    vendor: {
      verify: Secret(config.webhookSecret),
    },
  },
});

const { event, matchedVerifierIndex } = await postel.inbound.vendor.verify(body, headers);
```

Under the hood, for each configured verifier (in order):

1. **Header presence and shape.** All three headers exist and parse. The signature header carries at least one well-formed `<version>,<base64>` token. Any failure throws `MalformedHeader`.
2. **Timestamp window.** Reject if `|now - webhook-timestamp| > toleranceSeconds` (default 300). Throws `TimestampTooOld` — this short-circuits the entire verifier array, since timestamp validity is independent of which key signed. (`ConfigurationError` short-circuits the array the same way: a config bug in one verifier is not evidence about the signature, so it is rethrown immediately instead of being folded into `SignatureInvalid`.)
3. **Key lookup** (JWKS mode only). If the current verifier is a `Keyset(...)`, read the `kid` from the `webhook-key-id` header and look up the matching public key. A miss throws `UnknownKeyId`; the next verifier (if any) is tried.
4. **Signature comparison.** Compute the expected signature for this verifier and compare it in **constant time** to one of the tokens in the header. A match wins immediately. If no token matches and there are more verifiers, try the next. If every verifier exhausts without a match, throws `SignatureInvalid`.
5. **Body parse.** Once a verifier matches, the verified raw bytes are parsed as JSON; the event is returned. A body that isn't a JSON object with a string `type` throws `MalformedHeader` — the body is part of the wire format, and the error names the actual problem. (When several verifiers fail for *different* reasons, the composed error is `SignatureInvalid`; a unanimous wire-format failure surfaces as `MalformedHeader`.)

The return value names which verifier matched (`matchedVerifierIndex`) so callers can detect when a producer is still using a deprecated key. First match wins; the only thing ordering controls is which `matchedVerifierIndex` you observe.

## The signature schemes [#the-signature-schemes]

### `v1` — HMAC-SHA256 (Standard Webhooks default) [#v1--hmac-sha256-standard-webhooks-default]

```
webhook-signature: v1,base64(HMAC-SHA256(signed-content, secret))
```

Symmetric: producer and receiver share the same secret. Cheap (\~µs per verification). Default for Standard Webhooks producers.

The secret format is the Standard Webhooks convention: `whsec_<base64>` — a 32-byte random key, base64-encoded. The `whsec_` prefix is a hint to humans and to leak-scanners; the verifier strips it before computing the HMAC.

### `v1a` — Ed25519 (Postel extension) [#v1a--ed25519-postel-extension]

```
webhook-signature: v1a,base64(Ed25519(signed-content, private-key))
```

Asymmetric: the producer signs with a private key, the receiver verifies with the corresponding public key. Slightly slower (\~tens of µs) but unlocks **public-key distribution** — receivers never hold signing material. Pairs naturally with JWKS.

`v1a` is a Postel extension on top of Standard Webhooks. Byte-compatible with the v1 envelope: a request can carry both `v1,...` and `v1a,...` tokens in the same header, and the receiver accepts on first match.

## Multi-verifier composition [#multi-verifier-composition]

Each source's `verify` slot accepts a single `Verifier` or a `ReadonlyArray<Verifier>`. Arrays are tried in order; first match wins:

```ts
const postel = Postel({
  inbound: {
    vendor: {
      verify: [
        Secret(config.webhookSecretCurrent),
        Secret(config.webhookSecretPrevious),
      ],
    },
  },
});

const { matchedVerifierIndex } = await postel.inbound.vendor.verify(body, headers);

if (matchedVerifierIndex > 0) {
  log.warn("webhook signed with deprecated verifier", { matchedVerifierIndex });
}
```

Mixed-scheme arrays are supported — e.g. `[Secret(LEGACY_HMAC), Keyset({ jwksUri: NEW_JWKS })]` for an HMAC → Ed25519 migration window. The same `matchedVerifierIndex` signal works for both same-scheme rotation and cross-scheme migration. See [Key rotation](/docs/inbound/key-rotation).

`Verifier` is an open contract, so the array can also include your own [custom verifiers](/docs/inbound/custom-verifiers) — they participate in the same ordering and `matchedVerifierIndex`.

## Constant-time comparison [#constant-time-comparison]

The signature check uses Web Crypto's constant-time comparison (or an equivalent constant-time XOR-and-OR when the platform doesn't ship it). Naive `===` leaks information about which byte differs — an attacker observing response timing can mount a byte-by-byte chosen-plaintext attack on the signature.

Timing indistinguishability can't be asserted by a black-box test suite, so the [compliance suite](https://github.com/postel-sh/postel/tree/main/compliance) explicitly marks constant-time comparison as suite-untestable — every port satisfies it by construction, using the platform's constant-time primitive.

## Structured errors [#structured-errors]

`verify` never returns a boolean. Failures are typed:

```ts
import {
  SignatureInvalid,
  TimestampTooOld,
  MalformedHeader,
  UnknownKeyId,
} from "@postel/core";

try {
  const { event } = await postel.inbound.vendor.verify(body, headers);
} catch (err) {
  if (err instanceof SignatureInvalid) {
    // no verifier matched
  } else if (err instanceof TimestampTooOld) {
    // outside the window — replay or clock skew
  } else if (err instanceof MalformedHeader) {
    // a required header is missing, or the body isn't a valid event
  } else if (err instanceof UnknownKeyId) {
    // JWKS: kid not in the keyset
  }
}
```

Each subclass carries a stable SCREAMING\_SNAKE `code` (`'SIGNATURE_INVALID'`, `'TIMESTAMP_TOO_OLD'`, …) for log filters and cross-port JSON contracts. Error messages are safe to log — they never contain the secret or the signature bytes.

## What's next [#whats-next]

* [Key rotation](/docs/inbound/key-rotation) — the multi-secret window and JWKS.
* [Raw bytes](/docs/concepts/raw-bytes) — why framework integration matters.
* [Deduplication](/docs/inbound/deduplication) — at-least-once delivery and the dedup helper.
