Public API

/signal.ashx · public API

The WebRTC signaling backbone. The conference page upgrades the HTTP connection here to a WebSocket and stays there for the duration of the call. A handful of plain HTTP query flags expose anonymous-safe metadata used by the status page, the public virtual numbers directory, and the camera registry.

Anonymous-safe. The endpoints listed below intentionally never reveal a caller number, an internal IP, or a tenant secret. Anything that does — ?stats, ?diallog, ?cdrlog, ?registrar, ?aliases, ?whitelist, the ?virtualnumbers CRUD — lives behind admin HMAC or an OIDC Bearer and is excluded here.
Native clients: the full frame catalogue and JSON Schemas for every message (client → server and server → client) live at /asyncapi.json (AsyncAPI 3.0). Import it into Studio, Postman, or Insomnia and generate a typed client. The remote-control extension is documented at /remote_control_api.html.

WS /signal.ashx #

Bidirectional WebRTC signaling channel. The browser conference client connects here on page load and remains for the lifetime of the call. Every signaling message after the upgrade is JSON-encoded.

Request

Standard WebSocket upgrade. No query parameters required for join. The server reads room + display-name + optional dial=<vnum> from the URL the page was loaded with and conveys them to the server via the first join frame.

Response

Server → client frame types include welcome (peer-id + minted ICE servers including TURN credentials when configured), peer-joined, peer-left, signal (carries SDP + ICE candidates from another peer), ring, ring-cleared, kicked, error. Client → server frame types include join, signal (to relay SDP/ICE to a specific peer), dial, leave, ping.

Errors

The endpoint also responds to a plain GET (no Upgrade header) with a one-line build-stamp text/plain response, useful for liveness probes.

Designed for the bundled browser client. Keepalive pings every ~25 s. Anonymous join is allowed; OIDC sign-in upgrades the connection's verifiedIdentity field which is broadcast to other peers as a green badge.

GET /signal.ashx?healthsummary=1 #

Public health snapshot consumed by status.html. Lists live rooms, current peer count, trunk status (with hostnames redacted), and CDR aggregates. Safe to expose — no caller numbers, no IPs.

Request

No request body. No auth. No cookies.

Response

Returns application/json shaped roughly (verified 2026-06-05):

{
  "ts": "2026-06-05T11:22:50.6387028Z",
  "trunks": [
    { "role": "Malta",            "registered": true,  "lastSeenSec": 18 },
    { "role": "Upstairs Germany", "registered": true,  "lastSeenSec": 47 }
  ]
}

The shape is intentionally minimal — just the timestamp the snapshot was taken and the up/down state of the configured SIP trunks. Per-room headcount and CDR aggregates live behind the admin UI; if you need them, ask the operator for an OIDC client with the right scope.

Errors

500 only on internal error.

Example

curl https://phone.aloaha.com/signal.ashx?healthsummary=1
Intentionally low-detail. Anyone polling this endpoint can monitor uptime without revealing telephony state.

GET /signal.ashx?vnum-overview=1 #

List of public virtual numbers a visitor can dial right now. Each row carries the dial-in URL, the human name, and the mode (AI receptionist vs. TTS).

Request

No body. Optionally accepts an OIDC Bearer token; signed-in users see additional internal rows. Anonymous callers see only rows marked visibility=anonymous.

Response

Returns application/json (verified 2026-06-05):

{
  "role": "anonymous",
  "virtualNumbers": [
    { "number": "1234", "name": "CodeBDemo",
      "mode": "gemini-live", "dialUrl": "https://phone.aloaha.com/room.html?room=random&dial=1234&cam=off",
      "visibility": "anonymous" }
  ]
}

The top-level role field reflects the caller’s identity at request time. Anonymous callers receive only visibility=anonymous rows; an OIDC Bearer with role=admin additionally sees internal vnums.

Example

curl https://phone.aloaha.com/signal.ashx?vnum-overview=1
Used by status.html and the public virtual-numbers-overview.html directory page. Only rows marked visibility=anonymous appear publicly; operators who set a vnum name to personal data should mark it non-anonymous to keep it off this endpoint.

