Webhooks
We POST to you when a conversation ends. Verify the signature, respond 2xx fast.
Add a Send webhook step to a job's process. That step is what sends: a job without one sends no webhook for its conversations. HTTPS only, no private IPs.
Events
| Event | When |
|---|---|
call.completed | Call ended normally. Has summary. |
call.no-answer | Rang out. |
call.busy | Busy tone. |
call.failed | Carrier or system error. |
chat.completed | Chat ended. |
chat.resolved | Chat ended with the visitor's issue resolved. |
chat.abandoned | The visitor left without ending the chat; sent once it is closed for inactivity. |
chat.failed | Chat ended in an error. |
Tick the ones you want on the step. Unticked events are never sent. Branch on conversationType as well as on the event name.
Payload
{
"event": "call.completed",
"timestamp": "2026-09-11T10:00:00.000Z",
"webhookId": "9c1b…",
"organizationId": "…",
"agentId": "…",
"agentName": "Collections",
"conversationId": "…",
"conversationType": "phone",
"status": "completed",
"outcome": "resolved",
"summary": "Customer confirmed the invoice was paid on Monday. No further action.",
"duration": 125,
"direction": "outbound",
"agentNumber": "+358…",
"twilioCallSid": "CA…",
"metadata": { "contactName": "Ada", "contactPhone": "+358…", "contactEmail": "ada@example.com" },
"callMetadata": { "orderId": "A-1041" },
"jobId": "…",
"jobName": "Overdue invoices",
"jobExecutionId": "…",
"attemptNumber": 1,
"structuredOutcome": "resolved"
}Always present: event, timestamp, webhookId, organizationId, agentId, agentName, conversationId, conversationType, metadata. The rest depends on type.
- Phone:
status, outcome, summary, duration, direction, agentNumber, twilioCallSid, callMetadata.summaryis only filled oncall.completed. - Chat:
duration, messageCount, chatMetadatawithresolved, feedback, toolsUsed, firstMessageAt, lastMessageAt. jobId … structuredOutcomeonly when the conversation came from a job.marketingonly on chat payloads, and only when the website gave the widget a marketing context. See below.
Ignore fields you don't know. We add, we don't remove.
Marketing context
Your website can tell the chat widget where the visitor came from, and the chat's webhooks hand it back as a top-level marketing object of strings:
"marketing": { "utm_source": "linkedin", "utm_campaign": "q4-demo", "landing_path": "/pricing" }The website sets it through the chat widget (setContext, in a widget release that follows this one). Until then no chat carries marketing.
It is sent once, when the chat starts: the last value set before then wins, and a later call only reaches the next chat. It is never shown to the AI worker.
Only these keys are kept: utm_source, utm_medium, utm_campaign, utm_term, utm_content, referrer, landing_path, first_touch_at, lt_source, lt_medium, lt_campaign, lt_term, lt_content, lt_referrer, lt_landing_path, lt_at, gclid, gbraid, wbraid, li_fat_id, fbclid, msclkid, click_id_at, touch_count, locale, ad_consent, context_id. Values must be strings of at most 300 characters and are trimmed. Anything else is dropped without an error. Phone payloads never carry marketing.
Headers
Content-Type: application/json
User-Agent: Heppu-Webhooks/1.0
X-Webhook-Event: call.completed
X-Webhook-ID: 9c1b…
X-Webhook-Timestamp: 1757584800
X-Webhook-Attempt: 1
X-Webhook-Signature-256: sha256=…Plus any custom headers you configured.
Verify the signature
HMAC-SHA256 of the raw body with your secret, hex, prefixed sha256=. Only sent if you set a secret. Set a secret.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody, header, secret) {
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
return header?.length === expected.length && timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(header or "", expected)Sign the raw bytes, not a re-serialized object. The timestamp header is not part of the signed material, so check it yourself if you want replay protection.
Delivery and retries
- Timeout defaults to 30s. Respond 2xx and do the work after.
- A 5xx, a 429 or no answer is retried by a job that runs every 5 minutes, with backoff, up to 3 attempts in total. After that it lands in the dashboard as
max_retries_exceeded, and you can retry it from there by hand. - Any other 4xx, a hostname that does not resolve and an invalid URL are not retried.
webhookIdis one UUID per event, the same across retries. Use it to dedupe.
