Webhooks
We tell your system what happened to each order by POSTing a signed JSON event to your webhook URL.
Where events go
- The
webhook_urlyou set on the order, or else - your workspace's default webhook URL, if one is set. Leads from the trigger URL and calls people make to your numbers always use the default URL.
The URL must use https:// and must be reachable on the public internet. URLs that point at private, loopback or link-local addresses (or host names that resolve to them) are refused at every delivery attempt.
Test orders never fall back to the default webhook. A test call from the dashboard or a test lead (?test=1) only sends a webhook if that order has its own URL, which those two never do. Your production CRM never receives test results.
Webhook settings
In the dashboard under Developer. Every member sees the URLs; only an owner or admin can change them, view the signing secret, rotate it or send a test event. Each of these is recorded in the workspace audit log.
- Default webhook URL and test webhook URL: set or clear each one. A URL is checked when you save it: it must be
https://, carry no user name or password, and its host must resolve to public addresses only (IPv6 forms of private IPv4 addresses, such as NAT64, 6to4 and IPv4-mapped addresses, count as private). It is checked again before every delivery. - Signing secret: view it to set up verification (it starts with
whsec_), or rotate it. - Send test event: we POST a sample
order.completedto the URL you give, or to your default (or test) webhook URL, signed exactly like a real delivery and with"test": true. The answer shows the HTTP status code your server returned (not its reply body). It is sent once, not retried, and not stored as a delivery. At most 10 test events per workspace per hour.
Events
| Event | Sent when |
|---|---|
order.completed | The order reached a final status: BOOKED, QUALIFIED, DISQUALIFIED, FAILED, or VOICEMAIL_SMS_SENT with no attempts left. |
order.updated | An attempt did not reach anyone and another attempt is scheduled (status is RETRY_SCHEDULED). Sent once per order when it starts waiting for money (status stays QUEUED or RETRY_SCHEDULED, reason is waiting_for_balance, plan_inactive or plan_past_due). Also sent after order.completed when the call analysis finishes and adds to qualification; the status does not change then. |
No event is sent for DIALING or IN_PROGRESS, nor for QUEUED except the money wait above (sent again if the order waits again after a later attempt). An order that fails before it is dialled (for example a number found not to be a US number at dial time) gets order.completed with status: "FAILED" and its reason. Orders made with an av_test_ key send the same events with "test": true, only to the order's webhook_url or your workspace's test webhook URL, never to the production default (see Test keys). An identical payload for the same order and event is sent only once.
Calls people make to your Avoonta number also produce order.completed (their external_reference_id is null).
The request we send
POST /your/webhook/path HTTP/1.1
Content-Type: application/json
User-Agent: Avoonta-Webhooks/1.0
X-Avoonta-Event: order.completed
X-Avoonta-Delivery: 6f1c3e2a-8b4d-4f7a-9c1e-2d3b4a5c6e7f
X-Avoonta-Signature: t=1792000000,v1=5f2b…(64 hex characters)
| Header | Meaning |
|---|---|
X-Avoonta-Event | The event name, the same as event in the body. |
X-Avoonta-Delivery | The ID of this delivery. Retries of the same delivery reuse it, so you can use it to ignore repeats. |
X-Avoonta-Signature | t= the Unix time we signed at, v1= the signature. See Verify the signature. |
Payload
order.completed: booked
{
"event": "order.completed",
"order_id": "ord_9b1e0c4d2a7f4e8b9c3d5a6f7e8d9c0b",
"external_reference_id": "crm_contact_8821",
"status": "BOOKED",
"attempt_number": 1,
"duration_seconds": 134,
"billed_minutes": 3,
"charged_cents": 75,
"qualification": {
"qualified": true,
"score": 92,
"extracted_answers": {
"homeowner": "Yes",
"monthly_electric_bill": "$320",
"timeline": "Within 30 days"
},
"summary": "Daniel owns his home and pays about $320 a month for power. Booked a consultation for Thursday at 2:00 PM."
},
"appointment": {
"booked": true,
"start_time": "2026-10-22T14:00:00Z",
"calendar_event_id": "e5b1c2d9a0f3"
},
"recording_url": "https://api.avoonta.com/v1/recordings/ord_9b1e0c4d2a7f4e8b9c3d5a6f7e8d9c0b.mp3",
"transcript": [
{ "role": "agent", "text": "Hi, this is Alex calling from Acme Solar. I'm their virtual AI assistant. This call is transcribed and may be recorded. If you would rather not get these calls, just say stop calling and we will not call you again. Hi Daniel, you asked about a solar quote…" },
{ "role": "user", "text": "Oh hey, yes! That was fast." }
]
}
order.updated: retry scheduled
{
"event": "order.updated",
"order_id": "ord_9b1e0c4d2a7f4e8b9c3d5a6f7e8d9c0b",
"external_reference_id": "crm_contact_8821",
"status": "RETRY_SCHEDULED",
"attempt_number": 1,
"duration_seconds": 0,
"billed_minutes": 0,
"charged_cents": 0,
"qualification": null,
"appointment": { "booked": false, "start_time": null, "calendar_event_id": null },
"recording_url": null,
"transcript": []
}
order.completed: never reached
{
"event": "order.completed",
"order_id": "ord_9b1e0c4d2a7f4e8b9c3d5a6f7e8d9c0b",
"external_reference_id": "crm_contact_8821",
"status": "FAILED",
"attempt_number": 3,
"duration_seconds": 0,
"billed_minutes": 0,
"charged_cents": 0,
"qualification": null,
"appointment": { "booked": false, "start_time": null, "calendar_event_id": null },
"recording_url": null,
"transcript": []
}
Fields
| Field | Type | Meaning |
|---|---|---|
event | string | order.completed or order.updated. |
order_id | string | The order, as returned by POST /v1/orders. |
external_reference_id | string or null | Your own ID, as you sent it. |
status | string | See the order lifecycle. |
attempt_number | integer | Dials made so far (1 to 3). |
duration_seconds | integer | Talk time from the moment a person answered. 0 when nobody did. |
billed_minutes | integer | Talk time rounded up to whole minutes. |
charged_cents | integer | What this order's call cost you, in cents, including any high-cost area surcharge. Fallback texts are charged separately and are not included. |
qualification | object or null | qualified (true, false or null when not scored), score (0 to 100 or null), extracted_answers (the answers to your agent's questions, keyed by question), summary (text or null). null when nobody was reached. |
appointment | object | booked, start_time (ISO 8601), calendar_event_id (the event ID in your calendar). Always present in webhooks; booked: false when nothing was booked. |
recording_url | string or null | Where to download the recording. Needs your API key. See Recordings. |
transcript | array | { "role": "agent" | "user", "text" } turns. Empty when nobody was reached. |
reason | string or null | Why the order is waiting or failed: waiting_for_balance, plan_inactive, plan_past_due, no_answer, machine, do_not_contact, unsupported_destination, agent_inactive, no_caller_id and similar. null when there is nothing to report. |
test | boolean | true for an order made with an av_test_ key: nobody was rung and nothing was charged. Ignore these in production. |
Verify the signature
Every request carries X-Avoonta-Signature: t=<unix seconds>,v1=<hex>, where
v1 = hex( HMAC-SHA256( key = your webhook signing secret,
message = t + "." + raw request body ) )
- Read the raw body bytes. Do not parse and re-serialise the JSON first: any change in spacing breaks the signature.
- Split the header on
,and taketand everyv1. There are twov1values for up to 24 hours after the secret is rotated: one made with the new secret, one with the old. - Compute the HMAC with your workspace's signing secret. The secret starts with
whsec_; use the whole string, prefix included, as the key. - Compare in constant time. Accept the request when any
v1matches. - Reject a
tmore than 5 minutes away from your clock, to block replays.
Each attempt is signed again with a fresh t, so a retry hours later still verifies.
Node.js (no dependencies)
import http from "node:http";
import crypto from "node:crypto";
const SECRET = process.env.AVOONTA_WEBHOOK_SECRET; // whsec_…
export function verifyAvoonta(rawBody, header, secret = SECRET, toleranceSec = 300) {
if (!header || !secret) return false;
let t = NaN;
const v1s = []; // two of them for up to 24 h after a secret rotation
for (const piece of header.split(",")) {
const i = piece.indexOf("=");
if (i <= 0) continue;
const k = piece.slice(0, i).trim(), v = piece.slice(i + 1).trim();
if (k === "t") t = Number(v);
else if (k === "v1" && /^[0-9a-f]{64}$/.test(v)) v1s.push(v);
}
if (!Number.isInteger(t) || !v1s.length) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = Buffer.from(crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"), "hex");
return v1s.some((v1) => crypto.timingSafeEqual(expected, Buffer.from(v1, "hex")));
}
http.createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const raw = Buffer.concat(chunks).toString("utf8");
if (!verifyAvoonta(raw, req.headers["x-avoonta-signature"])) {
res.writeHead(400).end("bad signature");
return;
}
const event = JSON.parse(raw);
// Answer fast, then do the slow work (CRM update) in the background.
res.writeHead(200).end("ok");
console.log(event.event, event.order_id, event.status);
});
}).listen(3000);
With Express, use express.raw({ type: "application/json" }) on the webhook route so you get the raw bytes, not a parsed object.
Python
import hashlib
import hmac
import os
import time
SECRET = os.environ["AVOONTA_WEBHOOK_SECRET"] # whsec_…
def verify_avoonta(raw_body: bytes, header: str, secret: str = SECRET, tolerance: int = 300) -> bool:
if not header or not secret:
return False
pairs = [p.strip().split("=", 1) for p in header.split(",") if "=" in p]
t_values = [v for k, v in pairs if k == "t"]
v1s = [v for k, v in pairs if k == "v1"] # two for up to 24 h after a rotation
try:
t = int(t_values[0])
except (IndexError, ValueError):
return False
if not v1s or abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, v1) for v1 in v1s)
# Flask example
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/webhooks/avoonta")
def avoonta_webhook():
raw = request.get_data() # raw bytes, before any JSON parsing
if not verify_avoonta(raw, request.headers.get("X-Avoonta-Signature", "")):
abort(400)
event = request.get_json()
print(event["event"], event["order_id"], event["status"])
return "ok", 200
Rotating the secret
An owner or admin can make a new signing secret at any time (dashboard, Developer, Rotate secret). The new secret is shown in the answer and can be viewed again later.
- By default the old secret keeps working for 24 hours: during that window every delivery (and every test event) carries two
v1values under the samet, the first made with the new secret and the second with the old one:X-Avoonta-Signature: t=1760000000,v1=<hex, new secret>,v1=<hex, old secret> - You can choose a shorter window (0 to 24 hours). With 0 the old secret stops working at once; use that when the old secret may have leaked.
- Only one old secret is kept: rotating again inside the window replaces it with the secret you just retired.
- To switch without dropping events: rotate, put the new secret in your verifier (which accepts any matching
v1), and deploy before the window ends.
Older verifiers. Code that keeps only one v1 from the header works while there is a single secret, but during a rotation window it may check the wrong one. Use the samples above, which accept any matching v1.
Responses, timeouts and retries
- Answer with any
2xxstatus within 10 seconds. Do slow work after you answer. - Redirects are not followed. A
3xxcounts as a failure; give us the final URL. - Any other status, a timeout or a connection error is a failure. We retry the same delivery after about 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours: six attempts in all. After the sixth failure we stop.
- Deliveries are processed about once a minute, so the first attempt can take up to a minute after the status changes.
- Events can arrive more than once and, after retries, out of order. Use
X-Avoonta-Deliveryto skip repeats, and treat a finalstatusas final even if an olderorder.updatedarrives after it. - If you missed an event, read the order with
GET /v1/orders/{order_id}.
Every delivery and its response code is shown on the order's timeline in the dashboard, where a member can send a delivery again. Owners and admins can also send a test event. Coming soon Resending through the API.
Recordings
When the call was recorded, recording_url points at our own host, never at a third party:
curl -L -o call.mp3 \
-H "Authorization: Bearer av_live_…" \
https://api.avoonta.com/v1/recordings/ord_9b1e0c4d2a7f4e8b9c3d5a6f7e8d9c0b.mp3
- The download needs an API key of the order's workspace. Without one, or for another workspace's order, you get
404 not_found. - To play a recording in a browser (an
<audio>tag cannot send a header), ask for a short-lived link:GET /v1/recordings/{order_id}/linkwith your key returns{ "success": true, "url": "…mp3?t=…", "expires_at": "…" }. The link works for 15 minutes. - Recording starts once the person first speaks, after they have heard the recording notice. If they object, the agent stops recording. Not every call has a recording.
- Recordings are kept for 30 days after the call by default. A workspace owner or admin can choose 1 to 365 days, or keep them until deleted. After that the recording is deleted for good, including the copy at our voice network,
recording_urlbecomesnull, and the download and/linkanswer410 recording_deleted. Download what you need to keep.