Webhooks#
Instead of polling, subscribe and let changes reach you.
Register an endpoint on the webhook_endpoints resource — it needs webhook_endpoints.write; the exact routes are in the API Reference. Creation returns a signing secret (whsec_…) once. Store it immediately; it cannot be recovered.
Events are thin#
The body is deliberately small:
{
"id": "0194d1c0-…",
"object": "event",
"type": "shift.published",
"created": "2026-07-22T10:04:11.123456+00:00",
"related_object": {
"id": "0194d1c0-…",
"object": "shift",
"url": "/api/public/v1/shifts/0194d1c0-…/"
}
}
related_object.url is a path — join it against the api_base from your token. related_object.object names the resource type, so a mixed handler can branch without parsing the URL.
Fetch the current state from that URL rather than trusting the event to carry it. That keeps you correct when several changes land close together. A 404 there is an answer too — for example, the record was deleted, or it’s a shift that isn’t published, which the shifts resource never returns.
Delivery is at-least-once and possibly out of order, so your handler must be idempotent — dedupe on the event id.
Verify every delivery#
Each POST carries a Webhook-Signature header (plus Webhook-Id):
Webhook-Signature: t=1753178651,v1=5f3a…
To verify:
Recompute
HMAC-SHA256(secret, "{t}.{raw_body}")— over the raw body bytes, before any JSON parsing or re-serialization.Constant-time-compare it against a
v1=value.Reject if
tis outside a ~5-minute tolerance.
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> 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() + raw_body, hashlib.sha256
).hexdigest()
# A rotation sends several v1= values — accept a match against any of them.
signatures = [v for k, v in (p.split("=", 1) for p in header.split(",")) if k == "v1"]
return any(hmac.compare_digest(expected, sig) for sig in signatures)
Important
During a secret rotation the header carries multiple v1= signatures — old and new — for an overlap window. Accept a match against any of them, or rotations will drop deliveries.
Catching up after an outage#
A delivery succeeds when your endpoint answers 2xx within 10 seconds. A delivery that fails with a network error, a timeout, a 408, a 429 or a 5xx is retried with exponential backoff — honoring a 429’s Retry-After in seconds — for up to eight attempts in total. Any other status fails the delivery at once, and a 410 also disables the endpoint.
The dispatched-event history is retained on the read-only webhook_log resource (webhook_log.read), and webhook_endpoints/<id>/deliveries/ shows each delivery’s status and attempts. If your endpoint was down you can reconcile from that history instead of re-polling the domain resources.
Subscribe to the whole lifecycle#
The commonest integration bug here is subscribing only to the happy path and leaving a dead posting live in your copy.
Marketplace events come in open_shift.* / swap.* pairs — one offer is published as two resources (open_shifts/ for vacancies, swaps/ for give-aways and swaps), and the event type tells you which collection to fetch. Both carry the terminal transitions as well as the awards:
open_shift.claimed,open_shift.awarded,swap.approvedopen_shift.rejected,swap.rejected,*.withdrawn,*.expired
Subscribe to the terminal transitions too, not just the awards. Besides a manual withdrawal, *.withdrawn fires when a change to the shift takes a live offer down — for example when it’s rescheduled, unpublished or deleted — or when the company turns the shift marketplace or that offer type off. *.expired fires when an offer runs out — for example because its deadline passed with no pending response, or its shift ended, was cancelled or had hours approved. Fetch the offer to see how it ended: its status is expired, or completed if someone was already picked. *.auto_rejected is still accepted in a subscription but is no longer sent.
List the types you want in the endpoint’s enabled_events, or leave it empty to receive every type. An unknown type is a 400. Every type, by the resource its related_object points to:
|
Event types |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
leave_request.withdrawn means the requester withdrew their own request; leave_request.revoked means someone else ended it.
Queued deliveries stop while an endpoint is disabled. A late failed attempt cannot replace a recorded success, and a 410 response from an old URL does not disable a replacement URL. Terminal deliveries are not sent again by a repeated task. An hourly check sends whatever is still pending once it’s due — after you enable the endpoint again, after the company’s plan includes the API again, or when a queued attempt was lost.