Webhooks
AirFone sends an HTTP POST to your endpoint for each event you subscribed to.
Event shape
Section titled “Event shape”{ "id": "evt_3bQ9", "type": "call.ended", "created": "2026-10-04T09:12:44Z", "livemode": true, "api_version": "2026-10-04", "data": { "object": { "id": "call_9f3a", "status": "completed", "duration_seconds": 142, "metadata": { "crm_ticket": "T-881" } } }}Events can arrive more than once and out of order. Use id to skip ones you have handled.
Events
Section titled “Events”| Event | When |
|---|---|
call.started |
A call connected, inbound or outbound. |
call.ended |
A call finished. Includes duration and outcome. |
call.transferred |
A call went to a person or another number. |
call.transcript.ready |
The transcript and recording are ready. |
message.received |
A customer wrote on WhatsApp, SMS or the widget. |
message.sent |
AirFone sent a message, from the assistant, a person or the API. |
message.failed |
A message could not be delivered. |
conversation.needs_person |
The assistant handed a conversation to staff. |
action.run |
A connector action ran. Includes the result or error. |
approval.requested |
An action is waiting for staff approval. |
approval.decided |
Staff approved or declined. |
booking.created, booking.updated, order.created, order.updated |
Raised by connectors that manage bookings or orders. |
Signature
Section titled “Signature”Every request carries:
AirFone-Signature: t=1791364364,v1=5f2b...e1v1 is the hex HMAC-SHA256 of t + "." + raw body, keyed with your endpoint’s signing secret. While you roll a secret, the header carries two v1 values. Accept the request if either matches.
Reject the request if t is more than 5 minutes from your clock. That stops replays.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody, header, secret, toleranceSeconds = 300) { const items = header.split(',').map((p) => p.split('=')); const t = items.find(([k]) => k === 't')?.[1]; const sigs = items.filter(([k]) => k === 'v1').map(([, v]) => v); if (!t || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false; const expected = Buffer.from(createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')); return sigs.some((s) => s.length === expected.length && timingSafeEqual(Buffer.from(s), expected));}Python
Section titled “Python”import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool: items = [p.split("=", 1) for p in header.split(",")] t = next((v for k, v in items if k == "t"), None) sigs = [v for k, v in items if k == "v1"] if t is None or abs(time.time() - int(t)) > tolerance: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, s) for s in sigs)function airfone_verify(string $rawBody, string $header, string $secret, int $tolerance = 300): bool { $t = null; $sigs = []; foreach (explode(',', $header) as $part) { [$k, $v] = array_pad(explode('=', $part, 2), 2, ''); if ($k === 't') $t = $v; if ($k === 'v1') $sigs[] = $v; } if ($t === null || abs(time() - (int)$t) > $tolerance) return false; $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); foreach ($sigs as $s) if (hash_equals($expected, $s)) return true; return false;}// $raw = file_get_contents('php://input');Retries
Section titled “Retries”Answer with any 2xx within 10 seconds. Anything else, or a timeout, is retried with backoff for up to 3 days: after 1 minute, 5 minutes, 30 minutes, 2 hours, then every 6 hours.
After 3 days of failures the endpoint is disabled and the organization’s admins get an email. Re-enable it in the console. Every delivery, with your server’s status code and the first 2 KB of its answer, is kept for 30 days under Webhooks, where you can send any delivery again.
Allow-listing
Section titled “Allow-listing”Requests come from the addresses listed at https://api.airfone.app/v1/webhook-ips. The signature is the real check. Use the list only as an extra layer.