Skip to content
Docs

Webhooks

We tell your system what happened to each order by POSTing a signed JSON event to your webhook URL.

Where events go

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.

Events

EventSent when
order.completedThe order reached a final status: BOOKED, QUALIFIED, DISQUALIFIED, FAILED, or VOICEMAIL_SMS_SENT with no attempts left.
order.updatedAn 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)
HeaderMeaning
X-Avoonta-EventThe event name, the same as event in the body.
X-Avoonta-DeliveryThe ID of this delivery. Retries of the same delivery reuse it, so you can use it to ignore repeats.
X-Avoonta-Signaturet= 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

FieldTypeMeaning
eventstringorder.completed or order.updated.
order_idstringThe order, as returned by POST /v1/orders.
external_reference_idstring or nullYour own ID, as you sent it.
statusstringSee the order lifecycle.
attempt_numberintegerDials made so far (1 to 3).
duration_secondsintegerTalk time from the moment a person answered. 0 when nobody did.
billed_minutesintegerTalk time rounded up to whole minutes.
charged_centsintegerWhat this order's call cost you, in cents, including any high-cost area surcharge. Fallback texts are charged separately and are not included.
qualificationobject or nullqualified (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.
appointmentobjectbooked, start_time (ISO 8601), calendar_event_id (the event ID in your calendar). Always present in webhooks; booked: false when nothing was booked.
recording_urlstring or nullWhere to download the recording. Needs your API key. See Recordings.
transcriptarray{ "role": "agent" | "user", "text" } turns. Empty when nobody was reached.
reasonstring or nullWhy 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.
testbooleantrue 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 ) )
  1. Read the raw body bytes. Do not parse and re-serialise the JSON first: any change in spacing breaks the signature.
  2. Split the header on , and take t and every v1. There are two v1 values for up to 24 hours after the secret is rotated: one made with the new secret, one with the old.
  3. Compute the HMAC with your workspace's signing secret. The secret starts with whsec_; use the whole string, prefix included, as the key.
  4. Compare in constant time. Accept the request when any v1 matches.
  5. Reject a t more 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.

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

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