Heppu APIv2.heppu.ai/api

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

EventWhen
call.completedCall ended normally. Has summary.
call.no-answerRang out.
call.busyBusy tone.
call.failedCarrier or system error.
chat.completedChat ended.
chat.resolvedChat ended with the visitor's issue resolved.
chat.abandonedThe visitor left without ending the chat; sent once it is closed for inactivity.
chat.failedChat 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. summary is only filled on call.completed.
  • Chat: duration, messageCount, chatMetadata with resolved, feedback, toolsUsed, firstMessageAt, lastMessageAt.
  • jobId … structuredOutcome only when the conversation came from a job.
  • marketing only 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.
  • webhookId is one UUID per event, the same across retries. Use it to dedupe.

On this page