Creating a Webhook Endpoint
Configure endpoints in Settings > Integrations in your Dashboard, or via the Integrations API. Each endpoint has:- URL — your HTTPS endpoint (HTTPS required in production)
- Event types — which events to subscribe to, from the event catalog. Prefix matching is supported: subscribing to
"recall"receives everyrecall.*event, including ones added later — the recommended form for safety-critical domains. Values outside the catalog are rejected with a422, so a typo can never leave you silently subscribed to nothing. - API version — the envelope version this endpoint receives. Pinned at creation; we never move it.
- Filters — optional structured filters, scoped to the recall payload shape (see Filters below)
- Signing secret — auto-generated
whsec_-prefixed secret for payload verification. Shown once; store it somewhere you can read it back. Endpoints created before this format shipped keep their existing secret, which stays valid.
A newly created endpoint starts in
state: pending_verification and receives nothing. It becomes active once it answers the verification handshake — usually within the same request, since we send it immediately.Destination requirements
A webhook URL is a URL we will make requests to on your behalf, so what we accept is deliberately narrow. Every rule below is applied when you register the endpoint and again immediately before every single delivery.
A rejected URL comes back as
422 with the specific reason:
Per-organization limits
Paused and unverified endpoints count against both; deleted ones do not. Several endpoints on one host count as one host. Neither limit depends on your plan — they bound our outbound request volume, not your access to safety events. If you have a legitimate need for more, ask us.
Endpoint verification
Before the first real delivery, your destination has to confirm that it wants our traffic. Closient uses the abuse-protection handshake from the CloudEvents HTTP webhook spec rather than a bespoke challenge, so an off-the-shelf CloudEvents receiver already implements it. We send anOPTIONS request to your endpoint URL:
200 with the origin echoed back:
WebHook-Allowed-Origin— required. Either the exact value ofWebHook-Request-Origin, or*. A comma-separated list is accepted.WebHook-Allowed-Rate— optional, requests per minute you are willing to accept, or*. We record it; we do not yet pace deliveries against it.
422 with the reason on failure. The same reason is readable on the endpoint any time as verification_error, alongside verified_at and the current state.
Filters
An endpoint can narrow which events it receives on five dimensions, all readable off the recall payload (data.* in the envelope):
AND across dimensions, OR within a dimension. An endpoint with
jurisdictions: ["US"] and lifecycle_statuses: ["PUBLISHED"] only receives an event that is both US-jurisdiction and published — but jurisdictions: ["US", "CA"] alone receives an event from either.
An empty (or unset) dimension always means “no restriction on this dimension.” An endpoint created with no filters at all receives every event its event-type subscription allows — filters only ever narrow, never require opting in.
gtins is answered by an indexed lookup, not a list scan — an endpoint can subscribe to hundreds of thousands of individual GTINs (built for retailers tracking a full catalog) with no per-event cost difference from subscribing to ten.
Event Catalog
Fetch the live catalog — this is the authoritative list, and it is also published in the OpenAPI spec:Recalls & safety
Consumer signals
Serial verification
Scans & resolves
scan.recorded carries the scan’s identity and the qualifiers that were on the wire: gtin, scanned_lot, scanned_serial, scan_type, channel, the resolved destination_url, and the ESL context (retailer_id, store_id, scanned_sku) when the scan came from a shelf label rather than a pack. scanned_lot and scanned_serial are the raw scanned values, including ones that match no lot or serial we have registered — which is exactly the case worth watching for.
Every qualifying scan fires — there is no sampling. Bot and crawler traffic is excluded (so these counts agree with the scan analytics in your dashboard), and so are scans of products no organization has claimed. Geographic and device fields are deliberately not in the payload: they are enriched asynchronously after the scan row is written, so including them would mean publishing an empty value that is indistinguishable from a real “unknown”.
Traceability
The payload is an identity plus a pointer, never the document:
event_id, epcis_event_type (ObjectEvent, AggregationEvent, TransformationEvent, …), action, biz_step, disposition, read_point, biz_location, epc_count, and url — the EPCIS 2.0 query URL you GET for the full event under your own credentials. When the capture also materialised an FSMA 204 Critical Tracking Event, cte names its type, date and lot; otherwise cte is null (present, so you can branch on it without a key check).
Catalog
previous_status and new_status are both always present and either may be null — as a previous status it means the GTIN had never entered the claim flow; as a new status it means the claim was released and the GTIN is unclaimed again. trigger names the cause, and automated is true when our scheduled re-verification demoted the claim (prefix drift or evidence expiry) rather than a person deciding — that distinction is the one worth routing on, because an automated demotion is a “re-verify” prompt rather than an adjudicated outcome.
A rejection or release is delivered to the organization that held the claim, not to whoever holds it afterwards. That is the one transition you would otherwise learn about only by polling.
System
Deprecated
offer.updated, product.recalled, and retailer.created predate the catalog. They remain valid in a subscription so existing integrations keep working, but no emitter is wired for them. product.recalled is superseded by the recall.* lifecycle above — build new integrations against that.
The catalog response also has a
planned section listing event types on our roadmap. Those are not subscribable yet: subscribing returns a 422 naming the tracking issue, rather than accepting a subscription that would silently never fire.Envelope Versioning
Every endpoint carries anapi_version that pins the shape of the envelope it receives. We never move an endpoint’s pin. Shipping a new envelope version does not change what your existing integration receives — change api_version yourself, with a PATCH, once your parser is ready.
The envelope pin also selects the signing scheme —
cloudevents-1.0 endpoints receive webhook-signature headers, the other two receive X-Closient-Signature. One knob, so an endpoint is never half-migrated. Changing api_version changes both at once; update your handler first.cloudevents-1.0
A structured-mode CloudEvents 1.0 event. Any CloudEvents SDK, Knative Eventing trigger, Azure Event Grid subscription or iPaaS connector parses it without a Closient-specific adapter — that is the point of adopting the standard.
Routing extension attributes
The fields you are most likely to route on are lifted into CloudEvents extension attributes, so a KnativeTrigger filter or an Event Grid advanced filter can match without parsing data:
Extension attribute names are lowercase letters and digits only, per the CloudEvents naming rules — which is why it is
recordversion here and record_version inside data.
2026-07-01
CloudEvents-shaped field names, plus a cursor for replay:
v1
cursor (v1 predates the replay feed). The data block is identical across versions — versioning governs the envelope only, never the event payload.
Replay on Reconnect
Retries cover an endpoint that is failing. They do not cover an endpoint that was simply down, was auto-disabled after sustained failures, or did not exist yet. For a recall feed that gap matters more than anything else in this document: a subscriber who missed a recall and can’t tell they missed it is worse off than one with no feed at all. So every event is written to a durable log when it is emitted — before any delivery is attempted — and you can walk that log forward from wherever you left off:- Persist the
cursorof the last event you durably processed — not the last one you received. - On reconnect, call the endpoint with
?after=<that cursor>. - Process the page, then repeat using the last cursor on the page, until a page comes back short.
envelope that is byte-identical to what a live delivery would have POSTed, so a replayed event goes through the same handler with no special casing, and event_id is stable across the live delivery and every replay — deduplicate on it.
The cursor is a keyset over
(occurred_at, id), not a timestamp. That is what makes the walk both gap-free and duplicate-free when two events share a timestamp — a plain since=<timestamp> filter can only pick one of those two failure modes.Request Headers
Every delivery includes these headers:Identifying our requests
Every request we make to your endpoint — deliveries, test pings and replays — carries exactly:webhook-signature (or X-Closient-Signature on a legacy pin) on every request and reject anything that fails; a request is genuine because it is signed with your endpoint’s secret, not because of where it came from. The User-Agent is for logging, routing and metrics — treat it as a hint, since anyone can send that string.
Closient does not currently publish a fixed egress IP range for allowlisting. If your receiver is behind a firewall that requires one, contact us with your requirements rather than allowlisting an address you observed — deployment moves, and an observed address is not a commitment.
Content-Type depend on your endpoint’s envelope pin:
Verifying Signatures (cloudevents-1.0)
Endpoints on the current envelope are signed with Standard Webhooks. The scheme is published and widely implemented, so the shortest correct integration is to not write one:
svix libraries implement the same scheme and work identically.
The scheme, if you’d rather implement it
- Read
webhook-id,webhook-timestampandwebhook-signature. - Reject if
webhook-timestampis more than 300 seconds away from now, in either direction. A timestamp far in the future is as suspicious as a stale one, and a one-sided check rejects honest clock skew. This is your replay protection — nothing else bounds how long a captured delivery stays valid. - Derive the key: strip the
whsec_prefix and base64-decode the rest. The key is those bytes, not the string. - Compute
base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{raw_body}")). Sign the raw bytes you received — re-serialising the parsed JSON changes key order and separators, and therefore the signature. webhook-signatureholds one or more space-delimitedv1,<base64>tokens. Accept if anyv1token matches, using a constant-time comparison. Skip tokens with an unrecognised version prefix rather than rejecting the whole header.
webhook-id equals the envelope’s id and is stable across every retry and every endpoint the event fans out to, so it doubles as your idempotency key. webhook-timestamp is not stable — it authenticates each transmission, which is what makes the replay window meaningful.Rotating a signing secret with no gap
POST /endpoints/{id}/rotate-secret starts a 24-hour window during which every delivery carries a signature under the new secret and one under the outgoing secret, space-delimited in the same header:
v1 token rather than only the first.
Verifying Signatures (2026-07-01 and v1)
Endpoints on a legacy envelope pin keep the Stripe-style X-Closient-Signature header, unchanged:
- Extract the timestamp (
t) and signature (v1) from the header. - Construct the signed payload:
{timestamp}.{raw_json_body}. - Compute HMAC-SHA256 using your signing secret.
- Compare your computed signature with
v1using a constant-time comparison. - Check the timestamp is within your tolerance window (recommended: 5 minutes).
- Python
- Node.js
Secret Rotation (legacy envelopes)
When you rotate a signing secret via the API (POST /endpoints/{id}/rotate-secret), the previous secret remains valid for a 24-hour grace period. During this window, deliveries include both signatures:
v1 first, then fall back to v1old. After the grace period, only v1 is included. The same 24-hour window applies on cloudevents-1.0, expressed the Standard Webhooks way — see rotating a signing secret with no gap.
Delivery Guarantees
Read these before you write a handler. Each one is a property you have to design for, not an edge case.- At-least-once. Every event is delivered at least once to an active endpoint, or recoverable from the feed if it is not. The same event can arrive more than once: a retry after a response we never saw, a worker restart mid-send, a manual replay, and catch-up from a cursor all resend. Deduplicate on the CloudEvents
id(event_idonv1), which is stable across every retry, replay and feed read of the same event. - No ordering guarantee. Events are sent independently, so a retry of an older event can land after a newer one, and one slow event never holds up the ones behind it. Do not infer order from arrival.
- Recall amendments: newest
record_versionwins. A recall is identified by its GDTI (recallgdti, andgdtiinsidedata), andrecord_versionincreases every time the recall changes meaningfully. Keep the highestrecord_versionyou have applied per GDTI and discard any event carrying a lower one — that is how an out-of-order arrival stops overwriting a newer amendment.
Delivery Lifecycle
Each delivery progresses through these statuses:Retry Policy
Failed deliveries are retried with exponential backoff and ±10% jitter:
Retrying is bounded two ways, and whichever runs out first ends it: at most 8 attempts, and no retry is scheduled more than 48 hours after the event was first queued. Then the delivery moves to
dead_letter. You can replay dead-lettered deliveries via the API:
Timeouts
Each attempt gets 5 seconds to connect and 15 seconds to respond. A slower response counts as a failed attempt and takes the retry ladder, so acknowledge first and process afterwards.Auto-Disable
Closient disables an endpoint automatically, and emails the organization’s owners and managers with the reason, when either:- 20 consecutive delivery attempts fail with no success in between (any 2xx resets the count), or
- every delivery in the last 24 hours failed, across at least three deliveries. The window never reaches back past the endpoint’s last state change, so a re-enabled endpoint is judged only on what happened since.
dead_letter. The endpoint’s consecutive_failures field shows how close a live endpoint is to the first rule. It counts attempts, not events: every retry counts, as does a delivery refused because the destination failed address policy. A quiet endpoint that is down reaches 20 after only a few events have worked through their retries.
Re-enabling does not resend what you missed. Re-enable from the Dashboard or with PATCH /integrations/api/v1/webhooks/endpoints/{endpoint_id}/ and {"is_active": true}, which re-runs the verification handshake. New events flow again from that moment. Nothing from the disabled period is replayed automatically, because a burst of stale events at a receiver that just recovered is the wrong default for a recall feed.
To recover the gap, use the endpoint’s catch_up_cursor. It is recorded at the moment of disable, included in the email, and returned on the endpoint until a later auto-disable replaces it. It is the cursor of the last event before the first one this endpoint missed, so reading the feed strictly after it returns everything the endpoint did not get:
+ and :. An empty catch_up_cursor means the gap starts at the oldest retained event, so read the feed from the start. Catch-up overlaps with events delivered live after you re-enable; deduplicate on id and the overlap is harmless.
Testing
Send a test event to verify your endpoint is reachable and signature verification works:test.ping event synchronously and returns the HTTP status code from your endpoint. It goes through exactly the same destination checks as a real delivery — a test ping cannot reach anywhere a delivery could not.
Best Practices
- Return 2xx quickly — acknowledge receipt, then process asynchronously. Closient gives each attempt 5 seconds to connect and 15 to respond; see Timeouts.
- Handle duplicates and reordering — use the
idfield (event_idonv1) for idempotency, and keep the highestrecord_versionper recall GDTI. See Delivery Guarantees. - Checkpoint your cursor — persist the
cursorof the last event you durably processed, so an outage becomes a replay rather than a silent gap. For recall events this is the difference between a feed you can rely on and one that manufactures false confidence. - Verify signatures — always validate the signature before processing, and reject unsigned or expired requests. On
cloudevents-1.0that meanswebhook-signatureplus the 300-secondwebhook-timestampwindow; on a legacy pin,X-Closient-Signature. - Use HTTPS, on a publicly reachable host — enforced everywhere, with no development exemption, and re-checked before every delivery. See Destination requirements.
- Monitor health — check delivery status in the Dashboard or via the Integrations API. Address failures before they accumulate into auto-disable.