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

  1. Sign up and wait for your account to be activated. See pricing for plans.
  2. 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.
  3. Under Settings → API keys, create a key. It is shown once, so copy it now.
  4. 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 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"
}
HTTPerrorMeaning
400INVALID_REQUESTThe 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.
403NOT_ENTITLEDThe account can't place calls: pending activation, suspended, cancelled, subscription lapsed, or over its plan limits.
429CONCURRENT_LIMITThe account (or the server) already has as many calls in progress as allowed. Retry when one ends.
503NO_CAPACITYNo ports are free for another call. Retry shortly.
502CALL_FAILEDThe call couldn't be placed, for example no public address for its audio.

Other errors:

HTTPWhenBody
400Settings and SIP connection requests that fail validation{"message":"..."}
400Malformed JSONStandard Spring error body
401Missing, invalid or revoked API key{"message":"Invalid or revoked API key"}
401An endpoint a key can't use, such as /api-keysEmpty
404Unknown call, or SIP connection not foundEmpty 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

FieldTypeDescription
phoneNumberstring, requiredThe number to call, as your SIP provider expects it, e.g. +15551234567.
sipConnectionstring, optionalWhich 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.)
targetstring, 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.

QueryDefaultDescription
page0Zero-based page number.
size25Items 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

FieldDescription
modeAI_AGENT, ECHO or WEBRTC.
sourceHow the call was asked for: API, WEB or WEBRTC.
placedBy, apiKeyThe user's email, and the key's public prefix when the call came through an API key.
statusREFUSED (never went out), DIALING, RINGING, CONNECTED, COMPLETED, NO_ANSWER, CANCELLED, REJECTED or FAILED.
livetrue while status is DIALING, RINGING or CONNECTED.
endReasonLOCAL_HANGUP, REMOTE_HANGUP, REJECTED, AUTH_FAILED, TIMEOUT, NO_MEDIA, FAILED, MAX_DURATION or SHUTDOWN; null while live.
error, messageFor refused or failed attempts: one of the error codes and a description.
sipStatusCode, sipReasonPhraseThe final SIP response to the INVITE, e.g. 486 Busy Here.
setupSeconds, talkSecondsSeconds from placing the call until it was answered or given up on, and seconds it was connected. null when not applicable.
hasRecording, hasTranscriptWhether the call's recording and transcript are still kept: see recordings.
other fieldsSIP 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

FieldTypeDescription
namestring, requiredUp to 60 characters, unique in your account.
usernamestring, requiredYour SIP username.
passwordstring, required on createYour SIP password.
domainstring, requiredA public SIP server, e.g. sip.example-trunk.com. Private and local addresses are refused.
portinteger, optionalSIP port, 1–65535. Leave out for the default, 5060. Give the port here or in domain (host:port), not both.
codecsarray, optionalAudio 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"].
transportstring, optionalUDP (default) or TCP.
authUsernamestring, optionalThe username for digest authentication, when your provider's differs from username.
fromUserstring, optionalCaller ID: the user part of the From header, when it differs from username (e.g. +302101234567).
fromDisplayNamestring, optionalThe display name sent with the caller ID. Up to 64 characters, no quotes or angle brackets.
outboundProxystring, optionalhost[:port] that every SIP request is sent through, while addresses still name domain.
registerboolean, optionalWhether 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

FieldTypeDescription
apiKeystring, optionalA new OpenAI key (starts with sk-). Leave out to keep the stored key.
clearApiKeyboolean, default falseRemove the stored key (ignored if apiKey is given).
modelstring, optionalOne of the values in models.
voicestring, optionalOne of the values in voices.
instructionsstring, optionalThe 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.