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://www.aloaha.com/api.ashx/v1/calls.
Machine-readable spec. Every public endpoint on the platform — OIDC provider, EU-Wallet verifier + issuer, LOTL cache, RP-certificate CA, chat, PMS, v1 REST outbound-AI, bridge health — is enumerated in an
OpenAPI 3.1 spec at /openapi.json (also served at
/.well-known/openapi.json). Import it into Postman, Insomnia, Swagger UI or any OpenAPI-3-compatible tool to explore the surface or generate a client. This human-readable page stays the source of truth for narrative + examples; the machine spec keeps integrators productive from the first minute.
Verified live 2026-06-05. Every GET on this page was probed against www.aloaha.com 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:
curl https://www.aloaha.com/api.ashx/v1
Then verify your key works by listing inbound routes:
curl -H "Authorization: Bearer $TOKEN" \
https://www.aloaha.com/api.ashx/v1/inbound-routes
-
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://www.aloaha.com/api.ashx/v1/calls
Response includes a callId like oac-0123456789ab. Watch progress at /outbound-ai-monitor.html. Step-by-step how-to with curl / Python / Node.js examples: /schedule-ai-call.html.
-
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://www.aloaha.com/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.aloaha.com/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://www.aloaha.com/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://www.aloaha.com/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.
Primary-tenant admins are super-admins
The bridge hosts many tenants but exactly one of them is the primary tenant (configured via Bridge.PublicBaseUrl in sipbridge/appsettings.json). Any account with role=admin on the primary tenant is automatically treated as super-admin across every super-admin-gated endpoint — no separate superuser=true claim required.
This matches operator expectation: the team running the primary deployment owns the box and shouldn’t have to maintain a parallel superuser account just to call cross-tenant endpoints. Admins of secondary tenants stay scoped to their own tenant’s data exactly as before.
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):
| 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 | Gemini prebuilt voice name. Supported: Achird, Aoede, Autonoe, Charon, Erinome, Fenrir, Kore, Laomedeia, Orus, Puck, Sadachbia, Schedar, Umbriel, Zephyr. 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://www.aloaha.com/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": "www.aloaha.com",
"whitelistAdded": true,
"whitelistError": null,
"bridgeReply": "{...}"
}
GET/v1/calls
List active + recent outbound AI calls. Includes scheduled, dialing, in-progress, completed, failed.
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://www.aloaha.com/api.ashx/v1/calls?status=in-flight,scheduled&limit=20"
Response (200):
{
"data": [
{
"callId": "oac-0123456789ab",
"tenant": "www.aloaha.com",
"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://www.aloaha.com/api.ashx/v1/calls/oac-0123456789ab
Response (200):
{
"callId": "oac-0123456789ab",
"tenant": "www.aloaha.com",
"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://www.aloaha.com/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://www.aloaha.com/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": "www.aloaha.com"
}
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:
| 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://www.aloaha.com/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://www.aloaha.com/api.ashx/v1/transcripts/oac-0123456789ab
Response (200):
{
"callId": "oac-0123456789ab",
"callerSource": "outbound-ai",
"tenant": "www.aloaha.com",
"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://www.aloaha.com/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://www.aloaha.com/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://www.aloaha.com/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://www.aloaha.com/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://www.aloaha.com/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://www.aloaha.com/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://www.aloaha.com/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://www.aloaha.com/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://www.aloaha.com/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://www.aloaha.com/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://www.aloaha.com/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://www.aloaha.com/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": "www.aloaha.com",
"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 secret
X-CodeB-Delivery-Id — stable across retries (use it to de-dup on your side)
X-CodeB-Attempt — 1 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).
| 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. (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 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 the SFU connection became healthy
"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
}
EVENTroom.client.preference.changed
[Reserved] Fires when a peer's client-side SFU preference changes. The meeting room's auto-promote threshold is weighted by aggregated peer preference: each prefer-sfu lowers the effective threshold; each prefer-mesh raises it. Admin pin (force-sfu/force-mesh) wins absolutely. Constant reserved today; fire site lands with the client-side SFU wiring.
Payload (data):
{
"room": "vnum-1234-d0va4s42",
"peerId": "f3d5452776f5",
"role": "human",
"prevPref": "auto",
"newPref": "prefer-sfu",
"voteTally": { "auto": 3, "prefer-mesh": 1, "prefer-sfu": 2 },
"effectiveThreshold": 5
}
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). |
Bridge security: TURN ACL gate & TLS auto-blacklist
The TURN server applies two complementary defences against unwanted inbound traffic, both managed without a deploy. They run on every TURN accept path — UDP/3478, TCP/3478, TLS/5349 and TLS/443.
ACL gate — operator-curated allow / deny
The same acl.html admin page that gates SIP traffic now also gates TURN media. When the operator adds a blacklist entry for an IP or CIDR (e.g. 62.210.136.0/22), all four TURN accept paths drop traffic from that source at the socket level — no SslStream allocation, no STUN parse, no per-IP quota cost.
- Rule subjects honoured:
ip and cidr. Identity-shaped subjects (sip-user, sip-from, sip-did, tenant, user-agent) are skipped — TURN packets carry no such fields.
- Scopes honoured:
sip-inbound, pstn-outbound, or any. An IP-shaped rule under any of these scopes applies to TURN too. A rule scoped to any with subject-type cidr is the cleanest way to block a scanner range from both SIP and TURN with one entry.
- Per-tenant rules are not consulted. TURN is single-process across all tenants; only the global rule pool applies. Per-tenant ACL still works for SIP as before.
- Private / loopback / link-local IPs always allowed. Same exemption as the SIP gate — a misconfigured blanket-deny cannot lock the operator off the LAN.
- No mode-default for TURN. An operator who flips
ModeSipInbound to deny for SIP will not accidentally lock all browsers out of their own TURN. No-match always allows.
- Per-IP log throttle. The gate emits one INF line per blocked source IP per minute, with the matched rule id and reason. The counter increments unthrottled so the stats line shows the true block volume.
TLS auto-blacklist — self-driving scanner defence
Per-source-IP sliding-window detector: more than 5 failed TLS handshakes from one IP in a 5-minute window blocks that IP at the TLS accept layer for 1 hour. Catches the noisy Censys / Palo Alto Xpanse-class scanners that hit TURN/5349 looking for misconfigured allocations.
- Three handshake-fail paths feed the detector: no-cert-loaded, handshake timeout (Slowloris-style stall), and any other handshake exception (junk ClientHello).
- Blocked IPs are auto-expired after 1 hour. A legitimate user who fumbles a renewal is not locked out forever.
- Private / loopback / link-local IPs never auto-blocked, regardless of failure count — same self-lockout protection as the manual ACL.
- First-block is race-safe. Concurrent failing handshakes from the same IP produce exactly one "blocked" INF line via
TryAdd; the win race is decided atomically.
- Memory-bounded: a ReaperLoop janitor prunes per-IP failure history and log-throttle dictionaries that haven't seen recent activity. A scanner that fires once and disappears does not leak forever.
Observability
The periodic TURN stats line (every 30 s) now includes both subsystems:
TURN stats: active=...
acl(blocked=N)
tls(accepted=A hsFail=H certReloads=R autoBlk=Z blockedIps=B)
...
acl(blocked=N) — total connections dropped by an operator ACL rule this lifetime.
tls(autoBlk=Z blockedIps=B) — total connections refused by the auto-blacklist this lifetime; current size of the live block list.
Order of precedence (when both fire)
- Operator ACL blacklist runs first — a manual block always wins over the self-driving detector. Both INF logs are emitted; the ACL counter increments.
- TLS auto-blacklist runs second on TLS-only paths if the ACL didn't already block.
- Per-IP allocation quota and unauth-binding rate-limits run third.
Both subsystems are operator-controlled at /acl.html (admin / superuser only). No REST endpoints are exposed publicly — the bridge applies rules on the wire and surfaces blocks in the stats line and per-IP INF logs.
Outbound number whitelist (per-tenant)
Outbound PSTN dials pass through a FraudGuard whitelist check before the bridge places the INVITE. The whitelist lives in App_Data/<tenant>/appsettings.json under WebPhone:NumberWhitelist (string array) and WebPhone:WhitelistComments (object mapping number to operator-supplied label). Both keys hot-reload on file change — no bridge restart needed after a save.
UI: the same operations are available without curl from
/whitelist-admin.html. Use the REST endpoints below when wiring CRM events, ticketing systems, or any external workflow that needs to add a number programmatically (e.g. a sales-engineer self-service request adds the prospect’s direct dial after the lead is qualified).
Auth: every endpoint requires an admin OIDC bearer (or HMAC via X-CodeB-Admin-Signature — see the canonical strings on each endpoint). Admins of the primary tenant pass the super-admin check automatically (see the Authentication section above). Tenant scope: requests are scoped by the Host header — the whitelist at phone.aloaha.com is independent from any other tenant’s.
List the whitelist
GET/signal.ashx?whitelist=list
Return the current whitelist for this tenant, in operator-typed order. The response unions NumberWhitelist with any extra keys from the WhitelistComments map and flags self-heal events in the connection log if the two keys drift out of sync.
HMAC canonical: whitelist-list|<tenant>
Response (200):
{
"tenant": "phone.aloaha.com",
"numbers": [
{ "number": "+15551234567", "comment": "sample number" },
{ "number": "+15551234568", "comment": "sample number" }
],
"count": 2
}
Add a single number
POST/signal.ashx?whitelist=add
Append one number to the whitelist. Idempotent — if the number already exists the call succeeds with added: false. Updates the comment if one is supplied. This is the endpoint to call from a CRM, ticketing system, or self-service form that needs to authorise an outbound dial without round-tripping a human through the admin UI.
HMAC canonical: whitelist-add|<tenant>|<bodyLen>
Request body (JSON):
| Field | Type | Required | Notes |
number | string | yes | 1–32 chars. Keep both E.164 and 00-prefix forms whitelisted if you accept either at dial time — the bridge normalises 00↔+ when matching, but two separate entries make the whitelist self-documenting. |
comment | string | no | Operator-facing label shown in the admin UI. Truncated to 200 chars. |
Example:
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"number":"+15551234569","comment":"Acme Ltd. main switchboard"}' \
https://phone.aloaha.com/signal.ashx?whitelist=add
Response (200) — new number:
{
"ok": true,
"tenant": "phone.aloaha.com",
"number": "+15551234569",
"comment": "Acme Ltd. main switchboard",
"added": true,
"count": 3
}
Response (200) — number already present (idempotent):
{
"ok": true,
"tenant": "phone.aloaha.com",
"number": "+15551234569",
"comment": "Acme Ltd. main switchboard (updated)",
"added": false,
"count": 3
}
Replace the entire whitelist
POST/signal.ashx?whitelist=write
Replace the full whitelist with the supplied array. Use sparingly — whitelist=add is safer for one-off additions because it never accidentally drops existing entries. The empty-array case has its own confirmation flow (the admin UI shows ?confirmEmpty=1) to avoid a button-misclick wiping the table.
HMAC canonical: whitelist-write|<tenant>|<bodyLen>
Request body (JSON):
{
"numbers": [
{ "number": "+15551234567", "comment": "sample number" },
{ "number": "+15551234568", "comment": "sample number" }
]
}
Response (200):
{ "ok": true, "tenant": "phone.aloaha.com", "count": 2 }
Error responses
400 — { "error": "body must be { number, comment? }" } or { "error": "number empty or too long (max 32)" }
401 — { "error": "bad signature" } — missing or wrong bearer / HMAC.
405 — method must be POST.
500 — { "error": "read failed: …" } or { "error": "write NumberWhitelist failed: …" } — underlying file IO error; the change was NOT applied.
503 — { "error": "admin disabled: set WebPhone:AdminSharedSecret (>=24 chars)" } or { "error": "App_Data/appsettings.json not found for this tenant" } — admin gate not configured yet.
OIDC client management (per-tenant)
Each tenant operates its own embedded OIDC IdP at /oidc.ashx (issuer = https://<tenant-host>). Third-party applications register as OAuth 2.0 / OIDC clients with a client_id and (optionally) a client_secret. Use these admin endpoints to list, create, update, regenerate secrets for, and delete the tenant’s OIDC clients.
Storage: App_Data/<tenant>/oidc/clients.json. Hot-reloaded on file mtime change — no bridge restart needed after a save. The built-in codeb-admin client is hard-coded in oidc.ashx and surfaces in the list as a read-only row (builtin: true).
Auth: every endpoint requires an admin OIDC bearer (same scheme as the rest of /v1 — see the “Auth” section above). A 401 response carries WWW-Authenticate: Bearer realm="codeb-admin".
Tenant scope: requests are scoped by the Host header. The client list at phone.aloaha.com is independent from the list at any other tenant’s host.
List clients
GET/signal.ashx?oidc-clients=1
Return the full list of OIDC clients registered for this tenant. The built-in codeb-admin client appears first as a read-only row. Client secrets are never echoed — the response carries has_secret (bool) only.
Response (200):
{
"count": 2,
"tenant": "phone.aloaha.com",
"issuer": "https://phone.aloaha.com",
"discovery_url": "https://phone.aloaha.com/.well-known/openid-configuration",
"items": [
{
"client_id": "codeb-admin",
"redirect_uris": ["https://phone.aloaha.com/oidc-callback.html"],
"post_logout_redirect_uris": [],
"description": "Built-in CodeB admin UI (read-only, hard-coded in oidc.ashx)",
"client_type": "public",
"has_secret": false,
"builtin": true
},
{
"client_id": "example-saas-app",
"redirect_uris": ["https://app.example.com/auth/oidc/callback"],
"post_logout_redirect_uris": ["https://app.example.com/logout"],
"description": "Example SaaS app federating sign-in via this tenant",
"client_type": "confidential",
"has_secret": true,
"builtin": false
}
]
}
Save / create / update a client
POST/signal.ashx?oidc-clients-save=1
Upsert one client by client_id. If a client with that id already exists, every field present in the request body replaces the stored value. If client_secret is present and non-empty, it is stored as the new secret (operator-supplied). Otherwise the existing secret is kept; use ?oidc-clients-genkey=1 to mint a fresh one.
Request body (JSON):
| Field | Type | Required | Notes |
client_id | string | yes | Lowercase, must not equal codeb-admin (reserved). |
redirect_uris | string[] | yes | HTTPS only, exact-match enforced at /authorize. |
post_logout_redirect_uris | string[] | no | HTTPS only. |
description | string | no | Operator-facing label shown in the admin UI. |
client_secret | string | no | Operator-supplied secret. Omit to keep existing; pair with /oidc-clients-genkey to auto-mint instead. |
Response (200):
{ "ok": true, "client_id": "example-saas-app", "has_secret": true }
Generate a fresh client secret
POST/signal.ashx?oidc-clients-genkey=1
Mint a cryptographically-random secret (auto-generated at first use, no operator step required), persist it against the supplied client_id, and return it once. The secret is then masked in subsequent ?oidc-clients=1 reads — record it now if the integrating application needs it.
Request body (JSON):
{ "client_id": "example-saas-app" }
Response (200) — the only time the secret is returned in cleartext:
{
"ok": true,
"client_id": "example-saas-app",
"client_secret": "<48-char URL-safe random>"
}
Delete a client
DELETE/signal.ashx?oidc-clients-delete=<client_id>
Remove the client from clients.json. Live access tokens issued to that client remain valid until natural expiry (the OIDC IdP does not maintain a revocation list); rotate the realm keys via the admin UI if you need to invalidate them immediately.
Response (200):
{ "ok": true, "client_id": "example-saas-app", "deleted": true }
Integration recipe (third-party app)
- Pick a
client_id (lowercase, hyphen-separated, unique within the tenant).
- POST to
/signal.ashx?oidc-clients-save=1 with client_id, redirect_uris, and description.
- POST to
/signal.ashx?oidc-clients-genkey=1 to mint and capture the secret.
- In the integrating application, set the IdP discovery URL to
https://<tenant-host>/.well-known/openid-configuration and configure the client_id + client_secret.
- Drive the standard authorization-code + PKCE flow against
https://<tenant-host>/oidc.ashx/authorize and exchange the code at /oidc.ashx/token.
UI: the same operations are available without curl from
/oidc-clients.html (admin or super-admin only). The page reveals a freshly-generated secret exactly once in a sticky banner above the table.
Local chat — 1:1 messaging + real-time push
CodeB ships a first-class local chat channel that lives alongside the voice stack. Two authenticated users on the same tenant can direct-message each other; messages persist to per-tenant JSON files (no external database) and can be delivered in real time over WebSocket. This is the same backend the browser /chat.html UI drives — use these APIs to embed chat in your own portal, CRM, or ticketing system, or to bridge messages into an existing helpdesk.
Endpoints: REST at /chat.ashx, WebSocket at /chat-ws.ashx. All state is per-tenant, scoped by HTTP Host. Storage layout: App_Data/<tenant>/chat/dms/dm-<sorted-login-pair>/YYYY/MM/DD/<ts-rand>.json for messages plus meta.json per DM for participants + unread counts. All writes are atomic via NTFS File.Replace with a .backup sibling; concurrent send on the same DM is serialised.
Auth: every REST endpoint (except ?build=1) requires an OIDC bearer minted by the tenant’s own /oidc.ashx issuer. See the “Authentication” section above for the flow. A 401 response carries WWW-Authenticate: Bearer realm="codeb-chat".
Sender-spoofing resistance: the sender identity of every posted message is clamped to the OIDC bearer’s preferred_username / sub. The request body cannot spoof sender. Both peers must be real tenant users listed in sip-credentials — anonymous participants use the separate /chat-invite.ashx flow.
Cross-tenant isolation: every filesystem access is rooted in App_Data/<HTTP-Host>/chat/. A bearer minted at tenant A can never read or write a DM on tenant B, even by supplying tenant B’s with= login.
Who am I
GET/chat.ashx?op=me
Echo the caller’s tenant + login + display name as resolved from the OIDC claims. Useful as a probe after sign-in.
Response (200):
{ "tenant": "phone.aloaha.com", "login": "alice", "displayName": "Alice Example" }
List tenant users (peer picker)
GET/chat.ashx?op=list-users
Return the tenant’s user roster with login, display name, email, and role. Excludes the caller and disabled accounts. Read from App_Data/<tenant>/sip-credentials/<slug>.json.
Response (200):
{
"tenant": "phone.aloaha.com", "self": "alice",
"users": [
{ "login": "bob", "displayName": "Bob Example", "email": "bob@example.com", "role": "user" }
]
}
List my DM threads
GET/chat.ashx?op=list-threads
Return every DM the caller participates in, newest first (sorted by lastMessageUtc), with an unread count and a short lastMessagePreview. Capped at 200 — hasMore is true when the cap is hit.
Response (200):
{
"self": "alice", "hasMore": false, "pageCap": 200,
"threads": [
{
"pairKey": "dm-alice_bob",
"peerLogin": "bob",
"peerName": "Bob Example",
"lastMessageUtc": "2026-07-02T14:23:11.4123456Z",
"lastMessagePreview": "See you at 3.",
"unread": 2
}
]
}
Fetch messages in a thread
GET/chat.ashx?op=get-thread&with=<peerLogin>&sinceMs=<epochMs>
Return messages in the DM with <peerLogin> whose tsUtcMs is strictly greater than sinceMs. Ordered oldest-first (chronological), capped at 500. Poll with the newest tsUtcMs you have seen to fetch only the delta.
Response (200):
{
"self": "alice", "peerLogin": "bob", "peerName": "Bob Example",
"pairKey": "dm-alice_bob",
"messages": [
{
"id": "1782968591234-9x8b3q",
"tsUtcMs": 1782968591234,
"tsUtcIso": "2026-07-02T14:23:11.234Z",
"senderLogin": "bob",
"senderDisplayName": "Bob Example",
"recipientLogin": "alice",
"body": "See you at 3."
}
]
}
Send a message
POST/chat.ashx?op=send&with=<peerLogin>
Post a message to <peerLogin>. Content-Type: application/json. Body: { "body": "<up to 4000 chars>" }. The sender is clamped to the OIDC subject — you cannot spoof it. Peer must be a real tenant user. Response includes the assigned id and server timestamp. Also fires a live push over WebSocket (type: chat-msg) to any connected sessions of both participants.
Rate limit: per-user token bucket — burst 30 messages, sustained ~30/min. Over the cap returns HTTP 429 with Retry-After: 2 and a problem+json body.
Response (200):
{ "ok": true, "id": "1782968591234-9x8b3q", "tsUtcMs": 1782968591234, "pairKey": "dm-alice_bob", "peerLogin": "bob" }
Mark a thread read
POST/chat.ashx?op=mark-read&with=<peerLogin>
Clear the caller’s unread counter on the DM with <peerLogin>. Empty body is fine.
Response (200):
{ "ok": true, "unread": 0 }
Unread count (for a badge)
GET/chat.ashx?op=unread-count
Return the total unread across every DM the caller participates in. Cheap enough to poll every 20–30 s from a background tab or PWA.
Response (200):
{ "self": "alice", "unread": 3, "dms": 2 }
Real-time WebSocket push
For live delivery without polling: mint a single-use ticket over REST, then open a WebSocket. Tickets expire after 60 seconds and can be redeemed once. This avoids putting the bearer in a WebSocket URL (which would land in access logs).
GET/chat.ashx?op=ws-ticket
Mint a nonce + return the WebSocket URL to open.
Response (200):
{
"ticket": "<24-byte base64url nonce>",
"wsUrl": "wss://phone.aloaha.com/chat-ws.ashx?ticket=<nonce>",
"ttlSeconds": 60,
"expiresUtc": "2026-07-02T14:24:11.000Z"
}
WS/chat-ws.ashx?ticket=<nonce>
WebSocket handshake. The server verifies the ticket + tenant match (Host header must equal the tenant the ticket was minted for), then upgrades. JSON-text frames only.
Server → client frames:
// On successful upgrade:
{ "type": "hello", "sid": "<server-session-id>", "tenant": "phone.aloaha.com", "login": "alice", "build": "<slug>", "serverUtc": "..." }
// On every new message to or from you (both participants’ connected devices receive it):
{
"type": "chat-msg",
"pairKey": "dm-alice_bob",
"senderLogin": "bob",
"recipientLogin": "alice",
"message": { /* same shape as get-thread items */ }
}
Client → server frames:
// Optional keep-alive (~25 s cadence recommended); server replies with pong:
{ "type": "ping", "tsMs": 1782968591234 }
Sends still go over REST. The WebSocket is push-only (server->client). Post via /chat.ashx?op=send. Multi-device: if you have chat.html open on desktop and the PWA on mobile, both receive the same chat-msg frame — use it to dedup + auto-mark-read the currently-open thread.
Group chats
Groups are named multi-participant threads. Members are identified by login; admins can add/remove members and rename the group; anyone can leave. list-threads, get-thread, send, mark-read, and unread-count are unified across DMs and groups — pass ?with=<peer> for a DM or ?groupId=<X> for a group.
Constraints: participants ≤ 50 per group, name ≤ 64 chars. Admin-only for add-member / remove-member / rename-group. Anyone can leave-group. The last admin cannot leave until a co-admin is present. Storage: App_Data/<tenant>/chat/groups/<groupId>/YYYY/MM/DD/<ts-rand>.json plus meta.json per group with {name, participants[], admins[], unread{}}.
POST/chat.ashx?op=create-group
Create a new group. Body: { "name": "Ops", "participants": ["bob", "carol"] }. The caller is added as the first admin. Returns { ok, groupId, name, participants, admins }. Fires a group-created WebSocket frame to every participant.
GET/chat.ashx?op=list-group-members&groupId=<X>
Return group members: { name, members: [{ login, displayName, isAdmin }] }. Caller must be a participant.
POST/chat.ashx?op=add-member&groupId=<X>
Admin-only. Body: { "login": "dave" }. 403 for non-admins.
POST/chat.ashx?op=remove-member&groupId=<X>
Admin-only. Body: { "login": "dave" }.
POST/chat.ashx?op=leave-group&groupId=<X>
Remove yourself from the group. Empty body.
POST/chat.ashx?op=rename-group&groupId=<X>
Admin-only. Body: { "name": "New name" }.
Unified endpoints in group mode — pass ?groupId=X instead of ?with=:
GET /chat.ashx?op=list-threads // both DMs and groups; group entries have kind:"group"
GET /chat.ashx?op=get-thread&groupId=X&sinceMs= // fetch group messages
POST /chat.ashx?op=send&groupId=X // body { body: "..." }
POST /chat.ashx?op=mark-read&groupId=X
GET /chat.ashx?op=unread-count // aggregated across DMs + groups
File attachments (images, audio, video, documents)
Attach a file to a DM or a group. Files are content-addressed by SHA-256 — if the same file is sent twice, only one copy is stored on disk. Downloads are ACL-gated to thread participants. Max 20 MB per file (enforced backend and via web.config request limit).
Storage: App_Data/<tenant>/chat/files/<sha[:2]>/<sha>.bin plus a <sha>.meta.json sidecar with {filename, contentType, sizeBytes, uploader, uploadedUtc}. Content-addressed dedupe: retransmitting the same bytes reuses the existing blob.
POST/chat.ashx?op=upload&with=<peer>
DM upload. Content-Type: multipart/form-data. Form field file is required, form field caption is optional (up to 400 chars). 20 MB cap. Returns { ok, id, tsUtcMs, fileSha, sizeBytes, filename, contentType }. Emits a chat-msg WebSocket frame with message.kind = "file" to both participants.
POST/chat.ashx?op=upload&groupId=<X>
Group upload. Same shape as DM upload. Every group participant receives a chat-msg WebSocket frame.
GET/chat.ashx?op=download&sha=<hex>
Stream the file bytes. ACL-gated: the caller must be a participant of at least one thread that contains a message referencing this fileSha. Response carries the original Content-Type and a Content-Disposition: inline; filename="..." header with the original filename.
File-message shape (returned by get-thread, embedded in the WebSocket chat-msg frame):
{
"id": "1782968591234-9x8b3q",
"tsUtcMs": 1782968591234,
"tsUtcIso": "2026-07-03T09:12:11.234Z",
"senderLogin": "alice",
"senderDisplayName": "Alice Example",
"kind": "file",
"fileSha": "<64-hex sha256>",
"filename": "handoff.pdf",
"contentType": "application/pdf",
"sizeBytes": 184203,
"caption": "signed by legal" // optional
// + "recipientLogin" for DMs, or "groupId" for groups
}
Errors: 400 missing file field, 403 not-a-participant, 404 peer or group not found, 413 payload > 20 MB, 415 not multipart, 429 rate-limited.
WebSocket frames — group & file additions
Additions to the chat-msg frame documented above:
// Group message (mutually exclusive with pairKey):
{
"type": "chat-msg",
"groupId": "g_1782968591234_a1b2",
"senderLogin": "alice",
"message": { /* group message body, may be text or file */ }
}
// New group created — delivered to every participant, including the creator’s other devices:
{
"type": "group-created",
"groupId": "g_1782968591234_a1b2",
"name": "Ops",
"participants": ["alice", "bob", "carol"],
"createdBy": "alice"
}
Meeting archives (per-user, per-day, per-room)
Signed-in participants in a WebRTC room can opt to write their outgoing in-meeting chat messages to a per-user archive. This is browseable from /chat.html under “Meeting archives.” Anonymous guests are ephemeral by design — they never touch disk. Each archive is per-tenant isolated and private to the login that wrote it.
Storage: App_Data/<tenant>/chat/meetings/<user>/<room>/<YYYY-MM-DD>/<msgId>.json. One file per outgoing message. Idempotency: clientMsgId becomes part of the filename — a repost is a silent 200 with dupe:true. Retention: default 90 days, opportunistically pruned by a per-tenant janitor (see below).
POST/chat.ashx?op=meeting-send
Persist one outgoing meeting message. JSON body: { room, body, tsMs?, clientMsgId?, senderName? }. room is [a-z0-9_-]{1,60}. body capped at message-body limit. Idempotent per clientMsgId. Returns { ok, id, tsUtcMs, room, dupe? }. Rate-limited via the general send bucket.
GET/chat.ashx?op=meeting-list
List the caller’s archived rooms (with newest-day tag + count). Response shape: { ok, meetings: [ { room, lastDay, days, msgCount } ] }.
GET/chat.ashx?op=meeting-thread&room=<name>&day=<YYYY-MM-DD>
Return the caller’s outgoing messages for one room + day. day is validated YYYY-MM-DD to block path traversal. Returns { ok, room, day, msgs: [ ... ] } in tsMs order.
Retention janitor (Chat:MeetingArchiveDays): Every
meeting-send opportunistically fires a per-tenant prune (Interlocked-claimed, at most once per 6 h per tenant, on ThreadPool). Day-directories older than the retention window are deleted; empty room/user directories collapse. Default
90 days, bounded 1–3649. To override, add to your IIS
<appSettings>:
<add key="Chat:MeetingArchiveDays" value="30" />
Setting
0 or invalid values falls back to the default. There is no admin UI — the retention window is per-process, read once per prune.
Errors
All errors use RFC 7807 application/problem+json:
{
"type": "https://codeb.io/problems/rate-limited",
"title": "message send rate limit exceeded",
"status": 429,
"detail": "cap=30 refillMs=2000"
}
Common status codes: 400 missing/bad param, 401 no or invalid bearer, 404 peer not a tenant user, 413 body too large (max 32 KB, message body max 4000 chars), 429 rate-limited, 500 internal error.
Integration recipe (third-party portal)
- Register your app as an OIDC client on the tenant (see “OIDC client management” above).
- Drive the auth-code + PKCE flow against
/oidc.ashx/authorize to obtain an access token for the signed-in user.
- Attach
Authorization: Bearer <token> to every request against /chat.ashx.
- Bootstrap:
?op=me to resolve identity, ?op=list-threads to show the inbox, ?op=unread-count for a badge.
- Open a WebSocket via
?op=ws-ticket + the returned wsUrl. Fall back to polling ?op=get-thread&sinceMs=<last-seen> every 3–5 s if the WS drops.
- Send with
?op=send&with=<peer>. Handle 429 by respecting Retry-After.
UI reference: the shipped
/chat.html is a full working example of this integration — it hits every endpoint documented above and runs the WebSocket. Read its source for a concise reference client.
Chat WebSocket — real-time frame delivery bearer + WS ticket
Persistent WebSocket that receives the JSON frames chat.ashx
pushes via ChatSessionRegistry. The browser first mints a
single-use nonce via chat.ashx?op=ws-ticket, then opens
wss://<tenant>/chat-ws.ashx?ticket=<nonce>. The
ticket carries the operator’s {tenant, login}, expires
60 s after mint, and is redeemed on the WS handshake —
mismatched Host → 401 tenant-mismatch.
Mint ticket
GET/chat.ashx?op=ws-ticket
Returns a fresh single-use nonce for one WebSocket handshake. Requires OIDC bearer. Nonce TTL 60 s.
Response (200):
{ "ticket": "<32-char-nonce>", "expiresInSec": 60 }
Open WebSocket
GET/chat-ws.ashx?ticket=<nonce>
HTTP GET with Upgrade: websocket. Server responds 101 Switching Protocols on success and immediately sends a JSON hello frame.
Server → client frames:
{"type":"hello","sid":"<12-hex>","tenant":"...","login":"...","build":"...","serverUtc":"..."}
{"type":"chat","from":"alice","body":"...","tsUtcMs":...}
{"type":"chat-deleted","msgId":"...","from":"alice","deletedAt":"..."}
{"type":"pong","sid":"...","serverUtc":"..."}
Client → server frames: only {"type":"ping"} is handled. Any other frame type is logged as RX-IGNORE and discarded. Binary frames ignored (JSON only). Max frame 8 KiB.
Errors (pre-upgrade):
400 not-a-websocket — non-WS request without ?build=.
400 missing-ticket — no ticket or length > 128.
401 bad-ticket — nonce unknown, expired, or already redeemed.
401 tenant-mismatch — ticket tenant ≠ Host header.
curl (probe only — full WS handshake needs a client library):
curl -sS -H "Authorization: Bearer <oidc-token>" "https://phone.aloaha.com/chat.ashx?op=ws-ticket"
Chat invitations — anonymous-invited participants HMAC-signed tokens
Operators mint HMAC-signed invitation tokens that let an
anonymous invitee join a specific chat thread. The invite token IS the
auth — the invitee doesn’t need an OIDC account. Per-tenant
HMAC secret lives at App_Data/<tenant>/_invites/hmac.secret
(auto-created if missing). Tokens are single-use: accepting one atomically
deletes the on-disk record. Body cap 32 KiB. Diag tag
[CHAT-INVITE-DIAG].
Create invite OIDC user
POST/chat-invite.ashx?op=create
Mint a signed token for an invitee. ttlHours clamped to 1..720 (default 72). Any authenticated OIDC user may create invites.
Body:
{
"channel": "dm" | "group" | ..., // required
"threadKey": "<target-thread>", // required
"inviteeEmail": "guest@example.com", // optional
"inviteeDisplayName": "Alice Guest", // optional
"scope": "chat" | "meeting", // default "chat"
"ttlHours": 72 // default 72, clamped 1..720
}
Response (200):
{ "token": "...", "hotlink": "https://<tenant>/chat-invite-accept.html?t=...",
"nonce": "...", "expiresUtc": "...", "scope": "chat" }
Create + email invite OIDC user
POST/chat-invite.ashx?op=send-email
Same as create but ALSO writes an RFC 5322 .eml to the IIS SMTP pickup dir (default C:\inetpub\mailroot\Pickup, override via emailPickupDir). inviteeEmail is required. From-address is no-reply@<tenant>.
Response (200):
{ "ok": true, "nonce": "...", "hotlink": "...", "expiresUtc": "...", "pickupDir": "..." }
Verify invite public
GET/chat-invite.ashx?op=verify&t=<token>
Non-mutating — safe to call from an invite-preview page. Validates HMAC + expiry, checks whether the persisted record still exists (deleted → consumed:true).
Response (200):
{ "tenant": "...", "channel": "...", "threadKey": "...",
"inviterDisplayName": "...", "inviteeDisplayName": "...",
"inviteeEmail": "...", "scope": "chat", "expiresUtc": "...",
"consumed": false }
Accept invite public
POST/chat-invite.ashx?op=accept
Consume the token (single-use, atomic delete) and mint an anonymous session. Sets cookie codeb-anon-session (HttpOnly; Secure; Path=/; SameSite=Strict).
Body:
{ "t": "<token>", "displayName": "Alice" }
Response (200):
{ "sessionId": "...", "tenant": "...", "channel": "...",
"threadKey": "...", "scope": "chat", "displayName": "Alice",
"expiresUtc": "..." }
Errors: 401 bad-invite, 409 already-consumed, 400 bad-body, 400 missing-token.
Rotate HMAC secret admin role
POST/chat-invite.ashx?op=rotate-hmac
Destructive: renames hmac.secret to hmac.secret.rotated-YYYYMMDD-HHMMSS-UTC (audit only, not used for verification), regenerates a fresh secret, and instantly invalidates every outstanding invitation. Requires role admin, superadmin, or superuser. Emits [AUDIT] ROTATE-HMAC.
Response (200):
{ "ok": true, "rotated": true, "secretLen": 64 }
Stub ops OIDC user (any)
?op=list and ?op=revoke are reserved for the invitation-management UI. Both require an OIDC bearer (any authenticated user is accepted today — will be tightened to admin-only when the impls land). Current stub impls return [] / {ok:true} respectively with no I/O. Do not depend on their behaviour yet.
“Chat with us” widget origin-gated
Cross-origin visitor widget for embedding “chat with us” on
tenant websites. Trust boundary is per-tenant
AllowedOrigins
(managed via
/chat-admin.html).
No OIDC bearer required from visitors. Session identified by cookie
codeb-widget-session (
HttpOnly; Secure; Path=/; SameSite=None).
Body cap 32 KiB. Message body cap 4000 chars (silently truncated,
not rejected). Diag tag
[CHAT-WIDGET-DIAG].
Probe
GET/chat-widget.ashx?op=probe
Public liveness. Wildcard CORS.
{ "ok": true, "build": "...", "tenant": "..." }
Start session
POST/chat-widget.ashx?op=start-session
Origin-gated: TenantWidgetConfig.Enabled must be true AND Origin must be on the allow-list. Rate-limited per source IP.
Body:
{ "visitorName": "Alice", "visitorEmail": "alice@example.com",
"channel": "widget" /* optional, defaults to cfg.DefaultChannel */ }
Response (200):
{ "sessionId": "...", "threadKey": "<server-minted>",
"channel": "widget", "expiresUtc": "..." }
Note: caller-supplied threadKey is ignored — the server always mints via WidgetSession.MintThreadKey to prevent thread-hijacking across widget sessions.
Errors: 400 missing-origin, 403 widget-disabled, 403 origin-not-allowed, 429 rate-limited.
Send message
POST/chat-widget.ashx?op=send
Cookie / bearer session + origin allow-list. Rate-limited per session id. Body truncated to 4000 chars.
Body:
{ "threadKey": "<must match session>", "body": "..." }
Response (200):
{ "ok": true, "messageId": "w-...", "timestampUtc": "..." }
Errors: 401 no-session, 403 origin-not-allowed, 403 origin-drift, 403 tenant-drift, 403 thread-drift, 429 rate-limited, 400 empty-body.
Poll thread
GET/chat-widget.ashx?op=poll&threadKey=<k>&sinceUtc=<ISO>
Cookie / bearer session + origin allow-list. Returns messages newer than sinceUtc (or since epoch if omitted), latest 100, ascending. Sender OIDC sub NEVER leaked — only display names.
Response (200):
{ "messages": [{ "messageId": "...", "direction": "in"|"out",
"timestampUtc": "...", "body": "...", "status": "...",
"senderDisplayName": "Support" }],
"serverUtc": "..." }
Email-to-chat bridge bearer (admin+)
REST facade over
ChannelMessageStore +
EmailSmtpSender +
EmailInboundDropWatcher.
Threads are keyed on the remote email address. Config lives at
App_Data/<tenant>/email.json (admin-managed via
/chat-admin.html).
All read/mutate ops now require admin bearer
(previously some were unauthenticated — hardened 2026-07-18).
Body cap 128 KiB. Diag tag
[CHAT-EMAIL-DIAG].
List email threads admin
GET/chat-email.ashx?op=list-threads
Latest 200 email threads for this tenant.
{ "threads": [{ "tenant", "channel", "threadKey", "lastMessageUtc",
"unreadCount", "status", "participantIdentity": { ... } }] }
Get messages admin
GET/chat-email.ashx?op=get-messages&thread=<k>
Up to 200 messages for one thread.
Send email admin
POST/chat-email.ashx?op=send
Sends via EmailSmtpSender (respects cfg.DryRun — status dry-run vs queued). Failed sends are still persisted with status=failed + failureReason.
Body:
{ "thread": "<defaults to 'to'>", "to": "...", "subject": "...", "body": "..." }
Poll inbox drop folder admin
POST/chat-email.ashx?op=poll-inbox
One-shot sweep of cfg.InboxDropFolder; ingests any new emails as inbound messages.
{ "processed": 3, "failed": 0, "skipped": 1 }
Mark read admin
POST/chat-email.ashx?op=mark-read&thread=<k>
Delete thread superuser
POST | DELETE/chat-email.ashx?op=delete-thread&thread=<k>
Strictly role=superuser (not admin). Destructive: removes thread + all messages from the store.
Get/set config admin
GET/chat-email.ashx?op=config
Returns tenant email config. Credential fields are redacted — the response includes hasWebhookVerifyToken as a truthy boolean instead of the token itself.
PUT | POST/chat-email.ashx?op=config
Partial-update body accepts any of: dryRun, enabled, smtpPickupDir, smtpFromAddress, smtpFromDisplay, inboxDropFolder, inboxPollIntervalSeconds (int, clamped >0 else 30), allowedFromDomains (comma-string → array).
Chat: delete message (chat.ashx addendum) bearer + sender-only
Tombstones a single DM or group message. Only the ORIGINAL sender can
delete, verified by re-reading the on-disk message's senderLogin
and comparing to the OIDC-clamped me. The message file is
atomically rewritten as a tombstone (body / kind / file references
stripped, deleted:true + deletedAt +
deletedBy added); shared content-addressed file blobs are
never touched. WebSocket chat-deleted frames fan out to every
target so live tabs remove the bubble immediately.
Delete a DM message
POST/chat.ashx?op=delete-message&msgId=<X>&with=<peer-login>
Delete a group message
POST/chat.ashx?op=delete-message&msgId=<X>&groupId=<X>
Group participants list is re-read under _metaGate immediately before the WS fan-out so a concurrent remove-member cannot leak the delete frame to a kicked user.
Response: 204 No Content on success and on idempotent repeat (already tombstoned — a WS chat-deleted frame is still emitted so tabs that missed the first fan-out catch up).
Errors:
403 not-sender — only the original sender can delete.
403 not-a-participant — caller is not in the group participants list.
410 too-old — message older than 24 h (DELETE_WINDOW_MS = 24h). Detail includes ageMs.
404 not-found / 404 group-not-found — msg file or group meta missing.
400 bad-msg-id / 400 bad-group-id / 400 bad-peer / 400 self-dm.
Chat channels simulator (stub) 503 by design
/chat-channels-sim.ashx is a deploy-gate stub. The
CodeB.Xmpp.Simulator DLL that would back it is not currently
deployed, so every op returns 503. ?build=1 still
responds normally so uptime probes can confirm the file is present. The
stub is kept in the tree so nothing 404s if a UI or test still links to
the old channel simulator endpoints.
GET/chat-channels-sim.ashx?build=1
Returns the stub’s build slug. Every other op returns 503.
Guest inbox — portal-hak PMS proxy
CodeB ships a first-class property-management-system inbox channel that lets operators reply to guest messages from the hosted PMS directly inside /chat.html. Backend /pms.ashx proxies to the tenant’s portal-hak account: list conversations, fetch a single thread, post a host-side reply. State lives entirely on the upstream PMS — the bridge stores no message bodies, only the per-tenant service credential.
Endpoints: REST at /pms.ashx. All state is per-tenant, scoped by HTTP Host. Configuration is a single per-tenant JSON blob at App_Data/<tenant>/pms.json that holds the portal-hak service credential (the account the bridge speaks to the PMS as — not a per-operator credential). Operators authenticate to /pms.ashx with their own OIDC bearer, and the bridge fans out to the PMS on their behalf using the shared service credential.
Auth: every op (except ?build=1) requires an OIDC bearer minted by the tenant’s own /oidc.ashx issuer, verified against the loopback userinfo endpoint. A 401 response carries WWW-Authenticate: Bearer realm="codeb-pms". ?op=config is safe to probe unauthenticated for the enabled flag, but every data op requires the bearer.
Reply body cap: body is capped at 8 KB; requests exceeding that return 413.
Rate limit: 60 requests / minute per operator across all /pms.ashx ops. Over the cap returns 429 with Retry-After and a problem+json body.
Cross-tenant isolation: every upstream call is bound to the tenant’s own pms.json credential. A bearer minted at tenant A can never fan out to tenant B’s PMS account.
Config & readiness probe
GET/pms.ashx?op=config
Return the tenant’s PMS provider readiness. Safe to probe without a bearer for the enabled flag — the UI uses this to decide whether to show the guest-inbox tab. The badge field is the label to render on that tab (defaults to “Guest inbox” if unset).
Response (200):
{
"provider": "portal-hak",
"enabled": true,
"badge": "Guest inbox",
"baseUrlHint": "https://api.portal-hak.example",
"build": "2026-07-19-01-haip-verifier-cert"
}
curl:
curl -sS -H "Authorization: Bearer <oidc-token>" \
"https://phone.aloaha.com/pms.ashx?op=config"
List guest conversations
GET/pms.ashx?op=list-threads&limit=<n>&offset=<n>
Return the tenant’s guest conversations, newest first (sorted by lastAt). Default limit=200, offset=0. preview is a short excerpt of the last message; channel identifies the upstream inbox channel (e.g. booking, airbnb, direct).
Response (200):
{
"provider": "portal-hak",
"total": 47,
"offset": 0,
"limit": 200,
"conversations": [
{
"id": 918273,
"guest": "Alice Example",
"listingId": "12345",
"listingName": "Seafront apartment, Sliema",
"reservationId": "R-2026-0642",
"channel": "booking",
"lastAt": "2026-07-18T11:04:12Z",
"preview": "Hi — can we check in one hour earlier?"
}
]
}
curl:
curl -sS -H "Authorization: Bearer <oidc-token>" \
"https://phone.aloaha.com/pms.ashx?op=list-threads&limit=200&offset=0"
Fetch one thread
GET/pms.ashx?op=thread&id=<conversationId>
Return the full message history for one conversation. from:"host" means the tenant’s side of the exchange (you), from:"guest" means the guest. Messages are ordered oldest-first (chronological). attachments is a list of upstream URLs or file references when the PMS surfaces them.
Response (200):
{
"id": 918273,
"conversation": {
"id": 918273,
"guest": "Alice Example",
"listingId": "12345",
"listingName": "Seafront apartment, Sliema",
"reservationId": "R-2026-0642",
"channel": "booking"
},
"messages": [
{
"id": 42,
"from": "guest",
"body": "Hi — can we check in one hour earlier?",
"at": "2026-07-18T11:04:12Z",
"channel": "booking",
"attachments": []
},
{
"id": 43,
"from": "host",
"body": "Absolutely — keys will be ready from 14:00.",
"at": "2026-07-18T11:07:44Z",
"channel": "booking",
"attachments": []
}
]
}
curl:
curl -sS -H "Authorization: Bearer <oidc-token>" \
"https://phone.aloaha.com/pms.ashx?op=thread&id=918273"
Reply as host
POST/pms.ashx?op=reply
Post a host-side reply to a conversation. Content-Type: application/json. Body: { "conversationId": <int>, "body": "<up to 8 KB>" }. On success, the bridge returns { ok: true, conversationId, upstreamStatus } and the reply is visible on the guest side of the PMS immediately — re-fetch ?op=thread&id=<id> to pull the newly persisted host message back.
Response (200):
{ "ok": true, "conversationId": 918273, "upstreamStatus": 201 }
curl:
curl -sS -X POST \
-H "Authorization: Bearer <oidc-token>" \
-H "Content-Type: application/json" \
-d '{"conversationId":918273,"body":"Keys ready from 14:00."}' \
"https://phone.aloaha.com/pms.ashx?op=reply"
Admin: get current config admin only
GET/pms.ashx?op=admin-get
Return the tenant’s current PMS configuration for the admin UI to prefill. Credential fields (auth.cookie / auth.bearer) are always redacted to empty string; auth.hasCookie / auth.hasBearer booleans tell the UI whether a secret is stored on disk. If App_Data/<tenant>/pms.json is missing, exists:false is returned with a placeholder config so the form can render cleanly. Requires role admin or superadmin.
Response (200) — not configured:
{
"tenant": "phone.aloaha.com",
"exists": false,
"config": {
"provider": "portal-hak",
"enabled": false,
"baseUrl": "",
"auth": { "mode": "cookie", "cookie": "" },
"timeoutMs": 15000,
"badge": "Guest inbox"
}
}
Response (200) — configured, credential set:
{
"tenant": "phone.aloaha.com",
"exists": true,
"config": {
"provider": "portal-hak",
"enabled": true,
"baseUrl": "https://portal-hak.example.com",
"auth": { "mode": "cookie", "cookie": "", "hasCookie": true, "hasBearer": false },
"timeoutMs": 15000,
"badge": "Guest inbox"
}
}
curl:
curl -sS -H "Authorization: Bearer <admin-oidc-token>" "https://phone.aloaha.com/pms.ashx?op=admin-get"
Admin: save config admin only
POST/pms.ashx?op=admin-save
Write App_Data/<tenant>/pms.json atomically (File.Replace + .backup). Body cap is 64 KB. If auth.cookie / auth.bearer is omitted or empty, the on-disk credential is preserved (server-side merge) — this lets the UI toggle enabled or edit the baseUrl without re-entering the secret. baseUrl must resolve to a public hostname: loopback, RFC 1918, CGNAT, link-local and IPv6 ULA literals are rejected with 400 ssrf-blocked. Every save emits [PMS-AUDIT] admin-save op-sub=… tenant=… enabled=… to the trace log — the credential value is never logged.
Request body:
{
"provider": "portal-hak",
"enabled": true,
"baseUrl": "https://portal-hak.example.com",
"auth": { "mode": "cookie", "cookie": ".AspNetCore.Session=<opaque>" },
"timeoutMs": 15000,
"badge": "Guest inbox"
}
Response (200):
{ "ok": true, "tenant": "phone.aloaha.com", "enabled": true }
curl:
curl -sS -X POST -H "Authorization: Bearer <admin-oidc-token>" -H "Content-Type: application/json" -d @pms.json "https://phone.aloaha.com/pms.ashx?op=admin-save"
Admin: test upstream credential admin only
POST/pms.ashx?op=admin-test
Dry-run verification: hits GET /auth/me on the supplied (or currently-saved) portal-hak config and reports whether the credential works. The response never echoes the credential; it whitelists only sub, name, role, email from the upstream user object. If the request body is empty, the on-disk config is used; otherwise the supplied body is tested without being persisted — useful for verifying a new cookie/bearer before Save.
Response (200) — credential works:
{
"upstreamStatus": 200,
"upstreamContentType": "application/json",
"ok": true,
"upstreamUser": {
"sub": "local:codeb",
"name": "Heaven & Keys Ltd",
"role": "superadmin"
}
}
Response (200) — credential expired:
{ "upstreamStatus": 401, "upstreamContentType": "text/html", "ok": false }
curl:
curl -sS -X POST -H "Authorization: Bearer <admin-oidc-token>" -H "Content-Type: application/json" -d '{"baseUrl":"https://portal-hak.example.com","auth":{"mode":"cookie","cookie":"<paste>"}}' "https://phone.aloaha.com/pms.ashx?op=admin-test"
Deploy safety: pms.json is never in source control — only App_Data/pms.json.sample ships. admin-save is the only path that creates or edits App_Data/<tenant>/pms.json. If the file is missing on a tenant, every read/reply op returns 503 not-configured and the Guest inbox tab stays hidden in chat.html.
Build / version probe
GET/pms.ashx?build=1
Return the build slug of the currently-loaded handler. Handy for smoke-checking a deploy. No auth required.
Response (200):
{ "build": "2026-07-18-mellieha-pms-evict" }
curl:
curl -sS "https://phone.aloaha.com/pms.ashx?build=1"
Errors
All errors use RFC 7807 application/problem+json, with a type URI of shape urn:codeb:pms:<slug>:
{
"type": "urn:codeb:pms:not-configured",
"title": "PMS not configured for this tenant",
"status": 503,
"detail": "App_Data/<tenant>/pms.json is missing or has no service credential"
}
Common status codes:
401 — no or invalid bearer (urn:codeb:pms:unauthorized).
429 — per-operator rate-limit exceeded (60 req/min) (urn:codeb:pms:rate-limited).
503 — tenant PMS not configured (urn:codeb:pms:not-configured). UI links the operator to /pms-admin.html to set up the service credential.
502 — upstream portal-hak API returned an error or timed out (urn:codeb:pms:upstream-error).
413 — body exceeds the 8 KB reply cap.
Integration recipe (third-party portal)
- Configure the tenant’s portal-hak service credential via /pms-admin.html (superadmin) — stored in
App_Data/<tenant>/pms.json.
- Probe
?op=config to decide whether the inbox tab should render for this tenant.
- Attach
Authorization: Bearer <token> minted from the tenant’s own OIDC issuer to every REST call.
- Bootstrap:
?op=list-threads&limit=200 to populate the inbox; poll every ~30 s while the tab is the active view.
- On row click:
?op=thread&id=<id> to fetch messages. Render from:"host" as your own-message bubble, from:"guest" as the peer bubble.
- On reply:
POST ?op=reply with { conversationId, body }. On 200, re-fetch the thread to pick up the just-persisted host message.
- Handle
429 by respecting Retry-After; handle 401 by re-driving the OIDC sign-in.
UI reference: the shipped
/chat.html renders the guest inbox as a third channel tab alongside DMs and Groups — it is a full working example of this integration and drives every endpoint above.
SIPS — SIP-over-TLS listeners (RFC 5630)
The bridge accepts encrypted SIP signalling on two dedicated TLS ports, mirroring the existing UDP topology so existing SIP infrastructure can adopt SIPS without re-architecting routing.
| Port | Role | Mirrors | Typical client |
TCP 5061 | Trunk-side | UDP 5060 | Carrier ITSP with SIPS endpoint; enterprise SBC |
TCP 6061 | Public listener | UDP 6070 | Hardphone or SIP Phone requiring TLS-only REGISTER |
Per-tenant certificate via SNI
Each TLS handshake selects the per-tenant cert from LocalMachine\My by SNI server name. Same cert store the WSS listener and TURN-TLS already use; same per-tenant LE cert auto-deployed via /acquire-cert (or wacs.exe directly). Adding tenant N+1 means installing one more cert in the store — no SAN-list reissue, no operator-visible SIPS reconfiguration.
Per-tenant SNI works on .NET Framework 4.8 via a manual ClientHello peek before SslStream wrapping: the bridge reads the SNI extension at the TCP layer, picks the right cert, then runs the legacy AuthenticateAsServerAsync overload — SslStream re-reads the same ClientHello bytes (we never consumed them) and the handshake completes against the right cert.
Defences shared with TURN
- ACL gate (per-IP / CIDR). The same
acl.html entries that block SIP over UDP block SIPS too — SIPS traffic enters the same InboundDispatcher.HandleSipRequest dispatch point, so all existing rules apply with no per-protocol carve-out.
- Per-IP TLS auto-blacklist. Same shape as the TURN auto-blacklist: more than 5 failed handshakes from one IP in a 5-minute window blocks that IP at the SIPS accept layer for 1 hour. Private / loopback / link-local IPs never blocked. Counts AuthenticationException, IOException, and unexpected handshake errors (catches malformed ClientHello variants).
- 15 s handshake timeout. Slow-loris peers that dribble bytes after the TLS record header are torn down rather than parking a thread-pool slot indefinitely.
Observability
Three log prefixes; grep one to see the whole SIPS picture:
[sips-arrival] — per inbound SIPS request (INF). Includes CallID for correlation with the existing [sip-trace] outbound lines.
[sips-downgrade] — per call where an inbound sips: leg bridges to a plain sip: outbound leg (WRN). Stefan's design choice: allow + log, never silently reject — some downstream legs can't be TLS, the operator must see when it happens.
[SIPS-chan] heartbeat — every 30 s, "N TLS handshake(s) completed in last 30s, M total since bridge start". Negative-control diagnostic for the SIPS read-loop handoff.
SIPS ↔ sip: scheme bridging policy
When a SIPS call enters and the outbound leg can only be reached via plain SIP (typical for carrier trunks), the bridge proceeds with the call and emits one [sips-downgrade] WRN per call with full correlation IDs (callId, tenant, bridge, inUri, outUri, remote). No silent downgrade, no 488 Not Acceptable Here reject — the wholesale-shaped model accepts that not every leg can be TLS but never lets it be invisible to operators.
Health endpoint: /sip-tls/health
Read-only JSON snapshot of bridge-global SIPS listener state. No HMAC or auth (mirrors /sip-ws/health and /sfu/health). Cert thumbprints and port numbers are already public via TLS handshake, and the auto-blacklist policy is intentionally surfaced so operators can plan capacity.
GET/sip-tls/health
SIPS listener wire-up state + lifetime counters. Returns within a few ms regardless of load (no I/O).
Response fields:
| Field | Type | Meaning |
status | string | "listening" (every bound port has its role registered) — "listening-partial" (at least one bound port failed role registration; traffic on that port won't route) — "enabled-but-no-port-bound" — "disabled" — "not-attempted" (StartIfEnabled has not run yet) |
enabled | bool | Mirrors Sip.Sips.Enabled in appsettings.json at call time |
anyBound | bool | True if at least one port came up |
globalFailureReason | string | Set when enabled=true but nothing bound. Empty otherwise |
ports[] | array | One row per configured port (trunks-tls = TCP 5061, public-tls = TCP 6061). Each row: {role, port, channelAdded, roleRegistered, placeholderCertThumb, failureReason} |
handshakesCompletedTotal | int | Lifetime count of successful TLS handshakes since bridge start |
autoBlocklistActive | int | Number of source IPs currently in the auto-blacklist |
autoBlocklistTotal | long | Lifetime count of IPs ever auto-blocked |
handshakeFailuresTotal | long | Lifetime count of TLS handshake failures recorded |
build | string | Build slug of the running bridge (e.g. 2026-06-22-comino-sips-phase8b-health-endpoint) |
asOfUtc | string | ISO-8601 UTC timestamp of the last StartIfEnabled run |
autoBlocklistPolicy | string | Human-readable summary of the blocking rules (threshold, window, TTL, exemptions) |
Observability: [SIPS-stats] log line (every 30 s)
Bridge emits one INF line per 30 s tick with the same counters in greppable shape:
[SIPS-stats] handshakes(ok=N delta30s=D) tls(hsFail=F autoBlk=A activeBlks=C) policy(threshold=15/5min ttl=1h)
handshakes.ok — lifetime successful TLS handshakes
handshakes.delta30s — new successful handshakes in the last 30 s window
tls.hsFail — lifetime handshake failures recorded by the tracker
tls.autoBlk — lifetime IPs ever auto-blocked (cumulative)
tls.activeBlks — size of the current live blocklist (1-hour expiry per entry)
No REST endpoints publicly exposed for SIPS configuration. Operator controls SIPS via Sip.Sips.{Enabled,TrunkPort,PublicPort} in bridge appsettings.json (per-tenant override via WebRtcTenantSettings in a future phase).
SRTP via SDES (per-trunk, RFC 4568)
Trunk-leg media encryption is configured per trunk in the same admin surface as the Tls / TlsPort SIPS fields. When the Srtp boolean is true on a trunk, outbound INVITEs offer SDES with the AES_CM_128_HMAC_SHA1_80 crypto suite (RFC 4568 mandatory baseline); inbound INVITEs from any trunk answer SDES whenever the peer offers a=crypto: (asymmetric, safe-by-default).
| Trunk field | Type | Description |
Srtp | boolean | Enable SDES on outbound INVITEs for this trunk. When true, offers m=audio … RTP/SAVP with a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:<base64-key>. If the peer rejects, the call ends — no silent plaintext fallback. Default: false. Inbound answer behaviour is asymmetric: a trunk with Srtp=false still answers SDES when the peer offers it. |
Pair Tls=true + Srtp=true on the same trunk for end-to-end-on-the-carrier-leg encryption: SIPS (RFC 5630) on the signalling plane, SDES (RFC 4568) on the media plane. The bridge terminates SDES, so AI receptionist, recording, transcription and lawful-intercept hooks continue to operate; the WebRTC browser leg keeps its own DTLS-SRTP.
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):
| 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://www.aloaha.com/api.ashx/v1/auditlog?event=maglink&limit=20"
Response (200):
{
"data": [
{
"ts": "2026-06-12T00:15:00.123Z",
"event": "maglink-finish",
"tenant": "www.aloaha.com",
"user": "alice@example.com",
"ip": "195.158.111.88",
"ua": "Mozilla/5.0 (iPhone)",
"extra": "tenant=www.aloaha.com result=ok jti=abc123..."
},
{
"ts": "2026-06-12T00:14:42.000Z",
"event": "passkey-signin-ok",
"tenant": "www.aloaha.com",
"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)
| 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.
EU List of Trusted Lists (LOTL) cache
Per-process cache of the European Commission’s LOTL XML and every Member-State Trusted List it points to. Extracts each service X.509 certificate and stores it as a downloadable trust anchor. Downstream verifiers (e.g. the OpenID4VP x5c chain check) will consume these anchors once the iter-2 flip lands in oidc.ashx.
Storage: shared across all tenants at
App_Data/_shared/lotl/.
eu-lotl.xml (raw),
manifest.json (cache metadata),
<CC>/<idx>_<sha8>.cer (per-Member-State DER anchors).
Refresh policy: lazy trigger on the first request whose cache age exceeds 24 hours. Single-flight lock ensures at most one refresh in flight per process. Serve last-good snapshot while a refresh runs in the background.
Iteration boundary: XAdES signature verification of the LOTL XML and each MS TL against the European Commission signing certificate is
not yet implemented (iter-3 on the roadmap in
HAIP notes). Today the anchors are trusted on network TLS + issuer-URL alone.
Cache status
GET/lotl.ashx?status
Public: cache manifest. Includes fetched_utc, lotl_sha256, ms_pointers_found, total_anchors, countries (map of CC → anchor count), per-Member-State breakdown with SHA-256 and byte size, and live refresh_in_flight bool.
Response (200):
{
"state": "ok",
"fetched_utc": "2026-07-12T08:00:12Z",
"source_url": "https://ec.europa.eu/tools/lotl/eu-lotl.xml",
"lotl_bytes": 218473,
"lotl_sha256": "a1b2c3...",
"ms_pointers_found": 27,
"total_anchors": 483,
"countries": { "DE": 62, "FR": 41, "IT": 33, ... },
"iter": 2,
"refresh_in_flight": false,
"per_ms": [
{ "country": "DE", "source_url": "https://...", "bytes": 84210, "sha256": "...", "anchor_count": 62 },
...
]
}
List anchor fingerprints
GET/lotl.ashx?anchors[&country=XX]
Public: return the SHA-256 fingerprint (hex) of every cached anchor, optionally filtered by ISO-3166 alpha-2 country code. Use this to check whether a given issuer certificate is currently trusted before initiating a verification flow.
Response (200):
{
"count": 62,
"country_filter": "DE",
"anchors": [
{ "country": "DE", "sha256": "e4a7...", "size_bytes": 1247, "file": "001_e4a70000.cer" },
...
]
}
Download one anchor
GET/lotl.ashx?anchor=<sha256hex>
Public: return the anchor certificate as PEM. 404 if the SHA-256 does not match any cached anchor. Regex-validated: only 64-char lowercase hex is accepted, no path traversal.
Force a refresh (admin)
POST/lotl.ashx?refresh
Admin: block until a full LOTL + all Member-State TLs refetch completes. Typically 20–60 seconds. Returns the fresh manifest. If another refresh is already in flight the call returns immediately with state: "already_in_flight".
OpenID Federation 1.0 entity statement
Signed ES256 JWT self-describing this tenant as an OpenID Federation entity, per OpenID Federation 1.0 §3.1. Wallets, verifiers and superior authorities that walk the federation trust chain to establish trust in this deployment start here.
Signing key: the same per-tenant ECDsa P-256 verifier leaf used for OID4VP client authentication (see
Relying-Party verifier certificate CA). Header carries the leaf’s
x5c chain so callers can validate offline against a known trust anchor.
Cache: the response is stable for 1 hour (
Cache-Control: public, max-age=3600). The JWT’s
exp claim matches the cache lifetime, so a resolver that respects HTTP caching will never see an expired statement.
Scope: today the entity self-anchors (empty
authority_hints). Once we register with a federation trust anchor the array populates and superior statements become resolvable via
/oidc.ashx?action=federation-fetch (planned).
Entity statement
GET/.well-known/openid-federation
Public: signed ES256 JWT. Content-Type: application/entity-statement+jwt. No parameters. Rewrite in web.config routes to /oidc.ashx?action=federation-entity.
Response headers (200):
Content-Type: application/entity-statement+jwt
Cache-Control: public, max-age=3600
Response body (decoded JWT):
{
"iss": "https://phone.aloaha.com",
"sub": "https://phone.aloaha.com",
"iat": 1721376000,
"exp": 1721379600,
"jwks": { "keys": [ { "kty": "EC", "crv": "P-256", "x": "...", "y": "...", "kid": "..." } ] },
"metadata": {
"openid_provider": { "issuer": "https://phone.aloaha.com", "jwks_uri": ".../oidc.ashx?action=jwks", ... },
"openid_relying_party": { "vp_formats_supported": { ... }, "client_registration_types": [ "automatic" ], ... }
},
"authority_hints": []
}
Verify from the shell:
python testscripts/openid-federation-probe.py https://phone.aloaha.com
EU Wallet verifier audit
Every OpenID4VP presentation-request that hits this tenant (start → wallet-response → verify-outcome → SSO-mint) is captured in the connection log as one or more vp-* events. The vc-audit.ashx handler exposes those events per-tenant as a paginated JSON list — feed it into SIEM (Splunk, Datadog, Elastic) for a GDPR Article 30 record of processing, or browse it live at /vc-audit.html.
Source: App_Data/<tenant>/logs/codeb-conn-YYYY-MM-DD.log (JSONL). Read-only projection — no verifier state is derived from this endpoint. Data survives app-pool recycle; retention is whatever your log-rotation policy is (default: 30 days sliding window).
Auth: every route requires an admin OIDC bearer (introspected against /oidc.ashx?action=introspect). Non-admin bearers return 401. Requests are tenant-scoped by the Host header — no cross-tenant read possible.
List verifier events
GET/vc-audit.ashx?list-vp-events=1&limit=500&since=<ISO8601>
Returns the newest-first VP-* events for this tenant. Optional since lower-bounds the result set; limit caps at 10 000. Events surfaced: vp-start, vp-request, vp-response-arrived, vp-verify-result, vp-sso-minted, plus observability events (vp-verify-fail, vp-response-cross-tenant, vp-response-body-read-fail, vp-response-lib-missing, vp-response-input-build-fail).
Response (200):
{
"tenant": "phone.aloaha.com",
"count": 3,
"limit": 500,
"since": null,
"events": [
{ "ts": "2026-07-12T09:14:33.421Z", "event": "vp-sso-minted", "peerId": "s_ab12", "ip": "46.11.x.x", "extra": "user=alice" },
{ "ts": "2026-07-12T09:14:33.117Z", "event": "vp-verify-result", "peerId": "s_ab12", "ip": "46.11.x.x", "extra": "verified=true iter=1 disclosures=3" },
{ "ts": "2026-07-12T09:14:22.008Z", "event": "vp-start", "peerId": "s_ab12", "ip": "46.11.x.x", "extra": "prefix=x509_hash nonce_len=32 ttl=300" }
]
}
Relying-Party verifier certificate CA
Each tenant runs its own private ECDsa P-256 root CA that issues X.509 leaf certificates for the tenant’s registered OIDC clients. Wallets that walk the RP’s x5c chain during an OID4VP interaction pin identity against this per-tenant CA. This is a WRPAC-lite for private deployments — not a national Relying-Party Access CA (see HAIP notes for the boundary).
Storage: App_Data/<tenant>/rp-ca/ca.pfx (root, ECDsa P-256, 5-year), ca.cer (public DER), leaves/<client_id>_<serial>.cer (audit trail; private keys are never persisted server-side).
Download the tenant CA (public)
GET/rp-cert.ashx?ca[&format=pem|der]
Public: download the tenant’s root CA certificate. Default format is PEM; add &format=der for the raw DER. Wallets that verify RP x5c chains ingest this into their trust store.
Issue a leaf verifier certificate
POST/rp-cert.ashx?issue&client_id=<id>&days=90&include_key=true
Admin: mint a fresh ECDsa P-256 leaf certificate bound to client_id via a SAN DNS:<client_id>.<tenant>. EKU = id-kp-clientAuth + id-kp-emailProtection. days is clamped to 1–730 (default 90). include_key=true returns the private key inline as PFX (over HTTPS only); default is to mint-and-discard the private key (audit-record only).
Response (200):
{
"client_id": "example-saas-app",
"tenant": "phone.aloaha.com",
"cert_pem": "-----BEGIN CERTIFICATE-----\n...",
"ca_pem": "-----BEGIN CERTIFICATE-----\n...",
"expires_utc": "2026-10-10T09:14:33Z",
"days_valid": 90,
"pfx_base64": "MIIF...",
"pfx_password": "<24-char URL-safe random>"
}
List issued leaves
GET/rp-cert.ashx?list
Admin: return every leaf audit record for this tenant (subject, issued-utc, expires-utc, serial, thumbprint, filename). No private-key material is exposed. Newest first.
Credential issuer — OpenID4VCI (SD-JWT VC)
Per-tenant OpenID4VCI issuer at /vci.ashx for non-regulated attestations — membership cards, employee IDs, event tickets, loyalty tokens. Not a Person Identification Data (PID) Provider, not a Qualified Trust Service Provider under eIDAS 2, not tied to any national eID scheme. See HAIP notes for the boundary. Admin console lives at /vci-admin.html.
Storage: App_Data/<tenant>/vci/issuer.pfx (auto-generated ECDsa P-256, 5-year), vci/offers/<id>.json (credential offers + pre-auth codes), vci/issued/*.json (audit records).
Format: dc+sd-jwt (IETF SD-JWT VC). Signed with the tenant issuer key using ES256. Holder-binding via optional wallet proof JWT (nonce + aud checked, jwk embedded as cnf.jwk).
Rate limits: 60 requests per minute per source IP on the three wallet-facing endpoints (?offer, ?token, ?credential). ?metadata and ?jwks are unrated. Exceeding the budget returns 429 with Retry-After: 30.
Trust: the tenant issuer key is a local ECDsa key, not chained to any Member State trust list. Wallets that accept this issuer’s credentials do so under their own trust policy (per-tenant whitelist, private ecosystem, or explicit user consent).
Issuer metadata (public)
GET/vci.ashx?metadata
Public: OpenID Credential Issuer metadata per
OpenID4VCI 1.0.
credential_configurations_supported is populated dynamically from the vcts the tenant has issued offers for; a fresh tenant returns a
membership_v1 placeholder.
Issuer JWKS (public)
GET/vci.ashx?jwks
Public: the issuer’s P-256 signing key as a JWKS. Wallets fetch this to verify the SD-JWT VC signature. kty=EC, crv=P-256, use=sig, alg=ES256, kid = truncated SHA-256 of the cert DER.
Create a credential offer
POST/vci.ashx?create-offer
Admin: mint a new credential offer with a pre-authorized_code grant. Returns the wallet deep-link (openid-credential-offer://?credential_offer_uri=…), the QR-friendly offer URI, the pre-authorized code (shown once), and the offer expiry.
Request body (JSON):
| Field | Type | Required | Notes |
vct | string | yes | Verifiable Credential Type URI. Any URI — https://your-issuer.example/membership/1.0, https://schemas.eudi.europa.eu/employment/1.0, etc. |
subject | string | yes | Holder identifier. [A-Za-z0-9_.@:-]+, max 128 chars. |
claims | object | no | Arbitrary JSON object. Each top-level key becomes a selectively-disclosable claim in the issued SD-JWT VC. |
days | int | no | Offer validity in days (1–30, default 7). The issued credential validity is fixed at 365 days. |
Response (200):
{
"offer_id": "5f8e2c9a7bfd3110",
"offer_uri": "https://phone.aloaha.com/vci.ashx?offer=5f8e2c9a7bfd3110",
"deep_link": "openid-credential-offer://?credential_offer_uri=...",
"pre_authorized_code": "<32-byte URL-safe random, shown ONCE>",
"expires_utc": "2026-07-19T09:14:33Z"
}
Retrieve a credential offer (public)
GET/vci.ashx?offer=<offer_id>
Public: the standard OpenID4VCI credential-offer JSON. Wallets fetch this after scanning the deep_link. Contains credential_configuration_ids derived from the offer’s vct, plus the pre-authorized_code grant.
Token endpoint (pre-authorized_code grant)
POST/vci.ashx?token
Public: exchange the pre-authorized code for a short-lived Bearer access_token (5-min TTL) and a fresh c_nonce. Body is application/x-www-form-urlencoded. Pre-auth codes are single-use (marked in the per-tenant state file under a per-tenant lock).
Request body (form):
grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code&pre-authorized_code=<code>
Response (200):
{
"access_token": "<32-byte URL-safe random>",
"token_type": "Bearer",
"expires_in": 300,
"c_nonce": "<16-byte URL-safe random>",
"c_nonce_expires_in": 300
}
Credential endpoint (mint SD-JWT VC)
POST/vci.ashx?credential
Public: Bearer access_token in the Authorization header. Body is JSON. Returns the SD-JWT VC in compact form (JWT ~ disclosure1 ~ disclosure2 ~ … ~).
Request body (JSON):
{
"format": "dc+sd-jwt",
"vct": "https://example.org/membership/1.0",
"proof": { <!-- optional but recommended -->
"proof_type": "jwt",
"jwt": "<compact JWS: header has typ=openid4vci-proof+jwt, jwk; payload has aud=<issuer>, nonce=<c_nonce from /token>, iat>"
}
}
When the proof JWT verifies (ES256 over the jwk in its own header, aud matches the issuer URL, nonce matches the c_nonce we handed you at /token), the wallet’s jwk is embedded as cnf.jwk in the issued SD-JWT VC. Wallets that enforce holder-binding refuse credentials without cnf; wallets that don’t enforce ignore the field.
Response (200):
{
"credential": "eyJ...jwt...bA~eyJ...disclosure1...~eyJ...disclosure2...~",
"format": "dc+sd-jwt"
}
List / revoke offers · list issued
GET/vci.ashx?list-offers
Admin: all active credential offers for this tenant. Pre-auth codes are masked (last-8 only).
GET/vci.ashx?list-issued
Admin: audit trail of every credential issued (offer_id, vct, subject, issued-utc, holder_bound bool, first 96 chars of the credential).
POST/vci.ashx?revoke-offer
Admin: invalidate an offer that has not yet been redeemed. Body: { "offer_id": "<id>" }. Under-lock revocation also removes the code from the in-memory index so it cannot be race-redeemed.
Rotate keys · rebuild code index (admin)
POST/vci.ashx?reload-keys
Admin: drop the cached X509Certificate2 for this tenant so the next /credential re-reads issuer.pfx from disk. Use after rotating the pfx out-of-band. Existing signing operations in flight continue to use their previously-taken reference.
POST/vci.ashx?rebuild-code-index
Admin: drop the cached pre-auth-code → offer index so the next /token rebuilds it by scanning the offers directory. Useful after out-of-band offer edits.
Integration recipe (wallet flow)
- Admin creates an offer via
POST /vci.ashx?create-offer and sends the deep_link (or its QR) to the holder.
- Holder’s wallet scans the QR, dereferences
credential_offer_uri, receives the offer JSON, notices the pre-authorized_code grant.
- Wallet POSTs
grant_type=…pre-authorized_code to /vci.ashx?token, gets an access_token and c_nonce.
- Wallet builds a proof JWT: header
{alg:ES256, typ:openid4vci-proof+jwt, jwk:<wallet-generated P-256 jwk>}, payload {iss:<wallet id>, aud:<issuer URL>, iat, nonce:<c_nonce>}, signs it with the wallet key.
- Wallet POSTs
{format:"dc+sd-jwt", vct:<same as offer>, proof:<the JWT above>} to /vci.ashx?credential with the Bearer access_token.
- Wallet receives the compact SD-JWT VC + disclosure array, verifies the issuer signature against
/vci.ashx?jwks, stores the credential.
OpenID4VCI code flow — PAR · authorize · authorization_code
Shipped 2026-07-20 alongside the existing pre-authorized_code grant. The /vci.ashx issuer now speaks the full OpenID4VCI 1.0 authorization-code flow: RFC 9126 Pushed Authorization Request, RFC 7636 PKCE (S256 only), RFC 9396 Rich Authorization Requests via authorization_details, and RFC 9207 Authorization Server Issuer Identification on the callback.
Client whitelist: the client_id presented at /vci.ashx?par and /vci.ashx?authorize must be in App_Data/<tenant>/wallet-clients.json. When the file is absent the issuer falls back to { "client_ids": ["aloaha-web-wallet"] }. The lookup is cached for 5 minutes per tenant.
Public client: no client secret; PKCE is mandatory. token_endpoint_auth_methods_supported: ["none"].
Pushed Authorization Request (PAR)
POST/vci.ashx?par
Public: wallet pushes the authorization request out-of-band, receives a one-shot request_uri. Body: application/x-www-form-urlencoded. Response: 201 Created + application/json.
Request body (form):
| Field | Type | Required | Notes |
client_id | string | yes | Must be present in wallet-clients.json for this tenant. |
response_type | string | yes | Must be code. |
redirect_uri | string | yes | Byte-exact match required at /token. |
code_challenge | string | yes | 43–128 chars of PKCE-unreserved ([A-Za-z0-9_.~-]). |
code_challenge_method | string | yes | Must be S256. plain is rejected. |
scope | string | no | Space-delimited. |
authorization_details | string | no | JSON per RFC 9396, e.g. [{"type":"openid_credential","credential_configuration_id":"...","vct":"..."}]. |
state | string | no | Echoed back on the callback. |
Response (201):
{
"request_uri": "urn:ietf:params:oauth:request_uri:<opaque>",
"expires_in": 60
}
Errors (400): invalid_request, invalid_client (with error_description). The request_uri is single-use and consumed at /authorize.
Authorize
GET/vci.ashx?authorize
Wallet-facing: the OID4VCI authorization endpoint. If the caller is not signed in, this redirects to /oidc.ashx?action=authorize and returns here after login. On success, redirects to the wallet’s redirect_uri with the freshly-minted code.
Query: either request_uri=<from PAR> alone, or the same direct parameters accepted at /par. When both are supplied the direct params are ignored.
Success redirect (302):
Location: <redirect_uri>?code=<opaque>&state=<echoed>&iss=<issuer_url>
The iss parameter is unconditional (RFC 9207). The code has a 90-second TTL and is invalidated at first use — even a failed token exchange consumes it.
Token — authorization_code grant
POST/vci.ashx?token
New grant branch alongside the existing urn:ietf:params:oauth:grant-type:pre-authorized_code. Body: application/x-www-form-urlencoded.
Request body (form):
| Field | Type | Required | Notes |
grant_type | string | yes | authorization_code |
code | string | yes | Value received on the /authorize callback. |
redirect_uri | string | yes | Byte-exact match with the value sent at PAR / /authorize. |
code_verifier | string | yes | 43–128 chars of PKCE-unreserved. Rejected with invalid_grant on mismatch. |
client_id | string | yes | Must match the whitelisted client from PAR. |
Response (200): identical shape to the pre-authorized_code grant — {access_token, token_type:"Bearer", expires_in, c_nonce, c_nonce_expires_in}.
Errors (400): invalid_grant (bad code / PKCE / redirect_uri), invalid_client. Codes are single-use even on failure.
Credential endpoint — mDoc / ISO 18013-5 format
Alongside the existing dc+sd-jwt format, the credential endpoint at POST /vci.ashx?credential now accepts format=mso_mdoc and returns a signed ISO/IEC 18013-5 mobile document (mDoc / mDL).
Scope today: only
doctype=eu.europa.ec.eudi.pid.1 (EUDI PID) is supported. Any other
doctype returns
unsupported_credential_type. Both
proof_type="jwt" (compact JWS) and
proof_type="cwt" (COSE_Sign1 wrapping CWT claims per
RFC 8747 /
RFC 8392) are accepted per HAIP §5.4. Wallets MUST send exactly one of
proof.jwt or
proof.cwt — sending both returns HTTP 400
invalid_proof multiple proof fields present. See
HAIP roadmap for what’s in flight.
Request body (JSON):
{
"format": "mso_mdoc",
"doctype": "eu.europa.ec.eudi.pid.1",
"proof": {
"proof_type": "jwt",
"jwt": "<compact JWS: header alg=ES256, typ=openid4vci-proof+jwt, jwk; payload aud=<issuer>, iat, nonce=<c_nonce>>"
}
}
Alternative request body (CWT proof, HAIP §5.4):
{
"format": "mso_mdoc",
"doctype": "eu.europa.ec.eudi.pid.1",
"proof": {
"proof_type": "cwt",
"cwt": "<base64url COSE_Sign1 wrapping CBOR CWT claims: iss/aud/iat + holder COSE_Key in unprotected header label -25>"
}
}
CWT proof shape. Outer envelope is a COSE_Sign1 (
RFC 9052) whose protected header carries
{alg:-7 (ES256), typ:"openid4vci-proof+cwt"}. The holder’s public COSE_Key sits at label
-25 in the unprotected header per
RFC 8747. The payload is a CBOR map of
RFC 8392 claims:
iss (1) = wallet client-id,
aud (3) = issuer origin,
iat (6) = unix seconds (int or RFC 3339 tstr), optional
nbf (5) /
exp (4) /
nonce (10).
iat/
nbf/
exp are enforced with ±60 s skew tolerance.
Response (200):
{
"credential": "<base64url mDoc bytes>",
"format": "mso_mdoc"
}
The mDoc bytes decode as CBOR containing nameSpaces (issuer-signed selectively-disclosable claim digests) and issuerAuth (COSE_Sign1 with the issuer’s X.509 x5chain). The wallet stores the whole blob and presents it verbatim under OID4VP.
Issuer key storage: App_Data/<tenant>/keys/mdoc-issuer-es256.pfx (auto-generated on first mint by MDocIssuer, 1-year self-signed P-256, password in the sibling .pwd file which lives outside the web root). Delete the .pfx to force regeneration on the next mint.
OID4VP direct_post — mDoc vp_token
POST /oidc.ashx (direct_post) now auto-detects an mDoc vp_token: when the decoded bytes start with a CBOR map header (0xA0–0xBB), the verifier routes to MDocVerifier.Verify. SD-JWT VC vp_token handling is unchanged and remains the default when the payload starts with an SD-JWT compact serialization.
Trust anchors required. The mDoc verifier chains issuerAuth x5chain to certificates under App_Data/<tenant>/trust/mdoc-issuers/*.cer (or *.crt). If the directory is empty or missing, the response is HTTP 501 {"error":"mdoc_verify_not_configured", "hint":"add issuer certs under App_Data/<tenant>/trust/mdoc-issuers/ to enable"}. Drop any Member State PID-issuer CA (DER or PEM-in-.cer) into that directory to switch verification on.
Success side-effects. Verified disclosures are emitted via LogOidcInternal("vp-verified", tenant, user, {format:"mso_mdoc", docType, claims}) and surface in vc-audit.html. SessionTranscript is constructed per OID4VP Annex B from client_id + response_uri + nonce.
Per-tenant configuration files (2026-07-20 additions)
Location. All paths below sit under App_Data/<tenant>/, which is not web-served. Missing files fall back to safe defaults documented per row; operators create them via SFTP/RDP, no admin UI required.
| Path | Purpose | Missing/empty behaviour |
wallet-clients.json |
Whitelist of client_id values accepted at /vci.ashx?par and /vci.ashx?authorize. Shape: {"client_ids": ["aloaha-web-wallet", "some-other-wallet-id"]}. 5-minute TTL cache per tenant. |
Auto-created on first credential-issuance request with defaults {"client_ids":["aloaha-web-wallet"]}. Malformed / empty / zero-entry files are NOT overwritten (operator may be mid-edit) — the process falls back to the hardcoded default until the file is repaired. Changes picked up within 5 min. |
trust/vc-issuers/*.cer (or *.crt) p1-b-fix 2026-07-22 |
Trust anchors for the SD-JWT VC issuer LOTL chain gate in oidc.ashx (iter-3). Drop DER-encoded X.509 issuer certificates in this folder; the verifier chains the SD-JWT issuer's x5c up to these anchors after the built-in EU LOTL store. Operator setup runbook » |
Iter-3 falls back to trust/mdoc-issuers/ (legacy, emits [LOTL-CHAIN] WRN overlay-legacy-fallback). If both are empty and the built-in LOTL store yields nothing, iter-3 records lotl-empty-no-overlay; response semantics unchanged (strict mode → HTTP 400, non-strict → iter-2 or lower). |
trust/mdoc-issuers/*.cer (or *.crt) |
Trust anchors for the mDoc verifier. Drop DER-encoded X.509 issuer certificates in this folder; the verifier chains x5chain from any inbound mDoc up to these anchors. Also used as legacy fallback for the SD-JWT VC LOTL overlay when vc-issuers/ is empty — migrate to the dedicated dir. Operator setup runbook » |
HTTP 501 mdoc_verify_not_configured on every mDoc verify attempt. |
| HAIP 5.11 Wallet Attestation trust anchors round3b 2026-07-22 |
App_Data/<tenant>/trust/wallet-providers/*.jwks.json or *.pem |
Public keys of trusted wallet providers whose Wallet Attestation JWTs the verifier will accept (draft-ietf-oauth-attestation-based-client-auth). Operator setup runbook » |
HTTP 501 wallet_attestation_not_configured whenever a wallet presents wallet_attestation OR the JAR requires it. |
keys/mdoc-issuer-es256.pfx + .pwd |
Signing material for MDocIssuer. Auto-generated on first mDoc mint (1-year self-signed P-256). Password is written in plaintext to the sibling .pwd file — App_Data is not web-served. |
Auto-created on first use. Delete the .pfx to force regeneration. |
Wallet client_id whitelist
Purpose. Sprint 3 authorization_code + PAR gating. Every wallet that presents a client_id at POST /vci.ashx?par or GET /vci.ashx?authorize is checked against a per-tenant whitelist before an authorization code is minted. Prevents rogue wallets from harvesting attestations against your issuer.
Location. App_Data/<tenant>/wallet-clients.json. App_Data is not web-served, so the file is safe from public GET.
Auto-created on first miss. The file is written automatically on the first credential-issuance request for a tenant that has no wallet-clients.json yet. Default contents: {"client_ids":["aloaha-web-wallet"]}. Concurrent requests are serialised by a per-tenant lock so the file is written exactly once. Edit the file to whitelist additional wallet client_ids; changes picked up within 5 min. Redeploy is safe: nothing in the source tree copies over an existing tenant's config — the auto-create only fires when the file is truly absent.
Schema. Single JSON object with a client_ids array of strings. Optional _schema and _notes siblings are ignored by the loader; they exist so tenants can pin the JSON Schema in openapi.json and leave inline operator notes.
{
"_schema": "https://phone.codeb.io/openapi.json#/components/schemas/WalletClientsConfig",
"_notes": "Per-tenant wallet client_id whitelist. Auto-created with defaults on first credential-issuance request.",
"client_ids": [
"aloaha-web-wallet",
"some-other-wallet-vendor-id"
]
}
Fallback. If the file exists but is unreadable, empty, malformed, or contains a zero-length client_ids array, the issuer falls back to the hardcoded default { "client_ids": ["aloaha-web-wallet"] } without overwriting the on-disk file (the operator may be mid-edit). Every fallback path emits an INF trace [VCI-CLIENTID] tenant=<tenant> ... using hardcoded fallback so operators can spot silent typos. If the file is entirely absent the trace instead reads auto-created wallet-clients.json with defaults.
Cache TTL. 5 minutes per tenant. Edit the file and wait up to five minutes for changes to take effect, or recycle the IIS app pool to force an immediate reload.
Wallet-clients status (admin)
GET/vci.ashx?wallet-clients-status
Return the currently-loaded wallet client_id whitelist for this tenant plus a source label indicating where the values came from — a live view for operators to confirm that the auto-create fired, or that a hand-edited whitelist is actually active.
Auth: admin (OIDC Bearer OR HMAC signature). HMAC canonical: vci-wallet-clients-status|<tenant>.
Response (200):
{
"tenant": "phone.example.com",
"client_ids": [ "aloaha-web-wallet" ],
"source": "auto-created", // one of: "auto-created" | "file" | "fallback"
"loaded_at": "2026-07-21T09:14:22.1234567Z"
}
source=auto-created means this process wrote the file on first miss. source=file means the loader read tenant-supplied contents. source=fallback means the on-disk file is malformed/empty and the issuer is running on the hardcoded default until the file is repaired.
Aloaha Web Wallet — new client-side primitives
The browser wallet shell at /web-wallet.html gained several dependency-free primitives tonight. All of them are wallet-side JavaScript modules that ship inside the PWA; there is no server endpoint change for these.
- QR camera scanner —
window.CodebWWScanner.open({onResult, onCancel, accept}). Uses the browser BarcodeDetector API when available; degrades gracefully with an actionable message on unsupported browsers. Ships in js/web-wallet-scanner.min.js.
- mDoc read / write —
window.CodebMdoc.parseIssuerSigned, verifyMso, checkDigests; window.CodebMdocDevResp.buildDeviceResponse. Dependency-free CBOR + COSE_Sign1 + mDoc primitives, matched byte-for-byte by App_Code/CborUtil.cs + CoseSign1Util.cs on the server. Ships in js/web-wallet-cbor.min.js, web-wallet-cose.min.js, web-wallet-mdoc.min.js, web-wallet-mdoc-devresp.min.js, web-wallet-mdoc-wallet.min.js.
- Visual credential render —
window.CodebWWVisual.render(credRec). Generic decoded-claims fallback for credentials that don’t have a bespoke visual template. Ships in js/web-wallet-visual.min.js.
- Recovery vault Sync/Pull — defensive errors. The wallet-side Sync/Pull actions now produce actionable text (e.g. “This tenant has not enabled wallet backup (501). Ask an operator to configure
/wallet-backup.ashx.”) instead of the previous “Unexpected end of JSON input” when the backup handler returns 404 / 501 / empty body.
Vault endpoint. The authoritative vault sync endpoint is /wallet-backup.ashx (Bearer OR HS256 fallback auth, 1 MB body cap, per-(sub,tenant) rate limit). The client points at /wallet-backup.ashx, not the earlier /oidc.ashx?action=wallet-backup-* paths.
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://www.aloaha.com/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> }"
}
Correlation ID. Every /api.ashx/* response carries an X-CodeB-Correlation-ID header. Send the same header on your request to have it propagated end-to-end in server logs; if you don't send one, the server mints a UUID. Include the correlation ID when opening a support ticket — it makes tracing a specific request across trunks, transcripts, and OIDC surfaces trivial.
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.