Webhooks
Push conversation and message events to your own backend, signed with HMAC-SHA256 so you can verify provenance and integrity.
Setup
- 1Open
Settings → Webhooksand click Add endpoint. - 2Paste the public HTTPS URL where Leadiosa should POST events.
- 3Tick the events you want to receive.
- 4Save. The signing secret is generated for you and shown on the endpoint card — copy it into your secret manager.
cloudflared or ngrok to tunnel.Payload format
{
"event": "message.created",
"timestamp": "2026-06-10T07:28:25.901Z",
"data": {
"conversationId": "…",
"conversation": { /* conversation record */ },
"message": { /* message record */ }
}
}Every delivery has the same envelope: event (the type), timestamp (ISO-8601, UTC), and a data object whose shape depends on the type. The event type is also mirrored in the X-Webhook-Event request header so you can route before parsing the body.
Event catalogue
| Event type | Fires when |
|---|---|
conversation.created | A visitor starts a new conversation (live chat or offline form). |
conversation.updated | A conversation changes status — including the automatic reopen when a visitor writes into a resolved thread. |
conversation.resolved | A conversation is marked resolved. Fires in addition to conversation.updated, so you can subscribe to just this one signal. |
message.created | A visitor sends a message. Operator and AI replies are not echoed — you hear from customers, not from yourself. |
contact.created | A new contact record appears — first widget visit, offline form, or created by an operator. |
contact.updated | A contact's identity changes — pre-chat form filled in, or an operator edits the record. Routine page-view activity does not fire this. |
Signature verification
Every delivery carries an X-Signature-256 header in the form sha256=<hex> — the HMAC-SHA256 of the raw request body, computed with your endpoint's signing secret.
You must verify the signature before trusting any payload. Without verification, anyone who guesses your URL can forge events.
import crypto from "crypto";
import express from "express";
const app = express();
// IMPORTANT: use express.raw, not express.json — we need the
// untouched bytes for HMAC verification.
app.post(
"/leadiosa-webhook",
express.raw({ type: "*/*" }),
(req, res) => {
const header = req.header("X-Signature-256") || "";
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.LEADIOSA_WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
const a = Buffer.from(header);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).end();
}
const { event, data } = JSON.parse(req.body.toString());
// dispatch on event ...
return res.status(200).end();
}
);import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/leadiosa-webhook")
def webhook():
sig = request.headers.get("X-Signature-256", "")
expected = "sha256=" + hmac.new(
os.environ["LEADIOSA_WEBHOOK_SECRET"].encode(),
request.get_data(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
body = request.get_json()
# dispatch on body["event"] ...
return ("", 200)Delivery and retries
- A 2xx response within 30 seconds counts as success. Anything else triggers a retry.
- Failed deliveries are retried up to 3 times with exponential backoff. After the final attempt the delivery is recorded as failed.
- Redirect responses (3xx) are treated as failures — we never follow them.
- Each event may be delivered more than once across retries — make your handler idempotent.
- Order is not guaranteed. Use the timestamps in the payload if sequence matters.
- The delivery log (status code, response time, outcome) for each endpoint is visible under Settings → Webhooks — click the clock icon on the endpoint card.
PII in payloads
By default, payloads are scrubbed of direct personal identifiers (email, phone, IP) before leaving the platform — you still get the event and the IDs, just not the raw PII. If your receiving system is a processor you've put under contract and you need full payloads, enable third-party data sharing in Settings → Widget.
Testing locally
For local development, tunnel your machine to a public HTTPS URL — the simplest way:
cloudflared tunnel --url http://localhost:3000
Paste the generated URL into the webhook config and send a message through your widget to fire a real event. Once you're happy, swap to your production URL and remove the tunnel.
Looking for other ways to connect? See Integrations.