Webhooks
We POST to you when a conversation ends. Verify the signature, respond 2xx fast.
Set up per worker in the dashboard (Workforce, pick a worker, Webhooks) or per job under Result delivery. Job config wins when both exist. 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. |
sms.sent sms.delivered sms.failed sms.undelivered | SMS lifecycle. |
chat.completed chat.resolved chat.abandoned chat.failed | Chat ended. Job-level config only. |
Tick the ones you want. Unticked events are never sent.
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" },
"goal": { "configured": true, "achieved": true, "detectedBy": "ai", "confidence": 0.92 },
"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. - SMS:
messageSid, fromNumber, toNumber, messageBody, errorCode. goalonly when goal tracking is on for the worker.jobId … structuredOutcomeonly when the conversation came from a job.
Ignore fields you don't know. We add, we don't remove.
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 is per config, default 30s, allowed 5 to 300. Respond 2xx and do the work after.
- Failed deliveries retry 3 times with exponential backoff, then land in the dashboard as
max_retries_exceeded. You can retry from there by hand. - Max 30 deliveries per minute per org, 5 concurrent. Bursts queue, they don't drop.
call.failedandsms.failedjump the queue.webhookIdis one UUID per event, the same across retries. Use it to dedupe.
