Platform API · v1

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.

Base URL. All endpoints live under /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.
Verified live 2026-06-05. Every GET on this page was probed against 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.

  1. 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
  2. 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).
  3. 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:
    curl https://phone.codeb.io/api.ashx/v1
    Then verify your key works by listing inbound routes:
    curl -H "Authorization: Bearer $TOKEN" \
      https://phone.codeb.io/api.ashx/v1/inbound-routes
  4. Place a test outbound AI call. Replace +SAFETESTNUMBER with a number you own and want to be called by an AI persona that just says “this is a test, goodbye.”
    curl -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/calls
    Response includes a callId like oac-0123456789ab. Watch progress at /outbound-ai-monitor.html.
  5. Receive + verify the webhook. Within seconds of the call ending, your receiver gets an outbound-ai.finished POST and (if the call produced one) a transcript.saved POST. The X-CodeB-Signature header 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>
Stuck? 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
Lifetime. Access tokens are short-lived (typically 1 hour). Refresh by re-running the token flow above, or use the 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 (defaults 50 / 0; limit capped at 500). Response shape: { data, total, limit, offset, next } where next is the relative URL to the next page (null on the last page).
  • Field casing. All response item fields use camelCase (e.g. did, fromNumber, createdAtUtc). Request bodies accept either case — did or Did — for compatibility with earlier examples, but emit camelCase going forward.
  • Build version. The /v1 root response carries a build field (e.g. "2026-06-09-list-envelope") so integrators can detect handler updates from monitoring.
  • Errors. { error, error_description }. HTTP status: 400 bad input, 401 missing/invalid bearer, 403 bearer lacks role=admin, 404 resource not found, 405 method not allowed, 502 bridge unreachable, 503 bridge 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

POST/v1/calls
Initiate an outbound AI call. The platform whitelists the target number, places a SIP call via the configured trunk, and attaches a Live Voice AI session that follows your systemPrompt.

Request body (JSON):

FieldTypeRequiredNotes
phonestringyesE.164, e.g. +15551234567
displayNamestringnoCaller-ID label; defaults to phone
emailstringyesWhere the transcript + outcome email is sent
systemPromptstringyesThe AI agent’s instructions. Plain text or a known prompt slug (e.g. reminder).
apiKeystringyesAI Engine API Key
modelstringnoDefaults to tenant config
voicestringnoe.g. Aoede, Charon; default Aoede
languagestringnoe.g. en-US, de-DE; default en-US
maxSecondsintno10–3600, default 300
retriesintno0–10 (default 0)
retryDelayMinutesintno1–1440 (default 5)
scheduleAtUtcstringnoISO-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": "{...}"
}
GET/v1/calls
List active + recent outbound AI calls. Includes scheduled, dialing, in-progress, completed, failed.

Query params:

NameTypeDefaultNotes
limitint50Page size, capped at 500
offsetint0Page offset
statusstring(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
}
GET/v1/calls/{id}
One call’s status, outcome, and metadata. Returned without the list envelope — the call record directly.

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-..." }
POST/v1/calls/{id}/hangup
Cancel a scheduled call or terminate an in-flight one. Idempotent — calling on an already-ended call returns 200 with "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

GET/v1/numbers
List inbound virtual numbers configured on this tenant. Each entry is a rule with the inbound DID, AI prompt (if any), routing, voice, recording flag.

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

GET/v1/transcripts
List transcripts (inbound vnum calls + outbound AI calls), newest first.

Query params:

NameTypeDefaultNotes
limitint50Page size, capped at 500
offsetint0Page offset
sourcestring(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.
qstring(none)Substring filter against caller / number / displayName / rule
sincestring(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
}
GET/v1/transcripts/{callId}
Full transcript JSON for one call — metadata header plus turn-by-turn transcript array.

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).

GET/v1/inbound-routes
List every configured inbound route in the order the bridge will evaluate them.

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).

GET/v1/inbound-routes/{did}
First row whose 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 row caveat. The "*" 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.
POST/v1/inbound-routes
Create a new inbound route. Inserted just before the "*" 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'" }
PUT/v1/inbound-routes/{did}
Change the 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='*'"
}
DELETE/v1/inbound-routes/{did}
Remove the first row whose 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'" }
Composite addressing. Rows are uniquely addressed by (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.

GET/v1/webhooks
List every configured webhook subscription. The HMAC 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.

Secrets are write-only. The HMAC signing secret is never returned by this endpoint. If you’ve lost your secret, rotate it from the admin UI (delete + recreate).

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-Signaturesha256=<hex> HMAC-SHA256 of the raw body using your subscription’s secret
  • X-CodeB-Delivery-Id — stable across retries (use it to de-dup on your side)
  • X-CodeB-Attempt1 through 4. 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
Raw body, not parsed JSON. Compute the HMAC over the exact bytes you received. Re-serialising the parsed JSON object will produce a slightly different byte sequence (whitespace, key order, escape style) and the signature will mismatch. Most middleware frameworks have a hook to expose the raw body before JSON parsing — use it.
Constant-time compare. == 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.
EVENTcall.started
Fires after the AI is connected and the caller / callee has heard the ringback cue. Use it as a heads-up to pre-fetch CRM data, warm a chat panel, or stamp an "AI engaged" marker before the real audio flows.

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"
}
EVENTcall.answered
Fires the moment a SIP dialog accepts the call — 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
}
EVENTcall.transferred
Fires when the AI hands the caller off to another party via its 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"
}
EVENTcall.ended
Fires when an AI call ends — both vnum (browser dials a virtual number) and SIP AI-receptionist paths. Best place to update your CRM with the outcome.

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"
}
EVENToutbound-ai.finished
Fires when an outbound AI call attempt completes. Includes call attempts that were never answered, voicemail-detections, and finished conversations.

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
}
EVENTtranscript.saved
Fires AFTER 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.

EVENTshare.created
Fires when an admin creates a public transcript-share link (revocable token-based URL). Use it to record the share in your audit system or notify downstream services.

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
}
EVENTshare.viewed
Fires every time a transcript-share URL is opened (no auth, public endpoint). Useful for analytics on shared transcripts — how many views, from which IPs.

Payload (data):

{
  "token":    "...abc12345",
  "file":     "vnum-office-1234-...",
  "viewerIp": "203.0.113.42"
}
EVENTshare.revoked
Fires when an admin revokes a transcript share, deleting the token. After this event the share URL returns 404.

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.

OutcomeWhere it appearsMeaning
finishedbothConversation completed cleanly — AI said goodbye or caller hung up after a real exchange.
far-hangupbothThe far end (caller or callee) hung up before the AI was done.
empty-roomvnumBrowser tab left without ever speaking, or never connected.
transferred-to-vnumbothAI handed off to another virtual number / human queue.
voicemailoutbound-aiOutbound dial reached voicemail; the AI hung up without leaving a message (configurable per campaign).
not-nowoutbound-aiCallee asked to be called later; not eligible for retry.
no-answeroutbound-aiCall rang out without pickup. Eligible for retry if Retries > 0.
cancelledoutbound-aiScheduled call was cancelled from the monitor UI.
max-durationbothHit 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.