Webhooks
Register an endpoint, handle a delivery in the right order, and recover when yours was down.
Every event reaches you as a POST to your endpoint. Two checks protect it, and your handler does both:
| Answers | Page | |
|---|---|---|
The webhook-signature header | Did Stridee send this? | Verifying a delivery |
| The encrypted body | Can only you read it? | Encrypted deliveries |
There is no shared secret to store, rotate or leak.
Registering an endpoint
Add the URL on the Webhooks screen and choose an encryption key — you can
generate one from the same form. It has to be https:// and resolve to a public address.
There is no cleartext mode and no verification step: the endpoint is live the moment it is
saved.
Choosing events
By default an endpoint receives every event type, including types added later. To narrow
it, pick Only selected on the form, or send enabled_events to
POST /platform/webhooks or PATCH /platform/webhooks/{id}:
["*"]is everything, and the default.- An exact type, such as
activity.created, gets only that type. - A family, such as
wellness.*, gets every type in it, including ones added later.
A name we don't send is refused with a 400. The list can't be empty; if an endpoint needs
nothing, remove it. A change applies from the next event, and deliveries already made are
unaffected.
Two types arrive whatever you subscribe to. ping is sent because you pressed the button
for that endpoint. account.deleted is sent because it tells you to erase a user's data.
However you subscribe, switch on type and return 2xx for anything you don't handle. A
handler that throws on an unfamiliar type turns a new event into a failed delivery.
{ "enabled_events": ["activity.created", "wellness.*"] }What arrives
webhook-id is the delivery id — stable across resends, so it is what you dedupe on.
The same id and timestamp are repeated inside the signature, and that signed copy is the one
to trust. enc opens to an event.
POST /webhooks HTTP/1.1
webhook-id: c41e9b02-7a3d-4e58-8f19-6b0d2c85af73
webhook-timestamp: 1770124811
webhook-signature: eyJhbGciOiJFZERTQSIsImtpZCI6…..iHpBfMmUqShn7ikCKRQc_GbqAGfi…
Content-Type: application/json
{
"id": "c41e9b02-7a3d-4e58-8f19-6b0d2c85af73",
"type": "encrypted",
"enc": "eyJhbGciOiJFQ0RILUVTIiwiZW5jIjoiQTI1NkdDTSIsImtpZCI6IjVhOGYzMWQ2LTBjOTQt…"
}Handling a delivery
In this order — each step is cheaper than the next, so a forged POST costs you one signature check and nothing more.
verifySignature and assertFresh are on Verifying a
delivery; decrypt is the
Quickstart handler plus the kid lookup on
Encrypted deliveries.
Answer fast. Nothing retries automatically, so a handler that times out while calling three other services has lost that event.
// 1. Who sent it? Verify the raw bytes, before parsing anything.
const { protectedHeader } = await verifySignature(req.headers['webhook-signature'], rawBody);
// 2. Is it recent?
assertFresh(protectedHeader['webhook-timestamp']);
// 3. Open it with the key named by `kid`.
const { enc } = JSON.parse(rawBody);
const event = await decrypt(enc);
// 4. Have we seen it? Still echo the nonce if so.
if (await seen(protectedHeader['webhook-id'])) return res.json({ nonce: event.nonce });
// 5. Store it, answer, and do the real work afterwards.
await store(event);
res.json({ nonce: event.nonce });Confirming a delivery
Every delivery carries a fresh random nonce inside the ciphertext, and your 2xx must
return it at the top level:
Only something holding your private key can produce it, so the echo proves your service
opened the body — not just that some server answered. A bare 200, {"ok":true} or a
nested nonce count as no echo.
A missing echo does not fail the delivery: you returned 2xx, so the event is yours. It is
recorded and shown in the console, which is how a wrong key, an undeployed private half or
a proxy answering for you shows up as delivered but not opened instead of healthy.
{ "nonce": "Kd4nWpLbEa9xTvRm2Cj7Lz0Bq2vNhCz7" }Sending a ping
A ping is a real delivery — minted, signed, sealed and recorded like any other — so it
tests the whole path:
- Locally:
stridee trigger pingsends one to every runningstridee listen. - Production: Send ping on the Webhooks screen. It reports the status code and timing, or which hop failed, and whether the nonce came back. Capped at 10 per endpoint per minute.
A ping has no user_id or provider — no athlete was involved.
When a delivery fails
Nothing is retried automatically. A delivery is POSTed once. If your endpoint times out,
refuses the connection or answers 5xx, it stops there. Every attempt is recorded on the
endpoint's row with the reason — dns, tls, timeout, connection, blocked or
status — and the round-trip time.
To recover:
- Resend any delivery from the console, or with
POST /platform/webhooks/{id}/deliveries/{delivery_id}/resend. Thewebhook-idstays the same, so your dedupe makes it safe; thenonceis new, so echo the one you just decrypted; and it is sealed to the endpoint's key as it is now, which is what fixes a rotation you got wrong. - Backfill activities you missed during an outage from
GET /v1/activities.
Something wrong or missing on this page? Tell us in Discord. Need something the API doesn’t do yet? Request it on the roadmap.