Skip to content
Technical reference

Webhooks

Documentation for integrators. If you are looking for how to configure Alpire, the admin guide is in Documentation.

← Back to Documentation

Settings → API & webhooks (/settings/api), “Event webhooks” card. You register a URL, pick events and receive a signed JSON POST every time something happens. Built for n8n, Make, Zapier or your own endpoint. Just want your team to hear about it in a Slack, Discord, Teams, Google Chat, Telegram or Webex channel, or on your phone with ntfy? Pick that destination and paste its webhook URL: a ready-to-read notice arrives, with nothing to set up in between (§8).

  • It is configured by an administrator with access to Settings. It is currently available on every plan, including the trial (it depends on the plan's integrations feature), with a maximum of 10 webhooks per organization (paused ones count).
  • Which events it receives. Creating a webhook in the dashboard starts with the 16 boxes ticked (all but call.missed, which you have to tick by hand: see its section) and saves that list as it is: events added to the catalogue later do not reach it on their own; edit the webhook and tick them. Only if you untick all the boxes does it become subscribed to all events, including future ones (and transcript.ready, which carries the entire conversation) except call.missed, which “all” does not cover: when editing, the dashboard asks for confirmation before saving it that way, and a webhook that was already on “all” stays that way as long as you don't touch its boxes.
  • Redirects are not followed: register the final URL. A 301 from http to https, or for a trailing slash, ends up in retries and then failed.

1. Envelope

Every event arrives in the same envelope:

{
  "id": "evt_01J8Z9K2QX7M3B4N5P6R7S8T9V",
  "type": "call.ended",
  "created_at": "2026-07-19T04:12:33.481203517+00:00",
  "org_id": "01J8...",
  "data": { }
}
FieldDescription
idEvent id: evt_ prefix + ULID. Stable across retries. If you have two endpoints subscribed, each one receives a different id for the same occurrence: to deduplicate across endpoints, use the resource key (call_id, chat_id…).
typeEvent type (table below).
created_atRFC 3339 UTC, with up to nanosecond precision, the moment the event was generated.
org_idYour organization.
dataEvent-specific payload.

Fields are stable; new fields may appear inside data. Ignore the ones you don't recognise instead of failing.

2. Events

typeWhen
call.startedThe platform answers the call (answered_at): it goes to the IVR, the AI agent or a person. It does not mean someone on your team picked up, and there is no event for that. Detected by a sweep every 15 s.
call.endedThe call ends (ended_at). Sweep every 15 s.
call.missedAn inbound call ends without anyone attending it (missed, no answer, busy, abandoned in queue) or in voicemail. Sweep every 15 s. You have to tick it: “all” does not include it.
call.transferredThe call is actually transferred to another destination (extension, queue or number).
transcript.readyThe post-call transcription moves to success. Sweep every 15 s.
appointment.bookedAppointment created: by voice, in the chatbot (the visitor confirms it), from the Calendar dashboard or via POST /v1/appointments.
appointment.cancelledAppointment cancelled: by voice, from the dashboard or via POST /v1/appointments/{id}/cancel. Native calendar only.
callback.createdA callback is scheduled.
campaign.finishedA dialing campaign finishes.
campaign.contact_completedAn AI-agent campaign finishes its conversation with a contact, with the outcome and the data collected.
qa.scoredQuality analysis scores a call. Only once per call (see its section).
chat.startedA chatbot conversation begins: the first message in the widget, asking for a person with no open conversation, a left message, or POST /v1/chatbots/{id}/chats.
chat.message.createdNew message in a chatbot conversation (visitor, bot or a team member; internal notes are excluded). Fires for every message.
chat.closedA chatbot conversation is closed (inactivity, visitor, dashboard or API), with its summary.
chat.handed_offA chatbot conversation moves to the human queue (the visitor asks for it, the bot suggests it, or POST /v1/chats/{chat_id}/handoff).
chat.ratedThe visitor rates a closed conversation (1-5 and an optional comment).
chat.qa_scoredThe AI evaluation scores a chatbot conversation that was handled by a person.
topic.matchedA call or chat matches a watched topic that has notifications turned on (Reports → Topics).

There is also ping, which is only emitted by the Send test button in the dashboard, with data: {message, webhook_id} (the message text is always in Spanish). It cannot be subscribed to and does not appear in the catalogue.

The three sweep events (call.started, call.ended and transcript.ready) are not emitted the instant they happen: a sweep of the call log detects them every 15 s. Two rules follow from that:

  • Only calls answered or ended after the webhook was created are sent. History is not re-sent; for anything earlier, use GET /v1/calls. If you add the first sweep event to a webhook that until then only received events of another kind, the dashboard moves that date forward to “now”: calls from before that moment are not sent either.
  • A webhook that already receives one of the three cannot add another (the dashboard rejects it with a 409): adding it would re-send the whole call history since creation, transcripts included. Create a separate webhook for the new event. All the other events (appointment.*, chat.*, qa.scored…) can be added and removed whenever you like: they only look forward.

PII notice: transcript.ready includes text, turns and qa — that is, the verbatim content of the conversation. The same goes for chat.message.created (the text of each message), chat.closed (the summary), chat.rated (the visitor's comment) and chat.qa_scored (summary and issues found in how the conversation was handled). chat.started, chat.closed and chat.handed_off also carry visitor.email and visitor.name exactly as the visitor provided them. And it is not only the conversation events: call.started and call.ended carry the phone numbers and names of the caller (and call.ended, the variables the flow saved), campaign.contact_completed carries the contact's phone number and variables, appointment.booked and appointment.cancelled carry the customer's name and phone number (booked also their email) and callback.created carries the phone number. Treat that endpoint with the same care as the recordings: TLS, access control and a retention period you can justify.

2.bis data fields, event by event

A field may arrive as null if the data doesn't exist for that call (an outbound call has no queue_id, a call nobody answered has no answered_at). Treat absence and null the same way.

call.started

FieldTypeNotes
call_idstringULID of the CDR. This is the key for matching the other events of the same call.
directionstringinbound / outbound / internal (call between extensions of the org). Of the internal ones, by default only those nobody picked up are logged, so for them call.ended arrives and call.started does not; to send the answered ones too, choose “All of them, answered included” under “Internal calls in the history” (Settings).
fromstringCaller number, exactly as received over SIP.
from_namestring | nullCaller display name, if the carrier sent one.
tostringDestination number.
to_namestring | nullDestination display name.
didstring | nullThe DID we presented (caller ID used).
started_atRFC 3339Call start.
answered_atRFC 3339When the platform answered (IVR, AI agent or person). In this event it is never null: it is what triggers it.
queue_idstring | nullQueue the call came in through, if any.

call.ended

All the fields of call.started (with answered_at now nullable) plus:

FieldTypeNotes
statusstringFinal status: completed, missed, busy, no_answer, rejected, failed or voicemail (older calls may carry abandoned).
ended_atRFC 3339Call end.
duration_msnumber | nullTotal duration in milliseconds.
hangup_reasonstring | nullReason for the hangup.
hangup_bystring | nullWho hung up.
amd_resultstring | nullAnswering machine detection result, if it was used.
has_recordingbooleanWhether there is an associated recording. The URL is not included: download the audio with GET /v1/calls/{id}/recording (recordings:read permission) or listen to it in the dashboard. The recording is closed in the background: if the file takes more than 60 s to be ready, the event is still sent with false for a call that was recorded, and since it is not re-sent it is not corrected. If you need certainty, query GET /v1/calls/{id}.
varsobjectThe variables the flow marked Save («Save variable», digit capture, HTTP response, AI agent data…), as name → text. {} if it saved none. Variables marked as sensitive, anything that looks like a card number and the engine's internal ones (__…) are never included.
vars_truncatedbooleantrue if vars exceeded 16 KB and was cut at whole variables: the full list is in GET /v1/calls/{id} (with recordings:read).

Personal data. vars carries what the flow chose to save from the call: if you save an ID number or a name, it arrives here. Save only what your integration needs.

call.missed

Same data as call.ended, field by field. It is only emitted for calls whose direction is inbound and whose final status is missed, no_answer, busy, voicemail or abandoned. It is not emitted for rejected (the platform cut it: blocklist, anti-fraud or expired plan) or failed (a fault).

It is a subset of call.ended: subscribed to both, a missed call reaches you twice, with a different type and the same call_id. That is why it is not part of “all” and has to be ticked. And since it comes from the sweep, the dashboard won't let you add it to a webhook that already receives call.started, call.ended or transcript.ready: it would get every missed call since it was created, all at once. For that, create another webhook.

call.transferred

FieldTypeNotes
call_idstringCDR of the transferred call. For some transfers made from the phone it may arrive empty.
target_typestringextension, extension_warm (with prior consultation), queue or number.
targetstringThe specific destination.

transcript.ready

FieldTypeNotes
call_idstringCDR.
directionstringinbound / outbound / internal.
from, tostringCaller and destination.
ended_atRFC 3339 | nullCall end.
duration_msnumber | nullDuration.
confidencenumber | nullAverage recognition confidence (0..1).
textstring | nullFull verbatim transcript.
turnsarray | nullTurns, with speaker and timings, of what was said with the customer. If there was a consultation between two employees during the call, those turns are not included here: a webhook is an automatic push to an external URL, and that conversation is internal. They are available through the API (transcript_turns_consulta, with recordings:read), which is a deliberate read.
qaobject | nullQuality evaluation. It normally arrives as null: the evaluation starts once the transcription is already in success and takes a few seconds, and the sweep usually emits this event before it finishes. Its result reaches you in qa.scored; don't rely on this field.

⚠️ The last three fields (text, turns and qa) are personal data. This is the only call event that carries the content of the conversation. See the PII notice in section 2.

The transcription_status states are pending · processing · success · failed · skipped (and NULL if the organization doesn't transcribe). The event fires on the move to success.

appointment.booked

The same type comes from four places: a booking made by the voice agent, one confirmed by a visitor in the chatbot, a new appointment created in the Calendar dashboard, and POST /v1/appointments. They mean the same thing —a new appointment— but data doesn't carry the same fields, so the table says which ones arrive in each case.

FieldTypeSent byNotes
call_idstringVoice onlyCall from which it was booked. From the chat, the dashboard and the API the key is absent: it doesn't arrive as null, it simply isn't there. A data.call_id === null check won't fire — check for absence.
chat_idstringChat onlyConversation in which the visitor confirmed the booking.
chatbot_idstringChat onlyChatbot of that conversation.
channelstringChat onlyAlways chat (WhatsApp conversations too, when they arrive: the conversation's channel lives on the chat itself).
providerstringAll fournative, calcom or google. The dashboard and the API only emit it with the native calendar; with Cal.com or Google the appointment lives in the customer's system, and that system is the one that notifies. By voice, you get whichever provider the org has configured.
event_idstringAll fourAppointment id in the provider. It is the one to use to cancel or cross-reference it, and the one that matches appointment.cancelled.
sourcestringDashboard and APImanual = created in the dashboard; voice = everything else. The public API also says voice, because it books through the same path as the agent: this field does NOT distinguish your integration from the phone. That is what call_id is for.
startRFC 3339All fourAppointment start.
endRFC 3339Dashboard and APIAppointment end, without buffer. Not present for voice or chat bookings.
resource_idstringDashboard and APIBooked resource (table, room, chair). Not present for voice or chat bookings: if you display the assigned table, those appointments —most of them— arrive without it.
party_sizenumberAll fourNumber of people.
namestringAll fourCustomer name. By voice, Cliente if the customer never gave it.
phonestring | nullAll fourCustomer phone number.
emailstring | nullAll fourEmail, if given.
notestring | nullAll fourFree text of the booking.

The last three arrive as null by voice and chat when there is no data, and as an empty string from the dashboard and the API. An if (data.email) covers both; an === null doesn't.

appointment.cancelled

Native calendar only, and it comes from the three places where an appointment can be cancelled: the voice agent, the Calendar dashboard (including when deleting a resource, which cancels its future appointments, one event per appointment) and POST /v1/appointments/{id}/cancel. With Cal.com or Google, the cancellation is reported by their system, not ours.

FieldTypeSent byNotes
call_idstringVoice onlyCall in which it was cancelled. From the dashboard and the API the key is absent, same as in booked.
providerstringAll threeAlways native.
event_idstringAll threeThe cancelled appointment. It is the same id that arrived in booked: match the cancellation on it.
startRFC 3339All threeTime the appointment was scheduled for.
resource_idstringAll threeResource that is freed up.
namestringAll threeCustomer name.
phonestringAll threeCustomer phone number.

It carries no end, source, party_size, email or note: anything that isn't here, take it from the booked you already received with that event_id.

callback.created

FieldTypeNotes
callback_idstringCallback id.
call_idstring | nullCall that originated it.
chat_idstring | nullChatbot conversation that requested it (schedule_callback, already confirmed by the visitor).
phonestringNumber to call back.
queue_idstring | nullOriginating queue.
list_idstring | nullList/campaign it belongs to.
kindstringoverflow (queue overflow), reminder (voice agent reminder) or chatbot (requested by a visitor in the website chat).
notestring | nullReason, if given.
scheduled_atRFC 3339 | nullWhen the call is due. null = as soon as possible.

Depending on who schedules it, some keys are absent (omitted, not null): overflow carries no note or chat_id; reminder carries no queue_id, list_id or chat_id; chatbot carries no call_id or queue_id. Check for absence, not for null.

campaign.finished

FieldTypeNotes
campaign_idstringCampaign id.
namestringName.
callbacks_totalnumberTotal calls in the campaign.
callbacks_donenumberCompleted.
callbacks_failednumberFailed.

callbacks_total counts every contact in the campaign, so it can be greater than callbacks_done + callbacks_failed: contacts discarded by the do-not-call list or because the destination is not allowed are counted too.

campaign.contact_completed

Fires when the flow of an answered campaign call with an AI agent finishes. If nobody answers, it doesn't fire (that is tracked by the campaign's retries and by call.ended). In campaigns with human agents it doesn't fire: there the flow only runs before the person takes over.

FieldTypeNotes
campaign_idstringCampaign id.
callback_idstringThe dial: the contact's row within the campaign.
call_idstringULID of this call's CDR. It is also the idempotency key.
phonestringNumber called, E.164.
attemptnumberAttempt on which the conversation took place.
outcomestring | nullWhat the flow left in vars.resultado, sanitised to [a-z0-9_]. Campaign templates use a closed list (e.g. confirmada, cambiar, cancelar, no_es_la_persona, no_llamar). null if the flow didn't set it (voicemail, hang-up midway…).
opted_outbooltrue if outcome is no_llamar: the number is now on the organization's do-not-call list.
varsobjectThe call's variables flagged “save to the call record” (plus resultado), all as text. Never the secret ones. Contains personal data.

qa.scored

FieldTypeNotes
call_idstringEvaluated CDR.
qaobjectScore and breakdown. Its shape is set by the org's QA template, so don't assume fixed fields.

It fires only once per call: while the original delivery is still kept (7 days), re-evaluating the call or transcribing it again does not emit another qa.scored. Once the retention has passed, a new evaluation does emit it again.

Don't confuse it with the “Webhook” in Settings → Call evaluation. That field is a different, older and much simpler system: it sends a POST with {cdr_id, qa} with no signature, no retries and no delivery log, in a single 15 s attempt. For a reliable integration use this event webhook.

chat.started

FieldTypeNotes
chat_idstringConversation id: the same one returned by /v1/chats/{chat_id} and by the widget's vs:chat:* events.
chatbot_idstringChatbot handling it.
sourcestringembed (widget) or api.
previous_chat_idstring|nullClosed conversation it follows on from: the new one starts with that one's summary as context.
visitorobject | nullMay be missing entirely if the conversation has no associated visitor (opened via the API without an identity).
visitor.idstringVisitor.
visitor.customer_idstring|nullYOUR customer's id, only if it arrived signed (signed identity or API). Whatever the visitor types does not end up here.
visitor.emailstring|nullThe visitor's known email.
visitor.email_verifiedbooltrue if the email arrived signed.
visitor.namestring|nullName, if given.
started_atRFC 3339

chat.message.created

FieldTypeNotes
chat_idstring
chatbot_idstring
message.idstringIdempotent: a resend after a disconnection carries the same id. Use it to deduplicate.
message.seqintArrival order within the conversation, and the cursor for GET /v1/chats/{chat_id}/messages?after=.
message.chat_idstringThe message's conversation (same as chat_id).
message.rolestringvisitor, bot or agent (a team member). Internal notes are not emitted.
message.bodystringPlain text. Verbatim conversation content.
message.authorstring|nullName of the team member on agent messages (the one the visitor saw).
message.confirmobject|nullOn bot messages that propose an action: {id, summary, expires_at}. It is confirmed with POST /v1/chats/{chat_id}/tool-proposals/{id}.
message.call_offerobject|nullOn bot messages that offer a web call: {reason}, the sentence the bot uses to justify it (text to display, not a code). It is what makes the widget show “Call now”; your app can show its own button. The bot never places a call on its own.
message.atRFC 3339

Web call traces (role: "event", kind webcall_started / webcall_ended) are not emitted through this event: read them via GET /v1/chats/{chat_id} (see the API reference).

All chat.* events are emitted only if some endpoint in the organization is subscribed: the emitter checks this beforehand against a one-minute cache per org and event, so a new endpoint can take up to 60 s to start receiving any of them (not just chat.message.created).

chat.closed

FieldTypeNotes
chat_idstring
chatbot_idstring
reasonstringidle (inactivity), visitor (asked for a new conversation), agent (from the dashboard) or api.
summarystring|nullTwo- or three-sentence summary generated on close. null if there were no visitor messages or the model failed.
resolvedbool|nullWhether the summarising model judges that the conversation was resolved without needing anyone else. null if there was no summary or the model gave no verdict. It is what the Results dashboard counts.
message_countintMessages in the conversation (approximate: updated every minute).
closed_atRFC 3339
closed_bystring|nullUser who closed it from the Inbox or the dashboard; null if it was closed by the system, the visitor or the API.
handled_by_user_idstring|nullThe person who handled it; null if only the bot handled it.
category_idstring|nullCategory chosen on close (the same ones used for calls). If nobody chose one, the model suggests one when summarising, only among the categories that exist.
visitorobjectSame fields as in chat.started.

It is emitted only once per close, even if two paths close the conversation at the same time: the close is conditional in the database and only the one that succeeds notifies. The idempotency key is the chat plus the instant of the close, so a conversation that is reopened and closed again emits again (with the final summary). It arrives a few seconds AFTER the close, because it waits for the summary.

chat.handed_off

FieldTypeNotes
chat_idstring
chatbot_idstring
reasonstringvisitor (widget button), the reason the bot gave in its own words, or the API reason (api if you didn't send one).
handed_off_atRFC 3339 | null
visitorobjectSame fields as in chat.started.

It only fires when the conversation actually enters the queue (someone is assigned and online, and it is within business hours). If nobody is available, the conversation switches to “leave a message” and it is not emitted. The idempotency key is the chat plus the instant of the handoff: a conversation that is reopened and asks for a person again emits again.

chat.rated

FieldTypeNotes
chat_idstring
chatbot_idstring
ratingintFrom 1 to 5.
commentstring|nullWhat the visitor wrote (up to 1,000 characters). Verbatim text.
handled_by_user_idstring|nullThe person who handled the conversation; null if only the bot handled it.

A conversation can be rated only once: a second rating is rejected and emits nothing.

chat.qa_scored

FieldTypeNotes
chat_idstring
chatbot_idstring
handled_by_user_idstringThe person being evaluated.
qa.scoreint0-100.
qa.summarystringOne or two sentences. May quote the conversation.
qa.answersarrayOne {q, a} entry per rubric criterion or question.
qa.flagsarrayQuotable issues; empty if there are none.
qa.confidencenumber0-1. Lower for trivial conversations or a truncated transcript.

These fields are fixed: they are always produced by the chatbot conversation evaluator (in call qa.scored, by contrast, the shape depends on the org's template). It only fires for chatbots with evaluation turned on, on plans that include human handoff and quality evaluation, and for conversations with at least one message from a team member. It arrives a few seconds after chat.closed; if the evaluation fails it is retried (up to three attempts in total, counting the first) and nothing is emitted in the meantime. With no balance, or once the chatbot's daily cap is reached, it is postponed, and 7 days after the close it is abandoned.

topic.matched

FieldTypeNotes
topic_idstringTopic id.
topicstringTopic name, as the org wrote it.
channelstringcall or chat.
call_idstringOnly when channel is call.
chat_idstringOnly when channel is chat.
sentimentstring | nullpositive, neutral, mixed or negative: how the customer leaves the conversation.
evidencestring | nullShort quote from the conversation that shows the topic. It is customer text.
occurred_atRFC 3339Start of the conversation.

Sent once per topic and conversation, only for topics with notifications turned on. For chats it arrives when the conversation closes, with the summary; for calls, after the transcription and only if the org also analyses calls (Reports → Topics), because each analysis uses credits.

3. Headers

HeaderValue
Content-Typeapplication/json
X-Alpire-EventThe event's type
X-Alpire-DeliveryULID of this delivery. Stable across retries → use it as the idempotency key.
X-Alpire-TimestampUnix time in seconds, the one that is signed. It changes on every attempt, and so does the signature.
X-Alpire-Signaturesha256=<hex>. Right after rotating the secret, two of them, comma-separated (§4).

We don't send a header with the retry number. To tell whether a delivery is a retry, check whether you have already processed that X-Alpire-Delivery.

Deduplicate by X-Alpire-Delivery (and by the envelope's id, which is also stable across retries), never by the timestamp or the signature: both are recalculated on every attempt, so a retry of the same delivery arrives with a different timestamp and a different signature.

4. Verifying the signature

Stripe-style: "<timestamp>.<raw-body>" is signed with HMAC-SHA256 using the endpoint's secret, and the result is hex-encoded. The secret is used as is, as text: the bytes of the string the dashboard shows you. Don't hex-decode it (in the n8n or Make Crypto node, the key goes in as text, not as “hex”).

signature = hex( HMAC_SHA256( secret, timestamp + "." + body ) )

If you verify in n8n or Make, two precautions: the HMAC node's key goes in as text (not “hex”), and the signature is computed over the body exactly as it arrived. In n8n, turn on the “Raw Body” option of the Webhook node so you get it without re-serialisation; in Make, don't rebuild the JSON before computing the HMAC. If the body has been rewritten on the way, the signature won't match even though the secret is correct.

The secret is shown only once, when you create the webhook. If you lose it, rotate it from the dashboard (“Rotate secret” button when editing the webhook): there is no need to delete and recreate the webhook.

Four rules you must not skip:

  1. Compute the signature over the raw body, before parsing the JSON. If you re-serialise it, whitespace and key order change and the signature won't match.
  2. Compare in constant time (timingSafeEqual / hmac.compare_digest), not with ==.
  3. Reject the delivery if X-Alpire-Timestamp or X-Alpire-Signature is missing, and then reject old timestamps (5 minutes is reasonable), or you will accept replays. The order matters: in JavaScript Number(undefined) is NaN and Math.abs(NaN) > 300 is false, so an age check that doesn't first check for the header lets a request without it through; in Python int("") raises and your server answers 500.
  4. The header may carry several comma-separated signatures, and the delivery is valid if any of them matches your secret.

Rotating the secret without downtime

When you rotate the secret in the dashboard, for 24 hours every delivery carries two signatures: first the one made with the new secret, then the one made with the previous secret (sha256=<new>,sha256=<previous>). That way you can change the secret in your automation whenever it suits you: while you still have the old one, the second signature matches; as soon as you set the new one, the first one matches. After 24 hours only the new one is sent. The dashboard shows until when the dual signature lasts.

If you rotate twice within 24 hours, the “previous” signature becomes the one from the first rotation: the original secret stops working at that moment.

If you are rotating because the secret has leaked, change yours as soon as possible: the dual signature gives nothing to whoever holds the old secret (with it they could already sign whatever they wanted), but your endpoint keeps accepting it until you change it.

Node.js / Express

const crypto = require('crypto');
const express = require('express');
const app = express();

// The delivery is valid if ANY of the signatures in the header matches your secret.
function signatureIsValid(secret, ts, body, header) {
  const expected = Buffer.from('sha256=' + crypto
    .createHmac('sha256', secret)
    .update(ts + '.' + body)
    .digest('hex'));
  return header.split(',').some((f) => {
    const received = Buffer.from(f.trim());
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const ts   = req.get('X-Alpire-Timestamp');
  const sig  = req.get('X-Alpire-Signature');
  const body = req.body.toString('utf8');       // RAW body

  // Check that they exist first: without this, Number(undefined) is NaN and the age check
  // on the next line rejects nothing.
  if (!ts || !sig || !/^\d+$/.test(ts)) return res.status(400).end();
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.status(400).end();
  if (!signatureIsValid(process.env.WEBHOOK_SECRET, ts, body, sig)) return res.status(401).end();

  const event = JSON.parse(body);
  // Idempotency: if you have already seen req.get('X-Alpire-Delivery'), answer 200 and don't repeat.
  res.status(200).end();                        // respond NOW, process in the background
});

app.listen(3000);

Python / Flask

import hmac, hashlib, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["WEBHOOK_SECRET"]            # the secret as is, as text

def signature_is_valid(secret: bytes, ts: str, body: bytes, header: str) -> bool:
    expected = ("sha256=" + hmac.new(secret, ts.encode() + b"." + body, hashlib.sha256).hexdigest()).encode()
    # The delivery is valid if ANY of the signatures matches (two during a rotation).
    # As bytes: with str, a header with non-ASCII characters raises TypeError and you answer 500.
    return any(hmac.compare_digest(f.strip().encode(), expected) for f in header.split(","))

@app.post("/webhook")
def webhook():
    ts   = request.headers.get("X-Alpire-Timestamp", "")
    sig  = request.headers.get("X-Alpire-Signature", "")
    body = request.get_data()                    # raw bytes

    # First check that they exist and are what they claim to be: int("") raises and Flask answers 500.
    # And a bare `isdigit()` accepts non-ASCII digits (“²”): `int("²")` raises too.
    if not (ts.isascii() and ts.isdigit()) or not sig:
        abort(400)
    if abs(time.time() - int(ts)) > 300:
        abort(400)
    if not signature_is_valid(SECRET.encode(), ts, body, sig):
        abort(401)

    return "", 200

PHP

<?php
$secret = getenv('WEBHOOK_SECRET');               // the secret as is, as text
$ts     = $_SERVER['HTTP_X_ALPIRE_TIMESTAMP'] ?? '';
$sig    = $_SERVER['HTTP_X_ALPIRE_SIGNATURE'] ?? '';
$body   = file_get_contents('php://input');       // RAW body

// First check that they exist and are what they claim to be.
if (!ctype_digit($ts) || $sig === '') { http_response_code(400); exit; }
if (abs(time() - (int)$ts) > 300)     { http_response_code(400); exit; }

$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, $secret);
$valid = false;
// The delivery is valid if ANY of the signatures matches (two during a rotation).
foreach (explode(',', $sig) as $f) {
    if (hash_equals($expected, trim($f))) { $valid = true; }   // constant time
}
if (!$valid) { http_response_code(401); exit; }

http_response_code(200);                          // respond NOW, process in the background

From the command line (openssl), to check a delivery by hand. $TS is that delivery's X-Alpire-Timestamp and body.json is its raw body, byte for byte, with no newline added:

printf '%s.' "$TS" | cat - body.json | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex
# → the signature in hexadecimal; the header has “sha256=” in front

Test vector. Before connecting your endpoint, check that your code produces this signature (it is the same vector that an Alpire test guards):

secret    = whsec_secreto_de_prueba
timestamp = 1700000000
body      = {"id":"evt_TEST","type":"call.ended"}
signature = 88dd9e1d959207951b1d2a98f66149531c4c469118aa9ca471d73f16410cef63
header    = sha256=88dd9e1d959207951b1d2a98f66149531c4c469118aa9ca471d73f16410cef63

The exact string that is signed is 1700000000.{"id":"evt_TEST","type":"call.ended"}.

5. Response codes and retries

The only thing we look at is the HTTP status code. The body of your response is ignored.

Your responseWhat we do
2xx (any)Delivery marked as delivered. Done.
Any other code (3xx, 4xx, 5xx)Retry until the 8 attempts are used up.
Network / TLS error / timeoutRetry just the same. Request timeout: 10 s.
URL rejected by the anti-SSRF filterImmediately failed, no retries.
The endpoint's hostname could not be resolvedRetry just the same (it is usually transient).
Endpoint deleted while the delivery was waitingImmediately failed, and its content is deleted.

You have 10 seconds to respond. Respond 2xx as soon as you have validated the signature and do the work in the background; if you process inline and take too long, we will retry a delivery that you actually did process.

Backoff between attempts (seconds), based on the attempts already made:

Attempts madeWait until the next one
160 s
25 min
330 min
42 h
56 h
6 and beyond24 h

After 8 attempts (about 2.4 days) the delivery moves to failed (dead-letter): it shows up in the webhook's Deliveries list with its last error, in Settings → Debugging as webhook_failed, and we email the organization's administrators the first time it happens. We won't notify about that webhook again until it delivers successfully once more: with the endpoint down every delivery fails, and a hundred identical emails help nobody.

Redeliver. A failed delivery that still keeps its content (about 7 days after it failed) and shows up in “Deliveries” (created less than 8 days ago) can be redelivered from there, while the webhook is active: it goes back into the queue with the 8-attempt ladder reset and the same X-Alpire-Delivery, since your endpoint never got to process it. Only failed deliveries can be redelivered, not delivered ones. Two things worth knowing:

  • Retention of pending deliveries counts from when the delivery was created, not from when you redelivered it. Redelivered more than about 4.6 days after it was created, it doesn't have time to complete the ladder (8 attempts take 2.4 days) before the purge discards it at 7 days; and once those 7 days have passed it will only get the immediate attempt. Redeliver early.
  • If your endpoint was down for several days, the oldest deliveries can no longer be redelivered (nor do they show in “Deliveries” after 8 days from their creation): reconcile with GET /v1/calls.

No guaranteed ordering

Deliveries go out in parallel —several at once, spread across organizations and, with several nodes, from more than one of them— and a retry goes out behind whatever was generated later: a call.started that fails on the first attempt may reach you after its call.ended. If your integration needs ordering:

  1. Group by resource: call_id, chat_id, an appointment's event_id, callback_id…
  2. Sort by the timestamps in data (answered_at, ended_at, closed_at, handed_off_at…), not by arrival order. The envelope's created_at works as a tie-breaker, but it is when the event was generated: for the sweep events (call.started, call.ended, call.missed, transcript.ready) it is the time of the sweep, not of the call.
  3. When updating your copy, discard any event older than the last one you have already applied for that resource.

⚠️ A 4xx does not stop retries. Today we don't distinguish "I can't process this right now" from "this will never work": a 400 or a permanent 410 Gone is retried all 8 times just the same, spread over about 2.4 days. If your endpoint goes away, delete or pause the webhook in the dashboard; returning an error is not enough. Chat destinations (§8) do make the distinction, because we know what each platform code means.

6. Status in the dashboard

The webhook list shows the result of the last attempt:

ValueMeaning
ok:200Last attempt succeeded, with its status code.
err:500 (or any other code)Last attempt returned that HTTP status code.
err:redCould not connect: TLS, timeout, connection refused.
err:dnsWe could not resolve the endpoint's hostname on that attempt. It is retried normally; if it keeps happening, check your domain's DNS.
err:URL rechazada: …The URL is not http/https or resolves to a disallowed address (§7). Those deliveries move to failed without retries: change the URL.
err:secretoWe could not decrypt the endpoint's secret in order to sign. The fault is ours and deliveries end up failed: rotate the secret and let us know.
err:429Chat destinations only (§8): the platform asked us to wait (rate limit). It is retried when it says, without using up an attempt.
err:formatoChat destinations only (§8): the notice could not be written, almost always a Telegram URL without chat_id. Those deliveries move to failed: fix the URL.

The Deliveries button shows the last 100 deliveries created in the last 8 days: when, event, status, attempts, next attempt and the last error of each one, with “Redeliver” on the failed ones. Some platform errors always appear in Spanish, whatever the dashboard language: endpoint eliminado, reenvío manual, caducada por retención, destino sin resolver: … and secreto del endpoint no descifrable.

Pausing a webhook freezes its pending deliveries (they don't use up attempts in the meantime). Each delivery is kept for 7 days from when it was created: any that go past that before you reactivate the webhook are discarded. The dashboard tells you how many are left when you pause. And what happens during the pause is not handled the same way:

  • Events emitted on the spot (chat.*, appointment.*, callback.created, campaign.*, qa.scored, call.transferred) are lost: they are not stored for later.
  • The sweep events (call.started, call.ended, call.missed, transcript.ready) do arrive when you reactivate, all at once (up to 500 per event and 15 s cycle) and with their full content —transcript.ready carries the transcript—, even if the pause lasted for months: every call after the webhook was created that had not been sent yet goes out.

If you don't want that bulk delivery, delete the webhook and create it again instead of reactivating it.

Retention. The content of each delivery is kept for 7 days (a deliberately short retention: transcript.ready carries PII) and then purged; the purge runs every hour. The 7 days count from when the delivery finishes (delivered or failed), or from when it was created if it is still pending: that is why a failed delivery lives about 9.4 days from its creation (2.4 of retries plus 7). For the three sweep events the whole row is not deleted, only its content: a minimal record remains, which does not show in “Deliveries”, so that call is not emitted again.

7. URL restrictions

Only http:// and https://. DNS is resolved before sending, and the request is rejected if any resulting IP is private or special-purpose:

IPv4What it is
0.0.0.0/8“This network” / unspecified
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16Private (RFC 1918)
100.64.0.0/10CGNAT
127.0.0.0/8Loopback
169.254.0.0/16Link-local (cloud metadata)
192.0.0.0/24IETF protocol assignments (includes NAT64)
192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24Documentation
198.18.0.0/15Benchmarking
224.0.0.0/4Multicast
240.0.0.0/4Reserved (includes 255.255.255.255)
IPv6What it is
::1, ::Loopback and unspecified
fc00::/7Unique local addresses (ULA)
fe80::/10Link-local
ff00::/8Multicast
::ffff:0:0/96, ::ffff:0:0:0/96, ::/96, 64:ff9b::/96, 2002::/16Embedded IPv4 (mapped, translated, compatible, NAT64 and 6to4): the inner IPv4 is checked against the table above
fec0::/10, 64:ff9b:1::/48, 2001::/32Site-local (deprecated), local-use NAT64 and Teredo

The platform's own IPs are not accepted either.

The resolved IP is pinned for that request, so DNS rebinding doesn't get through either.

It is validated when the webhook is created and on every delivery. In practice: your endpoint has to be on the public Internet. For development, use a tunnel (ngrok, Cloudflare Tunnel).

8. Chat destinations: Slack, Discord, Teams, Google Chat, Telegram, Webex and ntfy

If what you want is for your team to hear about it in their channel —“📵 Missed call”, “📅 New appointment”, “🙋 A visitor wants to talk to a person”—, there is no need to put an n8n in between: when you add the webhook, pick the Destination and paste the URL the platform gives you. With “Detect from the URL” it is worked out for you.

DestinationThe URLWhat arrives
SlackAn app's Incoming Webhook (https://hooks.slack.com/services/…). Mattermost and Rocket.Chat: their incoming webhook URL.A message with a colour bar and fields (the attachments format, which all three understand).
DiscordChannel settings → Integrations → Webhooks (https://discord.com/api/webhooks/…). Optional: the name the notices appear under and the ID of a thread or a forum post.An embed, with mentions disabled: nothing in the notice can ping @everyone or a role.
Microsoft TeamsThe channel workflow “Post to a channel when a webhook request is received”.An adaptive card with the “Open in Alpire” button.
Google ChatSpace → Apps & integrations → Webhooks (https://chat.googleapis.com/v1/spaces/…).A formatted text message.
TelegramThree fields, no URL: the bot token (@BotFather), the chat ID (or @channel) and, optionally, the group topic. The bot has to be added to the group.An HTML message, without link previews.
WebexThe “Incoming Webhooks” app from the Webex App Hub, connected to a space (https://webexapis.com/v1/webhooks/incoming/…).A markdown message: bold title, fields as a list and the “Open in Alpire” link.
ntfyhttps://ntfy.sh/<topic>, or your topic's URL on your own server (always https). A topic can only have letters, digits, _ and - (up to 64): it is its password, so pick one that is hard to guess.A push notification with title, plain text and an “Open in Alpire” button; high priority for missed calls.

When you pick the destination, the dashboard asks for what that destination needs and nothing else. Telegram builds the URL from its fields (https://api.telegram.org/bot<TOKEN>/sendMessage?chat_id=<ID>, plus &message_thread_id=<N> if there is a topic); with “Detect from the URL” pasting the whole thing also works. On Discord, the thread is ?thread_id=<ID> at the end of the webhook URL (required to post in a forum channel) and the name is ?username=<name>, which Alpire takes out of the URL and sends in the body (Discord allows 1 to 80 characters, without “discord” or “clyde” and without @ # :; with any other name the dashboard will not save). Slack, Teams, Google Chat, Webex and ntfy have no per-message settings. The settings live inside the saved URL, so there is nothing else to migrate, and a “Send to” block (§8.1) that uses that destination honours them. When editing, the chat, topic, thread and name are shown and can be changed; the token is never shown again and, left blank, the current one is kept. What changes compared to a generic webhook:

  • The notice comes written, in the dashboard language of whoever added the destination and with times in the organization's time zone, with an “Open in Alpire” link to the details: the call, the calendar, the callbacks, the campaign or the conversation in the inbox. Neither the envelope nor data arrive.
  • It is not signed and there is no secret to keep: the URL is the key to the channel. That is why, once saved, the dashboard shows it masked, and so does the failure email; to change it, paste another one.
  • The content never goes out: not the text of transcript.ready (the notice says it is ready and links to the call), not the flow vars, not the chat messages: chat.message.created is not offered for these destinations. The caller's number and name, the visitor's name and email, the appointment details and the summaries of chat.closed and chat.qa_scored do go out, and anyone in that channel will see them.
  • With no boxes ticked it does not get “all”, but what someone has to act on: call.missed, appointment.booked, appointment.cancelled, callback.created and chat.handed_off.
  • A 4xx does stop it: a 400, 401, 403, 404 or 410 from the platform means a deleted webhook, an archived channel, a removed bot or a rejected notice, and the delivery moves to failed right away, with what the platform said in the error and the email to the administrators. A 429 (rate limit) is retried when Retry-After says, without using up an attempt.
  • Whatever comes from outside (the caller's name comes from their SIP header; the visitor types their own) is escaped for each platform: nobody can slip a mention or a link with a different text into your channel.
  • Changing the destination of an existing webhook (from generic to Discord, from Slack to Teams…) is like adding it anew: it counts from that moment, without the earlier calls.

The Send test button sends a connection notice that says which events are going to arrive.

8.1 Messages from a flow: the «Send to» block

A chat destination can also be used by a flow to post in the channel whenever you want, with your own text: the Send to block (send_to) of the flow editor picks one of these destinations and posts the message you write, with call variables ({{vars.caller_number}}…). If the organization has no active chat destination, the block itself says so in the editor with a link to this screen, and a flow cannot be published pointing to a deleted, disabled or generic one.

  • Chat destinations only. A generic webhook does not show up in the block: it receives the signed envelope described in this document, and unsigned free text would break that contract.
  • It is a separate output channel, not an event: it does not go through the delivery queue, it is not retried or moved to failed, it does not count towards last_status and the list of ticked events does not affect it. It is sent right away with a few seconds of timeout (1–10, 5 by default); if the platform does not answer in time or rejects the message, the flow carries on through the block's error output and the failure class is left in {{vars.send_to_error}} (timeout, http_404, disabled…, never the URL).
  • A cap of 20 messages per destination per minute (per node): a loop in the flow cannot flood the channel or use up the platform limit that event notices need.
  • Whatever comes from a variable (what the caller says or keys in, what an API returns) is escaped for the platform: no mentions, no links with a different text and no formatting. Your own text goes as is. On Discord mentions are disabled altogether, and Telegram and ntfy get plain text.
  • Nothing is sent in Test.

Known limitations

  • No guaranteed ordering between deliveries, not even for the same resource. How to order them: §5.
  • A 4xx does not stop retries on a generic webhook (§5; on chat destinations it does, §8): if your endpoint goes away, delete or pause the webhook in the dashboard.
  • The data of appointment.booked differs depending on who books: voice bookings carry no end, source or resource_id; bookings from the dashboard and the API carry no call_id (and the key is omitted, it doesn't arrive as null). The table in section 2.bis details it field by field.
  • All deliveries go out in cycles of about 15 s, so an event can take that long to arrive. call.ended for a recorded call additionally waits up to a minute for the recording to be ready (about 75 s in the worst case).
  • Each cycle sends at most 50 deliveries per node, up to 8 at a time, spread across organizations, and the sweep materialises up to 500 calls per event and cycle. With high volume a queue builds up and the delay grows with it; chat.message.created generates one delivery per message and weighs the most.
  • If your endpoint's hostname resolves to several IPs, only the first one is used: if it is down, no other is tried.
  • A re-transcription does not re-emit transcript.ready (it already went out once for that call), and delivered deliveries cannot be redelivered, only failed ones.
  • The body size has no cap, except vars in call.ended (16 KB, with vars_truncated): a long transcript can be a POST of several megabytes, and campaign.contact_completed.vars is not trimmed either.
  • Incoming webhooks from Slack, Discord or Teams expect their own format, not our envelope: they will reject the deliveries with a 4xx and they will end up failed after 8 attempts. Put n8n, Make or Zapier in between.
  • We don't send a User-Agent header of our own and we don't publish outbound IPs: don't filter by IP or by user agent to validate deliveries; validate the signature.