POST /signal.ashx?office-ring #

Internal SIP-bridge webhook the bridge POSTs to when an inbound PSTN call lands. Not intended for third-party integration — documented here for transparency and security review. HMAC-validated against the per-tenant bridge secret; only the local bridge can reach it.

Request

POST JSON body with the inbound call descriptor plus an X-Sig: HMAC-SHA256 header signing office-ring|<host>|<body> with the bridge shared secret.

Response

Returns { "delivered": N } where N is the number of CodeB Webphone instances that accepted the ring.

Errors

401 on signature mismatch.

Only the SIP bridge running on the same machine knows the secret. Documented here because it appears on the wire and any reviewer should know what it is.

POST /signal.ashx?office-ring-clear #

Internal SIP-bridge webhook the bridge POSTs when an inbound call is cancelled, hung up, or picked up elsewhere. Not intended for third-party integration — documented here for transparency and security review. Same HMAC envelope as ?office-ring.

Request

POST JSON with the same HMAC-SHA256 envelope as ?office-ring.

Response

{ "ok": true }

Errors

401 on signature mismatch.

Same bridge-HMAC posture as ?office-ring.

GET /signal.ashx?passkey=list #

List the FIDO2 / WebAuthn passkeys registered to the calling user on this tenant. Used by account.html to render the user's passkey management table.

Request

Requires an OIDC Bearer token (any role). No body.

Response

{
  "passkeys": [
    {
      "credentialId":  "<base64url>",
      "credentialName": "Stefan's YubiKey",
      "createdUtc":    "2026-06-11T12:34:56Z",
      "lastUsedUtc":   "2026-06-11T16:01:02Z"
    }
  ]
}

Example

curl -H "Authorization: Bearer $TOKEN" \
  https://phone.aloaha.com/signal.ashx?passkey=list

Verified 2026-06-11 against live (Release 25).

Returns only the calling user's own passkeys — cross‑user enumeration is impossible from this endpoint. The COSE public key is intentionally not exposed; only metadata.

POST /signal.ashx?passkey=register-start #

Begin a passkey registration ceremony. Returns a fully-formed PublicKeyCredentialCreationOptions JSON document that navigator.credentials.create({ publicKey }) consumes directly.

Request

OIDC Bearer required. Optional JSON body: {"credentialName":"Stefan's laptop"} — a human label so the user can later identify which device a credential belongs to.

Response

{
  "rp":                     { "id":"www.aloaha.com", "name":"CodeB" },
  "user":                   { "id":"<b64url>", "name":"alice", "displayName":"Alice Walker" },
  "challenge":              "<b64url>",
  "pubKeyCredParams":       [ { "type":"public-key", "alg":-7 }, { "type":"public-key", "alg":-257 } ],
  "timeout":                300000,
  "attestation":            "none",
  "authenticatorSelection": { "userVerification":"required", "residentKey":"preferred" },
  "excludeCredentials":     [ … ],
  "extensions":             { },
  "session":                "<opaque-session-id>"
}

Example

curl -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"credentialName":"Stefan's YubiKey"}' \
  https://phone.aloaha.com/signal.ashx?passkey=register-start

Verified 2026-06-11 against live (Release 25).

Phase 0 policy: attestation=none (no enterprise device‑binding gates), userVerification=required, RP ID equals the tenant hostname. See loginpasskey.html for the matching sign‑in ceremony.

POST /signal.ashx?passkey=register-finish #

Complete a passkey registration ceremony. Verifies the attestation, parses the COSE public key, and stores it under the user's passkey collection.

Request

OIDC Bearer required. JSON body with the WebAuthn attestation response (as produced by navigator.credentials.create()) plus the session from register-start.

Response

{
  "ok":             true,
  "credentialId":   "<b64url>",
  "credentialName": "Stefan's YubiKey"
}

