GET /events - same
events, delivered instead of fetched.
Respond 2xx to acknowledge. Anything else (or a timeout) is treated as a
failure and the delivery is retried.
Events we deliver
Each event names itstype and carries the affected resource as data.object,
already in the shape that resource has on the API. Its object field names what
it is, so one handler can switch on type and read data.object without
guessing. The full catalog:
Delivery and retries
- A failed delivery is retried up to 20 times with jittered exponential
backoff, from 15 seconds up to 6 hours between attempts, so it keeps
trying for days until your endpoint answers
2xx. - The same event may arrive more than once - dedupe on
webhook-id(the eventid), which is stable across retries. - Events for one endpoint are delivered in order, best effort: a newer event
normally waits until the older ones for that endpoint are acknowledged, so
company.createdlands before therequirement.createdthat followed it. That hold is bounded, not absolute - an event that keeps failing is set aside after a few attempts so one bad delivery cannot hold the rest of your events back for days. Deliveries to a single endpoint are also paced: we send one at a time rather than in a burst, so a slow listener is not flooded. Treat order as the normal case, not a guarantee - dedupe onwebhook-idand tolerate the occasional reorder. To keep the order honest even for events created at nearly the same instant, a fresh delivery starts a couple of seconds after its event. - A
Retry-Afterheader on a429or503pushes our next attempt back. - Answer
410 Goneto unsubscribe: the endpoint is disabled and its queued deliveries are paused. - An endpoint failing for a while has the workspace owner emailed a warning,
and one that fails continuously for three days is then disabled
automatically (the owner is emailed again). No events are lost - replay what you
missed from
GET /events(see Reconciling by polling), and re-enabling the endpoint resumes whatever was queued when it was disabled. - Redirects are not followed - a
3xxcounts as a failure. If your URL moved, update the endpoint.
Verifying a delivery
Deliveries are signed per the Standard Webhooks specification, and every delivery carries both signature schemes the spec defines: a symmetricv1 HMAC signature and an asymmetric v1a ed25519
signature, space-delimited in one header. Verify whichever suits you - the HMAC
with your endpoint secret, or the ed25519 signature with the endpoint’s public
key, which needs no shared secret at all. The shortest path is an off-the-shelf
standardwebhooks library:
hand it your key material, the three webhook-* headers, and the raw request
body, and it does everything below.
Every endpoint has its own signing secret (whsec_...), shown once when you
create the endpoint, and its own keypair, whose public half (whpk_...) is
readable on the endpoint resource at any time. A valid signature proves the
delivery came from Clemta and was meant for that endpoint - a delivery for one
endpoint cannot be verified as another’s.
Each delivery carries three headers:
webhook-id,
webhook-timestamp, webhook-signature) with the same value - that is what
off-the-shelf libraries look up, so both naming styles verify.
Clemta-Webhook-Idis the event id - stable across retries, your idempotency key.Clemta-Webhook-Timestampis the unix time of this attempt (a retry is re-signed fresh, so it stays inside your tolerance window).Clemta-Webhook-Signatureis a space-delimited list of signatures, each prefixed with its scheme. Skip entries whose prefix you do not verify and match any one that you do:v1,+ base64 ofHMAC-SHA256(key, "<id>.<timestamp>.<body>"), where the key is your secret base64-decoded after thewhsec_prefix, over the exact raw request body. During a secret rotation there are twov1entries - old and new secret both verify until the overlap ends.v1a,+ base64 of the ed25519 signature over the identical"<id>.<timestamp>.<body>"string, verifiable with the endpoint’spublic_key(base64-decoded after thewhpk_prefix). Secret rotations never touch it.
<id>.<timestamp>.<rawBody>, compare
in constant time against each v1, entry, and reject if the timestamp is not
recent (a few minutes’ tolerance stops a captured signature being replayed
later):
Verifying without a shared secret (v1a)
If you would rather not hold a symmetric secret - say the verifying service is
one you do not fully trust with material that can also sign - verify the
v1a entry instead. It is an ed25519 signature over the same
<id>.<timestamp>.<body> string, and the endpoint’s public_key can only
verify, never forge:
public_key is on the endpoint resource in your dashboard, readable any
time - unlike the secret it is not sensitive, so you can fetch it again whenever
you adopt this scheme. Endpoints created before asymmetric signing shipped carry
no keypair and sign v1 only - recreate the endpoint to get one.
Rotating a secret
Roll an endpoint’s secret from the Webhooks page of your partner dashboard. It is a sensitive action, so the dashboard asks you to confirm it with a one-time code emailed to you before the roll goes through. A roll runs with an overlap: for a window, deliveries are signed under both the old and the new secret, so the signature header carries two space-delimitedv1, values and either verifies. Move your endpoint to the new secret during the
overlap, and the old one retires on its own - no delivery is lost mid-rotation.
Payload versioning
A delivery is rendered at the version pinned on the endpoint, or, when the endpoint has none, at the webhook version set for your workspace. Sodata.object keeps the shape you integrated against even as newer API
versions restructure the same resource, and you can move one endpoint to a new
version while the others stay put. See Versioning for
how this relates to the version your API calls resolve to.
Managing endpoints
Endpoints are managed from the Webhooks page of your partner dashboard:- Create an endpoint with its URL. Optionally restrict it to some event types and pin its payloads to an API version. The signing secret is shown once, so store it then.
- Roll its secret - confirmed by email, with the overlap above.
- Disable an endpoint - deliveries stop and queued ones are paused. This is reversible.
- Enable a disabled endpoint again - it keeps its id, secret and signing key,
so you need not recreate it, and the deliveries paused when it was disabled
resume. A manual disable, a
410, and an automatic disable all re-enable the same way. - Inspect deliveries for the last 30 days - the exact payload sent, the response body, and each attempt with its result - and retry a single stuck delivery or an endpoint’s whole backlog at once.
Reconciling by polling
Webhooks andGET /events read the same
stream: every webhook is fanned out from the event log, so an event can never
exist as a delivery only. Events never expire, which makes the endpoint good for
two jobs:
- A safety net beside webhooks - a periodic sweep that catches anything your endpoint missed (an outage longer than the retry horizon, an endpoint disabled for a while).
- A full replacement for webhooks - if you would rather not run a public endpoint, poll the stream on an interval and you receive everything, pulled instead of pushed.
- Remember when your last sweep ran.
- Ask for everything since a few minutes before that:
GET /events?created_at[gte]=<last_sweep - 5m>&sort=created_at, following the cursor (after=<end_cursor>) untilhas_next_pageis false. - Process each event idempotently, keyed by its
id- the same value thewebhook-idheader carries, so one dedupe set covers both channels.
created_at order, so a sweep that starts
exactly where the last one ended could permanently skip an event that landed
late. Re-reading a few minutes of already-seen events costs nothing once
processing is idempotent, and closes that gap completely.
Narrow the sweep when you only care about part of the stream:
?type=company.status.changed, several types at once with
?type[in]=company.created,company.incorporated, or one company with
?company_id=cmp_....
One thing polling is not for: detecting changes by re-listing resources.
List endpoints answer “what is the current state”, and an updated company does
not resurface in any list - the events stream is the only change feed. Poll
GET /events for what happened, then fetch the resource by id if you need its
latest shape.