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.

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.

GET/v1/outbound-routes
List every configured outbound route in the order the bridge will evaluate them. The top-level 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).

GET/v1/outbound-routes/{match}
Fetch a single route by its 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*'" }
POST/v1/outbound-routes
Create a new outbound route. Inserted at the end of the list (the catch-all 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.

PUT/v1/outbound-routes/{match}
Replace the 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" ] }
DELETE/v1/outbound-routes/{match}
Remove an outbound route. The catch-all 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.

First-match semantics. Rows are evaluated in document order. Put more‑specific patterns above broader ones (e.g. +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.

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"
}

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.

EVENTroom.cascade.promoted
Fires when a room transitions from mesh to SFU. 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
}
EVENTroom.cascade.demoted
Fires when an admin flips a room back from SFU to mesh (e.g. after a large meeting has shrunk and the operator wants to drop fan-out cost). No auto-demote path today — this event is admin-driven only.

Payload (data):

{
  "room":    "vnum-1234-d0va4s42",
  "mode":    "mesh",
  "reason":  "admin-override",                       // or operator-supplied reason
  "trigger": "admin",
  "actor":   "alex"                                // OIDC preferred_username / sub
}
EVENTroom.cascade.failed
Fires when the bridge-side SFU promote OR demote call fails (bridge unreachable, SFU module rejected the request, HMAC validation broke). The room falls back to its previous mode; clients are not migrated.

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.

EVENTroom.peer.joined
Fires the moment a peer is admitted to a room and its peer-joined signal is broadcast to siblings. Includes both the unlocked direct-join path and the knock-and-admit path (distinguish via 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
}
EVENTroom.peer.left
Fires when a peer’s WebSocket closes (clean close, page navigation, network drop, server kick). Fires for every leave, including the one that empties the room.

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.

EVENTroom.bandwidth.degraded
Fires when two or more peers in a room have crossed the stress threshold (any of: outgoing bitrate < 1.5 Mbps, packet loss > 5%, RTT > 300 ms) within the last 30 seconds. After firing, the event is suppressed for 5 minutes per room to prevent spam — if conditions persist, expect a re-fire on the next eligible report.

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

KeyDefaultRangeWhat it does
WebPhone:Sfu:AutoPromoteHumanCount62–100Join-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:AutoPromoteBwMinHumans32–100Minimum 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:StressWindowSec305–600Rolling 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:MinStressedPeers22–100Room-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:BwDegradedSuppressSec30030–86400Per-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:StressBwMbpsThreshold1.50.1–1000.0A 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:StressLossPctThreshold5.00.1–50.0Packet-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:StressRttMsThreshold30050–10000Round-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.

EVENTsfu.offer.received
Fires on every browser-originated 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)
}
EVENTsfu.publisher.connected
Fires when a publisher's 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
}
EVENTsfu.subscribe.received
Fires on every browser-originated 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
}
EVENTsfu.subscriber.connected
Fires when a subscriber edge's 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.

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

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.

GET/v1/auditlog
Recent OIDC and identity events, newest first, scoped to the request Host header (per-tenant defense-in-depth). Filterable by event name, user, and time range.

Query parameters (all optional):

ParameterTypeDescription
eventstringCase-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.
userstringCase-insensitive substring match against the user sub / email field.
sinceISO 8601Only return events newer than this timestamp. Parsed as UTC.
limitintegerRows per page. Defaults to 50; cap 500.
offsetintegerPaging 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).

Per-tenant scope. Events from other tenants never appear here, even if you have an admin bearer that was minted on another tenant. The filter is applied server-side via the request Host header per cross-tenant data isolation.

Event taxonomy (the common ones)

EventWhen it fires
login-ok / login-failUsername + password sign-in attempts.
passkey-signin-start / passkey-signin-ok / passkey-signin-failFIDO2 / WebAuthn sign-in flow.
maglink-start / maglink-finishPasswordless email magic-link flow.
recover-start / recover-finishSelf-service password reset flow.
vp-start / vp-request / vp-response-arrived / vp-verify-result / vp-sso-mintedEU Wallet (OID4VP 1.0) verifier interactions.
revoke / introspect / end-sessionToken revocation, introspection, RP-initiated logout.
claim-info / claimTenant-onboard claim flow (first-login password set).
login-rate-limited / claim-rate-limited / login-claim-pendingThrottling 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.