Errors

  • 400 missing_credentialId — payload incomplete.
  • 400 verification_failed — attestation, signature, or RP ID hash check failed.
  • 409 credential_already_registered — this exact credentialId is already on file for the user.

Example

Driven by the browser — see account.html for the full registration script.

Verified 2026-06-11 against live (Release 25).

POST /signal.ashx?passkey=delete #

Revoke one of the calling user's passkeys. Lost device, retired token, end‑of‑employment — any reason. The credential is removed from the per‑user store and any future assertion presented for it is rejected.

Request

OIDC Bearer required. JSON body:

{ "credentialId": "<base64url>" }

Response

{ "ok": true, "deleted": { "credentialId": "<b64url>", "credentialName": "Stefan's YubiKey" } }

Errors

  • 400 missing_credentialId
  • 404 not_found — user has no passkey with that credentialId.

Example

curl -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"credentialId":"AAECAwQ..."}' \
  https://phone.aloaha.com/signal.ashx?passkey=delete

Verified 2026-06-11 against live (Release 25).

Deletion is scoped to the calling user; admins cannot delete another user's passkey via this endpoint. For administrative revocation, the registered user's profile is rewritten directly.

GET /signal.ashx?room-info=<code> #

Cheap read of whether a room is currently active plus its shape. Anonymous, no PII, safe to poll from a mobile client. Used by the native tray app to short-circuit UI flow before opening a WebSocket.

Response

{
  "exists":        true,
  "mode":          "mesh",
  "peerCount":     2,
  "locked":        false,
  "controlLocked": false
}

Example

curl https://phone.aloaha.com/signal.ashx?room-info=hostinghelp
Room does not exist means either "nobody joined yet" or "everybody left"; there is no create-then-linger state today. Rooms disappear from the server when the last peer's WebSocket closes.

POST /signal.ashx?room-create #

Mint a fresh room code (or accept a caller-supplied one) plus, optionally, a short-lived invite JWS the peer will pass as ?tok= on the WS URL. Auth: OIDC Bearer or ak_ API key.

Request

POST /signal.ashx?room-create
Authorization: Bearer <token>
Content-Type: application/json

{
  "code":         "hostinghelp",   // optional; server picks a 6-char base32 code when absent
  "mintInvite":   true,            // optional; false by default
  "inviteExpMin": 60               // optional; default 60, max 1440 (24 h)
}

Response

{
  "code":   "hostinghelp",
  "invite": "eyJhbGciOi...",      // null when mintInvite was false
  "join":   "wss://phone.aloaha.com/signal.ashx"
}
Codes are not reserved — anybody who knows the code can join, subject to lock/knock. Use mintInvite=true to bind an invite to your identity in the audit trail.

POST /signal.ashx?invite-mint #

Mint an invite JWS for an existing room. Same auth surface as room-create. Useful for M2M pipelines that reserve rooms elsewhere.

Request

POST /signal.ashx?invite-mint
Authorization: Bearer <token>
Content-Type: application/json

{
  "room":   "hostinghelp",
  "sub":    "guest@partner.com",   // optional label carried in the JWS sub
  "expMin": 60
}

Response

{
  "invite":     "eyJhbGciOi...",
  "expiresUtc": "2026-09-28T17:20:00Z",
  "room":       "hostinghelp"
}
The JWS is HS256, signed with WebPhone:InviteSecret (falls back to WebPhone:AdminSharedSecret). Present it on the WS URL as ?tok=<jws> and the join is verified before the first frame.

GET /signal.ashx?turn-creds #

Mint fresh TURN credentials outside of a WebSocket session, e.g. so a native client can pre-configure its RTCPeerConnection before joining. Same shape as the welcome.iceServers array.

Response

