Kaleso API documentation
Place AI voice calls from your own programs over your own SIP trunk. Everything is plain JSON over HTTPS, authenticated with an API key.
Base URL: https://api.kaleso.cloud
Quickstart
- Sign up and wait for your account to be activated. See pricing for plans.
- Under Settings, add a SIP connection (your trunk's username, password and domain) and your OpenAI API key. Both are stored encrypted and never shown again.
- Under Settings → API keys, create a key. It is shown once, so copy it now.
- Place a call:
curl -X POST https://api.kaleso.cloud/calls \
-H "Authorization: Bearer kal_ab12cd_YOUR_SECRET" \
-H "Content-Type: application/json" \
-d '{"phoneNumber":"+15551234567","sipConnection":"My trunk","target":"Open AI Agent"}'A successful response looks like this. The call is now dialing; the AI agent talks as soon as it is answered.
{
"success": true,
"message": "Call initiated successfully to +15551234567",
"callId": "6f1d2c1e-3b7a-4c55-9d0e-5a2f3f6a7b10",
"webrtcUrl": null,
"error": null
}Use the returned callId to follow the call or end it.
Authentication
Send your key in the Authorization header of every request:
Authorization: Bearer kal_<prefix>_<secret>
- A key looks like
kal_ab12cd_…. Thekal_<prefix>part is public and identifies the key in Settings and in call history (apiKey); the secret is shown only once, when you create the key. Kaleso stores only a hash of it. - A key acts as the user who created it, inside that user's account, so it sees and uses only that account's calls, SIP connections and settings.
- Keys can reach
/me,/calls/**,/sip-connections/**and/settings/**. Anything else answers401. In particular, a key cannot create or revoke API keys: do that in Settings while logged in. - API-key requests need no cookies and no CSRF token.
- To revoke a key, use Revoke in Settings. A revoked key stops working immediately.
- Keep keys server-side. Never put one in a web page or a mobile app.
A missing, malformed, unknown or revoked key answers 401:
{"message":"Invalid or revoked API key"}Errors
Failures to place a call return the same body as a success, with success: false, a human-readable message and a machine-readable error code to act on:
{
"success": false,
"message": "Failed to initiate call: Your account is waiting to be activated",
"callId": null,
"webrtcUrl": null,
"error": "NOT_ENTITLED"
}| HTTP | error | Meaning |
|---|---|---|
| 400 | INVALID_REQUEST | The request is wrong: no SIP connection chosen when the account has several (or none), an unknown connection, no OpenAI key saved for the AI agent, and similar. Read message. |
| 403 | NOT_ENTITLED | The account can't place calls: pending activation, suspended, cancelled, subscription lapsed, or over its plan limits. |
| 429 | CONCURRENT_LIMIT | The account (or the server) already has as many calls in progress as allowed. Retry when one ends. |
| 503 | NO_CAPACITY | No ports are free for another call. Retry shortly. |
| 502 | CALL_FAILED | The call couldn't be placed, for example no public address for its audio. |
Other errors:
| HTTP | When | Body |
|---|---|---|
| 400 | Settings and SIP connection requests that fail validation | {"message":"..."} |
| 400 | Malformed JSON | Standard Spring error body |
| 401 | Missing, invalid or revoked API key | {"message":"Invalid or revoked API key"} |
| 401 | An endpoint a key can't use, such as /api-keys | Empty |
| 404 | Unknown call, or SIP connection not found | Empty for calls; {"message":"..."} for SIP connections |
Endpoints
All request bodies are JSON (Content-Type: application/json); timestamps are ISO-8601 UTC; ids are UUIDs.
POST/calls
Places a call to phoneNumber through one of your SIP connections.
Request body
| Field | Type | Description |
|---|---|---|
phoneNumber | string, required | The number to call, as your SIP provider expects it, e.g. +15551234567. |
sipConnection | string, optional | Which SIP connection to use, by id or name. Optional if the account has exactly one. (provider is the old name for this field and is still accepted.) |
target | string, default "Open AI Agent" | "Open AI Agent" connects the call to your AI voice agent; "Echo Test" echoes the audio back, handy for testing your trunk. "WebRTC Demo" is for the browser dialer only. Unrecognised values fall back to "Open AI Agent". |
Example
curl -X POST https://api.kaleso.cloud/calls \
-H "Authorization: Bearer kal_ab12cd_YOUR_SECRET" \
-H "Content-Type: application/json" \
-d '{
"phoneNumber": "+15551234567",
"sipConnection": "My trunk",
"target": "Open AI Agent"
}'Response 200
{
"success": true,
"message": "Call initiated successfully to +15551234567",
"callId": "6f1d2c1e-3b7a-4c55-9d0e-5a2f3f6a7b10",
"webrtcUrl": null,
"error": null
}Failures use the codes in Errors, e.g. 429:
{
"success": false,
"message": "Failed to initiate call: ...",
"callId": null,
"webrtcUrl": null,
"error": "CONCURRENT_LIMIT"
}A successful response means the call is on its way, not that it was answered. Check the call's status for the outcome.
GET/calls
The account's calls in progress.
curl https://api.kaleso.cloud/calls \ -H "Authorization: Bearer kal_ab12cd_YOUR_SECRET"
Response 200
[
{
"callId": "6f1d2c1e-3b7a-4c55-9d0e-5a2f3f6a7b10",
"destination": "+15551234567",
"startedAt": "2025-01-15T10:30:00Z",
"connectedAt": "2025-01-15T10:30:07Z"
}
]connectedAt is null until the call is answered. The list is empty ([]) when nothing is in progress.
DELETE/calls/{callId}
Ends one call: cancels it if it is still ringing, hangs up if it is connected.
curl -X DELETE https://api.kaleso.cloud/calls/6f1d2c1e-3b7a-4c55-9d0e-5a2f3f6a7b10 \ -H "Authorization: Bearer kal_ab12cd_YOUR_SECRET"
Returns 204 No Content, or 404 if there is no such call in progress on your account.
POST/calls/reset
Ends all of the account's calls in progress and frees their ports. No request body.
curl -X POST https://api.kaleso.cloud/calls/reset \ -H "Authorization: Bearer kal_ab12cd_YOUR_SECRET"
Response 200
{
"success": true,
"message": "Reset: any active call was ended",
"callId": null,
"webrtcUrl": null,
"error": null
}GET/calls/history
A page of the account's calls, newest first, including refused attempts and calls in progress.
| Query | Default | Description |
|---|---|---|
page | 0 | Zero-based page number. |
size | 25 | Items per page, from 1 to 100. |
curl "https://api.kaleso.cloud/calls/history?page=0&size=25" \ -H "Authorization: Bearer kal_ab12cd_YOUR_SECRET"
Response 200
{
"items": [
{
"callId": "6f1d2c1e-3b7a-4c55-9d0e-5a2f3f6a7b10",
"destination": "+15551234567",
"mode": "AI_AGENT",
"source": "API",
"placedBy": "[email protected]",
"apiKey": "kal_ab12cd",
"sipConnection": "My trunk",
"sipDomain": "sip.example-trunk.com",
"sipUsername": "myuser",
"aiModel": "gpt-realtime-mini",
"aiVoice": "marin",
"status": "COMPLETED",
"live": false,
"endReason": "REMOTE_HANGUP",
"error": null,
"message": null,
"sipCallId": "[email protected]",
"sipStatusCode": 200,
"sipReasonPhrase": "OK",
"sipPort": 5062,
"rtpPort": 10010,
"publicRtpAddress": "203.0.113.7:10010",
"remoteRtpAddress": "198.51.100.20:30456",
"rtpPacketsSent": 1840,
"rtpPacketsReceived": 1822,
"createdAt": "2025-01-15T10:30:00Z",
"ringingAt": "2025-01-15T10:30:03Z",
"connectedAt": "2025-01-15T10:30:07Z",
"endedAt": "2025-01-15T10:30:44Z",
"setupSeconds": 7,
"talkSeconds": 37,
"hasRecording": true,
"hasTranscript": true
}
],
"page": 0,
"size": 25,
"total": 1,
"totalPages": 1
}Call fields
| Field | Description |
|---|---|
mode | AI_AGENT, ECHO or WEBRTC. |
source | How the call was asked for: API, WEB or WEBRTC. |
placedBy, apiKey | The user's email, and the key's public prefix when the call came through an API key. |
status | REFUSED (never went out), DIALING, RINGING, CONNECTED, COMPLETED, NO_ANSWER, CANCELLED, REJECTED or FAILED. |
live | true while status is DIALING, RINGING or CONNECTED. |
endReason | LOCAL_HANGUP, REMOTE_HANGUP, REJECTED, AUTH_FAILED, TIMEOUT, NO_MEDIA, FAILED, MAX_DURATION or SHUTDOWN; null while live. |
error, message | For refused or failed attempts: one of the error codes and a description. |
sipStatusCode, sipReasonPhrase | The final SIP response to the INVITE, e.g. 486 Busy Here. |
setupSeconds, talkSeconds | Seconds from placing the call until it was answered or given up on, and seconds it was connected. null when not applicable. |
hasRecording, hasTranscript | Whether the call's recording and transcript are still kept: see recordings. |
| other fields | SIP and RTP details (ports, addresses, packet counts) and timestamps (createdAt, ringingAt, connectedAt, endedAt), null when unknown. |
GET/calls/history/{callId}
One call with every SIP step it went through, in order, and what was said: its recording and, for AI agent calls, the transcript. Poll this after placing a call to follow its outcome.
curl https://api.kaleso.cloud/calls/history/6f1d2c1e-3b7a-4c55-9d0e-5a2f3f6a7b10 \ -H "Authorization: Bearer kal_ab12cd_YOUR_SECRET"
Response 200
{
"call": {
"callId": "6f1d2c1e-3b7a-4c55-9d0e-5a2f3f6a7b10",
"destination": "+15551234567",
"mode": "AI_AGENT",
"status": "COMPLETED",
"...": "same fields as a history item"
},
"events": [
{
"at": "2025-01-15T10:30:00Z",
"direction": "OUT",
"method": "INVITE",
"statusCode": null,
"reasonPhrase": null,
"detail": null
},
{
"at": "2025-01-15T10:30:03Z",
"direction": "IN",
"method": null,
"statusCode": 180,
"reasonPhrase": "Ringing",
"detail": null
},
{
"at": "2025-01-15T10:30:07Z",
"direction": "IN",
"method": null,
"statusCode": 200,
"reasonPhrase": "OK",
"detail": null
}
],
"recording": {
"url": "/calls/history/6f1d2c1e-3b7a-4c55-9d0e-5a2f3f6a7b10/recording",
"durationSeconds": 37.2,
"sizeBytes": 1190444,
"expiresAt": "2025-01-20T10:30:45Z"
},
"transcript": [
{"speaker": "AGENT", "offsetSeconds": 0.9, "text": "Hi, this is the clinic. How can I help?"},
{"speaker": "CALLER", "offsetSeconds": 3.4, "text": "I'd like to move my appointment to Friday."}
]
}direction is OUT (we sent it), IN (the provider or other side sent it) or LOCAL (something Kaleso found or decided, such as a timeout). Returns 404 if the account has no such call.
recording is null when the call has none: it was never answered, it's still going on, or its recording was deleted. transcript holds the AI agent's conversation, line by line as OpenAI transcribed it, with speaker CALLER (the person called) or AGENT. Its offsetSeconds count from when the call was answered, the same timeline as the recording. When the caller talks over the agent, the agent's line is its whole reply, not only the part that was heard. The transcript is empty for echo and browser calls, and until OpenAI finishes transcribing each line.
GET/calls/history/{callId}/recording
The call's recording as a WAV file: 16-bit stereo at 8 kHz, with the person called on the left channel and Kaleso's side (the AI agent, the echo or your browser) on the right. Kaleso records every answered call, and deletes recordings and transcripts 5 days after the call. The call itself stays in the history.
The response is a 302 redirect to a download link that works for 5 minutes. Most clients follow it themselves (with curl, pass -L). Returns 404 if the account has no such call, or the call has no recording.
curl -L -o call.wav https://api.kaleso.cloud/calls/history/6f1d2c1e-3b7a-4c55-9d0e-5a2f3f6a7b10/recording \ -H "Authorization: Bearer kal_ab12cd_YOUR_SECRET"
Calls are recorded so you can review them. Where the law asks for it, tell the people you call that the call is recorded, for example in your AI agent's instructions.
GET/me
The user the key belongs to, their account, and whether the account can place calls right now. Useful as a check that your key works.
curl https://api.kaleso.cloud/me \ -H "Authorization: Bearer kal_ab12cd_YOUR_SECRET"
Response 200
{
"email": "[email protected]",
"displayName": "Ada Lovelace",
"admin": false,
"account": {
"id": "0b3a1c52-8f0e-4f4c-9c53-1d2a7b9e6a44",
"name": "Ada's account",
"status": "ACTIVE",
"paidUntil": "2025-02-15T00:00:00Z",
"maxConcurrentCalls": 3,
"billingProvider": "manual",
"plan": "BASIC",
"monthlyCallLimit": 1000,
"createdAt": "2025-01-02T09:00:00Z",
"members": []
},
"canCall": true,
"entitlement": "ALLOWED",
"entitlementMessage": "Your subscription is active",
"plan": "BASIC",
"monthlyCallLimit": 1000,
"callsThisMonth": 128
}account.status is PENDING, ACTIVE, SUSPENDED or CANCELLED. entitlement is ALLOWED, ACCOUNT_PENDING, ACCOUNT_SUSPENDED, ACCOUNT_CANCELLED or SUBSCRIPTION_LAPSED; when it isn't ALLOWED, calls fail with NOT_ENTITLED. plan is BASIC, ENTERPRISE or null; monthlyCallLimit is null when there is no monthly cap. callsThisMonth counts calls placed this calendar month (UTC); once it reaches the limit, new calls fail with 403 NOT_ENTITLED until the 1st. See pricing.
SIP connections
The SIP trunks your account calls through. Passwords are write-only: they are never returned.
GET/sip-connections
curl https://api.kaleso.cloud/sip-connections \ -H "Authorization: Bearer kal_ab12cd_YOUR_SECRET"
[
{
"id": "c2d7f0a4-5e1b-4a8e-b0a6-0f3d9d1e2b77",
"name": "My trunk",
"username": "myuser",
"domain": "sip.example-trunk.com",
"port": null,
"codecs": ["PCMU"],
"transport": "UDP",
"authUsername": null,
"fromUser": null,
"fromDisplayName": null,
"outboundProxy": null,
"register": true,
"createdAt": "2025-01-02T09:05:00Z",
"updatedAt": "2025-01-02T09:05:00Z"
}
]POST/sip-connections
| Field | Type | Description |
|---|---|---|
name | string, required | Up to 60 characters, unique in your account. |
username | string, required | Your SIP username. |
password | string, required on create | Your SIP password. |
domain | string, required | A public SIP server, e.g. sip.example-trunk.com. Private and local addresses are refused. |
port | integer, optional | SIP port, 1–65535. Leave out for the default, 5060. Give the port here or in domain (host:port), not both. |
codecs | array, optional | Audio codecs to offer, most preferred first: PCMU (G.711 µ-law, common in North America) and/or PCMA (G.711 A-law, common in Europe). Default ["PCMU"]. |
transport | string, optional | UDP (default) or TCP. |
authUsername | string, optional | The username for digest authentication, when your provider's differs from username. |
fromUser | string, optional | Caller ID: the user part of the From header, when it differs from username (e.g. +302101234567). |
fromDisplayName | string, optional | The display name sent with the caller ID. Up to 64 characters, no quotes or angle brackets. |
outboundProxy | string, optional | host[:port] that every SIP request is sent through, while addresses still name domain. |
register | boolean, optional | Whether to REGISTER before calling. Default true; set false for IP-authenticated trunks. |
curl -X POST https://api.kaleso.cloud/sip-connections \
-H "Authorization: Bearer kal_ab12cd_YOUR_SECRET" \
-H "Content-Type: application/json" \
-d '{"name":"My trunk","username":"myuser","password":"s3cret","domain":"sip.example-trunk.com",
"codecs":["PCMA","PCMU"],"fromUser":"+302101234567"}'Returns 201 Created with the connection (same shape as in the list). Validation failures return 400 with {"message":"..."}, for example an unknown codec, a bad port, or a private proxy address:
{"message":"You already have a SIP connection called \"My trunk\""}PUT/sip-connections/{id}
Same body as create. Leave out password to keep the current one; any other setting you leave out also keeps its current value. Send an empty string to clear a text setting, or "port": 0 to clear the port. Returns 200 with the updated connection, 400 on validation errors, or 404 with {"message":"..."} if there is no such connection.
curl -X PUT https://api.kaleso.cloud/sip-connections/c2d7f0a4-5e1b-4a8e-b0a6-0f3d9d1e2b77 \
-H "Authorization: Bearer kal_ab12cd_YOUR_SECRET" \
-H "Content-Type: application/json" \
-d '{"name":"My trunk","username":"myuser","domain":"sip.example-trunk.com"}'DELETE/sip-connections/{id}
curl -X DELETE https://api.kaleso.cloud/sip-connections/c2d7f0a4-5e1b-4a8e-b0a6-0f3d9d1e2b77 \ -H "Authorization: Bearer kal_ab12cd_YOUR_SECRET"
Returns 204 No Content, or 404 with {"message":"..."} if there is no such connection.
OpenAI settings
The OpenAI key and the voice agent's configuration. The key is stored encrypted and never returned in full.
GET/settings/openai
curl https://api.kaleso.cloud/settings/openai \ -H "Authorization: Bearer kal_ab12cd_YOUR_SECRET"
{
"hasApiKey": true,
"apiKeyHint": "sk-…Ab3d",
"model": "gpt-realtime-mini",
"voice": "marin",
"instructions": "You are a friendly receptionist for Acme Dental. Keep answers short.",
"models": ["gpt-realtime-mini", "gpt-realtime"],
"voices": ["alloy", "ash", "ballad", "cedar", "coral", "echo", "marin", "sage", "shimmer", "verse"]
}apiKeyHint is null when no key is stored. instructions is null when you use the default. models and voices list the accepted values.
PUT/settings/openai
| Field | Type | Description |
|---|---|---|
apiKey | string, optional | A new OpenAI key (starts with sk-). Leave out to keep the stored key. |
clearApiKey | boolean, default false | Remove the stored key (ignored if apiKey is given). |
model | string, optional | One of the values in models. |
voice | string, optional | One of the values in voices. |
instructions | string, optional | The agent's system prompt, up to 4000 characters. |
curl -X PUT https://api.kaleso.cloud/settings/openai \
-H "Authorization: Bearer kal_ab12cd_YOUR_SECRET" \
-H "Content-Type: application/json" \
-d '{
"apiKey": "sk-YOUR_OPENAI_KEY",
"model": "gpt-realtime-mini",
"voice": "marin",
"instructions": "You are a friendly receptionist for Acme Dental. Keep answers short."
}'Returns 200 with the settings in the same shape as GET. Invalid values return 400:
{"message":"That doesn't look like an OpenAI API key"}Python and Node examples
Place a call, then poll its history entry until it is no longer live.
Python
Uses only the standard library.
import json
import os
import time
import urllib.error
import urllib.request
BASE = "https://api.kaleso.cloud"
API_KEY = os.environ["KALESO_API_KEY"] # kal_<prefix>_<secret>
def request(method, path, body=None):
req = urllib.request.Request(
BASE + path,
method=method,
data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req) as res:
return res.status, json.loads(res.read() or b"null")
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b"null")
status, call = request("POST", "/calls", {
"phoneNumber": "+15551234567",
"sipConnection": "My trunk",
"target": "Open AI Agent",
})
if not call["success"]:
raise SystemExit(f"{status} {call['error']}: {call['message']}")
call_id = call["callId"]
while True:
_, detail = request("GET", f"/calls/history/{call_id}")
if not detail["call"]["live"]:
break
time.sleep(2)
print(detail["call"]["status"], detail["call"]["talkSeconds"])Node.js
Node 18 or newer (built-in fetch), as an ES module.
const BASE = "https://api.kaleso.cloud";
const API_KEY = process.env.KALESO_API_KEY; // kal_<prefix>_<secret>
async function request(method, path, body) {
const res = await fetch(BASE + path, {
method,
headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
return { status: res.status, data: text ? JSON.parse(text) : null };
}
const { status, data: call } = await request("POST", "/calls", {
phoneNumber: "+15551234567",
sipConnection: "My trunk",
target: "Open AI Agent",
});
if (!call.success) throw new Error(`${status} ${call.error}: ${call.message}`);
let detail;
do {
await new Promise((r) => setTimeout(r, 2000));
({ data: detail } = await request("GET", `/calls/history/${call.callId}`));
} while (detail.call.live);
console.log(detail.call.status, detail.call.talkSeconds);Plan limits
Your plan sets how many calls can run at once and how many you can place per month. Going over the concurrency limit returns 429 CONCURRENT_LIMIT; other account limits return 403 NOT_ENTITLED. See pricing for what each plan includes.
Need help? Contact us.