Webhooks & events
Receive real-time call, contact, campaign and wallet events at your own endpoint.
Webhooks let your systems react as things happen, without polling. When an event fires, oyehello sends an HTTP POST to each endpoint you’ve subscribed, with a signed JSON payload describing what happened.
Endpoints
Webhook endpoints belong to the workspace. Add them in the console under Settings → Webhooks, or on the API with the endpoints below. A workspace can have up to 20 endpoints.
Each endpoint has:
url: a public HTTPS URL. Private, loopback and non-HTTPS addresses are rejected (400 invalid_webhook_url).events: the event types it receives. Empty (the default) means every event except the WhatsAppmessage.*events, which an endpoint only receives when it picks them.agentIds: limit call events to these agents. Empty means every agent. Events that aren’t about an agent (wallet.low,contact.opted_out) always go to every endpoint subscribed to them.status:activeordisabled. A disabled endpoint receives nothing.- A signing secret (
whsec_…), returned once, when the endpoint is created or its secret is rotated. Store it; it can’t be read back.
Your endpoint must respond within 10 seconds with a 2xx status. Anything else (including a redirect) counts as a failure and is retried.
Delivery & retries
Deliveries are at-least-once. A failed delivery is retried after 30s, 2m, 10m, 30m, 1h, 2h, 4h, then every 8h, until it’s 24 hours old. After that it’s marked failed. It stays in the endpoint’s delivery log, and you can replay it.
The same occurrence always carries the same event id, including across retries and replays. Make your handler idempotent by keying off id.
Events can arrive out of order (a retried call.started can land after call.completed). Use createdAt and the call fields to reconcile, not arrival order.
Each request includes these headers:
Content-Type: application/json
User-Agent: oyehello-webhooks/1
X-Oyehello-Event: call.completed
X-Oyehello-Delivery: whd_…
X-Oyehello-Signature: t=1790000000,v1=<hex>
Verify the signature
Every request is signed with the endpoint’s secret. To verify:
- Split
X-Oyehello-Signatureintot(unix seconds) andv1(hex). - Compute
HMAC-SHA256(secret, "{t}.{raw_body}")over the raw request body, before any JSON parsing. - Compare it to
v1in constant time. Reject the request if they differ, or iftis more than 5 minutes old (replay protection).
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, rawBody, header) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1 ?? '', 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
}
After you rotate a secret, the new one signs every delivery from then on, including retries of older events.
Event types
| Event | Fires when |
|---|---|
call.started |
The call connected: the callee answered (outbound) or the agent picked up (inbound). |
call.completed |
A connected call ended. Carries the talk time and end cause. |
call.failed |
The call never connected (busy, no answer, cancelled, or blocked by a compliance/wallet gate during a campaign). |
call.transferred |
The agent handed the caller to a human and the human answered. |
transcript.ready |
The call transcript and extracted fields were saved. |
recording.ready |
The call recording is available (when recording is on). |
outcome.captured |
The call’s outcome (disposition) was set, by the agent or by a person wrapping up a manual call. |
nudge.scheduled |
A nudge is being held for a future time. |
nudge.dialing |
A nudge’s call was placed and answered; the agent is being connected. |
nudge.attempt_ended |
A nudge attempt ended unanswered and a retry was scheduled. |
nudge.finished |
A nudge reached its final result. |
contact.opted_out |
A contact opted out: put on the workspace do-not-call list (call, console, API), or replied STOP on WhatsApp (channel: "whatsapp", WhatsApp only). |
campaign.finished |
A campaign completed: its list was exhausted, or it reached its end date. A WhatsApp campaign adds channel: "whatsapp" and its final funnel. |
wallet.low |
The prepaid wallet dropped below the low-balance threshold (level: "low") or can no longer cover a call (level: "exhausted"). Sent once per level until the wallet is topped up. |
stream.record_rejected |
A data stream received a record it can’t dial (for example, a missing or invalid phone number). |
message.received |
A customer sent your WhatsApp number a message. Only sent to endpoints that pick it (not to “all events” endpoints). |
message.sent |
Meta accepted a WhatsApp message you sent. Pick-only. |
message.delivered |
The message reached the customer’s phone. Pick-only. |
message.read |
The customer read it (only if they have read receipts on). Pick-only. |
message.failed |
The message failed at Meta, or we refused it because the customer opted out. Pick-only. |
chat.started |
A chat began: a website chat, a /v1/chat session, or a WhatsApp conversation with one of your agents. |
chat.completed |
A chat ended. Carries the transcript and why it ended. |
chat.analyzed |
A finished chat was analyzed: summary, extracted fields, sentiment and outcome. |
chat.taken_over |
Your helpdesk took a WhatsApp conversation over through the API. Adds by: "helpdesk", agentName and note. Once per takeover. |
chat.handed_back |
Your helpdesk handed a WhatsApp conversation back to the agent. Adds by, note and messagesAdded. Once per handback. |
chat.assigned |
A conversation was assigned to a teammate in the console inbox (by hand, by the API or by round-robin). Adds assignedTo (userId, name), assignedBy (the user, the API key, or null for round-robin) and reason (manual or round_robin). Once per assignment. |
chat.handoff_requested |
A conversation was handed to a person and is waiting in your inbox (the agent asked for a teammate, or could not continue). Adds reason and handoffAt. Once per handoff, usually within 30 seconds - also when round-robin already assigned it to a teammate (then chat.assigned comes too). |
chat.evaluated |
A finished chat was graded against the agent’s eval criteria (again after each re-run). Pick-only. |
document.received |
A document step finished in a chat: the customer’s document was verified, could not be checked (unverified), or failed after the allowed tries. Once per step. |
document.updated |
A teammate marked a customer’s document verified or rejected it in the inbox. |
whatsapp.number_quality_changed |
A WhatsApp number’s quality rating changed. Carries phoneNumberId, businessNumber, previousQuality and quality (GREEN, YELLOW or RED). Checked once a day. |
whatsapp.account_restricted |
Meta restricted, flagged or disabled your WhatsApp Business Account. Carries event (ACCOUNT_RESTRICTION, ACCOUNT_VIOLATION or DISABLED_UPDATE) and Meta’s restrictionInfo, violationInfo and banInfo as sent. |
whatsapp.number_ai_paused |
Meta limited how many messages a WhatsApp number can send (error 131048), so AI replies on it are paused for one hour. Carries phoneNumberId, businessNumber and pausedUntil (ISO time). Sent once per pause, whichever of our services hit the limit (within about a minute). A further 131048 during the pause extends it; once the end has moved 30 minutes or more past the last pausedUntil sent, the event is sent again with the new end. A pause still running after the last announced end has passed is announced again. |
whatsapp.template.updated |
A WhatsApp template changed at Meta: approved, rejected, paused, disabled, flagged, its quality rating or its category (or a category change Meta announced). |
A call placed with POST /v1/calls that’s refused outright (for example 402 insufficient_balance) returns that error to you synchronously, so no call.failed event is sent for it.
Payload
Every event has the same envelope:
{
"id": "evt_9f2c…",
"type": "call.completed",
"createdAt": "2026-09-30T12:00:00.000Z",
"livemode": true,
"data": { … }
}
livemode is false for events about test-mode simulated calls, and for webhook.test events.
Call events
call.*, transcript.ready, recording.ready and outcome.captured carry the call’s fields:
{
"callId": "intr_1a2b…",
"agentId": "agt_…",
"campaignId": null,
"contactId": "bor_…",
"externalRef": "your-own-id",
"direction": "outbound",
"toNumber": "+9198…",
"fromNumber": "+9180…",
"answeredAt": "2026-09-30T11:58:24.000Z",
"endedAt": "2026-09-30T12:00:00.000Z",
"durationSeconds": 96,
"endCause": "caller_hangup",
"outcome": "interested",
"metadata": { "orderId": "ORD-42" }
}
durationSeconds is talk time (answer to hang-up). Fields not known yet are null: on call.started, endedAt and durationSeconds are null.
Extra fields per event:
call.transferred:transferredTo(the number dialled),transferredAt.outcome.captured:outcomeis the new outcome key.
metadata is what you passed on POST /v1/calls ({} otherwise). Use it, or externalRef (the contact’s externalRef), to match the event to a record in your system. Fetch the full transcript and extracted fields with GET /v1/calls/{callId}.
contact.opted_out
{ "contactId": "bor_…", "externalRef": "crm-123", "phone": "+9198…", "source": "call", "callId": "intr_…" }
source is call (the caller asked on a call; callId is included), console, api or whatsapp. A whatsapp opt-out (the customer replied STOP) carries "channel": "whatsapp": it stops WhatsApp messages only. Calls still follow your DND / do-not-call list.
message.* (WhatsApp)
{
"messageId": "wamsg_…", "wamid": "wamid.HBg…", "chatId": null, "threadId": "wathr_…", "agentId": null,
"contactId": "bor_…", "externalRef": "crm-123", "direction": "outbound", "source": "board_action",
"type": "template", "text": null, "template": { "name": "order_update", "language": "en", "category": null },
"media": null, "from": "+15550001111", "to": "+9198…", "phoneNumberId": "10123…", "status": "delivered",
"timestamp": "2026-10-06T10:00:02.000Z"
}
from and to are E.164 numbers: your business number and the customer’s (from is the customer on message.received). For a customer who hides their number, the customer’s side is null and customerUserId (their WhatsApp user id) and customerUsername identify them. phoneNumberId is Meta’s id for your number (WhatsApp Manager → Phone numbers), not a phone number; the business number is null only in the rare case Meta has not told us it yet. source is who sent it: customer, agent (your WhatsApp agent), human (a teammate in the inbox), voice_tool (the mid-call Send WhatsApp tool), board_action (an outcome-board follow-up), api (/v1/whatsapp), campaign (a WhatsApp campaign), test, system (for example the STOP confirmation, or a message sent from WhatsApp Manager). message.failed adds error: { code, title, detail }; code -1001 means we did not send because the customer opted out. Statuses can arrive out of order; each event fires once per message and status, and a status never moves backwards (a message already read never reports delivered). Media bytes are never included. After a contact is erased, text is null and the customer’s number is no longer included.
chat.*
{
"chatId": "chatsess_…", "channel": "whatsapp", "agentId": "agt_…", "contactId": "bor_…", "externalRef": "crm-123",
"customer": "+9198…", "businessNumber": "+91 80 1234 5678", "origin": "inbound", "linkedCallId": null,
"status": "ended", "endCause": "idle_timeout", "startedAt": "2026-10-06T10:00:00.000Z", "endedAt": "2026-10-06T13:05:00.000Z",
"messageCount": 6, "handoff": false, "isTest": false, "metadata": {},
"transcript": [{ "role": "user", "text": "Hi, is the gold plan still on offer?" }, { "role": "assistant", "text": "Yes - ..." }],
"analysis": { "summary": "…", "extracted": { "plan": "Gold" }, "sentiment": "positive", "outcome": "interested" }
}
channel is web (the website widget), api (/v1/chat) or whatsapp. customer and businessNumber are set for WhatsApp chats only. origin is inbound (the customer wrote first), call_followup (they replied to a message an agent sent during a call - linkedCallId is that call), template_start (started with POST /v1/whatsapp/conversations), campaign (they replied to a WhatsApp campaign message) or ai_unavailable (the agent could not run, so your team took the chat). metadata is what you passed when starting the conversation through the API ({} otherwise). transcript is on every chat.* event except chat.started (so chat.taken_over, chat.handed_back, chat.assigned and chat.handoff_requested carry the conversation so far); analysis only on chat.analyzed. endCause is one of completed, idle_timeout, max_duration, agent_ended, human_ended, opted_out, no_reply, erased, abandoned (nobody answered a handoff for hours, or the customer walked away from it), template_failed (the opening template of an API-started conversation could not be sent) and ai_unavailable (the agent could not run and the chat was closed without a reply). On chat.handoff_requested, reason is ai_error (the agent kept failing to answer), insufficient_balance (your wallet could not cover the agent), window_closed (the customer’s message was processed after their 24-hour window closed - answer with a template), number_unavailable (the WhatsApp number the customer wrote to was stopped, or WhatsApp was turned off), agent_unavailable (the agent was deleted or its flow unpublished mid-chat), another short code when the agent stopped being available, or null when the agent itself asked for a person. A chat started from an agent’s test link has isTest: true and arrives with livemode: false. Each event fires once per chat (chat.taken_over, chat.handed_back, chat.assigned and chat.handoff_requested once per takeover, handback, assignment and handoff). After the contact is erased, the payloads already delivered are emptied in your delivery log and can no longer be replayed.
adReferral is the click-to-WhatsApp ad or post that started a WhatsApp chat (null otherwise): { sourceId, sourceType, sourceUrl, headline, body, mediaType, imageUrl, videoUrl, thumbnailUrl, ctwaClid }. businessNumber is the number the chat is on - a workspace can have several live numbers.
chat.evaluated
Only sent to endpoints that pick it. One delivery per eval run: a re-run delivers again, so dedupe on data.runId.
{
"id": "evt_…", "type": "chat.evaluated", "createdAt": "2026-10-07T10:02:11.000Z", "livemode": true,
"data": {
"chatId": "chatsess_…", "channel": "whatsapp", "agentId": "agt_…", "contactId": "ct_…",
"runId": "evr_…", "runNo": 1, "trigger": "auto", "model": "claude-…", "evaluatedAt": "2026-10-07T10:02:10.000Z",
"passed": 3, "failed": 1, "notApplicable": 1,
"results": [
{ "criterionKey": "greeted", "name": "Greeted by name", "kind": "pass_fail", "passThreshold": null, "verdict": "pass", "score": null, "reasoning": "Greeted the customer by name in the first reply." },
{ "criterionKey": "tone", "name": "Tone", "kind": "score", "passThreshold": 7, "verdict": "fail", "score": 5, "reasoning": "Replies were curt." }
]
}
}
channel is web, api or whatsapp. trigger is auto (the chat ended), sweep (picked up later) or manual (someone clicked Run again). verdict is pass, fail or na (does not apply); a score criterion’s verdict comes from its passThreshold. Calls are graded too but send no webhook yet. A chat from a test link arrives with livemode: false. After the contact is erased, delivered payloads are emptied like the other chat.* events.
document.*
document.received and document.updated carry documentId, interactionId, chatSessionId, agentId, nodeId (the flow step), label, status (verified, unverified, failed, or - on document.updated - rejected), fields (the details read off the document; Aadhaar numbers masked as XXXX XXXX 1234), reasons (what the check found, and a teammate’s review), attempts, parts (only the accepted parts, for example [{ "part": "front", "contentType": "image/jpeg" }]), reviewedAt (null until a teammate reviews it), createdAt and updatedAt. They never include the files or links to them - open the conversation in the console to see the files. Both are filtered by agent like the other events.
campaign.finished
{ "campaignId": "cmp_…", "name": "October renewals", "agentId": "agt_…", "reason": "list_complete", "finishedAt": "…" }
reason is list_complete or end_date. A WhatsApp campaign adds "channel": "whatsapp" and stats, its final funnel:
{ "campaignId": "cmp_…", "name": "Diwali renewals", "agentId": "agt_…", "channel": "whatsapp", "reason": "list_complete", "finishedAt": "…",
"stats": { "total": 1200, "sent": 1100, "delivered": 1050, "read": 800, "replied": 140, "failed": 30, "frequencyCapped": 12, "skipped": 70, "deliveryUnknown": 0,
"pending": 0, "sending": 0, "skippedByReason": { "contact_opted_out": 40, "no_opt_in": 30 }, "failedByCode": { "131026": 30 }, "converted": 22, "optedOutAfter": 3 } }
Replies keep arriving after a campaign finishes; its funnel on GET /v1/campaigns/{id} keeps updating for a week.
whatsapp.template.updated
{ "templateId": "watpl_…", "metaTemplateId": "1234567890", "name": "renewal_reminder", "language": "en", "status": "paused", "category": "UTILITY",
"previousCategory": null, "pendingCategory": null, "qualityScore": "RED", "event": "PAUSED", "reason": null }
event is Meta’s event (APPROVED, REJECTED, PAUSED, DISABLED, FLAGGED, LOCKED, DELETED, …), QUALITY_<score> for a quality change, CATEGORY_PENDING (Meta announced a recategorisation: pendingCategory) or CATEGORY_CHANGED. status and category are the template’s state after the event. Meta’s redeliveries of one update are one event. A paused or recategorised template stops campaigns, follow-ups and agent tools that use it: they pause or refuse instead of sending.
wallet.low
{ "level": "low", "balanceMinor": 18000, "thresholdMinor": 20000, "currency": "INR" }
Amounts are in minor units (paise for INR).
stream.record_rejected
{ "streamId": "dst_…", "streamName": "Website enquiries", "recordId": "dsr_…", "externalId": "lead_42", "reason": "invalid phone number", "fields": ["name", "phone"] }
fields lists the field names the record had (not their values). externalId is null when the record had no id of its own.
webhook.test
Sent only by Send test event / POST /v1/webhooks/{id}/test, to that one endpoint, whatever it’s subscribed to. Use it to check your URL and signature code.
Manage endpoints on the API
These need webhooks:read (the GETs) or webhooks:write (everything else). Responses use the { "data": … } envelope.
GET /v1/webhooks
Lists the workspace’s endpoints (newest first). Secrets are never included.
POST /v1/webhooks
{
"url": "https://example.com/oyehello",
"description": "CRM sync",
"events": ["call.completed", "outcome.captured"],
"agentIds": []
}
Returns 201 with the endpoint and its secret, which is shown only this once. Errors: 400 invalid_webhook_url, 400 unknown_webhook_event, 400 agent_not_found, 409 webhook_endpoint_limit.
GET /v1/webhooks/{id}
One endpoint. 404 if it isn’t in this workspace.
PATCH /v1/webhooks/{id}
Change any of url, description, events, agentIds, status (active / disabled). Omitted fields keep their value.
DELETE /v1/webhooks/{id}
Deletes the endpoint and its delivery log.
POST /v1/webhooks/{id}/rotate-secret
Returns { "secret": "whsec_…" }. The old secret stops signing immediately, so update your receiver right after.
POST /v1/webhooks/{id}/test
Queues a webhook.test event to this endpoint. It’s delivered within seconds.
GET /v1/webhooks/{id}/deliveries
The endpoint’s recent deliveries, newest first. Query: limit (1-100, default 50), status (pending, succeeded, failed). Each delivery has id, eventId, eventType, payload, status, attempts, nextAttemptAt, lastStatusCode, lastError, createdAt, deliveredAt.
POST /v1/webhook-deliveries/{id}/replay
Sends a delivery again now, with the same event id, and restarts its 24-hour retry window. When a person’s data is erased, their call deliveries (call.*, transcript.ready, recording.ready, outcome.captured) and contact.* deliveries are emptied (payload becomes {}; one not yet sent is never sent and shows failed) and their nudge.* deliveries lose the number, variables and call content. Such a redacted delivery cannot be replayed: 409 delivery_redacted.