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