Events
What the platform emits, what an event body contains, and why it contains so little.
An event says that something happened: a type, the user it concerns, and the ids and summary you need to act on it. It never carries the recording itself — that is one signed call away.
Event types
| Type | Emitted when | |
|---|---|---|
ping | You sent a test delivery — see Sending a ping | Live |
activity.created | A finished workout landed from a provider | Live |
activity.updated | Its name or sport changed after it landed — see Edits after upload | Live |
wellness.created | A sleep, daily, HRV, stress or fitness summary landed — see Wellness | Live |
wellness.updated | The provider revised a summary you were already told about | Live |
account.connected | One of your users linked a provider | Live |
account.disconnected | That access ended — by your call, or by the user through their management link | Live |
account.reauth_required | The provider stopped accepting our credentials — your user has to reconnect | Live |
account.deleted | You dropped your reference to one of your users | Live |
activity.deleted | The user removed it on the provider side | Planned |
workout.pushed | A structured session reached the watch | Planned |
workout.completed | A pushed session came back as done | Planned |
Planned types are designed but not emitted yet. An endpoint receives every type unless you
choose which events it gets. Either way, switch on type
and ignore what you don't recognise.
What an event looks like
This is what a delivery opens to, never what is on the wire.
data.workout_id is there when the provider can say which workout the
athlete did this from — the id POST /v1/workouts returned. Apple can, for a session
started from a workout you pushed to an Apple Watch. On every other provider it is absent,
which means unknown rather than no.
| Field | |
|---|---|
id | The event. The same event sent to three endpoints has one id. |
user_id | Your user, by the id /v1/connect returned. Absent on ping. |
provider | Which integration caused it. Absent on ping. |
webhook_id | Which of your endpoints this copy went to. |
nonce | Fresh per delivery — echo it back. |
data | The object's type, ids and summary. |
{
"id": "9f2c1e7a-4b83-4d21-9a6e-3c5f0d8b71a4",
"type": "activity.created",
"created": "2026-08-04T06:14:02Z",
"webhook_id": "2d7b45c1-8e0a-4f36-b512-9c7d3a6e04f8",
"nonce": "Kd4nWpLbEa9xTvRm2Cj7Lz0Bq2vNhCz7",
"user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6",
"provider": "coros",
"data": {
"object": "activity",
"id": "0f31a8c4-59d2-4e07-b6a1-8c74e2f95d30",
"provider_activity_id": "465061532017233920",
"sport": "run",
"start_time": "2026-08-04T06:12:00Z",
"device": "PACE 3",
"name": "Morning Run",
"file": {
"format": "fit",
"url": "https://api.stridee.com/v1/activities/0f31a8c4-59d2-4e07-b6a1-8c74e2f95d30/file"
}
}
}Getting the file
data.file.url on an activity.created is the recording itself — the FIT, TCX or GPX the
device wrote. WHOOP is the exception: it exports no file, so the FIT there is one we write
from WHOOP's summary of the workout, with no GPS track or samples in it. Fetch it with an
ordinary signed request:
Download the raw file for an activity.
The data.file.url on an activity.created points here. It is an ordinary
signed request — sign it exactly the way you sign every other call — and it
answers 302 with a short-lived URL that needs no credential of its own.
Follow the redirect; do not store what it points at. That URL expires in
minutes, while the one in the event body does not, so keep the event's and
call this again whenever you need the bytes.
What you get is what the device wrote: the provider's own FIT, TCX or GPX,
unmodified and unparsed. data.file.format on the event tells you which, and
the redirect target carries the matching content type.
Access is the grant that was recorded when the activity arrived, so this
answers for exactly the workouts your user was connected to you for.
Disconnecting stops new activities; it does not take away files from the
period they had agreed to share. An activity whose provider offered no
downloadable file — "file": null on the event — has nothing to serve and
answers 404.
idstring · uuidRequiredThe activity, as data.id on activity.created
data.id is our id for this activity, and it is yours alone. Another developer whose user
is the same athlete gets a different id for the same workout.
file is null when there is nothing to fetch — Wahoo, for instance, won't share a
recording that started in a third-party app. The workout still happened; don't retry it.
GET /v1/activities/7e14a9c3-2b60-4f85-9d3a-6c081ef47b52/file HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:HTTP/1.1 302 FoundEdits after upload
An athlete who renames a workout on the provider's side — a ride retitled in Garmin
Connect after it synced — produces an activity.updated. Its data is the same shape as
the activity.created you already had, with the same id, describing the activity as it
is now. Overwrite what you stored rather than merging fields.
It is sent only when the name or the sport actually changed, and only to you if you
were already sent the activity.created. Garmin tells us about edits made in Garmin
Connect, and WHOOP re-sends a workout when it changes. Those two are where you will see it.
Any other provider produces it only if it happens to re-send a workout under a new name,
and most never do.
Listing what you were sent
Deliveries can be missed, and nothing retries them. This is how you catch up: every
activity you have been granted, in the same shape the event carried. A row is an
activity.created flattened — user_id and provider are the envelope's, the rest is the
data block — so hand one to the code that already handles a delivery.
List the activities you have been granted.
Every workout one of your users was connected to you for, newest first — the
same rows that were announced to your endpoint as activity.created, in the
same shape. This is the reconciliation path: if a delivery was missed, this
is how you find it, and file.url is the same download URL the event
carried.
Page against received_at, not start_time. Providers backfill and
correct, so a workout from March can arrive today; the order here is the
order you were told, which is the only one that is monotonic. since and
until window that same axis, half-open, so a nightly job can ask for
since=<last run> and get exactly what it has not seen.
Paged with a cursor: send starting_after = the id of the last row you
received, and keep going while has_more. next_starting_after is that id,
so a loop never has to reach into the array. A cursor names a row rather than
a distance from one, so activities arriving while you page cannot shift the
boundary under you — which an offset would, and did.
Access is the grant that was recorded when each activity arrived — the same
rule GET /v1/activities/{id}/file applies. Disconnecting stops new
activities; it does not remove the ones from the period your user had agreed
to share, so they stay listed here. An activity whose provider offered no
downloadable file has "file": null, and so does one whose bytes have not
landed yet.
user_idstring · uuidNarrow to one athlete, by our id for them — what POST /v1/connect
returned and what events carry as user_id. GET /v1/accounts is the way
back from your own id to this one.
providerstringcoros | polar | wahoo | garmin | zepp | hammerhead | fitbit | suunto | whoop. An unknown provider is an empty
page, not a 400.
sincestringOnly activities granted at or after this RFC 3339 instant.
untilstringOnly activities granted strictly before it. Half-open, so one window's
until is the next one's since and nothing is counted twice.
starting_afterstring · uuidThe id of the last activity on the previous page. Omit for the first one.
limitinteger · int64Defaults to 50, capped at 200.
A nightly reconcile is since=<last run>&until=<this run>, walked with starting_after
while has_more is true — then dedupe on id against what your handler already stored.
Pass both ends of the window and the set you are walking cannot change underneath you.
GET /v1/activities HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:{
"activities": [
{
"device": null,
"external_user_id": "user_4821",
"file": null,
"id": "7e14a9c3-2b60-4f85-9d3a-6c081ef47b52",
"name": null,
"provider": "coros",
"provider_activity_id": "string",
"received_at": "2026-08-05T11:44:02Z",
"sport": null,
"start_time": null,
"user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6",
"workout_id": null
}
],
"has_more": false,
"next_starting_after": null,
"total": 1
}Ordering and duplicates
- Order is not guaranteed. Providers backfill and correct, so a workout from March can arrive today.
- The same delivery can arrive twice — a resend reuses its
webhook-id. Dedupe on it, and still echo the nonce. - An event id is not a delivery id. One event sent to three endpoints is one
idand threewebhook-ids. Both are UUID v4 with no prefix; store them as strings and never parse one.
Treat an event as a signal to reconcile, not a transaction to apply, and none of these matter.
Something wrong or missing on this page? Tell us in Discord. Need something the API doesn’t do yet? Request it on the roadmap.