Webhooks
Subscribe once, as your app, and hear about every athlete who has authorized you. There is no per-athlete subscription to manage.
Subscribing
Webhooks belong to your app, not to an athlete, so they are managed with a client credentials token rather than someone's access token.
curl -X POST "https://api.stride.is/v1/app/webhooks" \
-H "Authorization: Bearer $APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.example/stride/hooks",
"events": ["activity.created", "activity.updated"]
}'Verifying your endpoint
Before anything is delivered we check the endpoint is expecting us. Your URL receives a GET with hub.mode, hub.challenge and hub.verify_token query parameters, and must answer 200 echoing the challenge:
{ "hub.challenge": "<the value you were sent>" }The subscribe call does this inline and fails if the handshake does not complete, so you find out immediately rather than wondering why nothing arrives. It is the same handshake Strava and Meta use.
What you receive
Events are deliberately thin: what happened and to whom. Call the API for the detail, so a change to an activity's shape is never a breaking change to your webhook.
{
"id": "evt_2f...",
"type": "activity.created",
"athlete_id": "usr_3Er...",
"object_id": "act_2zV...",
"object_type": "activity",
"occurred_at": "2026-09-17T08:14:22Z"
}athlete_id is what you route on, and occurred_at is a real UTC instant, unlike the wall-clock timestamps on activities themselves.
id is the same on every retry of one event and unique across events, and it is also sent as the Stride-Delivery-Id header. Delivery is at least once, so the same event can occasionally arrive twice even after you answered 2xx: de-duplicate on id. Events are not delivered in order.
Verifying the signature
Every delivery carries Stride-Signature: t=<unix>,v1=<hex>. The signature is HMAC-SHA256 over <timestamp>.<raw body> with your subscription secret. Compare in constant time, and reject a timestamp more than five minutes old - the timestamp is inside the signed payload, so an old delivery cannot be replayed with a fresh one. Every attempt is signed as it is sent, so a retry carries a new timestamp and signature over the same body.
import hmac, hashlib, time
def verify(secret: str, header: str, body: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(
secret.encode(), f"{parts['t']}.".encode() + body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts["v1"])Which events reach you
You hear about an athlete only while three things hold: your subscription covers the event, the athlete has authorized your app, and your grant carries the scope that event's data sits behind. An app holding only activities.read never receives metric.created. When an athlete disconnects you, the events stop with the access. Sessions and readings synced from Garmin are never announced, because the API cannot serve them.
Retries, and when we stop
Answer within 10 seconds. Any 2xx is success. A timeout, a connection error or any other status is a failure and is retried - including a 4xx, because an endpoint answering 404 is misconfigured rather than busy. Store the event and return, then do the work.
A failed event is retried with backoff, starting at 10 seconds and rising to every 10 minutes, for up to three days. Nothing is lost while your endpoint is down for a deploy or an outage: once five attempts in a row have failed we hold your events in the queue and send at most one a minute to see whether you are back. When one succeeds, the rest follow over the next few minutes, so expect a burst after a long outage.
If nothing has succeeded for three days, the subscription is disabled and the events still waiting are dropped. Events raised while it is disabled are not kept. Once your endpoint is answering again, POST /app/webhooks/{id}/enable repeats the verification handshake and turns it back on with the same ID and secret; call the API for anything that changed in the gap.
GET /app/webhooks shows failing_since and consecutive_failures so you can see it coming, and GET /app/webhooks/{id}/deliveries is the log of what we sent and what came back, with each attempt's event_id and attempt number - the first place to look when events are missing, because it distinguishes "we never sent it" from "your endpoint returned 500". It keeps seven days.