Skip to content

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:

  1. 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.
  2. 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 it
  • url must be https and 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.
  • events defaults to ['*'], every event including any added later. Name only the ones you handle.
  • An application has at most 16 endpoints; a seventeenth is refused 409 conflict.
  • 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 its url — and changes the one it finds with talkline.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_9a2b4c6d8e0f1a3b5c7d

X-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 ​

EventWhenWhat it is for
call.ringingAn invite went out. needsPush: true when no device of the invitee's could be reachedRinging a phone through your own push, as a fallback
call.answeredSomeone answered: by, answeredAtStarting your own timers, showing "in call"
call.endedAn answered call ended: durationSec, billableSec, endReason. Also sent for a call whose room ended while it was still ringing, with both durations nullBilling. billableSec is what to charge for
call.missedNobody answered before the ring timeout, or nobody could be reachedMissed-call notifications
call.rejectedThe callee declined
call.cancelledThe caller hung up before anyone answered
room.finishedA room endedCleaning up anything you keep per room
queue.enteredA queue call joined its queueAgent dashboards
queue.assignedA member answered it: assignedIdentity, attemptsLinking the call to your ticket
queue.abandonedThe caller hung up while waitingAbandonment rates
queue.overflowedThe queue gave up and its overflow action is webhookYour own fallback — a callback request, an email
recording.startedA recording started: recordingSid, roomSid, consentRequiredShowing it in your own records
recording.finishedA recording's files are uploaded: the recording, with its filesFetching the files — see Recording a room
recording.failedA recording stopped because its media server or recorder went, or never started: what it captured, partialTelling whoever relied on it
usage.thresholdA quota or an identity's credit crossed a configured percentageWarning 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=true accepts http URLs and private and loopback addresses, such as http://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 https address 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.

Talkline documentation