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 a separate base URL; using one against api.saturday.fit returns 401 invalid_api_key.
Registering a webhook
201 with the new webhook under webhook and a request_id beside it. The webhook carries id, url, events, active, fail_count, created_at and updated_at in milliseconds, and secret, a 64-character hex string.
A partner may hold up to 20 webhooks. Registration rejects URLs that resolve to private, loopback, or cloud-metadata addresses, and URLs on ports other than 443 and 80. The scheme must be https in production; the test environment also accepts http. Each of these rejections, and the 21st webhook, is a 400 with code webhook_error and the reason in message. If Saturday’s key service is briefly unavailable at creation time the request returns 503 with Retry-After: 30 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 the coach’s 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 (a 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). expires_at is in milliseconds.
subscription.cancelled means the athlete’s next calculate returns teaser ranges:
reason is the Stripe event type that ended access, such as customer.subscription.deleted, or one of expired, refund, dispute, email_match_decayed (an email-matched link that stopped matching), and simulated (test env only).
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 delivery has this envelope.id is a UUID and created_at is a Unix timestamp in milliseconds. This one carries a full-tier prescription from POST /v1/nutrition/calculate:
data carries depends on the event:
A calculation with no athlete (a
POST /v1/nutrition/calculate without athlete_id) carries neither id; tier tells you which tier the caller received.
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. A webhook you delete or deactivate mid-schedule stops retrying.
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, stampsdisabled_at, 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. Re-enabling resets fail_count to zero and clears disabled_at:
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
PATCH /v1/webhooks/{webhook_id} takes any of url, events, and active, validates a new URL and event list the same way registration does, and returns the updated webhook. A body naming none of the three returns 400 with code no_changes. DELETE returns 204.
POST /v1/webhooks/{webhook_id}/test delivers synchronously and returns the delivery record under delivery, 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 and a data object holding a single message string. 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, under deliveries. Each carries status (pending, delivered, failed, or exhausted), status_code, attempts, next_retry_at, completed_at, and response_body, the first kilobyte of your endpoint’s response body, which is usually enough to see why a delivery failed.