Webhooks
Register a webhook URL and Saturday POSTs events to it as they happen, so you do not have to poll the API for changes. The examples read your key from aSATURDAY_API_KEY environment variable, as in Quickstart. Sandbox keys are issued with their own base URL; using one against api.saturday.fit returns 401 invalid_api_key.
Registering a webhook
https in production; the test environment also accepts http. If Saturday’s key service is briefly unavailable at creation time the request returns 503 with Retry-After rather than storing an unencrypted secret, so retry on 503.
Available events
These event types are emitted today:
Registration also accepts four names that will not reach a partner webhook.
subscription.updated and partner.rate_limit_approaching are reserved and have no emitter at all. concern.detected and athlete.needs_attention are live, but they are delivered on the separate coach webhook lane, to endpoints a coach registers against their own account, never to a partner webhook; see the Coach API. Subscribing to any of the four on a partner webhook is accepted and then silent, so do not build against them here.
Any name outside this list is rejected with 400, and one bad name fails the entire registration rather than the single event, so register only the names above.
Subscription event payloads
subscription.created fires once per unlock, after the purchase record exists and never before. A resubscribe after cancellation fires it again.
source values: checkout_subscription, checkout_lifetime (no expires_at), existing_subscription_linked (an already-subscribed Saturday account tapped your CTA and was linked without being charged), email_match (a paying Saturday account was linked to your athlete automatically by email, see Freemium Model, Automatic linking by email), simulated (test env only).
subscription.cancelled means the athlete’s next calculate returns teaser ranges:
Don’t cache tier from webhooks alone.
GET /v1/athletes/{id} returns a computed subscription_status (full | trial | teaser) whenever you need ground truth, for example when an athlete returns from checkout.Webhook payload format
Every webhook delivery has this structure.id is a UUID and created_at is a Unix timestamp in milliseconds:
data object is the same resource body the REST API returns for that object, so athlete.* events carry the athlete record and prescription.calculated carries the calculate response. Athlete, activity, and event identifiers are all UUIDs; they carry no prefix, so do not pattern-match on one.
Verifying webhook signatures (HMAC-SHA256)
Every delivery carries these headers:
Verify the signature on every request. It is the only thing distinguishing a Saturday delivery from anyone who has guessed your endpoint URL.
The signature is computed as
HMAC-SHA256(webhook_secret, timestamp + "." + raw_body), where timestamp is the t value from the header in Unix seconds and raw_body is the exact bytes received. Parsing and re-serializing the JSON before verifying will change the bytes and break the comparison.
Verification steps
- Extract the timestamp and signature from the header
- Reconstruct the signed payload:
{timestamp}.{raw_body} - Compute HMAC-SHA256 using your webhook secret
- Compare with constant-time equality
Retry behavior
A delivery counts as failed if your endpoint returns a non-2xx status, times out, or cannot be reached. Saturday then retries on this schedule:
Retries are scheduled durably, so they survive a restart on Saturday’s side. Each retry re-reads your webhook’s current URL and secret, which means fixing a bad URL mid-schedule lets the remaining retries land. After the 5th retry the delivery is marked exhausted and is not attempted again.
Redirects are not followed: a
3xx counts as a failure, so register the final URL rather than a redirector.
Auto-disable
Each exhausted delivery increments a consecutive-failure counter on the webhook. Any successful delivery resets it to zero. At 15 consecutive exhausted deliveries Saturday sets the webhook inactive and stops sending to it. Given the retry schedule above, that is roughly three days of an endpoint being consistently down, though the exact wall time depends on your event volume. Re-enable it from the API once the endpoint is healthy:Best practices
- Return 200 immediately, then process asynchronously. Saturday closes the connection 10 seconds after the request starts, and a slow handler burns retries.
- Handle duplicates using the
idfield. Delivery is at-least-once, so the same event can arrive more than once. - Verify the signature on every request before acting on the payload.
- Serve HTTPS on port 443. Production registration requires
https; the test environment also acceptshttp, and both environments reject non-standard ports. - Log the raw body and the event
idbefore parsing. Signature failures are almost always a body-mutation problem, and the raw bytes are what let you prove it.
Managing webhooks
POST /v1/webhooks/{webhook_id}/test delivers synchronously and returns the delivery record, including the status code your endpoint returned. Use it to confirm signature verification works before you depend on live events. The test event has type webhook.test. Subscribing to it is valid but unnecessary: the test delivery reaches your endpoint regardless. Your handler should still ignore event types it does not recognize rather than erroring on them.
GET /v1/webhooks/{webhook_id}/deliveries returns the 50 most recent attempts for that webhook, newest first, each with its status, attempt count, and the first kilobyte of your endpoint’s response body, which is usually enough to see why a delivery failed.