{
  "iceServers": [
    { "urls": "stun:phone.aloaha.com:3478" },
    { "urls": [ "turn:phone.aloaha.com:3478?transport=udp",
                "turn:phone.aloaha.com:3478?transport=tcp",
                "turn:phone.aloaha.com:5349?transport=tcp",
                "turns:phone.aloaha.com:5349?transport=tcp" ],
      "username":   "1790000000:aa11bb22cc33",
      "credential": "<base64 HMAC>" }
  ],
  "expiresIn":  3600,
  "peerId":     "aa11bb22cc33"
}
TTL is the tenant's WebPhone:TurnTtlSec (default 3600 s; clamped 60 s to 86400 s). The client should refresh before expiresIn lapses and call RTCPeerConnection.setConfiguration().

GET /signal.ashx?attest-verify&t=<compact JWS> #

Verify an identity attestation minted by the identity-attest WS frame. Public; the JWS itself is short-lived (5 min) so exposing verification does not open a replay window.

Response

{
  "ok":      true,
  "payload": {
    "iss": "signal.ashx", "aud": "codeb-consent",
    "sub": "aa11bb22cc33", "name": "Alice Walker",
    "verified": true, "vsub": "alice@aloaha.com",
    "room": "hostinghelp", "tenant": "phone.aloaha.com",
    "iat": 1789999700, "exp": 1790000000
  }
}

Or, on failure: {"ok":false,"error":"invalid or expired"}.

GET /asyncapi.json #

Full AsyncAPI 3.0 document for the /signal.ashx WebSocket. Every frame (both directions) is a named message with a complete JSON Schema payload. Import into your AsyncAPI tool of choice.

Example

curl https://phone.aloaha.com/asyncapi.json
The convenience alias /signal.ashx?asyncapi=1 302-redirects here.

WS Frame catalogue #

Every frame the WebSocket accepts and emits. Full JSON Schemas: /asyncapi.json. All frames are UTF-8 JSON, text frames only, max 256 KiB.

Client → server

  • join — first frame after upgrade. Fields: room, name, authToken?, role?, preferredMode?, protoVersion?, client?.
  • signal — peer-to-peer relay. Fields: to, payload.
  • leave — voluntary leave. Closing the socket is equivalent.
  • ping — application keepalive. Expect pong.
  • dial — ask the room to place an outbound SIP call. Fields: number, trunk?.
  • lock / unlock — moderator toggles the room lock (drives knock-to-enter).
  • admit / deny — moderator handles a knocker. Field: peerId.
  • sfu-offer, sfu-subscribe-offer — SFU publish/subscribe.
  • bandwidth-report — client-side network telemetry.
  • control-request, control-grant, control-revoke, control-lock — remote-control channel (v2). Full schema in remote_control_api.html.
  • identity-attest-req — ask the server for a signed peer-id ↔ identity attestation.

Server → client

  • welcome — after successful join. Fields include peerId, peers[], iceServers[], mode, caps, forceRelay, protoVersion.
  • peer-joined, peer-left — room-membership updates.
  • signal — relayed peer signal. Fields: from, payload.
  • pong, error, kicked — keepalive, recoverable error, forced close.
  • dial-result — outcome of a dial.
  • ring, ring-cleared — inbound-call push to /office.html tabs.
  • knock, knock-pending, denied, room-locked, room-unlocked — locked-room flow.
  • mode-changed — mesh ⇄ SFU cascade.
  • sfu-answer, sfu-offer-failed, sfu-subscribe-answer, sfu-subscribe-failed.
  • dup-ip-warning — same-IP presence diagnostic.
  • control-request, control-grant, control-revoke, control-lock-changed — remote-control routing.
  • identity-attest — server-signed peer-id ↔ identity attestation.

WS Reference transcripts #

Full happy-path transcripts a non-browser client can mirror.

Mesh: connect → join → welcome → second peer joins → offer/answer/candidates → leave

// 1. WS upgrade (native client picks tenant, opens WSS)
GET /signal.ashx HTTP/1.1
Host: phone.aloaha.com
Upgrade: websocket
Connection: Upgrade
User-Agent: AloahaRDPSystemTray/1.4.0
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: ...

