Stridee
Stridee Docs

Encrypted deliveries

Sealing deliveries to an X25519 key only you hold — JWE compact, ECDH-ES + A256GCM — and rotating it without a gap.

HTTPS protects a delivery only as far as whatever terminates TLS — often a CDN, load balancer or logging sidecar you don't fully control. So every delivery body is sealed to a key you hold, and the plaintext exists only on our side and yours.

It is end-to-end, not zero-knowledge: we generated the event, so we have it, and we keep it for 30 days so a lost key means a resend rather than a hole in your history.

The key

It has to be X25519. Ed25519 — the curve request signing uses — has no decrypt operation. Generate one on Keys, from the endpoint form, or yourself:

Shell
openssl genpkey -algorithm x25519 -out stridee-enc.pem
openssl pkey -in stridee-enc.pem -pubout

A PEM or JWK naming the wrong curve is refused. A bare base64 key is taken at your word, and a wrong one fails the first time we seal to it.

Keep the private half in the service that ingests events, not on the proxy or worker in front of it — otherwise you have put back the hop this removes.

The scheme

Key agreementECDH-ES (X25519, ephemeral-static)
Content encryptionA256GCM
FormatJWE compact
Additional datathe protected header

Standard JWE, so opening it is a library call. A fresh sender key per delivery means a private key stolen next year does not open traffic captured this year.

The protected header, decoded
{
  "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
}

The header is the additional data, so webhook-id and webhook-timestamp are bound to the ciphertext: move it into another POST and it no longer matches the signed header beside it.

Only id and type: "encrypted" travel in cleartext. Everything that could name a person — the user, the provider, the event type — is inside.

What your handler must do

Decrypt in the request path, before you answer, and pick the key by kid rather than assuming one — that is what lets an endpoint hold two keys during a rotation:

decrypt.js
import { compactDecrypt, decodeProtectedHeader } from 'jose';

// Every private key this endpoint may be sealed to, by key id.
const keys = new Map([['5a8f31d6-0c94-4b27-a3e5-71fd2809bc4e', activeKey]]);

export async function decrypt(enc) {
  const { kid } = decodeProtectedHeader(enc);
  const { plaintext } = await compactDecrypt(enc, keys.get(kid));
  return JSON.parse(new TextDecoder().decode(plaintext));
}

A successful decrypt does not prove who sent it — anyone can seal to a public key. Verify the signature first; the full order is on Webhooks.

Rotating the key

An endpoint holds up to two keys, active and standby:

  1. Add the new key as the endpoint's standby. Nothing is sealed to it yet.
  2. Deploy its private half, and add it to your kid map.
  3. Press Test on the standby row. The ping is sealed to the standby key, so your handler has to open it — check your logs.
  4. Promote it. Deliveries are sealed to it from the next event on, and the old key leaves the endpoint.

Deliveries already on their way when you promote were sealed to the old key, so keep it in your map for a while. Resends are always sealed to the endpoint's current key.

Promote before step 2 and every delivery lands unopenable. They still return 200, so the only signal is the missing nonce echo — which is why step 3 exists.

The API version of step 3 names the key to seal to:

HTTP
POST /platform/webhooks/2d7b45c1-8e0a-4f36-b512-9c7d3a6e04f8/ping

{ "encryption_key_id": "e3c07b41-9d2f-4a8c-b06d-5f14e97a2b83" }

It must be one of the endpoint's two keys. The response's encryption_key_id and was_active_key say which one was actually used.

Something wrong or missing on this page? Tell us in Discord. Need something the API doesn’t do yet? Request it on the roadmap.