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
https://api.stridee.com/.well-known/jwks.jsonA standard JWK Set of Ed25519 keys, so any JOSE
library handles fetching, caching and picking the key by kid:
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "_1dXXcevi_xNCDfMHOIBe2hqiBRdxVealY40Yv6akI4",
"use": "sig",
"alg": "EdDSA",
"kid": "whk_2026_08"
}
]
}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.
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.
{
"alg": "EdDSA",
"kid": "whk_2026_08",
"webhook-id": "c41e9b02-7a3d-4e58-8f19-6b0d2c85af73",
"webhook-timestamp": 1770124811
}Read the id and timestamp from here, not from the HTTP headers. They carry the same values, but only this copy is signed.
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.
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'] }
);
}The body is base64url-encoded first because the JWS uses the default b64: true.
Freshness
A signature never expires, so reject old ones:
export function assertFresh(timestamp) {
if (Math.abs(Date.now() / 1000 - timestamp) > 300) throw new Error('outside the tolerance window');
}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.
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.