Webhooks
Get notified when calls end, recordings are ready, and transcriptions complete.
Overview
Webhooks let Aethex push events to your server in real time. Register a URL on your agent and Aethex will POST a signed JSON payload every time an event fires. No polling required.
Event types
Every webhook is an HTTP POST with a flat JSON body. The event type is in the `X-Aethex-Event` header — not the body — and every payload includes event_id; call, recording, knowledge-base, transcription, and TTS-batch events also include timestamp (Unix seconds). Account events (usage.threshold.crossed) use fired_at (ISO 8601) instead of timestamp.Call events — sent to the webhook_url configured on the agent:- `call.ended` — fired when a call ends (every transport, including WebRTC). Fields: call_id, conversation_id, agent_id, status (one of completed, failed, no-answer, busy, or canceled), direction, from_number, to_number, duration_seconds, cost_cents, started_at, ended_at, transcript, transcript_text, batch_id and batch_recipient_index (both null unless the call came from a batch), metadata (your own metadata, always an object), and the shedding flags metadata_truncated and transcript_truncated (always booleans) — set when the report was too large to send whole, so fetch the rest from GET /calls/{id}.
- `recording.ready` — fired once the call recording has finished uploading and is downloadable. Recordings upload asynchronously and this event is delivered independently of call.ended; it usually follows call.ended but ordering is not guaranteed, so it may arrive before it. Correlate the two by call_id and order events by timestamp. Fields: call_id, conversation_id, audio_url (a time-limited presigned URL), size_bytes, recording_region.Knowledge-base events — sent to the webhook_url configured on the agent, signed exactly like call.ended:- `knowledge.document.created` — fired after a document is added to the agent's knowledge base through POST /agents/{id}/knowledge-base or /knowledge-base/by-upload. Fields: agent_id, document_id, filename, status (completed or failed), content_type, size_bytes. The document's text is not in the payload; read it from the knowledge-base endpoints.
- `knowledge.document.deleted` — fired after a document is removed with DELETE /agents/{id}/knowledge-base/{doc_id}. Fields: agent_id, document_id, filename.Pages added by a reference-link crawl do not fire these events. Delivery is best-effort: a failing endpoint is retried over a few minutes and then dropped, so reconcile against GET /agents/{id}/knowledge-base if you must not miss a document.Job events — sent to the webhook_url you pass on the request that starts the job:- `transcription.completed` — fired when an async transcription (POST /transcribe/async) finishes. Fields: id, status, text, language, duration_seconds.
- `tts.batch.completed` — fired when a batch TTS job finishes processing (status completed, even if some items failed — see completed/failed counts). Fields: batch_id, status, total, completed, failed.
- `tts.batch.failed` — fired when a batch is rejected before any synthesis runs (e.g. a billing hard-block, or the requested voice is no longer usable). Same fields; status is failed with completed 0.Account events — sent to the event_callback_url on the usage trigger:- `usage.threshold.crossed` — fired when a usage trigger threshold is crossed. Fields: event_type, trigger_id, resource_type, threshold_type, threshold_value, observed_value, period, period_started_at, period_ends_at, fired_at.WhatsApp events — sent to the webhook_url of the agent mapped to the WhatsApp number, signed exactly like call.ended. Unlike the events above, these also repeat the event name in the body as event, alongside phone_number_id:- `whatsapp.message.received` — fired when a customer writes and the agent did not reply, so somebody has to: a person owns the thread, or the agent could not answer this message: no agent mapped, the agent deleted or not ready, billing blocked, or credentials refused. reason then says why. Fields: event, phone_number_id, thread_id, customer, customer_name, text, message_id, assigned_to, reason.
- `whatsapp.handoff.requested` — fired when a customer asks for a person and the thread switches to human mode. Same fields as above; reason carries why.
- `whatsapp.reply.undelivered` — fired when Meta refuses one of the agent's replies outright, or still has not accepted it on the last retry, so the customer never got it. Fields: event, event_id, phone_number_id, thread_id, customer, reply, in_reply_to (the customer's message it answered), refusal, reason, carrier_detail, folded, folded_since. refusal is one of recipient_cannot_receive, opted_out, sender_cannot_send, credential_rejected, throttled, carrier_error or unknown; reason says what it means in one sentence; carrier_detail is Meta's own error. folded is how many refusals of the same number for the same refusal were left out between the previous event and this one, 0 on an ordinary event. When a number is refused on every reply (sender_cannot_send, credential_rejected, unknown), or still is on the last retry (throttled, carrier_error), each clock hour normally brings you two events for that number and cause instead of one per incoming message: the hour's first refusal at once, then at the end of the hour the latest one, with the count of those in between in folded and the start of that hour in folded_since (null on an ordinary event). The two can cross at the hour boundary, which is what folded_since is for. Refusals about one customer (recipient_cannot_receive, opted_out) are never folded. A failure Meta reports only later arrives as a whatsapp.message.status of failed instead.
- `whatsapp.message.status` — a delivery receipt for a message you sent: sent, delivered, read or failed. Fields: event, phone_number_id, message_id, status, recipient, timestamp, conversation, pricing, errors. pricing is what WhatsApp billed for the conversation; errors is set only on failed.
- `whatsapp.campaign.paused` — fired when the worker stops a campaign on its own, today because the account is out of credit. Sent to the webhook_url the campaign was created with, not to an agent: a campaign names none. Fields: event, event_id, campaign_id, name, reason (a bounded class, today billing_hard_block), error (the sentence the campaign now shows). A pause you asked for yourself is not reported. Start the campaign again once the cause is fixed; the pending recipients are still there.Text-message events — sent to the webhook_url of the agent bound to the number, signed exactly like call.ended, with the event name in the body as event alongside the line's own phone_number:- `sms.handoff.requested` — fired when a text thread switches to human mode: the customer asked for a person, or the thread was answered so often in a short window that the other end is evidently automatic. Fields: event, event_id, phone_number, thread_id, customer, text, message_id, assigned_to, reason.
- `sms.reply.undelivered` — fired when the carrier refuses one of the agent's replies. Same fields and meanings as whatsapp.reply.undelivered, with phone_number in place of phone_number_id. A carrier that is throttling or briefly failing has not refused anything: the reply is retried, and the event is sent only if the last retry is still turned away. refusal is then throttled or carrier_error, or credential_rejected if the carrier was still rejecting the account's credentials.
- `sms.campaign.paused` — fired when the worker stops a campaign on its own. Sent to the webhook_url the campaign was created with, not to an agent. Fields: event, event_id, campaign_id, name, reason, error. reason is one of number_inactive, number_not_receiving, account_inactive (the messaging account a campaign sends under by name was released), sender_unresolvable, account_refused (the carrier refused the sending account), identical_refusals (five in a row), repeated_error or billing_hard_block; error is the sentence the campaign now shows. A pause you asked for yourself is not reported. Fix the cause and start the campaign again; the pending recipients are still there.
- `agent.prompt_variables.unfilled` — sent to the agent's webhook_url when conversations with the agent go ahead while its prompt has placeholders with no value: no default, a blank default, and not a built-in. No call or reply is ever refused over a variable, on any channel. The model reads [name: not provided] in each one's place. Fields add channel (voice, whatsapp, sms or chat). Sent at most once per agent per UTC day. Fields: event, event_id, agent_id, conversation_id (the day's first such call), reason_code (unfilled_prompt_variables), reason, variables.These are best-effort: the inbox has already recorded the event, so a tenant with no webhook configured is a no-op rather than an error, and an endpoint that keeps failing is retried and then dropped.
Signing and verification
Every webhook request carries an X-Aethex-Signature header formatted as t='<'unix_timestamp'>',v1='<'hmac_hex'>'. The HMAC is SHA-256(secret, "'<'t'>'.'<'raw_body'>'"). Verify it on your side before processing the payload. The example below shows a Node.js verifier.
import crypto from "node:crypto"
function verifyAethexWebhook(rawBody, signatureHeader, secret) {
// Header looks like: "t=1700000000,v1=abc123..."
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("="))
)
const t = parseInt(parts.t, 10)
const sig = parts.v1
if (!t || !sig) return false
// Reject anything older than 5 minutes to defend against replay.
if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex")
return crypto.timingSafeEqual(
Buffer.from(sig, "hex"),
Buffer.from(expected, "hex")
)
}Delivery and retries
Aethex first makes up to 3 short attempts, with a 10-second timeout per attempt and jittered exponential backoff. A failed call.ended delivery then remains durable in Postgres and is retried in longer rounds for roughly 3 days (up to 20 rounds, with the delay capped at 6 hours). Its call record moves through pending, delivering, and failed; it ends as delivered, unsafe (the destination failed the network-safety policy), skipped (the endpoint was removed), or exhausted. Transcription and TTS-batch deliveries retain their existing short-retry behavior.Delivery is at least once. Every replay of one call.ended event carries the same event_id, including recovery after a worker or Redis outage. Respond with 200 as fast as possible (before any heavy processing), process the payload asynchronously, and return 2xx even for duplicates. Store event_id and make your handler idempotent.
Security checklist
Before going live:1. Verify the signature on every request — reject anything that doesn't match.
2. Check the timestamp (t field) — reject events older than 5 minutes to defend against replay attacks.
3. Respond fast — return 200 before doing any processing to avoid spurious retries.
4. Deduplicate — store event_id and skip events you have already processed.
Rotating the signing secret
Rotate the webhook signing secret at any time via POST /usage/webhook-secret/rotate. The new secret takes effect immediately; update your verification logic before rotating in production to avoid a gap.
