Developers · API
v1Receive 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:
- Split
X-LiveSpeech-Signatureintotandv1. - Take the stored secret, drop the
whsec_prefix and hex-decode the rest to get the key. - HMAC-SHA256
{t}.{raw request body}with that key and compare it withv1in constant time. - Reject the request if
tis 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
| Item | Value |
|---|---|
| Counts as success | 2xx (the body is ignored) |
| Response timeout | 10 seconds |
| Redirects | Not followed — treated as failure |
| Retried | 5xx · 408 · 429 · no response |
| Not retried | Any other 4xx — treated as the request itself being refused |
| Retry interval | Starts at 1 minute and doubles each time, up to 1 hour |
| Total attempts | 8 (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:
GET /v1/webhooks— is the URL stored correctly?- Is the URL reachable from the outside over
https? (Private addresses cannot be registered at all.) - Does your server answer
2xxwithin 10 seconds? - 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.