Skip to main content
bidiDocs Dashboard

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.

Open your webhook settings

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.

lead.created
{
  "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.

ticket.updated
{
  "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.

Events
EventWhen it is sentScope an API key needs
lead.createdA visitor left their details in a conversation or a form.leads:read
lead.updatedA lead's details or notes changed.leads:read
lead.status_changedA lead moved between new, qualified and closed; data carries previous_status.leads:read
conversation.startedA visitor started a conversation: on the widget, the hosted page, by email or in Slack.conversations:read
conversation.handoff_requestedThe visitor asked for a person, or the assistant passed the conversation on.conversations:read
conversation.closedA conversation ended.conversations:read
message.createdAny message in any conversation. High volume: subscribe only for a full transcript mirror.conversations:read
ticket.createdA conversation became a helpdesk ticket.tickets:read
ticket.updatedA ticket's status, owner, priority, fields or tags changed.tickets:read
ticket.closedA ticket was closed.tickets:read
form.submittedA visitor sent a form the assistant asked them to fill in.leads:read
contact.createdA new contact: verified by your site, imported, or made through the API.contacts:read
source.syncedA knowledge source finished indexing.agents:read
source.failedA knowledge source could not be read.agents:read
action.calledThe assistant called one of its actions.agents:read
usage.thresholdThis month's conversations passed 80% or 100% of the allowance.usage:read
campaign.sentAn email campaign finished sending.contacts:read
voice.session.endedA voice conversation with the assistant ended.conversations:read
webhook.testSend 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.

Headers and signature
HeaderExample
X-Bidi-Eventlead.created
X-Bidi-Deliverydlv_9f2e4c6a8b1d4e7f9a0c2b3d5e6f7a81_1
X-Bidi-Timestamp1790000000
X-Bidi-Signaturet=1790000000,v1=5d41402abc4b2a76b9719d911017c592...
v1
v1 = HMAC-SHA256(secret, f"{t}.{raw_body}")   # hex digest; reject |now - t| > 300 s

Verify 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.

Python
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)
Node
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
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.