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
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 signing secret is in the response and is shown once. Store it before you close the window — you can rotate it later, but you cannot read it back.

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

EventFires when
conversation.startedA visitor opened a session with an assistant.
conversation.endedA session closed, by the visitor or by the sweep.
message.createdA turn was written. High volume — subscribe deliberately.
lead.capturedContact details were captured. The one most integrations want.
contact.updatedAn existing contact's details changed.
document.indexedA knowledge-base document finished indexing.
document.failedA document could not be indexed, with the reason.
handoff.requestedA 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

POST to your endpoint
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.

Node
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);
}
Python
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", ""))
Compare in constant time (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
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.

Building on it? Start on the free plan — no card — and call the same API the dashboard uses.

Start free