Webhook signatures

Verify the authenticity and integrity of webhook deliveries.
View as Markdown

Every webhook the Gateway delivers is cryptographically signed with an Ed25519 cluster key so you can verify it really came from us and was not tampered with in transit. Signing is cluster-wide and is always active — there is no per-app shared secret.

Signature Header

Every delivery carries all three headers below. The application UUID and idempotency key are part of the signed pre-image, so verify the signature before trusting either value:

HeaderValueDescription
X-Webhook-Signaturet=<unix>,akid=<kid>,v1a=<base64>A comma-separated list of key=value fields (see below).
X-Webhook-App-UUID{app_uuid}Application receiving the event. Require it and compare it with your configured application UUID.
X-Idempotency-Key{unique_key}Stable logical-event identity. Use it for deduplication only after signature verification succeeds.

Signature Fields

FieldMeaning
tUnix epoch seconds when the delivery was signed.
akidOpaque positive integer identifying the cluster Ed25519 public key used for v1a. Match this against the akid of a key returned by GET /.well-known/webhook-public-keys.
v1aBase64 Ed25519 signature over the signing input. Verified with the cluster public key — no secret needed.

Fields may appear in any order. Require exactly one each of t, akid, and v1a, reject duplicate known fields, and ignore unknown extension fields for forward compatibility. Webhook endpoints should enforce an application-appropriate request-body limit before buffering the raw bytes.

1X-Webhook-Signature: t=1750000000,akid=2,v1a=iQ8s...base64...Cg==
2X-Webhook-App-UUID: app_3f9c0b2a-7d41-4e8b-9f12-2a6c5d0e7b34
3X-Idempotency-Key: 01JY0M6M2C1Z8ZQ0CNA1K8A6JM

The Signing Input

v1a signs this exact byte sequence, with one LF byte (\n, byte 0x0a) after each of the first four fields and the raw request bytes appended without alteration:

v1a\n<unix_seconds>\n<app_uuid>\n<idempotency_key>\n<raw_body>

Use the exact textual t field from X-Webhook-Signature and the exact X-Webhook-App-UUID and X-Idempotency-Key header values. Do not add a trailing newline after the body.

Use the raw bytes. Compute the signature over the request body exactly as received, before any JSON parse/re-serialize. Re-encoding (whitespace, key order, unicode escaping) changes the bytes and breaks verification. Read the raw body first, verify, then parse.

Fetching the Public Key

The cluster’s Ed25519 public key(s) are published at an unauthenticated endpoint:

1GET /.well-known/webhook-public-keys HTTP/1.1
2Host: api.telekesher.dev

The endpoint is unauthenticated and returns key IDs, algorithm names, and base64-encoded public keys.

1{
2 "keys": [
3 {
4 "akid": 2,
5 "alg": "ed25519",
6 "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
7 },
8 {
9 "akid": 1,
10 "alg": "ed25519",
11 "public_key": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
12 }
13 ]
14}
FieldDescription
keysThe complete active key set. Array order has no signing meaning.
akidOpaque positive integer that identifies a key. Match it exactly against the akid in X-Webhook-Signature.
algSignature algorithm. Currently ed25519.
public_keyStandard-base64 encoding of the raw 32-byte Ed25519 public key.

The keys array always contains at least one active key and may contain two keys during rotation.

Match the delivery’s akid= field against a key’s akid, then verify v1a with that key only. Periodically refresh the complete key set and atomically replace the cached keyring so retired keys are evicted. Also refresh immediately when you encounter an akid you do not recognise, and reject the delivery if no exact match exists after refresh.

