Encrypted deliveries
Sealing deliveries to an X25519 key only you hold — JWE compact, ECDH-ES + A256GCM.
HTTPS protects a delivery as far as whatever terminates TLS, which in most stacks is a CDN, a load balancer or a logging sidecar you don't fully control. So every delivery is sealed to an X25519 key you hold — the one you named when you registered the endpoint — and the plaintext exists in two places, ours and yours, and at no hop in between.
This is not something to turn on. An endpoint names an encryption key when it is created, and there is no cleartext mode — an endpoint we have nothing safe to send to is not one we will register.
X25519, not Ed25519
The key has to be X25519. Ed25519 sits on the same curve but is its signature form and has no decrypt operation at all — no library exposes one. X25519 is the key-agreement form, which is what encryption is built out of.
In practice that is one word:
openssl genpkey -algorithm x25519 -out stridee-enc.pem
openssl pkey -in stridee-enc.pem -puboutNothing in 32 raw bytes distinguishes the two curves, so a PEM or JWK naming Ed25519 is refused when you register it, and a bare base64 paste is taken at your word. A key on the wrong curve fails the first time we try to seal to it.
Worth being exact about the claim: this is end-to-end, not zero-knowledge. We generated the event, so we have the data by definition, and we keep it for 30 days so a lost key is a replay rather than a hole in your history.
The scheme
| Key agreement | ECDH-ES (X25519, ephemeral-static) |
| Content encryption | A256GCM |
| Format | JWE compact |
| Additional data | the protected header |
Standard JWE, so a handler is jose and three lines rather than homegrown crypto.
Ephemeral-static ECDH means a fresh sender key per delivery: a private key stolen next
year does not open traffic captured this year.
The protected header carries the delivery's identity alongside the usual JOSE fields:
{
"alg": "ECDH-ES",
"enc": "A256GCM",
"kid": "5a8f31d6-0c94-4b27-a3e5-71fd2809bc4e",
"epk": { "kty": "OKP", "crv": "X25519", "x": "…" },
"webhook-id": "c41e9b02-7a3d-4e58-8f19-6b0d2c85af73",
"webhook-timestamp": 1770124811
}Compact serialization has no separate additional-data field — the encoded header is the
additional data — so putting webhook-id and webhook-timestamp there is what binds this
ciphertext to this delivery: change either and the tag fails.
Compare them against the ones in the verified signature header rather than the HTTP headers. All three carry the same values, and the HTTP headers are the only copy nothing signed.
What arrives
{
"id": "c41e9b02-7a3d-4e58-8f19-6b0d2c85af73",
"type": "encrypted",
"enc": "eyJhbGciOiJFQ0RILUVTIiwiZW5jIjoiQTI1NkdDTSIsImtpZCI6IjVhOGYzMWQ2LTBjOTQt…"
}Two cleartext fields, both of which a queue legitimately needs before anything is
decrypted. Everything that would name a person — account_id, the provider, the event
type — is inside the ciphertext.
Which key it was sealed to is the kid in the protected header above — read it there
rather than assuming one key, because that is what lets an endpoint hold two through a
rotation.
What your handler must do
// 1. verify the signature first — see /docs/webhooks/signatures
const { protectedHeader } = await flattenedVerify(…, JWKS, { algorithms: ['EdDSA'] });
// 2. pull the ciphertext out of the envelope
const { enc } = JSON.parse(rawBody);
// 3. pick the key named by `kid`, then open it
const { plaintext } = await compactDecrypt(enc, keyFor(kidOf(enc)));
const event = JSON.parse(new TextDecoder().decode(plaintext));
// 4. account_id lives inside the ciphertext, so check it here
assertOwned(event.account_id);
// 5. dedupe on the delivery id — retries reuse it
if (await seen(protectedHeader['webhook-id'])) return res.json({ nonce: event.nonce });
// 6. echo the nonce, which is what proves you opened this
res.json({ nonce: event.nonce });-
Verify before you decrypt. A signature check is cheaper than a key agreement, and it is the step that decides whether the bytes deserve any further work at all.
-
Read the
kidrather than assuming one key. It is what lets an endpoint hold two keys through a rotation instead of cutting over. -
Check
account_idafter the decrypt. It is inside the ciphertext, which is the point — it is exactly the kind of field that must not sit in a proxy log. -
Decrypt in the request path, before you ack. One key agreement and one AEAD open lands within milliseconds, but it does mean you can no longer enqueue the raw bytes and return
200blind. -
Keep the private key off your edge. It belongs in the service that ingests events, not the worker or proxy that fronts it — otherwise you have reintroduced the hop you turned this on to remove.
-
Echo the
noncefrom inside the plaintext. A200on its own says a server answered, not that it opened anything. The nonce is sealed in the ciphertext, so only something holding your private key can return it — which is what turns a check made once at setup into one made on every delivery. See Confirming a delivery.
What encryption does not tell you
Encryption answers one question — who can read this — and it does not answer the other. Anyone can seal a body to a public key, because that is what public means, so decrypting successfully does not prove we sent it.
That is what the signature on every delivery is for, and it is a separate check against a separate key. See Verifying a delivery — a handler needs both, and the signature goes first.
Rotating an encryption key
An endpoint may hold two of your account's keys at once — one active, one standby — which is what gives a rotation its overlap:
- Generate the new key on Keys, or from the endpoint itself.
- Assign it as the endpoint's standby. Nothing is sealed to it yet.
- Deploy its private half to your service.
- Promote it. Deliveries are sealed to it from the next event on, and the key it replaced leaves the endpoint.
The replaced key stays in your pool, and it is what still opens anything replayed from
before the switch — each delivery records the kid it was sealed to, so a replay is
decrypted with the key it was actually encrypted to. Delete it once the 30-day replay
window has passed.
Skip the overlap and every delivery in flight during the switch lands undecryptable. That
is the failure the nonce exists to surface: those deliveries keep returning 200 and stop
echoing, so the console shows them as delivered-but-unopened rather than as healthy. Ping
the standby before you promote and you find out while being wrong is still cheap.
Something wrong or missing on this page? Tell us in Discord.