Guides
Webhooks
Get told when something happens instead of polling for it. Register an endpoint, subscribe to the events you want, verify the signature on arrival.
- For
- Anyone who needs to react to an event, not poll for it
- You will need
- A public HTTPS endpoint you control
- About
- 10 minutes to a verified delivery
Registering an endpoint
In the dashboard under Settings → Webhooks, or over the API:
curl -X POST https://api.integrable.cloud/api/webhooks \
-H "Authorization: Bearer $INTEGRABLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/integrable",
"events": ["lead.captured", "handoff.requested"],
"description": "CRM sync"
}'The URL must be public HTTPS. Private, loopback and link-local addresses are refused when you save the endpoint and again on every delivery, because a name that resolved publicly at registration can be re-pointed at an internal address afterwards.
Events
| Event | Fires when |
|---|---|
| conversation.started | A visitor opened a session with an assistant. |
| conversation.ended | A session closed, by the visitor or by the sweep. |
| message.created | A turn was written. High volume — subscribe deliberately. |
| lead.captured | Contact details were captured. The one most integrations want. |
| contact.updated | An existing contact's details changed. |
| document.indexed | A knowledge-base document finished indexing. |
| document.failed | A document could not be indexed, with the reason. |
| handoff.requested | A visitor asked for a human, or a flow escalated. |
GET /api/webhooks/events returns this catalogue with a sample payload for each, so you can build against a real shape before any traffic exists.
What arrives
content-type: application/json
x-webhook-signature: t=1789012345,v1=5f3c1a...
x-webhook-event: lead.captured
x-webhook-id: 01a0652b-3713-7ea1-a6c9-2e895389ec34
x-webhook-delivery: 01a0652c-9f01-7e12-b3d4-71c2a8e5f003
x-webhook-timestamp: 1789012345
x-webhook-version: 2026-09-03
user-agent: integrable.cloud-Webhooks/1.0
{
"event": "lead.captured",
"data": { ... }
}Verifying the signature
The signature is an HMAC-SHA256 over <timestamp>.<raw body>, keyed with your endpoint's secret, in the form t=<timestamp>,v1=<hex>.
Sign the raw bytes you received. Parsing the JSON and re-serialising it changes whitespace and key order, and the signature will never match.
import crypto from "node:crypto";
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=").map((s) => s.trim())),
);
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
return false; // too old, or re-dated by an attacker
}
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(parts.v1 ?? "", "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
timestamp = int(parts.get("t", 0))
if not timestamp or abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))timingSafeEqual, compare_digest). A plain === leaks how much of the signature was correct, one byte at a time.The timestamp is inside the signed material specifically so replay detection works: an attacker who captured a valid delivery cannot re-date it without invalidating the signature. Reject anything outside a few minutes.
Deduplicating
Delivery is at least once. A network failure after your server committed but before it answered produces a retry of work you already did — so key on x-webhook-delivery, which is unique per attempt-group, and ignore one you have seen.
Answer 2xx quickly and do the work afterwards. A receiver that takes ten seconds to reply gets retried while it is still working.
Retries and failures
Failures are classified rather than blindly retried. A connection reset or a 503 is transient and comes back with backoff; a 404 or a TLS failure is not going to fix itself and is recorded rather than hammered.
The delivery log is at GET /api/webhooks/deliveries, with per-endpoint health, the response you returned, and the reason a delivery is being retried.
Replaying
curl -X POST \
https://api.integrable.cloud/api/webhooks/deliveries/$DELIVERY_ID/replay \
-H "Authorization: Bearer $INTEGRABLE_API_KEY"A replay writes a new delivery record rather than overwriting the failure it is replaying — so the history of what went wrong survives the fix.
Testing before you go live
POST /api/webhooks/{id}/test fires a sample payload down the real delivery path — the same signing, the same URL guard, the same retry classification. If a test fire arrives and verifies, so will a real one.
Rotating a secret is POST /api/webhooks/{id}/rotate-secret. Deploy the new secret to your receiver first if you can accept both during the changeover — see versioning for how we signal changes generally.
Something here wrong or missing? Tell us — the documentation and the API are maintained by the same person, so a correction is a fix rather than a ticket.