Verifying a delivery
Every delivery is signed with our Ed25519 key and verified against the JWK Set we publish — JWS detached, RFC 7515.
Anyone who learns your URL can POST to it, and anyone can seal a body to your public key.
The webhook-signature header is what proves a delivery came from us: it is made with a key
only we hold, over the exact bytes on the wire.
Our public keys
A standard JWK Set of Ed25519 keys, so any JOSE
library handles fetching, caching and picking the key by kid:
Fetch it and cache it (Cache-Control says ten minutes). Never copy a key's x into your
config — we rotate by publishing a second key, then signing with it, and a client that
fetches the set needs no change when we do. A pinned key breaks.
https://api.stridee.com/.well-known/jwks.json{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "_1dXXcevi_xNCDfMHOIBe2hqiBRdxVealY40Yv6akI4",
"use": "sig",
"alg": "EdDSA",
"kid": "whk_2026_08"
}
]
}The header
webhook-signature is a JWS with detached content
(RFC 7515 Appendix F) — three
segments with the middle one empty, because the payload is the request body you already
have.
Read the id and timestamp from here, not from the HTTP headers. They carry the same values, but only this copy is signed.
{
"alg": "EdDSA",
"kid": "whk_2026_08",
"webhook-id": "c41e9b02-7a3d-4e58-8f19-6b0d2c85af73",
"webhook-timestamp": 1770124811
}Verifying
Verify the raw body. A framework that parses JSON for you has already thrown away the signed bytes, and re-serializing them will not verify.
The body is base64url-encoded first because the JWS uses the default b64: true.
import { base64url, createRemoteJWKSet, flattenedVerify } from 'jose';
// Once, at startup.
const JWKS = createRemoteJWKSet(new URL('https://api.stridee.com/.well-known/jwks.json'));
export async function verifySignature(header, rawBody) {
const [protectedSegment, , signature] = header.split('.');
return flattenedVerify(
{ protected: protectedSegment, signature, payload: base64url.encode(rawBody) },
JWKS,
{ algorithms: ['EdDSA'] }
);
}Freshness
A signature never expires, so reject old ones:
Five minutes is a clock-skew allowance. It limits how long a captured delivery stays
useful; deduping on webhook-id is what stops it being processed twice. You want both —
the full order is on Webhooks.
export function assertFresh(timestamp) {
if (Math.abs(Date.now() / 1000 - timestamp) > 300) throw new Error('outside the tolerance window');
}If it doesn't verify
| Symptom | Usually |
|---|---|
signature verification failed | You verified re-serialized JSON rather than the raw bytes, or the body was changed in transit. |
No key matches the kid | A stale cache or a pinned key — fetch the set. |
| The header is missing | A proxy upstream stripped it. |
Something wrong or missing on this page? Tell us in Discord. Need something the API doesn’t do yet? Request it on the roadmap.