Skip to content

Webhooks

AirFone sends an HTTP POST to your endpoint for each event you subscribed to.

{
"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.

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.

Every request carries:

AirFone-Signature: t=1791364364,v1=5f2b...e1

v1 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));
}
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');

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.

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.