Appearance
Webhooks
Webhooks are how your backend learns what Talkline knows and it does not: that a call was answered, that it ended and how long it was billed, that a queue handed a caller to an agent. Talkline sends a signed POST to each endpoint you register.
Two rules come before anything else:
- Deliveries are at least once. The same delivery can arrive twice — a timeout on your side, a retry on ours. Do each delivery's work once per
id. - Verify the signature over the raw body, before any JSON parser has touched it. A body parsed and serialised again is not the body that was signed.
Registering an endpoint
ts
const endpoint = await talkline.webhooks.create({
url: 'https://app.example.com/talkline/webhooks',
events: ['call.answered', 'call.ended', 'call.missed', 'call.rejected', 'call.cancelled'],
});
console.log(endpoint.secret); // whsec_… — store it now; no later answer shows iturlmust behttpsand reach the public internet. Private and loopback addresses are refused, when you register and again at every delivery attempt, and a redirect is a failed attempt rather than followed. A development deployment can be told to allow them — Testing your receiver says how.eventsdefaults to['*'], every event including any added later. Name only the ones you handle.- An application has at most 16 endpoints; a seventeenth is refused
409conflict. - Registering a URL again does not find the endpoint you made before: it makes a second one, with its own secret, and every event then arrives twice, once for each, under two ids. Endpoints have no name. A setup script that may run more than once looks for its URL first —
talkline.webhooks.iterate()lists every endpoint with itsurl— and changes the one it finds withtalkline.webhooks.update(id, { events, status }). - The signing secret is in this answer and in the answer to a rotation, and nowhere else. To rotate it,
POST /v1/webhooks/{id}/secret(talkline.webhooks.rotateSecret(id)); every attempt after that is signed with the new one.
Receiving a delivery
Every delivery is one JSON envelope:
json
{
"id": "0f3c5e2a-…:call.ended:CS_9a2b4c6d8e0f1a3b5c7d",
"event": "call.ended",
"createdAt": "2026-10-12T09:41:07.512Z",
"data": {
"callSid": "CS_9a2b4c6d8e0f1a3b5c7d",
"roomSid": "RM_4f1c2a9e0b7d3c5a8e61",
"endReason": "hangup",
"durationSec": 184,
"billableSec": 370,
"endedAt": "2026-10-12T09:41:07.498Z"
}
}with three headers:
http
X-Talkline-Signature: t=1760262067,v1=5f0c…
X-Talkline-Event: call.ended
X-Talkline-Delivery: 0f3c5e2a-…:call.ended:CS_9a2b4c6d8e0f1a3b5c7dX-Talkline-Delivery is the envelope's id, and it is the same on every retry of that delivery: it is your idempotency key. Treat it as an opaque string. An id names one endpoint's copy: one event about one call reaches an endpoint once, under one id, and two endpoints subscribed to the same event receive it under two ids. Read the id from the envelope, which the signature covers, rather than from the header, which it does not.
With @talkline/server-sdk, parseWebhook verifies and parses in one step and returns the delivery typed by its event:
ts
import express from 'express';
import { parseWebhook, type WebhookDelivery } from '@talkline/server-sdk';
app.post('/talkline/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
let delivery: WebhookDelivery;
try {
delivery = parseWebhook(process.env.TALKLINE_WEBHOOK_SECRET!, req.body, req.get('x-talkline-signature'));
} catch {
return res.sendStatus(400);
}
// Inserting the id under a unique constraint is the simplest "once": a second insert fails.
const first = await db.query('INSERT INTO talkline_deliveries (id) VALUES ($1) ON CONFLICT DO NOTHING', [delivery.id]);
if (first.rowCount === 0) return res.sendStatus(204);
switch (delivery.event) {
case 'call.ended':
await billing.record(delivery.data.callSid, delivery.data.billableSec);
break;
case 'call.missed':
await notifications.missedCall(delivery.data.callSid);
break;
}
res.sendStatus(204);
});Answer quickly. Anything that takes more than a few seconds belongs in your own job queue: a slow answer is a timeout, and a timeout is retried.
Verifying without the SDK
The signature is a hex HMAC-SHA256, keyed with the whole secret string (whsec_…, as UTF-8), over the timestamp, a dot, and the raw body. Compare in constant time, and refuse a timestamp more than five minutes from now: the timestamp is inside what is signed, so a captured delivery cannot be replayed later.
python
import hashlib, hmac, time
def verify(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
parts = dict(item.split("=", 1) for item in header.split(","))
t = int(parts["t"])
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])Retries
A 2xx answer acknowledges a delivery. Anything else — another status, a timeout, a redirect, a connection refused — is a failed attempt, retried after 10 seconds, 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours: seven attempts, then the delivery is abandoned.
Twenty consecutive failed attempts move the endpoint to failing, and Talkline stops sending to it until a person resumes it, so a dead endpoint cannot pile up work. While an endpoint is paused or failing, its deliveries are held; setting it active again sends every held delivery, once each:
ts
await talkline.webhooks.update(endpoint.id, { status: 'active' });You can pause an endpoint yourself the same way (status: 'paused') — during a deploy of your receiver, for example.
GET /v1/webhooks/{id}/deliveries (talkline.webhooks.deliveries(id, { status })) is the endpoint's delivery log, newest first: every delivery, its attempts, and what your endpoint answered.
Which events to handle
| Event | When | What it is for |
|---|---|---|
call.ringing | An invite went out. needsPush: true when no device of the invitee's could be reached | Ringing a phone through your own push, as a fallback |
call.answered | Someone answered: by, answeredAt | Starting your own timers, showing "in call" |
call.ended | An answered call ended: durationSec, billableSec, endReason. Also sent for a call whose room ended while it was still ringing, with both durations null | Billing. billableSec is what to charge for |
call.missed | Nobody answered before the ring timeout, or nobody could be reached | Missed-call notifications |
call.rejected | The callee declined | |
call.cancelled | The caller hung up before anyone answered | |
room.finished | A room ended | Cleaning up anything you keep per room |
queue.entered | A queue call joined its queue | Agent dashboards |
queue.assigned | A member answered it: assignedIdentity, attempts | Linking the call to your ticket |
queue.abandoned | The caller hung up while waiting | Abandonment rates |
queue.overflowed | The queue gave up and its overflow action is webhook | Your own fallback — a callback request, an email |
recording.started | A recording started: recordingSid, roomSid, consentRequired | Showing it in your own records |
recording.finished | A recording's files are uploaded: the recording, with its files | Fetching the files — see Recording a room |
recording.failed | A recording stopped because its media server or recorder went, or never started: what it captured, partial | Telling whoever relied on it |
usage.threshold | A quota or an identity's credit crossed a configured percentage | Warning before a cut-off |
Exactly one of call.ended, call.missed, call.rejected and call.cancelled arrives for each call, at each endpoint subscribed to all four; exactly one of recording.finished and recording.failed for each recording. Every field of every event is in Webhook events.
A call.* event's endReason is the call's own record, the same as GET /v1/calls keeps: hangup when the person who placed the call hung up, remote_hangup when the other side did. In a client, the SDK tells each side its own: see Hanging up.
Named, not yet sent
room.started, participant.joined and participant.left can be subscribed to, and are not sent yet. An endpoint subscribed to * will start receiving them when they are.
Testing your receiver
Place a call from your backend to an identity nobody is signed in as — it rings nobody and ends unanswered — and watch your endpoint receive call.ringing and then call.missed:
ts
await talkline.calls.create({ from: 'test:caller', to: ['test:nobody'], ringTimeoutSec: 5 });A production deployment delivers only to https on the public internet. To receive deliveries on your own machine:
- From a development deployment you or a colleague runs, ask its operator to allow private addresses: a deployment started with
WEBHOOK_ALLOW_PRIVATE=trueacceptshttpURLs and private and loopback addresses, such ashttp://127.0.0.1:3000/talkline/webhooks, both when you register them and when it delivers. Never on a deployment other people's traffic reaches: it would deliver anywhere its own network can. - From any other deployment, give your receiver a public
httpsaddress with a tunnel to your machine, and register that.
A URL the deployment will not deliver to is refused at registration with 400validation_failed, and its detail says why.