Verifying the Ed25519 Signature (v1a)

  1. Read the raw request body plus X-Webhook-Signature, X-Webhook-App-UUID, and X-Idempotency-Key. Reject the request if any are missing or empty.
  2. Compare X-Webhook-App-UUID with your configured application UUID and reject a mismatch.
  3. Parse the signature header. Require exactly one each of t, akid, and v1a; reject duplicate known fields and ignore unknown extension fields.
  4. Reject the delivery if t is too far from your current time (for example, more than 5 minutes) to limit replay.
  5. Fetch the public key for this akid from GET /.well-known/webhook-public-keys. Refresh periodically by atomically replacing the full keyring, and immediately on an unknown akid.
  6. Strictly decode the public_key from canonical standard base64 and require exactly 32 bytes.
  7. Reconstruct the exact LF-delimited pre-image, strictly decode v1a from canonical standard base64, require exactly 64 signature bytes, and verify it with the Ed25519 public key.
  8. Only after verification succeeds, parse and validate the JSON body. Then atomically claim the signed application UUID plus X-Idempotency-Key in a durable deduplication store. Use a short processing lease and an opaque ownership token that fences every renew, complete, and release operation. While processing, renew the lease with margin before it expires and pass a cancellation signal into external operations; if renewal fails, cancel or stop side effects so a stale owner cannot keep mutating state after takeover. Mark the claim complete only when side effects commit, and release it on failure. If another handler still owns the lease, return 425 Too Early with Retry-After equal to the 30-second lease duration so the delivery is deferred without spending its failure budget. A completed claim should suppress later deliveries for at least 24 hours.

Redirects are not followed. Configure the final HTTPS webhook URL directly: a 3xx response is a retryable redirect-policy failure under the normal delivery budget, and the signed body and headers are never forwarded to its target.

Reject present-but-invalid signatures. If v1a is present but fails verification, treat the delivery as unauthenticated and reject it. Do not fall through to any other check.

1const crypto = require('crypto');
2
3function decodeCanonicalBase64(value) {
4 if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return null;
5 const decoded = Buffer.from(value, 'base64');
6 return decoded.toString('base64') === value ? decoded : null;
7}
8
9// publicKeys: Map of akid (string) -> Buffer (raw 32-byte Ed25519 public key)
10function verifyWebhook(rawBody, headers, publicKeys, expectedAppUuid) {
11 const header = headers['x-webhook-signature'];
12 const appUuid = headers['x-webhook-app-uuid'];
13 const idempotencyKey = headers['x-idempotency-key'];
14 if (!header || !appUuid || !idempotencyKey || !expectedAppUuid ||
15 appUuid !== expectedAppUuid) return false;
16
17 const fields = {};
18 const allowed = new Set(['t', 'akid', 'v1a']);
19 for (const part of header.split(',')) {
20 const i = part.indexOf('=');
21 if (i <= 0) return false;
22 const key = part.slice(0, i).trim();
23 if (!allowed.has(key)) continue;
24 if (Object.hasOwn(fields, key)) return false;
25 fields[key] = part.slice(i + 1).trim();
26 }
27 const { t, akid, v1a } = fields;
28 if (!t || !akid || !v1a || !/^[1-9]\d*$/.test(akid)) return false;
29
30 // Reject stale deliveries (replay protection).
31 if (!/^\d+$/.test(t)) return false;
32 const timestamp = Number(t);
33 if (!Number.isSafeInteger(timestamp)) return false;
34 if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
35
36 const rawPublicKey = publicKeys.get(akid);
37 if (!rawPublicKey || rawPublicKey.length !== 32) return false;
38
39 const prefix = `v1a\n${t}\n${appUuid}\n${idempotencyKey}\n`;
40 const signingInput = Buffer.concat([Buffer.from(prefix, 'utf8'), rawBody]);
41 const sig = decodeCanonicalBase64(v1a);
42 if (!sig || sig.length !== 64) return false;
43
44 try {
45 // Node's crypto API accepts an SPKI key, so wrap the published raw key.
46 const spkiPrefix = Buffer.from('302a300506032b6570032100', 'hex');
47 const publicKey = Buffer.concat([spkiPrefix, rawPublicKey]);
48 return crypto.verify(null, signingInput, { key: publicKey, format: 'der', type: 'spki' }, sig);
49 } catch {
50 return false;
51 }
52}
53
54// Call verifyWebhook before JSON.parse(rawBody) and before consulting or
55// updating your idempotency-key store.

Key Rotation

During a cluster key rotation, two public keys are published at GET /.well-known/webhook-public-keys simultaneously (current and previous). Deliveries signed under the previous akid remain verifiable for an overlap window. Periodically fetch the full list, build a new keyring, and atomically replace the old one so a retired key cannot remain trusted indefinitely. Also refresh immediately when a delivery uses an unknown akid.