Skip to content

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":{ … }}
FieldMeaning
webhook-idThe event's id, the same value as event_id. Signature verification reads it from here.
webhook-timestampWhen this attempt was sent (Unix seconds).
webhook-signatureSignature of this attempt (below).
event_idThe event's id. It stays the same across retries and manual resends — use it to ignore duplicates.
typeThe event name.
timestampWhen the event happened.
dataThe 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:

FieldMeaning
customer_idThe customer — the person chatting or calling.
external_idThe 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_idThe widget the customer used.
agent_idThe 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 ​

EventWhendata
conversation.startedA 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.endedA 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=…"
}
  • duration and first_response_time are in seconds (first_response_time is null when no agent replied).
  • A conversation's conversation.started and conversation.ended have the same customer_id and started_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=ASC returns the oldest message first; the default is newest first.
  • max is the page size, up to 100. When pager.next is not null, append &cursor=<pager.next> to the same link for the next page.
  • sender_type is customer, agent, ai_agent or system.
  • media lists attachments as { id, name, mime, size, url }; each url expires one hour after you fetched the page.
  • deleted_by_customer_at is 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 ​

EventWhendata
call.startedA 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.endedAn answered call ends.the call (below)
call.missedA call ends without being answered.the call (below)
call.recording_readyThe 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"
}
  • status is ended for call.ended; for call.missed it is missed (nobody answered, or the caller gave up), rejected (an agent declined) or failed (the call could not be connected).
  • agent_id is the agent who answered; on call.missed, the agent the call was last offered to, or null.
  • ended_by is customer, agent or system — whose side the call ended on. A caller who hung up while it was ringing is missed with ended_by: "customer" and reason: "cancelled".
  • reason explains the ending when there is more to say (cancelled, timeout, queue_timeout, visitor_disconnected, agent_disconnected, …); it can be null.
  • hold_duration is 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 ​

EventWhendata
customer.createdA 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.updatedA 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.

Webfon live-support widget documentation