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.
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.
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.
Retries, and when we stop
Any 2xx is success. Anything else is retried with backoff - including a 4xx, because an endpoint answering 404 is misconfigured rather than busy. After 20 consecutive failures the subscription is disabled and we stop, which is deliberate: an endpoint that has been dead for a day is not helped by being retried for a week.
GET /app/webhooks shows 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 - the first place to look when events are missing, because it distinguishes "we never sent it" from "your endpoint returned 500".