Skip to content
MeridFlow AiFlow v8.x • self-hosted

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 (triggered or no_match), matched, and the call_ids, outbound_message_ids, and agent_task_call_ids it produced.
  • whatsapp.sent, telegram.sent, or email.sent: message_id, channel, to_address, status, error_message, plus either agent_id or, 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.completed or agent_task.failed: call_id, agent_id, status.
  • orchestrator_task.completed or orchestrator_task.failed: just orchestrator_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 (approved or rejected), decided_by_admin_id, decided_at, note.
  • campaign.started: campaign_id, agent_id, name, pending_contacts. campaign.completed drops pending_contacts; campaign.cancelled replaces it with cancelled_contacts.
  • contact.created: end_user_id, name, email, phone_number. contact.updated: just end_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 hashlib
import hmac


def verify(secret: str, body: bytes, header: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header)
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.

curl -X POST https://api.your-domain.com/api/v1/webhook-subscriptions/3/deliveries/17/retry \
  -H "Authorization: Bearer $ADMIN_TOKEN"
{ "success": true, "error": null }

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.