Developers · API
v1Run many stores
With dozens or hundreds of customers, the whole job is keeping "our customer" and "the line" from drifting apart. There is one tool for it — externalId.
Attach your customer identifier to the line
When you create a line, put your own identifier in externalId. It comes back in every list response, so you do not need a separate mapping table.
| Your system | The line |
|---|---|
Customer mall_00123 | externalId: "mall_00123" |
The same value cannot be used twice. If it already exists you get 409 EXTERNAL_ID_EXISTS, so "try to create, and on 409 find it in the list" is the safe default flow.
Onboard in one go
The minimum sequence when a new customer signs up. Two rules:
- Use a fresh idempotency key for every creation. Reusing a key replays the first response stored under it for as long as the key exists (at least 24 hours) — re-onboarding a customer who was terminated would give you the terminated line's response. Put a value that is unique to one sign-up, such as your sign-up record's identifier, in the key, and reuse a key only when retrying that same request.
- Check the response and branch. Each
409means something different, and saving settings can fail too.
const BASE_URL = process.env.LS_BASE_URL;
const API_KEY = process.env.LS_PARTNER_KEY;
// Thrown when the same signupId should be tried again shortly. The caller catches it and retries.
class RetryLater extends Error {}
// signupId = your sign-up record's identifier. Reuse it on retries; a new sign-up gets a new value.
async function onboard(customer, signupId) {
// 1) Create the line
const created = await fetch(`${BASE_URL}/v1/tenants`, {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': `create-${customer.id}-${signupId}`,
},
body: JSON.stringify({
name: customer.name,
region: 'kr',
externalId: customer.id,
}),
});
let tenantId;
if (created.status === 201) {
tenantId = (await created.json()).tenantId;
} else if (created.status === 409) {
const body = await created.json();
if (body.code === 'EXTERNAL_ID_EXISTS') {
tenantId = await findTenantByExternalId(customer.id); // below
if (!tenantId) throw new Error('The line exists but is not in the list');
} else if (body.code === 'IDEMPOTENCY_IN_PROGRESS') {
// The same request is still being processed — call again shortly with the same signupId.
throw new RetryLater();
} else {
// IDEMPOTENCY_KEY_MISMATCH: the key was reused with a different body. Retrying will not change that.
throw new Error(`Line creation conflict: ${body.code}`);
}
} else {
throw new Error(`Line creation failed: ${created.status}`);
}
// 2) Agent settings — always check the result.
const configured = await fetch(`${BASE_URL}/v1/tenants/${tenantId}/agent`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
persona: customer.persona,
greeting: `Hello, this is ${customer.name}.`,
}),
});
if (!configured.ok) {
const body = await configured.json();
// If it is a persona.* code, body.error can be shown to the operator as is.
throw new Error(`Saving settings failed: ${configured.status} ${body.code ?? ''}`);
}
// 3) Number connection: send the tenantId and region to support@livespeech.ai.
return tenantId;
}import os, requests
BASE_URL = os.environ["LS_BASE_URL"]
API_KEY = os.environ["LS_PARTNER_KEY"]
class RetryLater(Exception):
"""Raised when the same signup_id should be tried again shortly. The caller catches it and retries."""
# signup_id = your sign-up record's identifier. Reuse it on retries; a new sign-up gets a new value.
def onboard(customer, signup_id):
# 1) Create the line
created = requests.post(
f"{BASE_URL}/v1/tenants",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"Idempotency-Key": f"create-{customer['id']}-{signup_id}",
},
json={"name": customer["name"], "region": "kr", "externalId": customer["id"]},
)
if created.status_code == 201:
tenant_id = created.json()["tenantId"]
elif created.status_code == 409:
code = created.json().get("code")
if code == "EXTERNAL_ID_EXISTS":
tenant_id = find_tenant_by_external_id(customer["id"]) # below
if not tenant_id:
raise RuntimeError("The line exists but is not in the list")
elif code == "IDEMPOTENCY_IN_PROGRESS":
# The same request is still being processed — call again shortly with the same signup_id.
raise RetryLater()
else:
# IDEMPOTENCY_KEY_MISMATCH: the key was reused with a different body. Retrying will not change that.
raise RuntimeError(f"Line creation conflict: {code}")
else:
raise RuntimeError(f"Line creation failed: {created.status_code}")
# 2) Agent settings — always check the result.
configured = requests.put(
f"{BASE_URL}/v1/tenants/{tenant_id}/agent",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={
"persona": customer["persona"],
"greeting": f"Hello, this is {customer['name']}.",
},
)
if not configured.ok:
body = configured.json()
# If it is a persona.* code, body["error"] can be shown to the operator as is.
raise RuntimeError(f"Saving settings failed: {configured.status_code} {body.get('code', '')}")
# 3) Number connection: send the tenant_id and region to support@livespeech.ai.
return tenant_id# SIGNUP_ID = your sign-up record's identifier. Reuse it only when retrying the same request.
SIGNUP_ID="2026-09-09-0001"
EXTERNAL_ID="mall_00123"
# 1) Create the line — capture the status code together with the body.
RESPONSE=$(curl -s -w '\n%{http_code}' -X POST "$LS_BASE_URL/v1/tenants" \
-H "Authorization: Bearer $LS_PARTNER_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: create-$EXTERNAL_ID-$SIGNUP_ID" \
-d "{\"name\":\"Example Mall\",\"region\":\"kr\",\"externalId\":\"$EXTERNAL_ID\"}")
STATUS=$(printf '%s' "$RESPONSE" | tail -n1)
BODY=$(printf '%s' "$RESPONSE" | sed '$d')
# On 201 use the tenantId from the response; on 409 EXTERNAL_ID_EXISTS use the one found in the list.
case "$STATUS" in
201)
TENANT_ID=$(printf '%s' "$BODY" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tenantId"])') ;;
409)
CODE=$(printf '%s' "$BODY" \
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("code", ""))')
if [ "$CODE" = "EXTERNAL_ID_EXISTS" ]; then
# Looks within the first 200 lines. With more lines, page through like the Node and Python examples.
TENANT_ID=$(curl -s "$LS_BASE_URL/v1/tenants?limit=200" \
-H "Authorization: Bearer $LS_PARTNER_KEY" \
| python3 -c "import json,sys; print(next(t['tenantId'] for t in json.load(sys.stdin)['tenants'] if t['externalId'] == '$EXTERNAL_ID'))")
else
# IDEMPOTENCY_IN_PROGRESS: try again shortly with the same SIGNUP_ID. IDEMPOTENCY_KEY_MISMATCH: use a new key.
echo "Line creation conflict: $CODE" >&2; exit 1
fi ;;
*)
echo "Line creation failed: $STATUS $BODY" >&2; exit 1 ;;
esac
echo "tenantId=$TENANT_ID"
# 2) Agent settings — with the TENANT_ID chosen above; check the status code.
curl -s -o /dev/null -w '%{http_code}\n' \
-X PUT "$LS_BASE_URL/v1/tenants/$TENANT_ID/agent" \
-H "Authorization: Bearer $LS_PARTNER_KEY" \
-H "Content-Type: application/json" \
-d '{"persona":"You are the customer service agent for Example Mall. Be polite, brief and friendly.","greeting":"Hello, this is Example Mall."}'Branch a 409 on code — EXTERNAL_ID_EXISTS means "the line exists, find it in the list", IDEMPOTENCY_IN_PROGRESS means "the first request with this key is still running, try again shortly with the same key", and IDEMPOTENCY_KEY_MISMATCH means "this key was used with a different body, so retrying gives the same result". Details are in Errors & limits.
Match by listing
Walk the list once to build an externalId → tenantId table, and later lookups can stay inside your own database.
const BASE_URL = process.env.LS_BASE_URL;
const API_KEY = process.env.LS_PARTNER_KEY;
async function buildTenantIndex() {
const index = new Map();
let cursor = null;
do {
const url = new URL(`${BASE_URL}/v1/tenants`);
url.searchParams.set('limit', '200');
if (cursor) url.searchParams.set('cursor', cursor);
const page = await (
await fetch(url, { headers: { Authorization: `Bearer ${API_KEY}` } })
).json();
for (const tenant of page.tenants) {
if (tenant.externalId) index.set(tenant.externalId, tenant.tenantId);
}
cursor = page.nextCursor;
} while (cursor);
return index;
}
async function findTenantByExternalId(externalId) {
return (await buildTenantIndex()).get(externalId);
}import os, requests
BASE_URL = os.environ["LS_BASE_URL"]
API_KEY = os.environ["LS_PARTNER_KEY"]
def build_tenant_index():
index, cursor = {}, None
while True:
params = {"limit": 200}
if cursor:
params["cursor"] = cursor
page = requests.get(
f"{BASE_URL}/v1/tenants",
headers={"Authorization": f"Bearer {API_KEY}"},
params=params,
).json()
for tenant in page["tenants"]:
if tenant["externalId"]:
index[tenant["externalId"]] = tenant["tenantId"]
cursor = page.get("nextCursor")
if not cursor:
return index
def find_tenant_by_external_id(external_id):
return build_tenant_index().get(external_id)curl -s "$LS_BASE_URL/v1/tenants?limit=200" \
-H "Authorization: Bearer $LS_PARTNER_KEY" \
| python3 -c 'import json,sys; [print(t["externalId"], t["tenantId"]) for t in json.load(sys.stdin)["tenants"]]'Terminated lines are not in the list. A customer you have that is missing from the list is one that was terminated.
Line quota
If your account has a line quota, creation fails with 402 TENANT_QUOTA_EXCEEDED the moment you exceed it. Terminated lines do not count. To raise the quota, email support@livespeech.ai.
Termination and re-onboarding
When a customer leaves, terminate the line. Once termination completes, the externalId is released, so a returning customer can be created with the same identifier. A new tenantId is issued — update your mapping too.
If a number is still attached, termination is held and you get 502 CARRIER_RELEASE_FAILED — email support@livespeech.ai with the tenantId to release the number, then call again.
Collect call results
With many customers, one webhook beats polling every line's call list. There is one webhook per account, and the payload's tenantId tells you which line it is. Use the list to fill gaps the webhook missed or to show calls on a screen.