Skip to main content
Webhooks deliver real-time notifications to your server when events occur in Closient. Each webhook endpoint receives signed HTTP POST requests with JSON payloads.

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 every recall.* event, including ones added later — the recommended form for safety-critical domains. Values outside the catalog are rejected with a 422, 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:
Re-checking at delivery time is deliberate. A hostname that resolved publicly at registration and privately at delivery time (DNS rebinding) is caught on the delivery, and the connection is opened against the address that passed the check rather than a freshly resolved one. When that happens no request is made at all: the delivery is dead-lettered immediately rather than retried, and the reason is on the delivery record. An endpoint whose deliveries are all being refused this way is disabled and you are emailed; an endpoint that was delivering successfully earlier in the day keeps dead-lettering each new event until those successes fall outside the 24-hour failure window, so check your dead-letter queue rather than waiting for the disable notice. If you see this, your DNS moved, not ours. A destination that simply cannot be resolved — a deleted record, a SERVFAIL, a resolver hiccup — is not that. It takes the ordinary retry ladder, because a transient DNS failure is a transient failure.

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 an OPTIONS request to your endpoint URL:
Answer 200 with the origin echoed back:
  • WebHook-Allowed-Origin — required. Either the exact value of WebHook-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.
A minimal handler:
Retry the handshake at any time:
It returns the endpoint on success, or 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.
Changing an endpoint’s url revokes its verification. The endpoint returns to pending_verification, stops receiving deliveries, and the handshake is re-run against the new destination. Consent belongs to a destination, not to a subscription — so plan a URL change as a short gap, and read the replay feed from your last cursor once the new URL is verified.

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.
company_prefixes reads the prefix out of a normalized GTIN-14 string. For a GTIN whose native length is 13 or 14 digits this is exact. For a GTIN sourced from UPC-A (GTIN-12, zero-padded to 14 under GS1’s convention), the prefix window is shifted by one digit, so a company_prefixes filter can under-match a UPC-A-sourced GTIN. If your integration deals primarily in UPC-A products, prefer the gtins dimension (exact match) over company_prefixes for that catalog.
Changing a filter is never retroactive. It takes effect for events emitted after the change — it does not replay, re-deliver, or backfill anything that was already excluded (or included) under the old filter, live or in the replay feed. If you need the history a filter previously excluded, read the event feed from a cursor that predates the filter change: GET /integrations/api/v1/webhooks/events/?after=<cursor> returns every matching event regardless of any endpoint’s current filters, since replay is scoped by event type, not by subscription filters.

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 an api_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 Knative Trigger 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

Same information, different field names, and no 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:
The loop:
  1. Persist the cursor of the last event you durably processed — not the last one you received.
  2. On reconnect, call the endpoint with ?after=<that cursor>.
  3. Process the page, then repeat using the last cursor on the page, until a page comes back short.
Events are returned oldest first. Each carries an 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.
An unrecognised cursor returns 422. We deliberately do not fall back to “start from the beginning” (which would flood you) or “start from now” (which would hide the very gap you reconnected to close).

Request Headers

Every delivery includes these headers:

Identifying our requests

Every request we make to your endpoint — deliveries, test pings and replays — carries exactly:
It is a published part of the contract: it does not vary by environment, event type or endpoint, and the version segment moves only with a documented change to the delivery mechanism. Signature verification is the control that matters, not the source address. Verify 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.
The signature headers and 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:
The svix libraries implement the same scheme and work identically.

The scheme, if you’d rather implement it

  1. Read webhook-id, webhook-timestamp and webhook-signature.
  2. Reject if webhook-timestamp is 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.
  3. Derive the key: strip the whsec_ prefix and base64-decode the rest. The key is those bytes, not the string.
  4. 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.
  5. webhook-signature holds one or more space-delimited v1,<base64> tokens. Accept if any v1 token 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:
Because the spec says to accept on any matching signature, there is no instant at which a genuine delivery fails verification: before you swap, the old signature matches; after you swap, the new one does. The published libraries handle multiple signatures for you. If you implement verification yourself, make sure you check every 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:
To verify:
  1. Extract the timestamp (t) and signature (v1) from the header.
  2. Construct the signed payload: {timestamp}.{raw_json_body}.
  3. Compute HMAC-SHA256 using your signing secret.
  4. Compare your computed signature with v1 using a constant-time comparison.
  5. Check the timestamp is within your tolerance window (recommended: 5 minutes).
Always verify the signature before processing a webhook payload. Reject requests with missing, expired, or invalid signatures.

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:
Your verification code should check 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_id on v1), 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_version wins. A recall is identified by its GDTI (recallgdti, and gdti inside data), and record_version increases every time the recall changes meaningfully. Keep the highest record_version you 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.
A disabled endpoint receives nothing, and the retries still waiting on its ladder are moved to 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:
URL-encode the cursor, since it contains + 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:
This delivers a 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 id field (event_id on v1) for idempotency, and keep the highest record_version per recall GDTI. See Delivery Guarantees.
  • Checkpoint your cursor — persist the cursor of 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.0 that means webhook-signature plus the 300-second webhook-timestamp window; 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.
See the Integrations API reference for full endpoint management, delivery inspection, and filtering options.