The REST API — integrate calling, monitoring and transcripts into your own systems.
The CodeB platform exposes a small, focused REST surface for operators who want to integrate calling, monitoring, and transcript retrieval into their own systems. JSON in and out, bearer-token auth, no SDK required — every example here is a single curl away from a working integration.
/api.ashx/v1/ on your CodeB host. The production host is whatever domain you set as PublicBaseUrl — e.g. https://phone.codeb.io/api.ashx/v1/calls.
phone.codeb.io with an admin OIDC Bearer (role=admin) and returns the documented response shape. Endpoints that mutate state (POST/DELETE) were exercised against synthetic data only — no real calls placed, no users deleted.
Quick start
Sixty-second path from “I have admin access” to “I’ve received my first verified webhook.” Each step links to deeper docs if you want to skip ahead.
-
Mint an API key. Sign in as admin, go to /api-keys-admin.html, click + New API key, name it, copy the
ak_…value shown on creation. It is shown once. Set it in your shell so the rest of these examples work:export TOKEN=ak_0123456789abcdef0123456789abcdef -
Subscribe a webhook. Go to /webhooks-admin.html, click + New webhook, paste your receiver URL (use webhook.site for a 30-second test endpoint), check the
*box to receive everything, click Create. Copy the secret shown on creation — you’ll need it to verify signatures (see Webhooks → Verifying signatures below). -
Confirm the API is alive. The unauthenticated self-describe endpoint enumerates every route. If this works, your host is reachable and the platform is configured:
Then verify your key works by listing inbound routes:curl https://phone.codeb.io/api.ashx/v1curl -H "Authorization: Bearer $TOKEN" \ https://phone.codeb.io/api.ashx/v1/inbound-routes -
Place a test outbound AI call. Replace
+SAFETESTNUMBERwith a number you own and want to be called by an AI persona that just says “this is a test, goodbye.”
Response includes acurl -X POST -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "phone": "+SAFETESTNUMBER", "displayName": "Quick-start test", "systemPrompt": "You are testing the CodeB platform. Say: this is a test, goodbye. Then end the call.", "maxSeconds": 30 }' \ https://phone.codeb.io/api.ashx/v1/callscallIdlikeoac-0123456789ab. Watch progress at /outbound-ai-monitor.html. -
Receive + verify the webhook. Within seconds of the call ending, your receiver gets an
outbound-ai.finishedPOST and (if the call produced one) atranscript.savedPOST. TheX-CodeB-Signatureheader is HMAC-SHA256 of the raw body with your subscription secret. Copy-paste verification code in your language is in Webhooks → Verifying signatures. Then fetch the full transcript:curl -H "Authorization: Bearer $TOKEN" \ https://phone.codeb.io/api.ashx/v1/transcripts/<callId>
GET /api.ashx/v1 returns the live endpoint catalogue; GET /api.ashx/v1/webhooks shows your active subscriptions; the Test fire button on /webhooks-admin.html sends a webhook.test event so you can debug signature verification before any real call happens.
Authentication
Every endpoint except GET /v1 requires an OIDC access token with role=admin. Get one from the platform’s own OIDC IdP:
curl -X POST https://phone.codeb.io/oidc.ashx/token \
-d grant_type=password \
-d client_id=codeb-admin \
-d username=<your-admin-user> \
-d password=<your-admin-password> \
-d scope=openid
The response contains access_token — pass it on every API request:
curl -H "Authorization: Bearer $TOKEN" \
https://phone.codeb.io/api.ashx/v1/calls
refresh_token if you requested scope=offline_access.
Long-lived API keys
For server-to-server integrations that shouldn’t hold an admin password, mint an ak_-prefixed API key from the admin UI at /api-keys-admin.html. The key is shown once on creation — copy it then. It’s stored as a SHA-256 hash on disk; revoke it any time without affecting the others.
Use it as a drop-in replacement for the OIDC access token:
curl -H "Authorization: Bearer ak_a1b2c3d4e5f6…" \
https://phone.codeb.io/api.ashx/v1/calls
Same authorisation surface, same endpoints. The only thing an ak_ key can’t do is mint another ak_ key — creating new keys still requires an admin OIDC bearer, so a leaked integration key can’t silently spawn siblings.
Conventions
- Pagination. Collection endpoints accept
?limit=N&offset=M(defaults50/0;limitcapped at500). Response shape:{ data, total, limit, offset, next }wherenextis the relative URL to the next page (nullon the last page). - Field casing. All response item fields use
camelCase(e.g.did,fromNumber,createdAtUtc). Request bodies accept either case —didorDid— for compatibility with earlier examples, but emit camelCase going forward. - Build version. The
/v1root response carries abuildfield (e.g."2026-06-09-list-envelope") so integrators can detect handler updates from monitoring. - Errors.
{ error, error_description }. HTTP status:400bad input,401missing/invalid bearer,403bearer lacksrole=admin,404resource not found,405method not allowed,502bridge unreachable,503bridge not configured. - Caching. Every response is
Cache-Control: no-store. Don’t cache API replies in your client. - Webhooks. Real-time events (
call.ended,transcript.saved,outbound-ai.finished) are delivered via the platform’s webhook system. REST for pull, webhooks for push.
Outbound AI calls
systemPrompt.Request body (JSON):
| Field | Type | Required | Notes |
|---|---|---|---|
phone | string | yes | E.164, e.g. +15551234567 |
displayName | string | no | Caller-ID label; defaults to phone |
email | string | yes | Where the transcript + outcome email is sent |
systemPrompt | string | yes | The AI agent’s instructions. Plain text or a known prompt slug (e.g. reminder). |
apiKey | string | yes | AI Engine API Key |
model | string | no | Defaults to tenant config |
voice | string | no | e.g. Aoede, Charon; default Aoede |
language | string | no | e.g. en-US, de-DE; default en-US |
maxSeconds | int | no | 10–3600, default 300 |
retries | int | no | 0–10 (default 0) |
retryDelayMinutes | int | no | 1–1440 (default 5) |
scheduleAtUtc | string | no | ISO-8601 UTC; if omitted, dial immediately |
Example:
curl -X POST https://phone.codeb.io/api.ashx/v1/calls \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"phone": "+15551234567",
"displayName": "Reminder",
"email": "ops@example.com",
"systemPrompt": "You are calling Alex to remind them about a test appointment tomorrow at 14:00.",
"apiKey": "AIza...",
"voice": "Aoede",
"language": "en-US",
"maxSeconds": 180
}'
Response (200):
{
"ok": true,
"callId": "oac-0123456789ab",
"tenant": "phone.codeb.io",
"whitelistAdded": true,
"whitelistError": null,
"bridgeReply": "{...}"
}
Query params:
| Name | Type | Default | Notes |
|---|---|---|---|
limit | int | 50 | Page size, capped at 500 |
offset | int | 0 | Page offset |
status | string | (all) | Comma-separated filter, e.g. scheduled,in-flight,ended-success. Valid values: scheduled, dispatching, in-flight, ended-success, ended-failed-retry-pending, ended-failed-final, cancelled. |
Example:
curl -H "Authorization: Bearer $TOKEN" \
"https://phone.codeb.io/api.ashx/v1/calls?status=in-flight,scheduled&limit=20"
Response (200):
{
"data": [
{
"callId": "oac-0123456789ab",
"tenant": "phone.codeb.io",
"requestedBy": "alex",
"phone": "+15551234567",
"displayName": "Reminder",
"email": "ops@example.com",
"voice": "Aoede",
"language": "en-US",
"model": "models/",
"status": "in-flight",
"createdAtUtc": "2026-06-04T17:55:12.401Z",
"scheduledForUtc": null,
"dispatchedAtUtc": "2026-01-01T12:00:00.000Z",
"answeredAtUtc": "2026-01-01T12:00:05.500Z",
"endedAtUtc": null,
"endedReason": "",
"durationSec": 0,
"trunkId": "tr_0123456789abcdef",
"transcriptPath": "",
"errorDetail": "",
"attempt": 1,
"retriesLeft": 2,
"retryDelayMinutes": 5
}
],
"total": 1,
"limit": 20,
"offset": 0,
"next": null
}
Example:
curl -H "Authorization: Bearer $TOKEN" \
https://phone.codeb.io/api.ashx/v1/calls/oac-0123456789ab
Response (200):
{
"callId": "oac-0123456789ab",
"tenant": "phone.codeb.io",
"requestedBy": "alex",
"phone": "+15551234567",
"displayName": "Reminder",
"status": "ended-success",
"createdAtUtc": "2026-06-04T17:55:12.401Z",
"dispatchedAtUtc": "2026-01-01T12:00:00.000Z",
"answeredAtUtc": "2026-01-01T12:00:05.500Z",
"endedAtUtc": "2026-01-01T12:02:30.000Z",
"endedReason": "finished",
"durationSec": 145,
"trunkId": "tr_0123456789abcdef",
"transcriptPath": "outbound-ai-20260101-120000-oac-0123456789ab.txt",
"attempt": 1,
"retriesLeft": 0
}
Response (404) — no such call:
{ "error": "not_found", "error_description": "No call with id=oac-..." }
"ok": true, "wasNoop": true.No body required — the callId comes from the path.
Example:
curl -X POST -H "Authorization: Bearer $TOKEN" \
https://phone.codeb.io/api.ashx/v1/calls/oac-0123456789ab/hangup
Response (200):
{
"ok": true,
"callId": "oac-0123456789ab",
"newStatus": "cancelled",
"actor": "alex"
}
Virtual numbers
Example:
curl -H "Authorization: Bearer $TOKEN" \
https://phone.codeb.io/api.ashx/v1/numbers
Response (200):
{
"data": [
{
"name": "codebdemo",
"number": "1234",
"mode": "live-voice-ai",
"voice": "Aoede",
"language": "en-US",
"saveTranscripts": true,
"maxDurationSec": 3500,
"visibility": "public"
},
{
"name": "MUSC",
"number": "24345",
"mode": "live-voice-ai",
"voice": "Charon",
"language": "en-US",
"saveTranscripts": true,
"maxDurationSec": 3500,
"visibility": "signed-in"
}
],
"total": 15,
"limit": 50,
"offset": 0,
"next": null,
"tenant": "phone.codeb.io"
}
Top-level fields follow the standard list envelope. tenant is included as a sibling for transparency (matches the bearer's tenant claim). Each item is a virtual-number record; sensitive fields like geminiApiKey and systemPrompt are present in the live response but never logged.
Transcripts
Query params:
| Name | Type | Default | Notes |
|---|---|---|---|
limit | int | 50 | Page size, capped at 500 |
offset | int | 0 | Page offset |
source | string | (both) | vnum for inbound (matches both vnum + office-tab callerSource), outbound-ai for outbound. Filter is applied client-side in api.ashx on the callerSource field. |
q | string | (none) | Substring filter against caller / number / displayName / rule |
since | string | (none) | ISO-8601 UTC; only transcripts with startedUtc ≥ since |
Example:
curl -H "Authorization: Bearer $TOKEN" \
"https://phone.codeb.io/api.ashx/v1/transcripts?source=outbound-ai&limit=10"
Response (200):
{
"data": [
{
"callId": "oac-0123456789ab",
"callerSource": "outbound-ai",
"startedUtc": "2026-01-01T12:00:00.000Z",
"endedUtc": "2026-01-01T12:02:30.000Z",
"mtimeUtc": "2026-06-04T17:57:44.649Z",
"durationSec": 150,
"rule": "outbound: +15551234567 (Reminder)",
"phone": "+15551234567",
"displayName": "Reminder",
"outcome": "finished",
"voice": "Aoede",
"language": "en-US",
"model": "live-voice-ai",
"tokensTotal": 18420,
"tokensPrompt": 1240,
"turnCount": 12,
"size": 8412,
"source": "tenant",
"file": "outbound-ai-20260101-120000-oac-0123456789ab.txt"
},
{
"callId": "vnum0123456789ab",
"callerSource": "office-tab",
"startedUtc": "2026-06-04T17:14:03.836Z",
"endedUtc": "2026-06-04T17:15:19.500Z",
"durationSec": 75,
"rule": "vnum:Shortletsmalta",
"number": "666",
"outcome": "empty-room",
"tokensTotal": 6240,
"turnCount": 8,
"size": 3104,
"file": "vnum-1234-20260101-120000-vnum0123456789ab.txt"
}
],
"total": 47,
"limit": 10,
"offset": 0,
"next": 10
}
Example:
curl -H "Authorization: Bearer $TOKEN" \
https://phone.codeb.io/api.ashx/v1/transcripts/oac-0123456789ab
Response (200):
{
"callId": "oac-0123456789ab",
"callerSource": "outbound-ai",
"tenant": "phone.codeb.io",
"requestedBy": "alex",
"phone": "+15551234567",
"displayName": "Reminder",
"email": "ops@example.com",
"voice": "Aoede",
"language": "en-US",
"model": "models/",
"trunkId": "tr_0123456789abcdef",
"startedUtc": "2026-01-01T12:00:00.000Z",
"answeredUtc": "2026-01-01T12:00:05.500Z",
"endedUtc": "2026-01-01T12:02:30.000Z",
"durationSec": 150,
"answered": true,
"outcome": "finished",
"errorDetail": "",
"inputTurns": 6,
"outputTurns": 6,
"tokensTotal": 18420,
"turns": [
{ "speaker": "AI", "text": "Hi Alex, calling about your test appointment tomorrow at 14:00.", "ts": "2026-01-01T12:00:06.450Z" },
{ "speaker": "Caller", "text": "Hi, yes, what about it?", "ts": "2026-01-01T12:00:10.880Z" },
{ "speaker": "AI", "text": "Just confirming you'll be there. Do you need to reschedule?", "ts": "2026-01-01T12:00:15.120Z" }
]
}
Response (404) — no transcript for that callId:
{ "error": "not_found", "error_description": "No transcript for callId=oac-..." }
Inbound routes
Read the per-tenant inbound DID routing table. The bridge uses first-match semantics by Did — the first row whose Did matches the incoming INVITE wins. "*" is the catchall row (always present; auto-inserted if missing).
Example:
curl -H "Authorization: Bearer $TOKEN" \
https://phone.codeb.io/api.ashx/v1/inbound-routes
Response (200):
{
"data": [
{ "did": "+15551234567", "user": "alex" },
{ "did": "anonymous", "fromNumber": "*", "user": "callcentre" },
{ "did": "*", "user": "codeb" }
],
"total": 3,
"limit": 50,
"offset": 0,
"next": null
}
Each row has did (required, the matched value), an optional fromNumber filter, and user (the office-tab user the call rings). did can be a phone number, an extension, "anonymous" (calls with no caller ID), or "*" (catchall — runs if no earlier row matched).
Did matches the URL segment. URL-encode * as %2A.Example:
curl -H "Authorization: Bearer $TOKEN" \
https://phone.codeb.io/api.ashx/v1/inbound-routes/%2A
Response (200):
{ "did": "*", "user": "codeb" }
Response (404) — no row with that did:
{ "error": "not_found", "error_description": "No inbound route with did='+4400000000'" }
"*" catchall is best fetched via the LIST endpoint (above). Some browsers / proxies strip or rewrite URL-encoded %2A before it reaches the handler, causing the request to be routed to the sign-in page instead of the JSON endpoint. List endpoints are immune.
"*" catchall so first-match semantics still let specific Dids win.Request body:
{
"Did": "+15551234567", // required: phone, extension, "anonymous", or any matchable string
"FromNumber": "+356*", // optional: restrict to calls FROM this pattern
"User": "alex" // required: office-tab user that should ring
}
Example:
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"Did":"+15551234567","User":"alex"}' \
https://phone.codeb.io/api.ashx/v1/inbound-routes
Response (201): the created row, echoed back.
{ "Did": "+15551234567", "User": "alex" }
Response (409) — a route with the same Did + FromNumber pair already exists:
{ "error": "duplicate_route", "error_description": "An inbound route already exists for Did='+15551234567'" }
User of an existing route. The row is addressed by Did (path) + fromNumber (optional query). Only User is editable; to change the addressing keys, DELETE + POST.Query string:
fromNumber— optional. Omit to address the row with no FromNumber filter; provide to address a row scoped to a specific caller pattern.
Request body:
{ "User": "newuser" }
Example — rotate the catchall to a new user:
curl -X PUT -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"User":"frontdesk"}' \
https://phone.codeb.io/api.ashx/v1/inbound-routes/%2A
Example — update a FromNumber-scoped route:
curl -X PUT -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"User":"callcentre"}' \
'https://phone.codeb.io/api.ashx/v1/inbound-routes/anonymous?fromNumber=%2A'
Response (200): the updated row.
{ "Did": "anonymous", "FromNumber": "*", "User": "callcentre" }
Response (404) — no row matches the (Did, FromNumber) composite:
{
"error": "not_found",
"error_description": "No inbound route with Did='anonymous' FromNumber='*'"
}
Did matches the URL segment. URL-encode special characters. The "*" catchall cannot be deleted.Example:
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
https://phone.codeb.io/api.ashx/v1/inbound-routes/%2B15551234567
Response (200):
{ "deleted": { "Did": "+15551234567", "User": "alex" } }
Response (400) — tried to delete the catchall:
{
"error": "cannot_delete_catchall",
"error_description": "The '*' catchall row cannot be deleted -- the bridge requires it for unmatched inbound calls"
}
Response (404) — no matching row:
{ "error": "not_found", "error_description": "No inbound route with Did='+4400000000'" }
(Did, FromNumber). POST rejects duplicate composites, so the pair always identifies at most one route. For legacy data with duplicates, PUT and DELETE act on the first matching row in document order.
Webhooks
Read the per-tenant webhook subscription list. Subscriptions are created and managed through the admin UI at /webhooks-admin.html — this endpoint exists so integrators can verify their subscriptions are active and discover which events the platform is firing.
secret is stripped from the response — it is shown once at creation through the admin UI and never again.Example:
curl -H "Authorization: Bearer $TOKEN" \
https://phone.codeb.io/api.ashx/v1/webhooks
Response (200):
{
"data": [
{
"id": "0123456789abcdef",
"url": "https://hooks.example.com/codeb",
"events": [ "*" ],
"active": true,
"createdAtUtc": "2026-01-01T12:00:00.000Z",
"createdBy": "alex",
"lastFiredUtc": "2026-01-01T12:00:05.000Z",
"failures": 0
},
{
"id": "fedcba9876543210",
"url": "https://ops.example.com/codeb-alerts",
"events": [ "call.ended", "transcript.saved" ],
"active": false,
"createdAtUtc": "2026-01-01T08:00:00.000Z",
"createdBy": "alex",
"lastFiredUtc": null,
"failures": 20
}
],
"total": 2,
"limit": 50,
"offset": 0,
"next": null
}
Each item: id (16-hex), url (the operator’s receiver endpoint), events (array of event names or ["*"] for all), active (auto-paused after 20 consecutive delivery failures), and counters for the last successful fire + the consecutive-failure tally. The bridge fires events automatically as calls happen; see /webhooks-admin.html to subscribe.
Event catalogue
The bridge fires three events today. Subscribe to "*" to receive all of them, or list specific names. All deliveries POST JSON to the subscriber’s URL with a top-level envelope:
{
"event": "<name>",
"tenant": "phone.codeb.io",
"ts": "2026-01-01T12:00:00.000Z",
"data": { /* event-specific fields */ }
}
Headers on every POST:
X-CodeB-Event— the event name (e.g.call.ended)X-CodeB-Signature—sha256=<hex>HMAC-SHA256 of the raw body using your subscription’s secretX-CodeB-Delivery-Id— stable across retries (use it to de-dup on your side)X-CodeB-Attempt—1through4. Retries follow 5 s → 30 s → 120 s back-off; after 20 consecutive failures the subscription auto-pauses.
Verifying signatures
On every delivery, compute HMAC-SHA256 of the raw request body using your subscription’s secret, hex-encode it, prefix with sha256=, and compare in constant time against X-CodeB-Signature. Reject mismatches with HTTP 401 — the bridge will retry with the same signature, so a real delivery eventually succeeds while a spoofed one keeps failing.
Python (Flask):
import hmac, hashlib
from flask import Flask, request, abort
SECRET = b"your_subscription_secret" # from /webhooks-admin.html on create
app = Flask(__name__)
@app.post("/codeb-webhook")
def codeb_webhook():
body = request.get_data() # raw bytes -- DO NOT use request.json here
got = request.headers.get("X-CodeB-Signature", "")
want = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(got, want):
abort(401)
event = request.headers.get("X-CodeB-Event", "")
delivery = request.headers.get("X-CodeB-Delivery-Id", "")
# ... process event ...
return "", 200
Node.js (Express):
const express = require("express");
const crypto = require("crypto");
const SECRET = "your_subscription_secret";
const app = express();
// IMPORTANT: capture the raw body BEFORE express.json() parses it.
app.post("/codeb-webhook", express.raw({ type: "*/*" }), (req, res) => {
const got = req.headers["x-codeb-signature"] || "";
const want = "sha256=" +
crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
const gotBuf = Buffer.from(got);
const wantBuf = Buffer.from(want);
if (gotBuf.length !== wantBuf.length ||
!crypto.timingSafeEqual(gotBuf, wantBuf)) {
return res.status(401).end();
}
const event = req.headers["x-codeb-event"];
const payload = JSON.parse(req.body.toString("utf8"));
// ... process event ...
res.status(200).end();
});
Bash (debugging):
SECRET="your_subscription_secret"
BODY=$(cat) # raw stdin
EXPECTED="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')"
echo "$EXPECTED"
# compare against the X-CodeB-Signature header you received
== in your language is almost certainly NOT constant-time and is theoretically vulnerable to timing side-channels. Use hmac.compare_digest (Python), crypto.timingSafeEqual (Node), cmp.Equal with subtle.ConstantTimeCompare (Go), or your language’s equivalent.
Payload (data):
{
"callId": "vnum0123456789ab",
"number": "1234", // vnum / SIP inbound only
"did": "+15551234567", // SIP inbound only
"phone": "+15551234567", // outbound-ai only
"displayName":"Reminder", // outbound-ai only
"room": "vnum-1234-d0va4s42", // vnum only
"direction": "inbound", // "inbound" | "outbound"
"source": "vnum" // "vnum" | "gemini-live" | "outbound-ai"
}
outbound-ai (callee picks up the phone) and SIP-inbound AI receptionist (UAS sends 200 OK). Useful for CDR-style start-of-talk tracking; arrives a beat before call.started. Not emitted for the WebRTC  vnum path (no SIP leg).Payload (data):
{
"callId": "oac-0123456789ab",
"phone": "+15551234567", // outbound-ai only
"displayName": "Reminder", // outbound-ai only
"did": "+15551234567", // SIP inbound only
"fromNumber": "+15551234567", // SIP inbound only
"trunkId": "tr_0123456789abcdef", // outbound-ai only
"direction": "outbound", // "outbound" | "inbound"
"source": "outbound-ai", // "outbound-ai" | "gemini-live"
"answerMs": 2755 // ms from INVITE/Ring to 200 OK
}
transfer_to_user tool. Three transfer types: vnum→vnum (AI routes to another vnum persona), vnum→PSTN (AI dials an external number to bridge the caller), and outbound-to-user (an outbound-AI campaign call gets transferred). Use it to log handoffs in your CRM or trigger downstream workflows.Payload (data):
{
"callId": "vnum0123456789ab",
"fromVnum": "8888", // vnum source only
"fromOutbound": "oac-0123456789ab", // outbound-ai source only
"fromPhone": "+15551234567", // outbound-ai source only
"toUser": "alex", // SIP / extension target
"toPhone": "+15551234567", // PSTN target (vnum-to-pstn only)
"transferType": "vnum-to-pstn", // "vnum-to-vnum"|"vnum-to-officetab"|"vnum-to-pstn"|"outbound-to-user"
"trunkId": "tr_0123456789abcdef", // outbound-to-user only
"room": "vnum-8888-d0va4s42", // vnum source only
"source": "vnum" // "vnum" | "outbound-ai"
}
Payload (data):
{
"callId": "vnum0123456789ab",
"number": "1234", // vnum number, or DID for inbound SIP
"room": "vnum-1234-d0va4s42", // signaling room name
"outcome": "empty-room", // see outcome glossary below
"durationSec": 39,
"tokens": 24823, // AI Voice Engine tokens used (input+output)
"source": "vnum" // "vnum" | "sip"
}
Payload (data):
{
"callId": "oac-0123456789ab",
"phone": "+15551234567",
"displayName": "Reminder",
"outcome": "finished", // see outcome glossary below
"answered": true,
"talkSec": 150,
"trunk": "tr_0123456789abcdef",
"inputTurns": 6, // caller turns recorded by the AI Voice Engine
"outputTurns": 6, // AI turns
"errorDetail": "" // populated on technical failures
}
call.ended / outbound-ai.finished if the call produced a saved transcript. Always preceded by one of the two terminal events. Use it to mirror transcripts into your own storage.Payload (data):
{
"callId": "oac-0123456789ab",
"phone": "+15551234567", // outbound-ai only; absent for vnum
"displayName": "Reminder", // outbound-ai only; absent for vnum
"number": "1234", // vnum only; absent for outbound-ai
"file": "outbound-ai-20260101-120000-oac-0123456789ab.txt",
"outcome": "finished",
"source": "outbound-ai" // "outbound-ai" | "vnum"
}
Fetch the full transcript JSON via GET /v1/transcripts/{callId} after receiving this event.
Payload (data):
{
"token": "...abc12345", // last 8 chars only -- safe to log
"file": "vnum-office-1234-20260101T120000Z-vnum0123456789ab.txt",
"ttlHours": 168, // 0 = never expires
"createdBy": "alex", // OIDC preferred_username / sub
"expiresUtc": "2026-01-08T12:00:00.000Z" // null if ttlHours == 0
}
Payload (data):
{
"token": "...abc12345",
"file": "vnum-office-1234-...",
"viewerIp": "203.0.113.42"
}
Payload (data):
{
"token": "...abc12345",
"file": "vnum-office-1234-...",
"revokedBy": "alex"
}
Outcome glossary
The outcome field is a short string that captures how the call ended. Use it to route follow-up actions.
| Outcome | Where it appears | Meaning |
|---|---|---|
finished | both | Conversation completed cleanly — AI said goodbye or caller hung up after a real exchange. |
far-hangup | both | The far end (caller or callee) hung up before the AI was done. |
empty-room | vnum | Browser tab left without ever speaking, or never connected. |
transferred-to-vnum | both | AI handed off to another virtual number / human queue. |
voicemail | outbound-ai | Outbound dial reached voicemail; the AI hung up without leaving a message (configurable per campaign). |
not-now | outbound-ai | Callee asked to be called later; not eligible for retry. |
no-answer | outbound-ai | Call rang out without pickup. Eligible for retry if Retries > 0. |
cancelled | outbound-ai | Scheduled call was cancelled from the monitor UI. |
max-duration | both | Hit the configured maximum talk-time (default 3500 s). |
Live API description
The endpoint GET /api.ashx/v1 (no auth required) returns a machine-readable description of every route. Point your OpenAPI generator or scaffolding tool at it:
curl https://phone.codeb.io/api.ashx/v1
Response (200):
{
"name": "CodeB Platform REST API",
"version": "v1",
"endpoints": [
{ "method": "POST", "path": "/api.ashx/v1/calls", "description": "Initiate an outbound AI call", "area": "outbound-ai" },
{ "method": "GET", "path": "/api.ashx/v1/calls", "description": "List outbound AI calls", "area": "outbound-ai" },
{ "method": "GET", "path": "/api.ashx/v1/calls/{id}", "description": "Get one call's status + outcome", "area": "outbound-ai" },
{ "method": "POST", "path": "/api.ashx/v1/calls/{id}/hangup", "description": "Cancel/terminate a call", "area": "outbound-ai" },
{ "method": "GET", "path": "/api.ashx/v1/numbers", "description": "List virtual numbers", "area": "numbers" },
{ "method": "GET", "path": "/api.ashx/v1/transcripts", "description": "List transcripts", "area": "transcripts" },
{ "method": "GET", "path": "/api.ashx/v1/transcripts/{callId}", "description": "Get full transcript by callId", "area": "transcripts" },
{ "method": "GET", "path": "/api.ashx/v1/inbound-routes", "description": "List inbound DID routes", "area": "inbound-routes" },
{ "method": "POST", "path": "/api.ashx/v1/inbound-routes", "description": "Create an inbound route", "area": "inbound-routes" },
{ "method": "GET", "path": "/api.ashx/v1/inbound-routes/{did}", "description": "Get one inbound route by DID", "area": "inbound-routes" },
{ "method": "DELETE","path": "/api.ashx/v1/inbound-routes/{did}", "description": "Delete one inbound route by DID", "area": "inbound-routes" },
{ "method": "PUT", "path": "/api.ashx/v1/inbound-routes/{did}", "description": "Update one inbound route\u0027s User", "area": "inbound-routes" },
{ "method": "GET", "path": "/api.ashx/v1/webhooks", "description": "List webhook subscriptions", "area": "webhooks" }
],
"auth": "Authorization: Bearer <token>. token = OIDC access token (role=admin) OR an ak_-prefixed API key minted via /api-keys-admin.html",
"pagination": "?limit=N&offset=M (defaults 50, 0)",
"errors": "{ error: <code>, error_description: <text> }"
}
Ready to integrate?
Sign in with your admin user to fetch an access token, then run any of the curl examples above against your CodeB host. Missing an endpoint that’s blocking you? Let us know.
Roadmap
v2 will add: dedicated API keys (so integrators don’t need to use admin user credentials), inbound-route CRUD, contact lists, scheduled-campaign primitives. The platform positioning sits on the self-hosted CPaaS page. If a missing endpoint is blocking you, tell us.