Events
Webhooks
Signed events posted to your endpoint as things happen: the envelope, every event, checking the signature, retries and testing.
Add an endpoint
Add an endpoint under Settings > Developers > Webhooks: an https URL, the events you want, optionally one assistant. bidi shows the signing secret once. Each endpoint gets its own secret; rotate it from the same screen.
The envelope
Every delivery is a POST with a JSON body of the same shape: the event's id (evt_..., the same on every retry), its type, when it happened, the workspace and the assistant, and data, the object itself as the API returns it. One schema serves both your webhook receiver and your API client.
{
"id": "evt_5f1c0b3a9d2e7c4b1a6f8e0d",
"type": "lead.created",
"created_at": "2026-10-01T09:12:44.512Z",
"workspace_id": "2c0d7a1e-4b8f-4b36-9b1a-0f3e2c5d7a90",
"agent_id": "7e1b4c2a-9d3f-4a68-8c21-5b6d0e9f3a12",
"data": {
"id": "a3f9c1e2-7b4d-4c6a-9e8f-1d2c3b4a5f60",
"name": "Mariam",
"contact": "050 123 4567",
"interest": "a villa viewing this weekend",
"channel": "widget",
"status": "new",
"data": {},
"conversation_id": "c9b8a7d6-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"created_at": "2026-10-01T09:12:44.401Z",
"utm": { "utm_source": "instagram", "utm_campaign": "eid-2026" },
"referrer": "https://l.instagram.com/"
}
}A ticket event carries the ticket as the API returns it, its tags included: the tags your helpdesk rules wrote on it. The API's ticket list takes ?tag= to find them.
{
"id": "evt_8a2d4f6b1c3e5a7d9f0b2c4e",
"type": "ticket.updated",
"created_at": "2026-10-03T10:41:07.218Z",
"workspace_id": "2c0d7a1e-4b8f-4b36-9b1a-0f3e2c5d7a90",
"agent_id": "7e1b4c2a-9d3f-4a68-8c21-5b6d0e9f3a12",
"data": {
"id": "5b7e9d1f-3a2c-4e6b-8d0f-1a3c5e7b9d24",
"agent_id": "7e1b4c2a-9d3f-4a68-8c21-5b6d0e9f3a12",
"conversation_id": "c9b8a7d6-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"status": "on_you",
"priority": "high",
"subject": "Where is my refund for order 552?",
"channel": "website",
"language": "en",
"custom": { "order_number": "552" },
"tags": ["refund", "vip"],
"first_response_seconds": null,
"created_at": "2026-10-03T10:12:30.004Z",
"updated_at": "2026-10-03T10:41:07.101Z"
}
}Events
Every event is one JSON object with a stable shape. data carries the object itself in the same form the API returns it, so one schema serves both.
| Event | When it is sent | Scope an API key needs |
|---|---|---|
lead.created | A visitor left their details in a conversation or a form. | leads:read |
lead.updated | A lead's details or notes changed. | leads:read |
lead.status_changed | A lead moved between new, qualified and closed; data carries previous_status. | leads:read |
conversation.started | A visitor started a conversation: on the widget, the hosted page, by email or in Slack. | conversations:read |
conversation.handoff_requested | The visitor asked for a person, or the assistant passed the conversation on. | conversations:read |
conversation.closed | A conversation ended. | conversations:read |
message.created | Any message in any conversation. High volume: subscribe only for a full transcript mirror. | conversations:read |
ticket.created | A conversation became a helpdesk ticket. | tickets:read |
ticket.updated | A ticket's status, owner, priority, fields or tags changed. | tickets:read |
ticket.closed | A ticket was closed. | tickets:read |
form.submitted | A visitor sent a form the assistant asked them to fill in. | leads:read |
contact.created | A new contact: verified by your site, imported, or made through the API. | contacts:read |
source.synced | A knowledge source finished indexing. | agents:read |
source.failed | A knowledge source could not be read. | agents:read |
action.called | The assistant called one of its actions. | agents:read |
usage.threshold | This month's conversations passed 80% or 100% of the allowance. | usage:read |
campaign.sent | An email campaign finished sending. | contacts:read |
voice.session.ended | A voice conversation with the assistant ended. | conversations:read |
webhook.test | Send test event in the dashboard, to one endpoint, signed like any other. | Sent by the test button only |
message.created fires for every message in every conversation; subscribe to it only when you need a full transcript mirror.
Headers and signature
Each POST carries four headers. The signature is an HMAC-SHA256 over the timestamp, a dot, and the raw request body, keyed with the endpoint's secret.
| Header | Example |
|---|---|
X-Bidi-Event | lead.created |
X-Bidi-Delivery | dlv_9f2e4c6a8b1d4e7f9a0c2b3d5e6f7a81_1 |
X-Bidi-Timestamp | 1790000000 |
X-Bidi-Signature | t=1790000000,v1=5d41402abc4b2a76b9719d911017c592... |
v1 = HMAC-SHA256(secret, f"{t}.{raw_body}") # hex digest; reject |now - t| > 300 sVerify a delivery
Read the raw body before parsing it, compute the same HMAC, compare in constant time, and refuse anything whose timestamp is more than 300 seconds from your clock. Answer with any 2xx within 8 seconds; do the real work after you have answered.
import hmac, hashlib, time
def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts.get("t", ""))
except ValueError:
return False
given = parts.get("v1", "")
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, given)import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, header, rawBody, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
return expected.length === (parts.v1 || "").length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Retries and the log
A 2xx is success. A 3xx counts as a failure (bidi never follows redirects), as do any other status, no answer within 8 seconds (for the whole exchange) and a refused host. A failed delivery is retried 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours after the attempt before it, so the last retry comes 14 hours 36 minutes after the first try, then it is marked failed; you can replay it from the deliveries log. After 20 failed events in a row the endpoint is switched off and the workspace owner is emailed. An endpoint a teammate made is paused when they leave the workspace (its disabled_reason is maker_left), and the workspace owner can turn it back on. Every attempt and every replay carries a new X-Bidi-Delivery, so do not dedupe on it: dedupe on the event id (the id field of the body, evt_...), which stays the same, and remember the ids you have handled for at least the 300-second window.
Testing
Send test event in the dashboard delivers a webhook.test event to one endpoint whatever it subscribed to, signed like any other, so you can finish your receiver before anything real happens.
Subscribe from your own code
Integrations subscribe without the dashboard: POST /api/v2/webhooks with an https URL and the events, using a key with webhooks:write, answers the endpoint's id and its signing secret once; DELETE /api/v2/webhooks/{id} unsubscribes. An API key can subscribe only to events whose data its own scopes may read (the last column below). Endpoints made this way show in Settings > Developers > Webhooks with their delivery log, and a workspace holds up to 10 endpoints.
curl -X POST https://www.bidii.ai/api/v2/webhooks \
-H "Authorization: Bearer bidi_sk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://hooks.example.ae/bidi", "events": ["lead.created"]}'Recipe: every new lead into your CRM or a sheet
In Zapier, start a Zap with Webhooks by Zapier > Catch Hook and copy its URL. In bidi, open Settings > Developers > Webhooks, add that URL and tick lead.created and lead.status_changed. Send a test event, then leave your own details in the widget once so Zapier sees a real lead. Add an action, for example Google Sheets > Create Spreadsheet Row or your CRM's Create Contact, and map data.name, data.contact, data.interest, data.status and data.notes; each of your own lead fields arrives by its key under data.data, so an Emirate field is data.data.emirate. For lead.status_changed the data also carries previous_status, which lets a second Zap update the row when your team marks a lead qualified or closed. Prefer to pull? A scheduled Zap can call GET /api/v2/leads?status=new with a key that carries leads:read.
No code needed: in Zapier create a Zap with the trigger Webhooks by Zapier > Catch Hook, copy the hook URL it gives you, paste it as a bidi endpoint and tick lead.created. Send a test event from bidi to see the hook answer, then leave your own details in the widget once so Zapier learns the fields from a real lead, and map data.name, data.contact and data.interest to your CRM, sheet or chat tool. Make's Custom webhook and n8n's Webhook node work the same way. The bidi apps for Zapier and Make add instant triggers and ready actions on top.