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.
Outbound routes
Read and manage the per-tenant outbound dial routing table. The bridge picks the first row whose match pattern fits the dialled number and dials through one of the listed trunkIds. The catch-all is the defaultTrunkId that lives at the top level of the response.
defaultTrunkId is the catch-all that runs when no row matches.Example:
curl -H "Authorization: Bearer $TOKEN" \
https://phone.codeb.io/api.ashx/v1/outbound-routes
Response (200):
{
"data": [
{ "match": "+356*", "trunkIds": [ "malta" ] },
{ "match": "+49*", "trunkIds": [ "upstairs-de", "downstairs-de" ] },
{ "match": "global", "trunkIds": [ "malta" ] }
],
"defaultTrunkId": "malta",
"total": 3,
"limit": 50,
"offset": 0,
"next": null
}
Each row has match (the dial-prefix pattern; * = wildcard suffix; the literal string "global" is the catch-all matcher) and trunkIds (an ordered list — the bridge tries them in order until one accepts the INVITE).
match pattern. URL-encode * as %2A and + as %2B.Example:
curl -H "Authorization: Bearer $TOKEN" \
https://phone.codeb.io/api.ashx/v1/outbound-routes/%2B356%2A
Response (200):
{ "match": "+356*", "trunkIds": [ "malta" ] }
Response (404) — no row with that match:
{ "error": "not_found", "error_description": "No outbound route with match='+44*'" }
defaultTrunkId always wins last).Request body:
{
"match": "+44*", // required: dial-prefix pattern
"trunkIds": [ "uk-primary", "uk-failover" ] // required: ordered trunk list
}
Example:
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"match":"+44*","trunkIds":["uk-primary"]}' \
https://phone.codeb.io/api.ashx/v1/outbound-routes
Response (201):
{ "match": "+44*", "trunkIds": [ "uk-primary" ] }
Response (409) — a route with that match already exists.
trunkIds of an existing route. Use POST + DELETE if you want to change the match pattern itself.Request body:
{ "trunkIds": [ "uk-primary", "uk-failover" ] }
Example:
curl -X PUT -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"trunkIds":["uk-primary","uk-failover"]}' \
https://phone.codeb.io/api.ashx/v1/outbound-routes/%2B44%2A
Response (200): the updated row.
{ "match": "+44*", "trunkIds": [ "uk-primary", "uk-failover" ] }
defaultTrunkId cannot be deleted — it lives at the appsettings level, not in the route table.Example:
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
https://phone.codeb.io/api.ashx/v1/outbound-routes/%2B44%2A
Response (200):
{ "deleted": { "match": "+44*", "trunkIds": [ "uk-primary" ] } }
Response (404) — no matching row.
+44207* before +44*) or the broader row will swallow them.
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"
}
Room cascade events
Fires when a meeting room flips media topology between peer-to-peer mesh and server-side fan-out (SFU). Two triggers: auto (a 7th human joiner crosses the mesh capacity threshold) and admin (operator flips mode via /sfu.html or the ?sfu-mode= REST endpoint). See dataflow.html for the topology background.
trigger=auto-join means a 7th human pushed the mesh past its capacity; trigger=auto-bandwidth means the room.bandwidth.degraded detector decided the room is struggling and flipped it automatically; trigger=admin means an operator clicked Promote.Payload (data):
{
"room": "vnum-1234-d0va4s42",
"mode": "sfu",
"reason": "auto-promote-join-human-count-7", // or "auto-promote-bandwidth-degraded-stressed-N-of-M", "admin-override"
"trigger": "auto-join", // "auto-join" | "auto-bandwidth" | "admin"
"humanCount": 7, // auto-join + auto-bandwidth (count at trigger time)
"stressedCount": 3, // auto-bandwidth only -- peers above threshold
"peerCount": 5, // auto-bandwidth only -- total peers (incl. ai-vnum / sip-bridge)
"actor": "alex" // admin only -- OIDC preferred_username / sub
}
Payload (data):
{
"room": "vnum-1234-d0va4s42",
"mode": "mesh",
"reason": "admin-override", // or operator-supplied reason
"trigger": "admin",
"actor": "alex" // OIDC preferred_username / sub
}
Payload (data):
{
"room": "vnum-1234-d0va4s42",
"mode": "sfu", // or "mesh" -- direction that FAILED
"reason": "auto-promote-join-human-count-7", // or "admin-override"
"trigger": "auto-join", // "auto-join" | "admin"
"humanCount": 7, // auto-join only
"actor": "alex", // admin only
"error": "BridgeUnreachable: connection refused" // truncated to 200 chars
}
Room presence events
Fires every time a peer joins or leaves a meeting room. Use these to mirror live presence into a CRM, drive a wall-board, run billing per-minute counters, or push notifications to other channels when a specific person walks in. Both events tenant-scoped via webhook subscription.
joinPath).Payload (data):
{
"room": "vnum-1234-d0va4s42",
"peerId": "f3d5452776f5",
"name": "alex", // peer-supplied display name
"role": "human", // "human" | "ai-vnum" | "sip-bridge"
"verified": false, // true if X-CodeB-Identity / OIDC backed
"ip": "203.0.113.42",
"joinPath": "direct", // "direct" | "admit"
"peerCount": 3 // total peers in the room AFTER this join
}
Payload (data):
{
"room": "vnum-1234-d0va4s42",
"peerId": "f3d5452776f5",
"name": "alex",
"role": "human",
"verified": false, // mirrored from the peer.joined event
"ip": "203.0.113.42",
"reason": "ws-close", // future values: "kick", "timeout"
"peerCount": 2 // total peers in the room AFTER this leave (0 if room emptied)
}
peerCount vs humanCount. Presence events count every peer in the room — browser humans, server-side AI personas (ai-vnum), and SIP-bridge legs (sip-bridge). The cascade events’ humanCount is narrower: browser-humans only (used to decide when to flip mesh → SFU). Use peerCount for billing / CDR / capacity; use cascade events’ humanCount for topology decisions.
Room health events
Fires when multiple peers in a room report sustained network stress at the same time. Use it to alert ops, prompt an admin to flip the room to SFU, surface a degraded-call warning to the host, or trigger a customer-side capacity check. Browser peers report bandwidth, packet loss, and RTT to signal.ashx every few seconds; this event is the room-wide rollup, not per-peer noise.
Payload (data):
{
"room": "vnum-1234-d0va4s42",
"stressedCount": 3, // peers currently above threshold within the rolling window
"peerCount": 5, // total peers in the room (incl. ai-vnum / sip-bridge legs)
"humanCount": 5, // browser-humans only (for cascade-trigger correlation)
"windowSec": 30, // rolling window the count covers
"triggerPeerId": "f3d5452776f5", // the peer whose report tipped the eval over the line
"sampleMetrics": {
"abrMbps": 0.92, // minimum outgoing bitrate observed at trigger time (Mbps)
"lossPct": 7.4, // peak packet-loss percentage
"rttMs": 420 // peak RTT in milliseconds
}
}
Per-tenant SFU tunables
Every SFU threshold is configurable per tenant via your appsettings.json under the WebPhone:Sfu:* namespace. Defaults below are the safe values shipped out of the box; clamping ranges are enforced server-side (out-of-range values silently snap to the nearest valid boundary).
| Key | Default | Range | What it does |
|---|---|---|---|
WebPhone:Sfu:AutoPromoteHumanCount | 6 | 2–100 | Join-time auto-promote threshold. When the (N+1)th human joins a mesh room with N ≥ this value, the room auto-promotes to SFU. Set higher to delay; set lower to promote earlier. |
WebPhone:Sfu:AutoPromoteBwMinHumans | 3 | 2–100 | Minimum humans required for the bandwidth-driven auto-promote (Trigger A). Below this, mesh stays even when the room is degraded — SFU adds latency without enough fan-out savings to be worth it for tiny rooms. |
WebPhone:Sfu:StressWindowSec | 30 | 5–600 | Rolling window for the bandwidth-degraded eval. A peer counts as “currently stressed” if it reported any stress within this window. Browser poller cadence is ~5s, so the default catches ~6 reports per peer. |
WebPhone:Sfu:MinStressedPeers | 2 | 2–100 | Room-level threshold: room.bandwidth.degraded fires only when at least this many humans are simultaneously stressed in the window. Floors at 2 to avoid flapping on a single bad uplink. |
WebPhone:Sfu:BwDegradedSuppressSec | 300 | 30–86400 | Per-room re-fire suppression. After room.bandwidth.degraded fires, it’s suppressed for this many seconds. Set higher (e.g. 1800) for low-noise alerting; lower for tighter monitoring. |
WebPhone:Sfu:StressBwMbpsThreshold | 1.5 | 0.1–1000.0 | A peer is flagged as stressed when its outgoing bitrate drops below this many Mbps. Default targets 720p video budgets; raise for HD-only deployments. |
WebPhone:Sfu:StressLossPctThreshold | 5.0 | 0.1–50.0 | Packet-loss percentage threshold. A peer is flagged as stressed when loss exceeds this %, evaluated only after ≥50 packets sent (low-traffic noise filter). |
WebPhone:Sfu:StressRttMsThreshold | 300 | 50–10000 | Round-trip-time threshold in milliseconds. A peer is flagged as stressed when RTT exceeds this value, evaluated only after a non-zero measurement. |
All eight keys are hot-reloaded — edit appsettings.json and the next eval picks up the new value (no IIS restart needed). The bw-degraded-fire connection-log line emits the effective values alongside each fire, so you can verify the in-effect config from logs.
SFU signaling events
Fires when a browser interacts with the SFU. Phase 1b.2 (this build) ships end-to-end audio fan-out — peer A's published RTP arrives at the bridge and is forwarded to every peer B that has subscribed to A. Each (publisher, subscriber) edge has its own RTCPeerConnection with fresh DTLS-SRTP keys; the bridge does NOT mix on the browser-facing leg (the receiving browser composites multiple inbound streams locally). Subscriber-side conference.js wiring is the next round; today these events are operator-testable via wscat.
sfu-offer the signal layer proxies to the bridge, regardless of outcome. Operators use it to see SFU upgrade attempts. As of Phase 1b.1 the bridge returns real answer SDPs — outcome is "answer" on successful negotiation, "failed" with code + error on any failure (HMAC, codec mismatch, body too large, etc).Payload (data):
{
"room": "vnum-1234-d0va4s42",
"peerId": "f3d5452776f5",
"role": "human",
"offerLen": 2840, // bytes of SDP the browser sent
"outcome": "failed", // "answer" (1b.1+) | "failed"
"code": "not-implemented", // bridge-supplied error code on failure
"phase": "1b.1-live", // bridge-supplied phase marker (or "1b.1-live" w/ ok=true)
"httpStatus": 200 // bridge HTTP status code (200 on success; 4xx/5xx on failure)
}
RTCPeerConnection reaches connected state on the bridge — i.e. the browser's DTLS-SRTP handshake completed and media can flow. The gap between sfu.offer.received (outcome=answer) and this event is the browser-side ICE+DTLS time, typically 100–500 ms on LAN, up to a few seconds with TURN.Payload (data):
{
"room": "vnum-1234-d0va4s42",
"peerId": "f3d5452776f5",
"role": "human",
"connectedUtc": "2026-06-15T14:23:18.456Z", // when connectionState=connected fired
"packetsAtConn": 0 // RTP packets received at moment of fire
}
sfu-subscribe-offer the signal layer proxies to the bridge, regardless of outcome. Mirrors sfu.offer.received for the subscribe path so operators see all SFU-side attempts.Payload (data):
{
"room": "vnum-1234-d0va4s42",
"publisherPeerId": "f3d5452776f5", // upstream publisher
"subscriberPeerId": "a8e21fc09d12", // receiving browser
"offerLen": 2840, // bytes of SDP the browser sent
"outcome": "answer", // "answer" | "failed"
"code": null, // bridge-supplied error code on failure
"phase": "1b.2-live",
"httpStatus": 200
}
RTCPeerConnection reaches connected on the bridge — i.e. the browser's DTLS handshake on the subscribe leg completed and forwarded media can flow. After this fires you'll see packetsForwarded climbing in /sfu/tenant-health.Payload (data):
{
"room": "vnum-1234-d0va4s42",
"publisherPeerId": "f3d5452776f5",
"subscriberPeerId": "a8e21fc09d12",
"connectedUtc": "2026-06-15T14:23:18.789Z",
"packetsForwardedAtConn": 0 // typically 0; first forwarded packet usually arrives in the same ms
}
Need a different event?
The list above is what fires today. We add events when integrators ask for them — SIP-register, trunk health, license-threshold crossings, custom CDR shapes, EU Wallet verifier outcomes … tell us what you need and we’ll wire it up against your existing webhook subscription.
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). |
Audit log
Read identity and authentication events for this tenant — sign-ins (password / passkey / EU Wallet / magic link), recovery attempts, token revocations, and EU Wallet verifier interactions. The same data that powers the admin browser at /audit-log.html, exposed as a paginated JSON list for SIEM / Splunk / Datadog polling.
Host header (per-tenant defense-in-depth). Filterable by event name, user, and time range.Query parameters (all optional):
| Parameter | Type | Description |
|---|---|---|
event | string | Case-insensitive substring match against the event name. Examples: maglink-start, passkey-signin-ok, vp-sso-minted, recover-finish, or just fail to catch every failure. |
user | string | Case-insensitive substring match against the user sub / email field. |
since | ISO 8601 | Only return events newer than this timestamp. Parsed as UTC. |
limit | integer | Rows per page. Defaults to 50; cap 500. |
offset | integer | Paging cursor. Defaults to 0. |
Example:
curl -H "Authorization: Bearer $TOKEN" \
"https://phone.codeb.io/api.ashx/v1/auditlog?event=maglink&limit=20"
Response (200):
{
"data": [
{
"ts": "2026-06-12T00:15:00.123Z",
"event": "maglink-finish",
"tenant": "phone.codeb.io",
"user": "alice@example.com",
"ip": "195.158.111.88",
"ua": "Mozilla/5.0 (iPhone)",
"extra": "tenant=phone.codeb.io result=ok jti=abc123..."
},
{
"ts": "2026-06-12T00:14:42.000Z",
"event": "passkey-signin-ok",
"tenant": "phone.codeb.io",
"user": "bob",
"ip": "10.0.0.42",
"ua": "Mozilla/5.0",
"extra": "credentialId=Zm9vYmFy..."
}
],
"total": 2,
"limit": 20,
"offset": 0,
"next": null
}
Each item carries the standard LogOidc shape: ts (ISO 8601 UTC), event (the canonical event name — not the URL action name, e.g. maglink-start not magic-link-start), tenant (Host-scoped, matches your bearer’s tenant), user (sub or email), ip (full IPv4 / IPv6 of the originator), ua (User-Agent string), and extra (free-form key=value tail with everything the event handler emitted).
Host header per cross-tenant data isolation.
Event taxonomy (the common ones)
| Event | When it fires |
|---|---|
login-ok / login-fail | Username + password sign-in attempts. |
passkey-signin-start / passkey-signin-ok / passkey-signin-fail | FIDO2 / WebAuthn sign-in flow. |
maglink-start / maglink-finish | Passwordless email magic-link flow. |
recover-start / recover-finish | Self-service password reset flow. |
vp-start / vp-request / vp-response-arrived / vp-verify-result / vp-sso-minted | EU Wallet (OID4VP 1.0) verifier interactions. |
revoke / introspect / end-session | Token revocation, introspection, RP-initiated logout. |
claim-info / claim | Tenant-onboard claim flow (first-login password set). |
login-rate-limited / claim-rate-limited / login-claim-pending | Throttling decisions. |
For the complete list query the live event names directly: GET /v1/auditlog?limit=500 and inspect the distinct event values.
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" },
{ "method": "GET", "path": "/api.ashx/v1/auditlog", "description": "List identity/auth events (audit log)","area": "auditlog" }
],
"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.
Coming soon
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.