Webhook subscriptions¶
This is the opposite direction from Sending events:
instead of you telling AiFlow something happened, AiFlow tells you.
Subscribe to a lifecycle event, a call finishing, a transcript becoming
available, a message being sent, and AiFlow posts a signed payload to your
URL every time it happens. This differs from the send_webhook tool an
agent can be configured to call mid-conversation ("notify Slack if the
caller sounds upset"), which fires only when the model decides to. A
subscription fires deterministically, every time the underlying event
happens, with no AI decision involved.
Setting up a subscription¶
Capped by your licence
This deployment's licence sets a webhook_subs limit. Creating one more
past that cap returns 402 rather than succeeding; see
Licence limits.
An admin creates a subscription, either from the dashboard
(Settings → Webhooks) or by calling POST /api/v1/webhook-subscriptions
with an admin session token (see
Authentication), supplying a URL
and which event types it should fire for. The signing secret referenced
throughout this page is shown exactly once, in that creation response.
Copy it immediately: there's no way to retrieve it again afterward, so
create a new subscription if it's lost. The rest of this page covers the
receiving side: what AiFlow sends you, and how to verify it's really
AiFlow sending it.
Event catalog¶
| Event | Fires when |
|---|---|
call.started |
A call (inbound, outbound, or widget) begins, once a real conversation is under way. Headless agent_task and inbound-email runs do not fire this. |
call.finished |
A call ends successfully. |
call.failed |
A call ends in a failed, no-answer, or busy state. |
call.transferred |
A call is handed to a human by the transfer_to_human tool. The AI portion completed, so this is deliberately not a call.failed. |
transcript.finalized |
A call's transcript is fully written. Fired alongside the call's terminal event, as a separate event so you can react to transcript availability on its own. |
event.received |
An inbound event is accepted at the events endpoint or the MCP record_event tool, before its triggers run. |
event.processed |
That inbound event finishes trigger evaluation. The payload's processing_status is triggered or no_match. |
whatsapp.sent |
A WhatsApp send resolves, whether it succeeded or failed; the payload's status field distinguishes them. |
whatsapp.delivery_updated |
A later Twilio delivery-status callback moves a sent WhatsApp message to delivered, read, or undelivered. |
telegram.sent |
A Telegram send resolves, whether it succeeded or failed; to_address holds the destination chat id. |
email.sent |
An email send resolves; same success/failure shape as whatsapp.sent. |
email.received |
A new message arrives on a connected mailbox, before the agent reacts to it. |
agent_task.completed / agent_task.failed |
A silent agent_task Trigger (see Triggers & events) finishes running, no call placed, no message sent. |
orchestrator_task.completed / orchestrator_task.failed |
A native Agent's delegation to an Orchestrator finishes. Only fires once an Orchestrator actually ran, not for a configuration problem caught before one was attempted. |
approval.requested |
An Orchestrator tool call gated behind requires_approval gets queued instead of running, so you can be notified the moment something needs a decision. |
approval.resolved |
An admin approves or rejects a queued approval; the payload's status field says which. |
campaign.started |
A batch calling campaign moves to running. |
campaign.completed |
A campaign dispatches its last contact and has no pending contacts left. |
campaign.cancelled |
A campaign is cancelled; every still-pending contact is dropped. |
contact.created |
A caller, chat visitor, or email sender is recognized as a brand-new Contact. |
contact.updated |
A Contact's remembered profile (memory_notes) changes after a call. |
federation_link.auto_disabled |
A federation link's inbound call-rate circuit breaker trips and the link is auto-disabled (see Federation). |
Payload envelope¶
Every delivery has the same shape: an event name and a data object
whose fields depend on which event it is.
{
"event": "call.finished",
"data": {
"call_id": 123,
"agent_id": 4,
"status": "completed",
"phone_number": "+15551234567",
"duration_seconds": 87,
"summary": "Caller asked about order #4821, confirmed it shipped."
}
}
call.finished, call.failed, and call.transferred share that shape.
The other event types' data differs:
call.started:call_id,agent_id,direction,channel,phone_number.transcript.finalized:call_id,agent_id.event.received:event_id,agent_id,event_type,payload(the raw inbound body),source_ip.event.processed:event_id,agent_id,event_type,processing_status(triggeredorno_match),matched, and thecall_ids,outbound_message_ids, andagent_task_call_idsit produced.whatsapp.sent,telegram.sent, oremail.sent:message_id,channel,to_address,status,error_message, plus eitheragent_idor, for a send originating from an Orchestrator's own send tool rather than an Agent,orchestrator_id.whatsapp.delivery_updated:message_id,agent_id,channel,to_address,status,error_message.email.received:agent_id,call_id,from_address,subject,message_id.agent_task.completedoragent_task.failed:call_id,agent_id,status.orchestrator_task.completedororchestrator_task.failed: justorchestrator_id. The delegating Agent's own tool call already returned the actual answer into that conversation, so this event is purely a deterministic "it finished, here's whether it succeeded" signal for automation.approval.requested:approval_id,orchestrator_id,tool_name,arguments(the exact keyword arguments the model supplied, the same ones approving would replay),created_at.approval.resolved:approval_id,orchestrator_id,tool_name,status(approvedorrejected),decided_by_admin_id,decided_at,note.campaign.started:campaign_id,agent_id,name,pending_contacts.campaign.completeddropspending_contacts;campaign.cancelledreplaces it withcancelled_contacts.contact.created:end_user_id,name,email,phone_number.contact.updated: justend_user_id.federation_link.auto_disabled:link_id,name,reason.
Verifying the signature¶
Every request carries an X-AiFlow-Signature: sha256=<hex> header: an
HMAC-SHA256 of the raw request body, keyed with your subscription's
secret. Verify it before trusting the payload. Without that check,
anyone who knows your URL can POST a fake call.finished at you.
import { createHmac, timingSafeEqual } from "crypto";
function verify(secret: string, body: Buffer, header: string): boolean {
const expected =
"sha256=" + createHmac("sha256", secret).update(body).digest("hex");
const expectedBuf = Buffer.from(expected);
const headerBuf = Buffer.from(header);
return (
expectedBuf.length === headerBuf.length &&
timingSafeEqual(expectedBuf, headerBuf)
);
}
The same HMAC-SHA256-over-the-raw-body construction verifies in any
language: sign the exact bytes you received (before any JSON parsing)
with your subscription's secret, and compare against the
X-AiFlow-Signature header using a constant-time comparison.
Sign the raw body, not the re-serialized JSON
Parsing the payload and re-serializing it before hashing almost always produces a different byte sequence (key order, whitespace) than what was actually signed. Hash the request body exactly as received, before your framework decodes it.
Delivery¶
A failed delivery (a non-2xx response, a timeout, a connection error) is
retried a bounded number of times with a short delay in between, then
given up on: delivery isn't infinite. The dashboard's subscription list
shows last_triggered_at and last_error for the most recent attempt,
useful for confirming whether AiFlow ever actually reached your endpoint.
Failed deliveries and manual replay¶
A delivery that exhausts its retries is saved in full (event type,
payload, and error), not just summarized in last_error, so a receiver
having a bad few minutes doesn't silently lose those events for good:
GET /api/v1/webhook-subscriptions/{subscription_id}/deliveries
POST /api/v1/webhook-subscriptions/{subscription_id}/deliveries/{delivery_id}/retry
The dashboard's View recent failures link under each subscription
lists these with a Retry button per row. retry reconstructs and
re-signs the original payload exactly and sends it through the same
3-attempt delivery logic as the original dispatch, using the
subscription's current URL and secret rather than whatever was true at
the time of the original failure, in case you've since fixed the URL. A
successful retry removes the delivery from the list; a failed one stays,
with its error updated to this attempt's, and can be retried again.
Recipes¶
This works as-is with most no-code workflow tools:
- n8n: a Webhook trigger node gives you a URL to paste into a new
AiFlow subscription. Verify the signature in a Function node before
branching on
event. - Zapier: use "Webhooks by Zapier" with "Catch Hook" as your trigger, and verify the signature in a "Code by Zapier" step before trusting the payload.
- Make.com: a "Webhooks" module with "Custom webhook" gives you a URL the same way. Add an HTTP or Tools module to verify the signature before a scenario acts on it.
In every case, verify the signature in whichever code or function step the platform offers before trusting the payload. Exposing the raw webhook URL publicly is not itself a security boundary.