// 2. First join
→ { "type":"join", "room":"hostinghelp", "name":"Bob",
      "authToken":"eyJhbGci...", "role":"human",
      "preferredMode":"auto", "protoVersion":"2",
      "client":"AloahaRDPSystemTray/1.4.0" }
← { "type":"welcome", "peerId":"dd44ee55ff66", "peers":[],
      "iceServers":[ {...}, {...} ],
      "suggestedName":"bob@aloaha.com",
      "mode":"mesh",
      "caps":{ "dial":true, "maxPeers":6, "sfuEnabled":true,
               "controlLocked":false, "remoteControl":true },
      "forceRelay":false, "protoVersion":"2" }

// 3. Alice joins from the browser
← { "type":"peer-joined",
      "peer":{ "id":"aa11bb22cc33", "name":"Alice", "verified":true, "role":"human" } }

// 4. Alice is impolite (larger peer id), creates offer, sends to Bob:
← { "type":"signal", "from":"aa11bb22cc33",
      "payload":{ "sdp":{ "type":"offer", "sdp":"v=0\r\no=- ... " } } }

// 5. Bob answers
→ { "type":"signal", "to":"aa11bb22cc33",
      "payload":{ "sdp":{ "type":"answer", "sdp":"v=0\r\no=- ... " } } }

// 6. ICE candidates trickle both ways
← { "type":"signal", "from":"aa11bb22cc33",
      "payload":{ "candidate":{ "candidate":"candidate:1 1 udp 2130706431 ...",
                                "sdpMid":"0", "sdpMLineIndex":0 } } }
→ { "type":"signal", "to":"aa11bb22cc33",
      "payload":{ "candidate":{ "candidate":"candidate:2 1 udp 2130706431 ...",
                                "sdpMid":"0", "sdpMLineIndex":0 } } }

// 7. Keepalive
→ { "type":"ping" }
← { "type":"pong" }

// 8. Bob leaves
→ { "type":"leave" }
← (server closes the socket with 1000 NormalClosure)

SFU: publish (sfu-offer / sfu-answer) — audio only, subscribe is 501-pending

// After welcome mode="sfu" (either the join triggered an auto-promote, or a
// mode-changed frame arrived mid-call). Bob publishes his own audio track:
→ { "type":"sfu-offer", "sdp":"v=0\r\no=- ..." }
← { "type":"sfu-answer", "sdp":"v=0\r\no=- ...", "phase":"1b.0" }

// Bob would like to receive Alice's audio. Today (2026-09) this comes back
// as sfu-subscribe-failed because /sfu/subscribe is still pending on the
// bridge. Native clients should stay in mesh for receive until that lands.
→ { "type":"sfu-subscribe-offer", "publisherPeerId":"aa11bb22cc33",
      "sdp":"v=0\r\no=- ..." }
← { "type":"sfu-subscribe-failed", "publisherPeerId":"aa11bb22cc33",
      "code":"pending", "error":"bridge /sfu/subscribe returns 501" }
Recommendation for the native tray app: stay in mesh (≤ 6 humans). Mesh works fully today for both audio and video. The SFU subscribe path lands in the next bridge phase.

WS Remote-control channel #

Consent-gated screen control on top of a CodeB meeting. A helper asks; the target's application shows the consent dialog; on Allow, both peers open an RTCDataChannel labelled codeb-control-v1 on their existing PeerConnection and exchange pointer / keyboard events.

Full protocol spec: /remote_control_api.html. Frame schemas: /asyncapi.json. Webhook audit events: control.requested, control.granted, control.revoked (payloads in remote_control_api.html#webhooks).

The remote-control channel is opt-in per-tenant on the target side (target user clicks Allow every session; the panic hotkey Ctrl+Alt+F12 kills the session immediately). Moderators can shut new sessions off room-wide with the control-lock frame.
Need an admin endpoint? Admin-only and OIDC Bearer-gated routes are documented inside the admin UI itself (visible only to signed-in admins on this host). The public API set on this page is the surface you can integrate against without provisioning a CodeB user.

Questions? Ask us · Index: All public APIs · Machine-readable: asyncapi.json