Developer docs: Webhooks
Webhooks
We send an HTTPS POST to your server when something happens in your account: a call ends, its AI summary is ready, a lead or appointment is created. Every delivery is signed and retried until your server accepts it.
Subscribe
Add an endpoint in the app, or with the API (scope webhooks:write). The URL must be public https. The response contains the endpoint's signing secret, shown once; you can also send your own (16 to 256 characters).
curl -X POST "https://bookifycall.com/api/v1/external/webhooks" \
-H "X-API-Key: $BOOKIFYCALL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"CRM sync","url":"https://crm.example.com/hooks/bookifycall","events":["call.completed","call.analyzed","lead.created"]}'Manage subscriptions with GET, PATCH and DELETE /webhooks (reference). Webhooks are part of the Enterprise plan.
What a delivery looks like
A JSON body with a unique event id, the event name, when it happened, your organization id and the event data. Headers:
X-Webhook-Signature:sha256=followed by the hex HMAC-SHA256 of the raw body, keyed with your endpoint secret.X-Webhook-IdX-Webhook-EventUser-Agent: BookifyCall-Webhooks/1.0
{
"id": "evt_01JABCDEFGHJKMNPQRSTVWXYZ0",
"event": "lead.created",
"timestamp": "2026-01-15T16:34:10.000Z",
"organization_id": "00000000-0000-4000-8000-000000000100",
"data": {
"lead_id": "00000000-0000-4000-8000-000000000003",
"name": "Jane Doe",
"email": "jane@example.com",
"phone": "+15551230001",
"company": "Example Dental",
"status": "qualified",
"score": 72,
"temperature": "hot",
"source_call_id": "00000000-0000-4000-8000-000000000001",
"created_at": "2026-01-15T16:34:10.000Z",
"updated_at": "2026-01-15T16:34:10.000Z"
}
}Verify the signature
Compute the HMAC over the raw request body and compare in constant time. Reject anything that does not match, then deduplicate on the event id: a retry of the same event carries the same id.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.BOOKIFYCALL_WEBHOOK_SECRET;
// Verify against the raw bytes: re-serialised JSON would not match the signature.
app.post('/hooks/bookifycall', express.raw({ type: 'application/json' }), async (req, res) => {
const expected =
'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
const given = req.get('X-Webhook-Signature') ?? '';
const valid =
given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
if (!valid) return res.status(401).end();
const event = JSON.parse(req.body.toString('utf8'));
if (await alreadyProcessed(event.id)) return res.status(200).end(); // retries reuse the id
await enqueue(event); // do the work after answering
res.status(200).end();
});Retries, ordering and replay
- Answer with any
2xxwithin 10 seconds. Anything else, a timeout or a redirect counts as a failure. Do slow work after answering. - A failed delivery is retried after 1 min, 5 min, 30 min, 2 h, 8 h (6 attempts in all, about 10.5 hours).
- An endpoint is switched off only after a delivery used every retry and nothing reached it for 24 hours. Events keep being recorded meanwhile.
- After an outage, turn the endpoint back on (
PATCH /webhooks/:idwith{ "enabled": true }) and resend what it missed withPOST /webhooks/replay(sincea date, 500 per call). - Deliveries run in parallel and can arrive out of order. Every record carries
updated_at: keep the newest.
Events
16 events. Payload fields are snake_case and only ever added to, never renamed or removed. call.completed is sent when the call ends, before the AI analysis exists; call.analyzed follows with the summary, usually seconds later. Join them on call_id. All values below are examples.
| Event | Sent when |
|---|---|
| call.started | An AI call started (inbound or outbound) |
| call.completed | A call ended normally; includes the transcript and recording link. The AI summary arrives later in call.analyzed |
| call.failed | A call failed or was not answered |
| call.transferred | The AI handed a live call to a person |
| message.taken | The AI took a message for your team |
| lead.created | A new lead was created |
| lead.status_changed | A lead's status changed |
| appointment.booked | An appointment was booked |
| appointment.confirmed | An appointment was confirmed |
| appointment.cancelled | An appointment was cancelled |
| scheduled_call.created | A callback/outbound call was scheduled |
| scheduled_call.completed | A scheduled call was placed |
| scheduled_call.failed | A scheduled call gave up after its retries |
| call.analyzed | The AI summary of a call is ready: summary, outcome, intent, sentiment and caller details |
| appointment.rescheduled | An appointment moved to a new time |
| call.recording_ready | The audio recording of a call is stored and can be fetched from the recording endpoint |
call.startedAn AI call started (inbound or outbound)
{
"id": "evt_01JABCDEFGHJKMNPQRSTVWXYZ0",
"event": "call.started",
"timestamp": "2026-01-15T16:34:10.000Z",
"organization_id": "00000000-0000-4000-8000-000000000100",
"data": {
"call_id": "00000000-0000-4000-8000-000000000001",
"agent_id": "00000000-0000-4000-8000-000000000002",
"direction": "inbound",
"status": "connected",
"from": "+15551230001",
"to": "+15551230002",
"started_at": "2026-01-15T16:30:00.000Z",
"ended_at": null,
"duration_seconds": null,
"end_reason": null,
"updated_at": "2026-01-15T16:34:10.000Z"
}
}call.completedA call ended normally; includes the transcript and recording link. The AI summary arrives later in call.analyzed
{
"id": "evt_01JABCDEFGHJKMNPQRSTVWXYZ0",
"event": "call.completed",
"timestamp": "2026-01-15T16:34:10.000Z",
"organization_id": "00000000-0000-4000-8000-000000000100",
"data": {
"call_id": "00000000-0000-4000-8000-000000000001",
"agent_id": "00000000-0000-4000-8000-000000000002",
"direction": "inbound",
"status": "ended",
"from": "+15551230001",
"to": "+15551230002",
"started_at": "2026-01-15T16:30:00.000Z",
"ended_at": "2026-01-15T16:34:10.000Z",
"duration_seconds": 250,
"end_reason": "caller_hangup",
"summary": null,
"topics": [
"pricing"
],
"updated_at": "2026-01-15T16:34:10.000Z",
"call_sid": "CA00000000000000000000000000000000",
"outcome": null,
"transcript": [
{
"speaker": "agent",
"text": "Thanks for calling Example Dental, how can I help?",
"at": "2026-01-15T16:30:00.000Z"
},
{
"speaker": "user",
"text": "I would like to book a demo.",
"at": "2026-01-15T16:30:05.000Z"
}
],
"transcript_truncated": false,
"transcript_entry_count": 2,
"recording": {
"url": "https://recordings.example.com/calls/example.mp3?X-Amz-Expires=3600&X-Amz-Signature=example",
"expires_in_seconds": 3600,
"duration_seconds": 250
},
"lead_id": "00000000-0000-4000-8000-000000000003",
"appointment_id": "00000000-0000-4000-8000-000000000004",
"ai_provenance": {
"prompt_id": "agent:00000000-0000-4000-8000-000000000002:inbound",
"prompt_version": "3f9a1c0e",
"provider": "openai",
"model": "gpt-realtime",
"voice_mode": "openai_native"
}
}
}call.failedA call failed or was not answered
{
"id": "evt_01JABCDEFGHJKMNPQRSTVWXYZ0",
"event": "call.failed",
"timestamp": "2026-01-15T16:34:10.000Z",
"organization_id": "00000000-0000-4000-8000-000000000100",
"data": {
"call_id": "00000000-0000-4000-8000-000000000001",
"agent_id": "00000000-0000-4000-8000-000000000002",
"direction": "inbound",
"status": "failed",
"from": "+15551230001",
"to": "+15551230002",
"started_at": "2026-01-15T16:30:00.000Z",
"ended_at": "2026-01-15T16:34:10.000Z",
"duration_seconds": 0,
"end_reason": "no_answer",
"updated_at": "2026-01-15T16:34:10.000Z"
}
}call.transferredThe AI handed a live call to a person
{
"id": "evt_01JABCDEFGHJKMNPQRSTVWXYZ0",
"event": "call.transferred",
"timestamp": "2026-01-15T16:34:10.000Z",
"organization_id": "00000000-0000-4000-8000-000000000100",
"data": {
"call_id": "00000000-0000-4000-8000-000000000001",
"agent_id": "00000000-0000-4000-8000-000000000002",
"direction": "inbound",
"status": "transferring",
"from": "+15551230001",
"to": "+15551230002",
"started_at": "2026-01-15T16:30:00.000Z",
"ended_at": "2026-01-15T16:34:10.000Z",
"duration_seconds": 250,
"end_reason": "caller_hangup",
"updated_at": "2026-01-15T16:34:10.000Z",
"transfer_to": "+15551230003",
"contact_name": "Front desk",
"reason": "Caller asked for a person"
}
}message.takenThe AI took a message for your team
{
"id": "evt_01JABCDEFGHJKMNPQRSTVWXYZ0",
"event": "message.taken",
"timestamp": "2026-01-15T16:34:10.000Z",
"organization_id": "00000000-0000-4000-8000-000000000100",
"data": {
"call_id": "00000000-0000-4000-8000-000000000001",
"agent_id": "00000000-0000-4000-8000-000000000002",
"direction": "inbound",
"status": "ended",
"from": "+15551230001",
"to": "+15551230002",
"started_at": "2026-01-15T16:30:00.000Z",
"ended_at": "2026-01-15T16:34:10.000Z",
"duration_seconds": 250,
"end_reason": "caller_hangup",
"updated_at": "2026-01-15T16:34:10.000Z",
"lead_id": "00000000-0000-4000-8000-000000000003",
"caller_name": "Jane Doe",
"callback_number": "+15551230001",
"message": "Please call back about rescheduling.",
"urgency": "normal",
"taken_at": "2026-01-15T16:34:10.000Z"
}
}lead.createdA new lead was created
{
"id": "evt_01JABCDEFGHJKMNPQRSTVWXYZ0",
"event": "lead.created",
"timestamp": "2026-01-15T16:34:10.000Z",
"organization_id": "00000000-0000-4000-8000-000000000100",
"data": {
"lead_id": "00000000-0000-4000-8000-000000000003",
"name": "Jane Doe",
"email": "jane@example.com",
"phone": "+15551230001",
"company": "Example Dental",
"status": "qualified",
"score": 72,
"temperature": "hot",
"source_call_id": "00000000-0000-4000-8000-000000000001",
"created_at": "2026-01-15T16:34:10.000Z",
"updated_at": "2026-01-15T16:34:10.000Z"
}
}lead.status_changedA lead's status changed
{
"id": "evt_01JABCDEFGHJKMNPQRSTVWXYZ0",
"event": "lead.status_changed",
"timestamp": "2026-01-15T16:34:10.000Z",
"organization_id": "00000000-0000-4000-8000-000000000100",
"data": {
"lead_id": "00000000-0000-4000-8000-000000000003",
"name": "Jane Doe",
"email": "jane@example.com",
"phone": "+15551230001",
"company": "Example Dental",
"status": "qualified",
"score": 72,
"temperature": "hot",
"source_call_id": "00000000-0000-4000-8000-000000000001",
"created_at": "2026-01-15T16:34:10.000Z",
"updated_at": "2026-01-15T16:34:10.000Z",
"previous_status": "new"
}
}appointment.bookedAn appointment was booked
{
"id": "evt_01JABCDEFGHJKMNPQRSTVWXYZ0",
"event": "appointment.booked",
"timestamp": "2026-01-15T16:34:10.000Z",
"organization_id": "00000000-0000-4000-8000-000000000100",
"data": {
"appointment_id": "00000000-0000-4000-8000-000000000004",
"lead_id": "00000000-0000-4000-8000-000000000003",
"agent_id": "00000000-0000-4000-8000-000000000002",
"client_name": "Jane Doe",
"client_email": "jane@example.com",
"client_company": "Example Dental",
"meeting_purpose": "Product demo",
"scheduled_at": "2026-01-20T15:00:00.000Z",
"end_at": "2026-01-20T15:30:00.000Z",
"timezone": "America/New_York",
"duration_minutes": 30,
"status": "scheduled",
"confirmed": true,
"meet_link": "https://meet.google.com/abc-defg-hij",
"updated_at": "2026-01-15T16:34:10.000Z"
}
}appointment.confirmedAn appointment was confirmed
{
"id": "evt_01JABCDEFGHJKMNPQRSTVWXYZ0",
"event": "appointment.confirmed",
"timestamp": "2026-01-15T16:34:10.000Z",
"organization_id": "00000000-0000-4000-8000-000000000100",
"data": {
"appointment_id": "00000000-0000-4000-8000-000000000004",
"lead_id": "00000000-0000-4000-8000-000000000003",
"agent_id": "00000000-0000-4000-8000-000000000002",
"client_name": "Jane Doe",
"client_email": "jane@example.com",
"client_company": "Example Dental",
"meeting_purpose": "Product demo",
"scheduled_at": "2026-01-20T15:00:00.000Z",
"end_at": "2026-01-20T15:30:00.000Z",
"timezone": "America/New_York",
"duration_minutes": 30,
"status": "scheduled",
"confirmed": true,
"meet_link": "https://meet.google.com/abc-defg-hij",
"updated_at": "2026-01-15T16:34:10.000Z"
}
}appointment.cancelledAn appointment was cancelled
{
"id": "evt_01JABCDEFGHJKMNPQRSTVWXYZ0",
"event": "appointment.cancelled",
"timestamp": "2026-01-15T16:34:10.000Z",
"organization_id": "00000000-0000-4000-8000-000000000100",
"data": {
"appointment_id": "00000000-0000-4000-8000-000000000004",
"lead_id": "00000000-0000-4000-8000-000000000003",
"agent_id": "00000000-0000-4000-8000-000000000002",
"client_name": "Jane Doe",
"client_email": "jane@example.com",
"client_company": "Example Dental",
"meeting_purpose": "Product demo",
"scheduled_at": "2026-01-20T15:00:00.000Z",
"end_at": "2026-01-20T15:30:00.000Z",
"timezone": "America/New_York",
"duration_minutes": 30,
"status": "cancelled",
"confirmed": true,
"meet_link": "https://meet.google.com/abc-defg-hij",
"updated_at": "2026-01-15T16:34:10.000Z"
}
}scheduled_call.createdA callback/outbound call was scheduled
{
"id": "evt_01JABCDEFGHJKMNPQRSTVWXYZ0",
"event": "scheduled_call.created",
"timestamp": "2026-01-15T16:34:10.000Z",
"organization_id": "00000000-0000-4000-8000-000000000100",
"data": {
"scheduled_call_id": "00000000-0000-4000-8000-000000000005",
"lead_id": "00000000-0000-4000-8000-000000000003",
"agent_id": "00000000-0000-4000-8000-000000000002",
"scheduled_at": "2026-10-20T15:00:00.000Z",
"timezone": "America/New_York",
"reason": "Follow up on the demo request",
"status": "pending",
"attempts": 0,
"call_id": null,
"updated_at": "2026-01-15T16:34:10.000Z"
}
}scheduled_call.completedA scheduled call was placed
{
"id": "evt_01JABCDEFGHJKMNPQRSTVWXYZ0",
"event": "scheduled_call.completed",
"timestamp": "2026-01-15T16:34:10.000Z",
"organization_id": "00000000-0000-4000-8000-000000000100",
"data": {
"scheduled_call_id": "00000000-0000-4000-8000-000000000005",
"lead_id": "00000000-0000-4000-8000-000000000003",
"agent_id": "00000000-0000-4000-8000-000000000002",
"scheduled_at": "2026-10-20T15:00:00.000Z",
"timezone": "America/New_York",
"reason": "Follow up on the demo request",
"status": "completed",
"attempts": 1,
"call_id": "00000000-0000-4000-8000-000000000001",
"updated_at": "2026-01-15T16:34:10.000Z"
}
}scheduled_call.failedA scheduled call gave up after its retries
{
"id": "evt_01JABCDEFGHJKMNPQRSTVWXYZ0",
"event": "scheduled_call.failed",
"timestamp": "2026-01-15T16:34:10.000Z",
"organization_id": "00000000-0000-4000-8000-000000000100",
"data": {
"scheduled_call_id": "00000000-0000-4000-8000-000000000005",
"lead_id": "00000000-0000-4000-8000-000000000003",
"agent_id": "00000000-0000-4000-8000-000000000002",
"scheduled_at": "2026-10-20T15:00:00.000Z",
"timezone": "America/New_York",
"reason": "Follow up on the demo request",
"status": "failed",
"attempts": 3,
"call_id": null,
"updated_at": "2026-01-15T16:34:10.000Z",
"failure_reason": "no_answer"
}
}call.analyzedThe AI summary of a call is ready: summary, outcome, intent, sentiment and caller details
{
"id": "evt_01JABCDEFGHJKMNPQRSTVWXYZ0",
"event": "call.analyzed",
"timestamp": "2026-01-15T16:34:10.000Z",
"organization_id": "00000000-0000-4000-8000-000000000100",
"data": {
"call_id": "00000000-0000-4000-8000-000000000001",
"agent_id": "00000000-0000-4000-8000-000000000002",
"direction": "inbound",
"status": "ended",
"from": "+15551230001",
"to": "+15551230002",
"started_at": "2026-01-15T16:30:00.000Z",
"ended_at": "2026-01-15T16:34:10.000Z",
"duration_seconds": 250,
"end_reason": "caller_hangup",
"updated_at": "2026-01-15T16:34:10.000Z",
"call_sid": "CA00000000000000000000000000000000",
"summary": "Caller asked about pricing and booked a demo for Tuesday.",
"outcome": "appointment_booked",
"intent": "book_demo",
"intent_description": "Wants a product demo",
"sentiment": "positive",
"satisfaction_score": 4,
"topics": [
"pricing"
],
"key_points": [
"Team of 12",
"Wants a demo"
],
"follow_up": {
"needed": true,
"suggested_action": "Send pricing sheet",
"due_hint": "before the demo"
},
"caller": {
"name": "Jane Doe",
"company": "Example Dental",
"email": "jane@example.com",
"callback_number": "+15551230001"
},
"lead_id": "00000000-0000-4000-8000-000000000003",
"analyzed_at": "2026-01-15T16:34:30.000Z"
}
}appointment.rescheduledAn appointment moved to a new time
{
"id": "evt_01JABCDEFGHJKMNPQRSTVWXYZ0",
"event": "appointment.rescheduled",
"timestamp": "2026-01-15T16:34:10.000Z",
"organization_id": "00000000-0000-4000-8000-000000000100",
"data": {
"appointment_id": "00000000-0000-4000-8000-000000000004",
"lead_id": "00000000-0000-4000-8000-000000000003",
"agent_id": "00000000-0000-4000-8000-000000000002",
"client_name": "Jane Doe",
"client_email": "jane@example.com",
"client_company": "Example Dental",
"meeting_purpose": "Product demo",
"scheduled_at": "2026-01-20T15:00:00.000Z",
"end_at": "2026-01-20T15:30:00.000Z",
"timezone": "America/New_York",
"duration_minutes": 30,
"status": "scheduled",
"confirmed": true,
"meet_link": "https://meet.google.com/abc-defg-hij",
"updated_at": "2026-01-15T16:34:10.000Z",
"previous_scheduled_at": "2026-01-19T15:00:00.000Z"
}
}call.recording_readyThe audio recording of a call is stored and can be fetched from the recording endpoint
{
"id": "evt_01JABCDEFGHJKMNPQRSTVWXYZ0",
"event": "call.recording_ready",
"timestamp": "2026-01-15T16:34:10.000Z",
"organization_id": "00000000-0000-4000-8000-000000000100",
"data": {
"call_id": "00000000-0000-4000-8000-000000000001",
"recording_id": "00000000-0000-4000-8000-000000000007",
"duration_seconds": 250,
"format": "mp3",
"stored": true,
"recording_endpoint": "/api/v1/external/calls-history/00000000-0000-4000-8000-000000000001/recording"
}
}Coming from the previous BookifyCall API?
Subscriptions that use a previous event name (call_completed, lead_captured, appointment_booked, lead.captured) keep receiving the previous camelCase format with a bare hex signature, so existing receivers work unchanged. Register with the names above to switch to the current format.