Skip to content
Start free

Developers · API

v1

Receive webhooks

When a call ends and its summary is ready, LiveSpeech sends call.completed to your server. Use it when you want the result right away, without polling.

There is one webhook per account. You do not register one per line; the payload's tenantId tells you which line it belongs to.

1. Register a URL

PUT /v1/webhooks — send just the URL (url) to subscribe to every supported event (call.completed). To receive only some, list their names in events.

URL rules: it must be https, it cannot contain a username or password, and private or loopback addresses (localhost, 127.0.0.1, 10.*, 192.168.* and the like) are refused. It has to be reachable from the outside.

curl -s -X PUT "$LS_BASE_URL/v1/webhooks" \
  -H "Authorization: Bearer $LS_PARTNER_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://partner.example.com/livespeech/webhook" }'
const BASE_URL = process.env.LS_BASE_URL;
const API_KEY = process.env.LS_PARTNER_KEY;

const res = await fetch(`${BASE_URL}/v1/webhooks`, {
  method: 'PUT',
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ url: 'https://partner.example.com/livespeech/webhook' }),
});
const config = await res.json();
// config.signingSecret is returned in full only in this response — store it now.
import os, requests

BASE_URL = os.environ["LS_BASE_URL"]
API_KEY = os.environ["LS_PARTNER_KEY"]

res = requests.put(
    f"{BASE_URL}/v1/webhooks",
    headers={
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": "application/json",
    },
    json={"url": "https://partner.example.com/livespeech/webhook"},
)
config = res.json()
# config["signingSecret"] is returned in full only in this response — store it now.
{
  "url": "https://partner.example.com/livespeech/webhook",
  "events": ["call.completed"],
  "signingSecret": "whsec_0123abcd...",
  "createdAt": "2026-09-09T05:20:00.000Z",
  "updatedAt": "2026-09-09T05:20:00.000Z"
}

Read the current configuration with GET /v1/webhooks. If nothing is registered it answers 404 WEBHOOK_NOT_CONFIGURED.

{
  "url": "https://partner.example.com/livespeech/webhook",
  "events": ["call.completed"],
  "signingSecretHint": "ab12",
  "createdAt": "2026-09-09T05:20:00.000Z",
  "updatedAt": "2026-09-09T05:20:00.000Z"
}

A URL that breaks the rules answers 422 WEBHOOK_URL_INVALID (the reason is in error); an unsupported event name answers 422 UNKNOWN_EVENT, and nothing is saved. Every request and response field is in the API reference; failure responses are in Errors & limits.

2. What arrives

The request is a POST with a JSON body. tenantId says which line the call belongs to; callId is the key for de-duplication. The headers carry the event name (X-LiveSpeech-Event), a delivery identifier (X-LiveSpeech-Delivery, the same value as callId, unchanged across retries) and the signature (X-LiveSpeech-Signature).

{
  "event": "call.completed",
  "tenantId": "8f2c1d40-5b7a-4c31-9e08-1a2b3c4d5e6f",
  "callId": "6f1e0c2a-4b7d-4c1e-9a52-3f8d2f10ab34",
  "occurredAt": "2026-09-09T04:32:22.000Z",
  "summary": "The caller asked about the delivery status of an order and was told it ships today.",
  "resolution": "Resolved",
  "recordingAvailable": true
}

Every header and body field is in the API reference.

Phone numbers, transcripts and recording URLs are not included. summary, though, is free text summarizing the conversation, so it can contain a name, an order number or an address the caller mentioned — store and forward it as personal data.

3. Verify the signature

Check that a request really came from us. The steps:

  1. Split X-LiveSpeech-Signature into t and v1.
  2. Take the stored secret, drop the whsec_ prefix and hex-decode the rest to get the key.
  3. HMAC-SHA256 {t}.{raw request body} with that key and compare it with v1 in constant time.
  4. Reject the request if t is more than five minutes away from the current time.

Use the body exactly as received. Parsing it as JSON and serializing it again breaks the signature.

The examples read LS_WEBHOOK_SECRET from the environment — the signingSecret you stored when registering the webhook.

import crypto from 'node:crypto';
import express from 'express';

const app = express();

// Express: only this route receives the raw body.
app.post(
  '/livespeech/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const header = req.get('X-LiveSpeech-Signature') || '';
    const parts = Object.fromEntries(
      header.split(',').map((piece) => piece.split('=')),
    );
    const timestamp = Number(parts.t);
    if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) {
      return res.status(400).end();
    }

    const secret = process.env.LS_WEBHOOK_SECRET.replace(/^whsec_/, '');
    const key = Buffer.from(secret, 'hex');
    const expected = crypto
      .createHmac('sha256', key)
      .update(`${timestamp}.${req.body.toString('utf8')}`)
      .digest('hex');

    const given = Buffer.from(parts.v1 || '', 'utf8');
    const mine = Buffer.from(expected, 'utf8');
    if (given.length !== mine.length || !crypto.timingSafeEqual(given, mine)) {
      return res.status(401).end();
    }

    const event = JSON.parse(req.body.toString('utf8'));
    // If you have already handled this callId, just answer 200 here.
    enqueue(event);
    res.status(200).end();
  },
);
import hashlib
import hmac
import os
import time
from flask import Flask, request

app = Flask(__name__)

@app.post("/livespeech/webhook")
def livespeech_webhook():
    header = request.headers.get("X-LiveSpeech-Signature", "")
    parts = dict(piece.split("=", 1) for piece in header.split(",") if "=" in piece)

    try:
        timestamp = int(parts["t"])
    except (KeyError, ValueError):
        return "", 400
    if abs(time.time() - timestamp) > 300:
        return "", 400

    secret = os.environ["LS_WEBHOOK_SECRET"].removeprefix("whsec_")
    key = bytes.fromhex(secret)
    expected = hmac.new(
        key, f"{timestamp}.".encode() + request.get_data(), hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, parts.get("v1", "")):
        return "", 401

    event = request.get_json()
    # If you have already handled this callId, just answer 200 here.
    enqueue(event)
    return "", 200
# Verification happens in your server code. First just check that the registered URL is reachable.
curl -s -o /dev/null -w '%{http_code}\n' \
  -X POST "https://partner.example.com/livespeech/webhook" \
  -H "Content-Type: application/json" \
  -d '{"ping":true}'

4. Responses and retries

ItemValue
Counts as success2xx (the body is ignored)
Response timeout10 seconds
RedirectsNot followed — treated as failure
Retried5xx · 408 · 429 · no response
Not retriedAny other 4xx — treated as the request itself being refused
Retry intervalStarts at 1 minute and doubles each time, up to 1 hour
Total attempts8 (including the first delivery)

Queue heavy work and answer 200 first. A response that takes longer than 10 seconds counts as a failure and is sent again.

Delivery is at least once. The same callId can arrive twice on rare occasions, so de-duplicate by callId before processing.

5. Check the connection

After registering, make a test call to the connected number. The event goes out when the call ends and the summary is ready. If nothing arrives, check in order:

  1. GET /v1/webhooks — is the URL stored correctly?
  2. Is the URL reachable from the outside over https? (Private addresses cannot be registered at all.)
  3. Does your server answer 2xx within 10 seconds?
  4. Does the call appear in the call list (GET …/calls)? No summary yet means no event yet.

If it still does not arrive, send the tenantId and the time of the call to support@livespeech.ai.