Theme
Webhooks
Webhooks let your own backend react to what happens in Webfon — a call that nobody answered, a conversation that ended, a customer who updated their email — without polling. Webfon sends an HTTPS POST to a URL you choose each time a subscribed event occurs.
Setting up an endpoint
In the admin panel, open Webhooks and add a webhook:
- URL — must be
https://and reachable from the internet. Addresses in private or internal ranges (10.x,192.168.x,127.0.0.1, cloud metadata addresses …) are refused at send time. - Events — the events this endpoint receives (see Events).
- Active — a switched-off webhook receives nothing.
Each webhook has its own signing secret (whsec_…), shown on the webhook's page. Use it to verify every request (below). If it leaks, regenerate it — the next delivery is signed with the new one.
Request format
http
POST /your/endpoint HTTP/1.1
Content-Type: application/json
webhook-id: 0192f3c1-7a2e-7c4d-9b1e-5f0a3c2d1e4b
webhook-timestamp: 1758621600
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
X-Webhook-Event: call.missed
User-Agent: Webfon-Webhook/1.0
{"event_id":"0192f3c1-7a2e-7c4d-9b1e-5f0a3c2d1e4b","type":"call.missed","timestamp":"2026-09-23T10:00:00+00:00","data":{ … }}| Field | Meaning |
|---|---|
webhook-id | The event's id, the same value as event_id. Signature verification reads it from here. |
webhook-timestamp | When this attempt was sent (Unix seconds). |
webhook-signature | Signature of this attempt (below). |
event_id | The event's id. It stays the same across retries and manual resends — use it to ignore duplicates. |
type | The event name. |
timestamp | When the event happened. |
data | The event's content (see Events). |
Verifying signatures
Requests are signed following the Standard Webhooks specification, so you can verify them with one of its ready-made libraries (Node.js, Python, PHP, Go, Java, Ruby, C# …) using your webhook's secret:
js
import { Webhook } from 'standardwebhooks';
const wh = new Webhook(process.env.WEBFON_WEBHOOK_SECRET); // "whsec_…"
app.post('/webfon', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = wh.verify(req.body, req.headers); // throws on a bad signature or a stale timestamp
} catch {
return res.status(401).end();
}
res.status(204).end(); // answer first …
handle(event); // … then do the work
});Verify against the raw request body, byte for byte — re-serialising parsed JSON changes the bytes and the signature no longer matches. To verify by hand: the signed content is {webhook-id}.{webhook-timestamp}.{raw body}, the key is your secret without its whsec_ prefix, base64-decoded, and the signature is v1, followed by the base64 HMAC-SHA256. Reject requests whose timestamp is more than a few minutes old.
Responses, retries and resending
- Answer with any 2xx status within 10 seconds. Anything else — another status, a timeout, a connection error — counts as a failure.
- A failed delivery is retried twice, at least 10 minutes apart. After that it is marked failed.
- Deliveries are not guaranteed to arrive in order, and the same event can arrive more than once (for example a retry after your server did process it but answered too late). Deduplicate on
event_id. - Every attempt is listed in the panel's webhook log with the request and your response. Log entries are kept for 14 days.
- Links inside an event (
messages_url, recording URLs) are generated for each attempt, so a retry always carries working links.
Events
An event's data is the record the event is about, as it was at that moment — a delivery that arrives late, or a retry, still carries the values the event had. Related records appear as ids:
| Field | Meaning |
|---|---|
customer_id | The customer — the person chatting or calling. |
external_id | The customer's id on your side: your own user id for members who signed in through your app, the Telegram user id for Telegram contacts, and a Webfon-generated id for anonymous visitors. |
provider_id | The widget the customer used. |
agent_id | The agent — a user of your organization in the admin panel — or null. |
Keep what customer.created and customer.updated send if you need a customer's details alongside their calls and conversations.
Conversations
| Event | When | data |
|---|---|---|
conversation.started | A conversation opens — the customer's first message, or an agent reopening or taking over a closed one. | customer_id, provider_id, external_id, started_at |
conversation.ended | A conversation is closed. | id, customer_id, provider_id, agent_id, started_at, closed_at, duration, first_response_time, messages_url |
conversation.ended:
json
{
"id": "5b0c…",
"customer_id": "0192f3c1-…",
"provider_id": "0192f2aa-…",
"agent_id": "0192f1d0-…",
"started_at": "2026-09-23T09:40:00+00:00",
"closed_at": "2026-09-23T10:05:00+00:00",
"duration": 1500,
"first_response_time": 32,
"messages_url": "https://api.webfon.io/v1/public/webhook/conversations/5b0c…/messages?_expiration=…&_hash=…"
}durationandfirst_response_timeare in seconds (first_response_timeisnullwhen no agent replied).- A conversation's
conversation.startedandconversation.endedhave the samecustomer_idandstarted_at— pair them on those two.
Fetching the messages
conversation.ended does not contain the messages themselves — a long conversation would make every delivery huge. Fetch them from messages_url with a plain GET, within 24 hours of the delivery; the link needs no other credentials, so treat it like a password.
bash
curl "$MESSAGES_URL&sort=ASC&max=100"json
{
"data": [
{
"id": "0192f3c1-…",
"sender_type": "customer",
"sender_agent": null,
"content": "Hi, my order has not arrived.",
"reply_to_id": null,
"media": [],
"deleted_by_customer_at": null,
"created_at": "2026-09-23T09:40:00+00:00"
}
],
"pager": { "max": 100, "next": "0192f3c9-…", "sort": "ASC" }
}sort=ASCreturns the oldest message first; the default is newest first.maxis the page size, up to 100. Whenpager.nextis notnull, append&cursor=<pager.next>to the same link for the next page.sender_typeiscustomer,agent,ai_agentorsystem.medialists attachments as{ id, name, mime, size, url }; eachurlexpires one hour after you fetched the page.deleted_by_customer_atis set when the customer cleared the message from their own chat window. Your agents still see those messages in the panel; decide for yourself whether you keep them.
Calls
| Event | When | data |
|---|---|---|
call.started | A customer starts a call (it rings, or waits in the queue while every agent is busy). | id, customer_id, provider_id, external_id, status, created_at |
call.ended | An answered call ends. | the call (below) |
call.missed | A call ends without being answered. | the call (below) |
call.recording_ready | The call's recording files are available. | id, customer_id, provider_id, external_id, recordings |
call.started's status is ringing, or pending while the call waits in the queue.
call.ended and call.missed share one shape:
json
{
"id": "0192f3d0-…",
"customer_id": "0192f3c1-…",
"provider_id": "0192f2aa-…",
"external_id": "user-42",
"agent_id": null,
"status": "missed",
"reason": "cancelled",
"ended_by": "customer",
"hold_duration": 18,
"created_at": "2026-09-23T10:00:00+00:00",
"answered_at": null,
"ended_at": "2026-09-23T10:00:18+00:00"
}statusisendedforcall.ended; forcall.missedit ismissed(nobody answered, or the caller gave up),rejected(an agent declined) orfailed(the call could not be connected).agent_idis the agent who answered; oncall.missed, the agent the call was last offered to, ornull.ended_byiscustomer,agentorsystem— whose side the call ended on. A caller who hung up while it was ringing ismissedwithended_by: "customer"andreason: "cancelled".reasonexplains the ending when there is more to say (cancelled,timeout,queue_timeout,visitor_disconnected,agent_disconnected, …); it can benull.hold_durationis the waiting time in seconds — until an agent answered, or until the call ended unanswered.
call.recording_ready:
json
{
"id": "0192f3d0-…",
"customer_id": "0192f3c1-…",
"provider_id": "0192f2aa-…",
"external_id": "user-42",
"recordings": [
{ "id": "…", "name": "customer_audio.webm", "mime": "audio/webm", "size": 731234, "url": "https://…" }
]
}Recording URLs are valid for 24 hours — download the files if you want to keep them. They play in the Recording Player.
Customers
| Event | When | data |
|---|---|---|
customer.created | A new customer: a first message, a first call, a new Telegram contact or a member signing in for the first time. | the customer (below) |
customer.updated | A customer's name, username, email, phone, block state, account deactivation or dedicated agent changes. | id, provider_id, external_id, changes |
customer.created:
json
{
"id": "0192f3c1-…",
"provider_id": "0192f2aa-…",
"external_id": "user-42",
"first_name": "Ada",
"last_name": "Lovelace",
"username": null,
"email": "[email protected]",
"phone": null,
"language": "en",
"country_code": "GB",
"auth_provider": "google",
"created_at": "2026-09-20T08:12:00+00:00"
}auth_provider is how a member signed in — custom for a login through your own app (and through the Telegram Mini App), or google, facebook, apple, github, x — and null for anonymous visitors and Telegram chat contacts.
changes holds each changed field with its new value, e.g. {"email": "[email protected]"}. The fields are first_name, last_name, username, email, phone, is_blocked, deactivated_at and private_agent_id (the customer's dedicated agent, null when removed). A deactivated_at that becomes a date means a member closed their account from the chat window — the moment to run your own account-deletion flow.