Webhooks
Registering an endpoint, opening a delivery, and what happens when yours is down.
Every delivery is sealed to an X25519 key you hold, so the body is readable by your service and by nothing between us and it. There is no per-endpoint shared secret to store, rotate or leak.
Registering an endpoint
Add the URL on the Webhooks screen and choose an encryption key. The key is not optional and there is no cleartext mode — an endpoint we have nothing safe to send to is not one we will register. If you have no key yet, you can generate one without leaving the form; it lands in your key pool.
An endpoint may hold two keys, one active and one standby, which is what lets you rotate without a window where a delivery can't be opened. See Encrypted deliveries.
There is no verification step to wait through. Nothing is pending: the endpoint is live from the moment it is registered, and the next matching event goes to it.
Sending a ping
You do not have to wait for a real upload to find out whether any of this works. Send
ping on the Webhooks screen delivers a ping event to that endpoint and
tells you what happened — the status code and how fast, or which hop failed and why.
It is a real delivery on the real path: minted, sealed to your active key, sent to your URL, and recorded on the event stream like anything else. Nothing about it is a special case, which is the point — a test that skipped the encryption would prove nothing about the deliveries that don't.
{
"id": "9f2c1e7a-4b83-4d21-9a6e-3c5f0d8b71a4",
"type": "ping",
"created": "2026-08-04T06:14:02Z",
"webhook_id": "2d7b45c1-8e0a-4f36-b512-9c7d3a6e04f8",
"nonce": "Kd4nWpLbEa9xTvRm2Cj7Lz0Bq2vNhCz7",
"data": {}
}No account_id and no provider: a ping is not about an athlete and no integration caused
it. It carries webhook_id instead, so a handler can bind the decrypted body to the
endpoint it arrived at rather than trusting the cleartext envelope for that.
Pings are capped at 10 per endpoint per minute.
What a green ping tells you
The ping reports two separate things, and the second is the one worth having: delivered
says your endpoint answered 2xx, and nonce_echoed says it could actually open the body.
delivered: true with nonce_echoed: false is a service that accepted a ciphertext it
could not read — a wrong key, an undeployed private half, or a proxy answering on your
handler's behalf.
That second answer only exists because your handler returns the nonce. See Confirming a delivery below.
Testing a standby key before you promote it
A rotation is three steps — assign a standby, deploy its private half, promote it — and the middle one is the only one nothing exercises. Promote before that private key is actually loaded and every delivery from that moment is sealed to a key your service cannot open.
So a ping can name which of the endpoint's two keys to seal to:
POST /platform/webhooks/2d7b45c1-8e0a-4f36-b512-9c7d3a6e04f8/ping
{ "encryption_key_id": "e3c07b41-9d2f-4a8c-b06d-5f14e97a2b83" }Omit the body and it uses the active key. The key you name has to be one this endpoint currently holds — active or standby — because sealing to any other key of yours would test a configuration this endpoint is not in.
The response says which key it was actually sealed to in encryption_key_id, resolved
by us rather than assumed by you, and was_active_key tells you whether that was the one
live traffic uses. Combine it with the rule above: the ping puts a body the new key has to
open in front of your handler, and your logs are what confirm it did.
In the console this is the Test button beside Promote on the standby row. Do it in that order.
The delivery headers
| Header | Example | What it is |
|---|---|---|
webhook-id | c41e9b02-7a3d-4e58-8f19-6b0d2c85af73 | Stable across retries — dedupe on it |
webhook-timestamp | 1770124811 | Unix seconds |
Both also appear inside the JWE protected header, where the AEAD tag covers them — so a ciphertext lifted into a different POST no longer agrees with the headers beside it, and comparing the two in your handler is worth doing.
Opening a delivery
// 1. pull the ciphertext out of the envelope
const { id, enc } = JSON.parse(rawBody);
// 2. 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));
// 3. account_id lives inside the ciphertext, so check it here
assertOwned(event.account_id);
// 4. dedupe on the delivery id — retries reuse it
if (await seen(id)) return res.json({ nonce: event.nonce });
// 5. echo the nonce back, always — see below
res.json({ nonce: event.nonce });Check account_id against your own records after the
decrypt, since that field lives inside the ciphertext.
And do not read a successful decrypt as proof of sender: anyone can seal a body to a public key, because that is what public means.
Confirming a delivery
Every delivery carries a fresh random nonce inside the ciphertext, and your 2xx has
to return it:
{ "nonce": "Kd4nWpLbEa9xTvRm2Cj7Lz0Bq2vNhCz7" }That is the whole contract — a top-level nonce, matching the one you decrypted. A bare
200, an empty body, {"ok":true} or the value nested inside another object all count as
no echo. The check is strict on purpose: a lenient one could be passed by accident, and
passing by accident is exactly what it exists to catch.
It is one line, and it buys the thing a status code cannot give you. Only something holding
your private key can produce that value, so the echo is live proof that this delivery was
opened — not that some server answered. A key rotated before its private half was deployed,
a lapsed domain, a reclaimed subdomain, a proxy that started answering in front of your
service: all of them return a healthy 200, and all of them stop echoing.
Echo it on the dedupe path too. A retry you have already processed is still a delivery you could open, and staying silent on it would report a decryption problem you do not have.
A missing echo does not fail the delivery. You returned 2xx and by then you had
already processed the event — retrying it would only manufacture duplicates to punish a
configuration error. We record it against the delivery and show it in the console instead.
Retries and health
- Any
2xxis an ack. We record the status code and whether the nonce came back with it. - Non-
2xxand timeouts are retried with exponential backoff. A ping is the exception: it is sent once, inline, because a person is waiting on the answer. webhook-idis stable across every attempt. Dedupe on it and a retry storm is a no-op instead of nine copies of a workout.- Sustained failures mark the endpoint degraded, then failing, on the Webhooks screen. Deliveries are queued while you fix it, not dropped.
Return 2xx with the nonce as soon as you have decrypted and persisted the event, and do
the real work after. A handler that calls three services before acking is a handler that
will time out on the day one of them is slow.
Going further
Encrypted deliveries covers the scheme itself, what your handler has to do with it, and how to rotate the key an endpoint is sealed to.
Something wrong or missing on this page? Tell us in Discord.