📖 API Documentation
Everything you need to connect your systems to TISA calls and texts. You need a TISA Cloud PBX account to create a key; signing in to the portal and opening Voice API Keys takes a minute.
Contents
📋 Overview
The TISA Voice API lets you read your call history, recordings and transcriptions, place calls from your own systems, receive real-time call events by webhook, and add a click-to-call button to your website. Use it to connect TISA to your CRM or in-house tools. Texts are sent with the separate SMS API.
The API is hosted at https://api.tisa.one on a dedicated server, separate from the customer portal and designed for stable, versioned access.
🚀 Quick Start
- Create an API key in Voice → API Keys with the scopes you need.
- Store the key securely on your server (never commit to source control).
- Send it as a Bearer token to
https://api.tisa.one/v1/.... - (Optional) Subscribe to webhooks to get real-time call events.
🔑 Authentication
All API requests require authentication via a Bearer token in the Authorization header.
Authorization: Bearer tisa_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Each key has a scope set: the permissions it's allowed. Pick the minimum scopes your application needs:
| Scope | What it allows |
|---|---|
call:originate | Place outbound calls from your extensions. |
call:read | Read call status, history, and recording metadata. |
webhook:manage | Create, list, and delete webhook subscriptions. |
IP allowlist (recommended): on the API Keys page you can limit a key to your servers' public IP addresses or ranges. Requests from anywhere else get 403 forbidden, even with the right key. It is required for keys with the call:originate scope; for other keys you can leave it empty to allow any IP. If your server has both an IPv4 and an IPv6 address, add both.
Limits: 60 requests per minute and 5,000 per day per key. Over the limit you get 429 rate_limited with a Retry-After header.
🌐 Base URL & Versioning
All endpoints are prefixed with a version: /v1. Breaking changes will only land in a new version (/v2, etc.). The previous version stays supported with a deprecation notice for at least 12 months.
https://api.tisa.one/v1/...
Every response includes an X-Request-Id header (format: req_<hex>) to use when contacting support.
📞 Click-to-Call Widget PREVIEW
A drop-in JavaScript snippet that adds a "Call us" button to your website. When a visitor clicks it, the call rings the destination you configured (extension, ring group, IVR, or DDI) directly through the visitor's browser using WebRTC.
Embed snippet
<script
src="https://api.tisa.one/v1/widget.js"
data-widget="wgt_xxxxxxxxxxxxxxxxxxxxxxxx"
defer></script>
Anti-abuse
- Origin allowlist per widget: JS only works from the domains you register.
- Cloudflare Turnstile (invisible CAPTCHA) gates every call attempt.
- Per-IP, per-origin, and per-customer rate limits.
- Behavioural auto-suspend on abandoned-call floods.
Create and style buttons in the portal under Voice, Click-to-Call Buttons.
🩺 Site Setup (CSP & Permissions-Policy)
Most websites can drop in the click-to-call snippet and it just works. Sites with hardened security headers need two small adjustments so the browser will let the widget run. Below are the exact directives.
🩺 Run the in-portal site checker: paste any URL and we'll grade your site against these requirements.
1. Permissions-Policy
The browser only grants microphone access on pages whose Permissions-Policy allows it. If you don't send this header at all, the default is permissive and you're fine. If you do send one, make sure microphone is allowed for the page itself:
Permissions-Policy: microphone=(self), camera=(self)
This will fail: microphone=(): an empty allowlist disables the microphone for everyone, no matter what the visitor clicks in the browser permission prompt.
2. Content-Security-Policy
If you have a CSP, three directives need to allow our domain:
script-srcneedshttps://api.tisa.oneso widget.js loadsconnect-srcneedshttps://api.tisa.onefor the session token, andwss://api.tisa.onefor the SIP-over-WebSocket callstyle-srcneeds'unsafe-inline'for the widget's small inline stylesheet
Minimal CSP that supports the widget:
Content-Security-Policy: default-src 'self';
script-src 'self' 'unsafe-inline' https://api.tisa.one;
style-src 'self' 'unsafe-inline';
connect-src 'self' https://api.tisa.one wss://api.tisa.one;
img-src 'self' data: blob:;
media-src 'self' blob:;
Adjust to your existing CSP: only the parts above are click-to-call-specific.
3. HTTPS
The page must be served over HTTPS. Browsers refuse microphone access on plain HTTP unconditionally. Most hosts provide free Let's Encrypt certificates.
How to apply
Set these as response headers from your web server:
Apache (in .htaccess or vhost):
Header set Permissions-Policy "microphone=(self), camera=(self)"
Header set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://api.tisa.one; style-src 'self' 'unsafe-inline'; connect-src 'self' https://api.tisa.one wss://api.tisa.one; img-src 'self' data: blob:; media-src 'self' blob:;"
Nginx:
add_header Permissions-Policy "microphone=(self), camera=(self)";
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://api.tisa.one; style-src 'self' 'unsafe-inline'; connect-src 'self' https://api.tisa.one wss://api.tisa.one; img-src 'self' data: blob:; media-src 'self' blob:;";
PHP (before any output):
header('Permissions-Policy: microphone=(self), camera=(self)');
header('Content-Security-Policy: ...');
How visitors will know
If a visitor's browser blocks the call due to a missing header on your site, the widget will show a clear, branded error message naming the missing directive, so visitors know to contact you, and your webmaster knows what to fix. They won't be left staring at a generic "Permission denied".
☎️ Calls API
Read your call history, recordings and transcriptions. Times are UTC (ISO 8601). Calls appear here a few seconds after they end.
List calls, newest first. Required scope: call:read. Optional query parameters:
| Parameter | Meaning |
|---|---|
direction | inbound, outbound, internal or forwarded |
status | answered, missed, voicemail, not_answered |
extension | Extension that made or answered the call, e.g. 201 |
from, to | Date range on the call end time. Default: the last 30 days. Maximum range 92 days. |
limit | 1 to 100, default 50 |
before | Paging: pass next_before from the previous page |
GET /v1/calls?direction=inbound&limit=20
Authorization: Bearer tisa_live_...
{
"object": "list",
"data": [ { ...call... } ],
"has_more": true,
"next_before": "2026-10-20T09:52:23Z"
}
One call. Required scope: call:read.
{
"id": "call_in_1792489715.3568",
"object": "call",
"direction": "inbound",
"status": "answered",
"from": "+447700900123",
"to": "+441908000000",
"ddi": "+441908000000",
"extension": "302",
"answered_by": { "extension": "302", "name": "Reception" },
"routed_to": "Reception (Ext 302)",
"started_at": "2026-10-20T09:48:35Z",
"answered_at": "2026-10-20T09:48:50Z",
"ended_at": "2026-10-20T09:50:03Z",
"duration_seconds": 88,
"ring_seconds": 15,
"talk_seconds": 73,
"recording": { "available": true },
"transcription": { "available": true, "status": "completed", "language": "en", "sentiment": "neutral" }
}
Returns a download link for the recording (WAV) that expires after 5 minutes. Request a fresh link each time. Required scope: call:read.
{ "call_id": "call_in_...", "object": "recording", "available": true,
"url": "https://...", "content_type": "audio/wav", "expires_at": "2026-10-20T10:05:00Z" }
Transcript text, timed segments by speaker, sentiment and AI summary, if call transcription is switched on for your account. Required scope: call:read.
Place a call from one of your extensions (click-to-dial). We ring the extension first; when it's answered we dial to. Required scope: call:originate.
POST /v1/calls
Authorization: Bearer tisa_live_...
Content-Type: application/json
{ "from": "201", "to": "+447700900123" }
HTTP/1.1 202 Accepted
{
"id": "orig_1ef282ce0ba1b8f431e4dcb2",
"object": "call_origination",
"status": "queued",
"from": "201",
"to": "+447700900123",
"call_id": null,
"failure_reason": null,
"created_at": "2026-10-20T10:54:47Z"
}
from: one of your extension numbers. It must have a phone or the TISA app signed in. If it has several devices, one of them rings.to:+447700900123,07700900123or0033....- The call follows that extension's own settings, exactly as if dialled from its phone: its caller ID, call recording, outbound permissions (UK only, blocked) and spend limits. It's billed at your normal rates and appears in call history.
- Not allowed through the API: emergency numbers, premium-rate and revenue-share numbers (09, 118, 0871 to 0873, 070, 076).
- Limits: 30 calls per hour per key, 5 in progress at once per account.
Progress of a call you placed. status moves through queued, ringing_extension, dialling, answered, then ended (with outcome: answered, noanswer, busy, cancel...) or failed with a failure_reason: extension_no_answer (nobody picked up the extension within 30 seconds) or extension_unreachable. Once the extension rings, call_id is set: use it with GET /v1/calls/{call_id} after the call, and to match webhook events.
🪝 Webhooks LIVE
We POST a signed JSON event to your HTTPS endpoint when something happens on your calls. Required scope: webhook:manage.
Events
| Event | When |
|---|---|
call.started | A call you placed with POST /v1/calls is ringing your extension. Includes origination_id and the call_id used by the later events. |
call.failed | A call you placed never got going: failure_reason is extension_no_answer or extension_unreachable. |
call.ringing | A phone is ringing: one of your extensions on an inbound or internal call (a ring group sends one event per extension rung), or the number you dialled on an outbound call. Sent within about 2 seconds. data.call.ringing says who is ringing. |
call.answered | The call has been picked up (once per call). For inbound calls data.call.answered_by is the extension that answered. |
call.ended | Any call (inbound, outbound, internal, forwarded) has finished, answered or not. Sent within about 10 seconds. |
call.recorded | The recording of an answered call is ready to download |
call.transcribed | The transcription is ready (accounts with call transcription switched on) |
The data.call.id is the same in every event for one call, so you can match call.ringing, call.answered and call.ended together and look the call up with GET /v1/calls/{id}. Ringing and answered events carry a smaller call object (id, direction, status, from, to, ddi, extension, started_at, answered_at) because the call is still in progress.
{
"id": "evt_9b1c...",
"object": "event",
"type": "call.ringing",
"api_version": "2026-10-20",
"created": "2026-10-20T10:34:07Z",
"data": {
"call": {
"id": "call_in_1792492447.5598",
"direction": "inbound",
"status": "ringing",
"from": "+447700900123",
"to": "+441160000000",
"ddi": "+441160000000",
"started_at": "2026-10-20T10:34:05Z",
"ringing": { "extension": "619", "name": "Reception", "number": null }
}
}
}
Manage subscriptions
Create a subscription. events is optional (default: all). The response contains the signing secret, shown only once.
curl -X POST https://api.tisa.one/v1/webhooks \
-H "Authorization: Bearer tisa_live_..." \
-H "Content-Type: application/json" \
-d '{"url": "https://your-app.example.com/tisa-events", "events": ["call.ended", "call.recorded"]}'
{
"id": "whsub_...",
"object": "webhook",
"url": "https://your-app.example.com/tisa-events",
"events": ["call.ended", "call.recorded"],
"status": "active",
"secret": "whsec_...",
...
}
List your subscriptions.
One subscription, including last_success_at, last_failure_at and consecutive_failures.
Change url, events, or status (active / disabled). Setting active also re-enables a suspended subscription.
Stop sending to this endpoint.
Send a signed webhook.test event right now and see what your endpoint returned.
Payload
POST /tisa-events
Content-Type: application/json
X-Tisa-Event: call.ended
X-Tisa-Event-Id: evt_3f2a...
X-Tisa-Signature: t=1792489810,v1=5d41402a...
{
"id": "evt_3f2a...",
"object": "event",
"type": "call.ended",
"api_version": "2026-10-20",
"created": "2026-10-20T09:50:03Z",
"data": { "call": { ...same shape as GET /v1/calls/{id}... } }
}
Signature verification
Sign the raw request body exactly as received, before parsing it:
// Pseudocode
expected = hex(hmac_sha256(secret, t + "\n" + raw_body))
if !constant_time_equals(expected, header.v1) -> reject
if abs(now - t) > 300 -> reject (stale)
PHP example:
$raw = file_get_contents('php://input');
preg_match('/t=(\d+),v1=([a-f0-9]+)/', $_SERVER['HTTP_X_TISA_SIGNATURE'] ?? '', $m);
$ok = $m && abs(time() - (int)$m[1]) <= 300
&& hash_equals(hash_hmac('sha256', $m[1] . "\n" . $raw, getenv('TISA_WEBHOOK_SECRET')), $m[2]);
if (!$ok) { http_response_code(400); exit; }
Delivery and retries
- Reply with any
2xxwithin 10 seconds to acknowledge. Do slow work after replying. - Anything else (or a timeout) is retried after 1 min, 5 min, 15 min, 30 min, 1 h, 2 h, 4 h, then every 6 h, for up to 24 hours.
- Each event is sent at least once, so it may occasionally arrive twice: de-duplicate on
id(orX-Tisa-Event-Id). Events can arrive out of order. - Redirects are not followed. The URL must be HTTPS on a public address.
- Subscriptions only receive events that happen after they are created.
- After 200 failed deliveries in a row a subscription is suspended. Fix your endpoint, then
PATCHit back toactive.
⚠️ Error Format
All errors return a JSON body with the same shape:
{
"error": {
"code": "not_implemented",
"message": "Voice API endpoints will be available in Stage 6"
},
"request_id": "req_407808017dc1439f7e9502d4"
}
| Status | Code | Meaning |
|---|---|---|
| 400 | bad_request | Malformed request body or parameters. |
| 401 | unauthenticated | Missing or invalid Authorization header. |
| 403 | forbidden | API key lacks the required scope. |
| 404 | not_found | Resource doesn't exist. |
| 409 | conflict | State conflict (e.g. revoking an already-revoked key). |
| 429 | rate_limited | Too many requests; honour Retry-After. |
| 500 | server_error | Something broke on our side. Include request_id when contacting support. |
| 501 | not_implemented | Endpoint exists but isn't live yet. |
| 503 | service_unavailable | Temporary outage; retry with backoff. |
⏱️ Rate Limits
Default limits per API key:
- Requests: 60 / minute, 5,000 / day
- Originated calls: 30 / hour
- Webhook delivery retries: exponential backoff up to 24 hours
Limits can be raised on request: contact [email protected].
💻 Code Examples
cURL
curl -X POST https://api.tisa.one/v1/calls \
-H "Authorization: Bearer tisa_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"from": "201", "to": "+447700900123"}'
Node.js (fetch)
const res = await fetch('https://api.tisa.one/v1/calls', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.TISA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ from: '201', to: '+447700900123' }),
});
const data = await res.json();
console.log(data);
PHP (curl)
$ch = curl_init('https://api.tisa.one/v1/calls');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('TISA_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode(['from' => '201', 'to' => '+447700900123']),
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);
Python (requests)
import os, requests
res = requests.post(
'https://api.tisa.one/v1/calls',
headers={'Authorization': f'Bearer {os.environ["TISA_API_KEY"]}'},
json={'from': '201', 'to': '+447700900123'},
timeout=10,
)
print(res.json())
🔗 CRM Integrations
Ready-made CRM connections are set up in the portal under Your CRM, with no code: call popups, automatic call logging and contact lookup. Pipedrive and Pabau are live, with more CRMs in beta.
For any other CRM, or your own system, use the Voice API and webhooks above.
📱 SMS API LIVE
Send texts from your own systems: confirmations, reminders, alerts and one-time codes. Separate from the Voice API, with its own keys, created in the portal under SMS, API Keys.
Send the key in an X-API-Key header. The body can be JSON or a form.
| Parameter | Meaning |
|---|---|
sender_id | Required. One of your approved sender IDs. |
recipients | Required. Up to 5 mobile numbers, as an array or comma separated. |
message | Required. Long messages are sent in parts and joined on the phone, up to 10 parts (about 1,530 characters). |
int_ext | Optional. Your extension number, e.g. 201, so the cost counts towards that extension's spend limit. |
These placeholders are filled in for you: {DATE} (DD/MM/YYYY), {TIME} (HH:MM), {MY_COMPANY} and {MY_CONTACT}.
curl -X POST https://business.tisa.one/api/send-sms.php \
-H "X-API-Key: YOUR-SMS-KEY" \
-H "Content-Type: application/json" \
-d '{"sender_id": "YourBrand", "recipients": ["07700900123"], "message": "Your booking on {DATE} is confirmed."}'
Limit: 100 messages an hour per account. For campaigns and larger lists, use TISA Business Messaging.