Embed a full-featured SIP phone into any web application with 3 lines of code. Multi-call, recording, audio clips, hold music, caller ID, PWA — everything your agents need.
<script src="/webphone/loader.js"></script>
<script>
BitcallWebphone.init({
key: 'YOUR_API_KEY',
sip: { username: 'agent1', domain: 'sip.example.com' },
phoneSelectors: ['.phone-number']
});
</script>phoneSelectors.A complete SIP/WebRTC phone widget with enterprise-grade features, ready to embed in any web application.
Add a full SIP/WebRTC phone to any website with a single script tag and one init() call. No build tools required.
Control outbound calling via JavaScript API. call() for auto-dial, dial() for prefill, and hangup() for termination.
Detect tel: links, visible +international numbers, or host-configured phoneSelectors and inject a small inline call icon. Icon styling follows the tenant accent color.
Handle multiple concurrent calls with hold & answer, end & answer, and seamless call switching.
Send in-call DTMF tones via RFC2833 (out-of-band) or SIP INFO. Configurable per tenant.
Place calls on hold with Web Audio melody (C major arpeggio fallback) or configurable OGG/MP3 file.
Two-way audio recording mixing local + remote streams. Auto-record mode, per-call control, and browser-dependent output/export handling.
Upload short audio clips in settings and play them into a live call. The callee hears the clip, and the caller gets local playback monitoring with stop control.
Toggle microphone mute/unmute with real-time visual indicators. State synced across all UI components.
Blind transfer for connected calls with transfer-mode UI, recents/keypad target selection, and lifecycle events.
Add, delete, and select from multiple caller IDs. Optional per-call API override. Automatic SIP header fallback on server rejection.
Password or HA1 digest auth with automatic realm discovery. Secure postMessage credential delivery.
Three modes: auto-login (zero-touch), connect-only (confirm), and full login form with prefill.
Automatic session restore from stored credentials. Remember-me support with configurable persistence.
System-aware theming with CSS variables. Backend can lock theme. Full customization via branding config.
Installable as PWA with protocol handlers (tel:), shortcuts, badge API, and service worker.
Open widget in popup or tab with session handoff. Encrypted token transfer for auth context.
ScaleWrapper adapts to any container. Mobile-first with safe area support and touch-optimized controls.
8 events: callStarted, callIncoming, callAnswered, callEnded, transferState, registered, unregistered, appStateChanged.
Runtime mic and speaker switching via RTCRtpSender.replaceTrack(). Hot-plug detection built-in.
Persistent per-user call log with direction, duration, timestamps. Grouped display with search and redial.
Strict CORS, origin validation, tenant isolation, encrypted handoff tokens, sessionStorage-only credentials.
Test every API method live. The widget is running on this page — try it out.
{
"key": "DEMO_KEY",
"sip": {
"username": "",
"domain": ""
},
"behavior": {
"host": {
"mode": "confirm"
},
"sessionRestore": "auto",
"theme": {
"start": "light"
}
},
"interceptTelLinks": true,
"phoneSelectors": [
".phone-number"
]
}Secrets are masked. Use either password or HA1Password.
Complete method and event documentation for the loader API.
| Method | Status | Returns | Summary |
|---|---|---|---|
| init(config) | — | Promise<void> | Bootstraps loader, fetches backend tenant config, creates launcher+iframe, and starts event/app bridges. |
| destroy(options?) | — | void | Full teardown: iframe, launcher, styles, listeners, channels, and loader state. |
| toggle() | — | void | Opens or closes the embedded widget iframe. |
| call(number, options?) | — | void | Routes to the standalone app when detected and eligible; otherwise routes to iframe. If init is still in flight, the call intent is queued. |
| dial(number) | — | void | Routes like call() but prefill-only (autoCall=false). When unregistered, the prefill intent is carried into the post-login dialer in the same session. |
| hangup(callId?) | — | void | Hangs up the active call by default. Pass callId to target a specific leg in multi-call scenarios. |
| transfer(target, callId?) | — | void | Requests a blind transfer for the active connected call by default. In multi-call scenarios, pass callId explicitly to target a specific connected leg. Transfer does not use caller ID overrides because it sends SIP REFER on an existing call rather than creating a new outbound leg. Result delivery is event-driven through transfer lifecycle events. |
| openApp(options?) | — | Window | null | Single public app-entry point for tab/PWA, popup, and install-intent launches. Reuses/focuses the tracked popup when possible. |
| closeAppPopup() | — | boolean | Closes the tracked popup handle only. It does not close tabs or installed PWA windows. |
| getAppState() | — | { appDetected, lastCommandTarget, controlMode, popupOpen } | Returns the cached app-control snapshot. This is not a forced live probe. |
| getState() | — | { registered, inCall, activeNumber, callCount } | Returns the telephony snapshot: registration, active-call state, active number, and call count. |
| isRegistered() | — | boolean | Boolean shorthand for getState().registered. |
| isInCall() | — | boolean | Boolean shorthand for getState().inCall. |
| on(event, handler) | — | void | Subscribes a handler to loader events. Duplicate callback references for the same event are ignored. |
| off(event, handler) | — | void | Removes a previously registered handler. The same function reference used in on() must be passed back to off(). |
| Field | Type | Required | Notes |
|---|---|---|---|
| key | string | Yes | Tenant API key. Required for backend config fetch. |
| sip.username | string | No | Host-provided username hint or credential input. |
| sip.domain | string | No | SIP domain. Backend (MongoDB) value is authoritative when set; host value is fallback only. |
| sip.server | string | No | Legacy alias for sip.domain; still supported. |
| sip.password | string | No | Password auth secret. Use either password or HA1Password. |
| sip.HA1Password | string | No | HA1 auth secret. Use either password or HA1Password. |
| embedOrigin | string | No | Host-provided origin hint passed to the iframe; defaults to current page origin. Not tenant-backed from Mongo/backend config. |
| interceptTelLinks | boolean | No | Defaults to true. When false, the loader disables all host-page phone enhancement: tel: interception, visible +international detection, and phoneSelectors scanning. |
| phoneSelectors | string | string[] | No | Optional host-side trusted selectors for CRM/page phone elements. Matched elements get an inline call icon. No automatic selector defaults are scanned. Selector-based values are normalized for dialing, but no leading + is invented. |
| behavior.host.mode | 'confirm' | 'autologin' | 'prefilled' | No | Onboarding/login behavior hint merged with backend behavior. |
| behavior.sessionRestore | 'auto' | 'off' | No | Host preference for credential/session restore behavior. |
| behavior.theme.start | string | No | Host theme start hint merged with backend theme config. Theme lock remains backend-controlled. |
| debug | boolean | No | Enables loader-side debug logging. |
| onError | (payload) => void | No | Receives loader init/setup errors instead of only console output. |
Host-provided uiUrl and branding are ignored; backend tenant config is authoritative.
Backend-owned fields such as wss, iceServers, storageNamespace, capabilities, and cookieMode are not host-overridable.
tenant.allowedOrigins is the backend API-origin policy for external embed/config access. Internal first-party app hosts are trusted separately for handoff.
init(config).embedOrigin is a host-side iframe hint. It is not fetched from tenant config.
phoneSelectors is host-only and intended for trusted CRM/page selectors. Target the element that contains one phone value, not a large container.
When interceptTelLinks is false, the loader skips all host-page phone enhancement, including phoneSelectors.
Inline call icons inherit the tenant branding themeColor exposed by backend config. If custom branding is not available, the default accent color is used.
init(config) is async and can be awaited, but integration errors are surfaced through onError/console rather than a typed public error object.
| Name | Type | Required | Description |
|---|---|---|---|
| config | object | Yes | Host-side init object. See init(config) host fields below. |
await BitcallWebphone.init({
key: 'YOUR_API_KEY',
sip: { username: 'agent1', domain: 'sip.example.com', password: 'secret' },
interceptTelLinks: true,
phoneSelectors: ['.phone-number', '[data-bitcall-number]']
});| Name | Type | Required | Description |
|---|---|---|---|
| options.closePopup | boolean | No | Also closes the tracked standalone-app popup handle. |
| options.clearEventHandlers | boolean | No | Removes all callbacks previously registered with on(). |
BitcallWebphone.destroy({
closePopup: true,
clearEventHandlers: true
});No parameters.
BitcallWebphone.toggle();| Name | Type | Required | Description |
|---|---|---|---|
| number | string | Yes | Target number to dial. Empty/whitespace-only values are ignored. |
| options.callerId | string | No | One-shot caller ID override for this call only. |
BitcallWebphone.call('+15551234567', {
callerId: '+15559876543'
});| Name | Type | Required | Description |
|---|---|---|---|
| number | string | Yes | Target number to prefill into the dialer. |
BitcallWebphone.dial('+15551234567');| Name | Type | Required | Description |
|---|---|---|---|
| callId | string | No | Optional explicit call target. When omitted, the current active call is used. |
BitcallWebphone.hangup();
BitcallWebphone.hangup('call-123');| Name | Type | Required | Description |
|---|---|---|---|
| target | string | Yes | Transfer destination number or URI. |
| callId | string | No | Optional explicit connected call target. When omitted, the current active call is used. Recommended whenever multiple calls exist. |
// Single connected call
BitcallWebphone.transfer('+15551230000');
// Multi-call: explicitly target the connected leg you want to transfer
BitcallWebphone.transfer('+15551230000', 'call-123');| Name | Type | Required | Description |
|---|---|---|---|
| options.mode | 'auto' | 'popup' | No | Default auto app launch or forced popup mode. |
| options.install | boolean | No | Adds install intent for browsers that support install/PWA takeover behavior. |
| options.width | number | No | Popup width in popup mode. |
| options.height | number | No | Popup height in popup mode. |
| options.left | number | No | Popup left position in popup mode. |
| options.top | number | No | Popup top position in popup mode. |
| options.name | string | No | Optional popup window name. |
BitcallWebphone.openApp();
BitcallWebphone.openApp({ mode: 'popup', width: 420, height: 780 });
BitcallWebphone.openApp({ install: true });No parameters.
const hadPopup = BitcallWebphone.closeAppPopup();No parameters.
const appState = BitcallWebphone.getAppState();No parameters.
const state = BitcallWebphone.getState();No parameters.
if (BitcallWebphone.isRegistered()) { /* ... */ }No parameters.
if (BitcallWebphone.isInCall()) { /* ... */ }| Name | Type | Required | Description |
|---|---|---|---|
| event | 'callStarted' | 'callIncoming' | 'callAnswered' | 'callEnded' | 'transferState' | 'registered' | 'unregistered' | 'appStateChanged' | Yes | Event name to subscribe to. |
| handler | (payload) => void | Yes | Callback invoked with the event payload. |
BitcallWebphone.on('callEnded', (payload) => {
console.log(payload);
});| Name | Type | Required | Description |
|---|---|---|---|
| event | string | Yes | Event name previously used in on(). |
| handler | function | Yes | Exact callback reference to remove. |
BitcallWebphone.off('callEnded', handleCallEnded);| Event | Source | Payload | When Emitted |
|---|---|---|---|
| callStarted | BITCALL_CALL_STARTED | { callId, number, direction, requestedCallerId? } | Outbound call session appears. |
| callIncoming | BITCALL_CALL_INCOMING | { callId, number, direction } | Incoming call session appears. |
| callAnswered | BITCALL_CALL_ANSWERED | { callId, number, direction, requestedCallerId? } | Call transitions to answered state. |
| callEnded | BITCALL_CALL_ENDED | { callId, number, direction, requestedCallerId?, duration, cause, sipCode } | Call end is confirmed (including synthetic replaced path). |
| transferState | BITCALL_TRANSFER_STATE | { callId, target, status: 'started' | 'accepted' | 'failed', reason?, message?, uri? } | Blind transfer lifecycle changes. started = request initiated, accepted = REFER accepted, failed = validation/request failed. |
| registered | BITCALL_REGISTERED | { username, domain, userKey } | Registration state transitions false -> true. |
| unregistered | BITCALL_UNREGISTERED | { reason } | Registration state transitions true -> false. |
| appStateChanged | Loader internal publish | { appDetected, lastCommandTarget, controlMode, popupOpen } | App-control state changes. |
Guides for every audience — developers, product teams, and end users.
Step-by-step integration guide
Include the loader script in your HTML page. No build tools or npm packages needed.
<script src="https://your-domain.com/webphone/loader.js"></script>Call init() with your API key and SIP/auth hints. Password or HA1 auth is supported, backend tenant config stays authoritative, and host-page phone enhancement can inject inline call icons for tel links, visible +numbers, or trusted phoneSelectors.
BitcallWebphone.init({
key: 'YOUR_API_KEY',
sip: {
username: 'agent1',
domain: 'sip.example.com',
password: 'secret'
// Or: HA1Password: '...'
},
behavior: {
host: { mode: 'autologin' },
sessionRestore: 'auto'
},
interceptTelLinks: true,
phoneSelectors: ['.phone-number', '[data-bitcall-number]']
});The current code supports three host modes. confirm is the default. sessionRestore controls whether remembered stored credentials may auto-login before the form/connect step.
// confirm (default)
// Complete host SIP => connect-only
// Partial host SIP => login form with missing fields
behavior: {
host: { mode: 'confirm' },
sessionRestore: 'auto'
}
// autologin
// Complete host SIP => zero-touch auto-login
behavior: {
host: { mode: 'autologin' },
sessionRestore: 'auto'
}
// prefilled
// Shows login form with prefilled values
behavior: {
host: { mode: 'prefilled' },
sessionRestore: 'off'
}Subscribe to lifecycle and app-control events. Keep a stable handler reference if you plan to remove it later with off().
const handleCallStarted = (e) => {
console.log('Call started:', e.callId, e.number);
console.log('Requested caller ID:', e.requestedCallerId);
updateCRM(e.number);
};
const handleCallEnded = (e) => {
console.log('Duration:', e.duration, 'seconds');
logCallRecord(e);
};
const handleTransferState = (e) => {
console.log('Transfer status:', e.status, 'call:', e.callId, 'target:', e.target);
};
const handleAppStateChanged = (state) => {
console.log('App detected:', state.appDetected);
console.log('Last command target:', state.lastCommandTarget);
};
BitcallWebphone.on('callStarted', handleCallStarted);
BitcallWebphone.on('callEnded', handleCallEnded);
BitcallWebphone.on('transferState', handleTransferState);
BitcallWebphone.on('appStateChanged', handleAppStateChanged);Control outbound calls, open or focus the standalone app, and query both telephony state and app-control state from your host page. getState() is the phone snapshot; getAppState() is the standalone app popup/tab/PWA routing snapshot.
// Make a call
BitcallWebphone.call('+15551234567');
// Make a call with one-shot caller ID override
BitcallWebphone.call('+15551234567', { callerId: '+15559876543' });
// Prefill dialer without calling
BitcallWebphone.dial('+15551234567');
// Request blind transfer for the active connected call
BitcallWebphone.transfer('+15551230000');
// Multi-call: explicitly target the connected leg to transfer
BitcallWebphone.transfer('+15551230000', 'call-123');
// End active call
BitcallWebphone.hangup();
// Open the standalone app (tab/PWA) or popup
BitcallWebphone.openApp();
BitcallWebphone.openApp({ mode: 'popup', width: 420, height: 780 });
// Query state
const telephonyState = BitcallWebphone.getState();
const appState = BitcallWebphone.getAppState();
// getState() => registered/inCall/activeNumber/callCount
// getAppState() => appDetected/lastCommandTarget/controlMode/popupOpen
console.log(telephonyState.registered, telephonyState.inCall);
console.log(appState.appDetected, appState.lastCommandTarget);Most clients do not need this. Use it only in SPA route teardown, tenant switching, or full widget removal flows. destroy({ clearEventHandlers: true }) already clears listeners, so manual off() calls are only needed when you want to keep the widget running but detach specific handlers.
// Full teardown
BitcallWebphone.destroy({
closePopup: true,
clearEventHandlers: true
});
// Or: remove one listener but keep the widget alive
BitcallWebphone.off('callEnded', handleCallEnded);What your panel backend should call, which secrets belong on the server, and how tenant, admin, internal, and runtime flows fit together.
Never send BITCALL_PANEL_KEY or APP_MASTER_SECRET from browser code. Your frontend should call your own panel backend, then your backend calls Bitcall control-plane endpoints.
Owns tenant membership, user auth, billing state, and all /api/tenant/* calls. This is the layer that turns UI actions into trusted Bitcall API requests.
Owns widget/bootstrap/runtime config, auth handoff tokens, tenant API keys, and host/domain resolution. Runtime routes are for the widget and standalone app, not your panel settings UI.
Use this only from your panel backend for /api/tenant/* routes. Pass tenantId in body for POST/PATCH/DELETE and in query/body for GET as required by the handler.
Use this for /api/admin/* and /api/internal/* routes from support tooling, cron, or trusted automation only.
Used by the widget/runtime and auth-link flows. Do not use tenant API keys for /api/tenant/* or /api/admin/*.
Provision tenants from your panel backend once an account/customer is created.
Persist the returned tenantId and initial API key in your own control-plane records.
Hydrate the settings screen with GET /api/tenant before rendering forms or toggles.
Send user-driven settings changes through PATCH /api/tenant/config instead of writing Mongo directly.
Use dedicated subdomain, custom-domain, and API-key endpoints for those flows; they contain validation, limits, and lifecycle guards.
Reserve PATCH /api/tenant/plan and /api/admin/* for billing sync, support actions, and trusted operations.
POST /api/tenant/provision from your panel backend after signup or account creation.
Store tenantId + returned initial API key securely. The API key is for runtime/auth usage, not panel-admin calls.
Load GET /api/tenant to render the control-plane settings page with effective branding, domains, capabilities, and keys.
Embed the widget with the tenant API key or issue auth links from your backend with POST /api/auth/link.
PATCH /api/tenant/config for origins, branding, app toggles, behavior, DTMF, and tenant transport overrides.
GET /api/tenant/live-calls when the settings dashboard needs current live usage vs maxSessions.
GET /api/tenant/subdomains/check before offering a subdomain choice, then POST /api/tenant/subdomains to claim it, DELETE to remove it.
POST /api/tenant/api-keys to create a new runtime key and DELETE /api/tenant/api-keys/:id to revoke one safely.
PATCH /api/tenant/plan only from billing/admin logic when planId or planStatus changes.
POST /api/tenant/domains and show the returned TXT + CNAME instructions to the tenant.
The CNAME should point to the shared Bitcall ingress host returned by the API response, not to the tenant subdomain.
Poll GET /api/tenant/domains for domain status, tlsStatus, verifiedAt, and lastError.
Run POST /api/internal/domains/sync or POST /api/admin/domains/sync from trusted ops automation to verify TXT/CNAME/TLS state.
Certificate automation is implemented in the repo: POST /api/internal/certificates/sync delegates to the shared cert sync service, while scheduled production runs should use backend/scripts/cert-sync.js via systemd/cron on the host.
GET /api/admin/tenants for search, filtering, and support lookup.
GET /api/admin/tenants/:tenantId for full internal detail including raw + effective values.
GET /api/admin/live-calls for the current global live-call picture, tenant capacity usage, and active-client counts.
GET /api/admin/settings/transport-defaults and PATCH the same endpoint to manage Bitcall-managed SIP/WSS/ICE defaults.
PATCH /api/admin/tenants/:tenantId only for trusted overrides or support fixes.
POST /api/admin/tenants/:tenantId/enable and /disable for emergency tenant status control.
DELETE /api/admin/tenants/:tenantId for admin-only tenant teardown and cleanup.
POST /api/admin/domains/:domain/activate only as a manual override when you intentionally want to force an active state.
Use this as the implementation map for your panel screens and backend handlers.
| Screen | Owned By | Endpoint | Request | What To Do After Save |
|---|---|---|---|---|
| Tenant creation / signup completion | Your panel backend | POST /api/tenant/provision | { tenantId, displayName, planId, apiKeyLabel? } | Create the Bitcall tenant once, persist tenantId + returned apiKey in your own system, then redirect support/admin to the tenant settings screen. |
| General settings bootstrap | Your panel frontend via your backend | GET /api/tenant | tenantId in query + X-Bitcall-Panel-Key on the backend call | Use this response to hydrate all settings tabs. Treat it as the source of truth for effective entitlements, effective branding, domains, and panel-visible API keys. |
| Live usage dashboard | Your panel frontend via your backend | GET /api/tenant/live-calls and GET /api/admin/live-calls | tenant uses tenantId in query, admin uses optional filters such as activeOnly or tenantId | Use the tenant route for one-tenant live usage cards and the admin route for global capacity visibility. Treat this as dynamic operational data, not cached profile config. |
| Branding + allowed origins + app behavior | Your panel frontend | PATCH /api/tenant/config | { tenantId, branding?, origins?, app?, behavior? } | Submit only the sections that changed. After save, replace the local form state with response.data so the UI reflects normalized/effective values. |
| Platform transport defaults | Your support/admin backend | GET /api/admin/settings/transport-defaults and PATCH /api/admin/settings/transport-defaults | { sip?, wss?, iceServers? } with Bearer APP_MASTER_SECRET | Default-mode tenants inherit these values automatically. Tenants in custom mode keep their explicit overrides. |
| Subdomain settings | Your panel frontend | GET /api/tenant/subdomains/check then POST /api/tenant/subdomains or DELETE /api/tenant/subdomains | check uses ?tenantId=...&value=..., assign uses { tenantId, subdomain } | Check availability before enabling the save CTA. On success, refresh GET /api/tenant to update the domains block. |
| Custom domain settings | Your panel frontend + trusted ops automation | POST /api/tenant/domains, GET /api/tenant/domains, POST /api/tenant/domains/recheck, DELETE /api/tenant/domains, POST /api/internal/domains/sync | request uses { tenantId, domain } | Show TXT + CNAME instructions from POST immediately, use tenant-safe recheck after DNS changes, and poll GET /api/tenant/domains for the latest stored status. Trusted ops automation still owns admin/internal sync flows. |
| API key rotation | Your panel frontend | POST /api/tenant/api-keys and DELETE /api/tenant/api-keys/:id | create uses { tenantId, label? } | Show the plain apiKey only once after create. After revoke or create, refresh GET /api/tenant or update the returned apiKeys list locally. |
| Billing / plan sync | Your panel backend or billing worker | PATCH /api/tenant/plan | { tenantId, planId, status? } | Run this from billing events, not directly from a tenant-facing browser action. Refresh GET /api/tenant afterward if the panel is open. |
| Support / admin console | Your support backend | GET /api/admin/tenants, GET/PATCH /api/admin/tenants/:tenantId, POST enable/disable | Bearer APP_MASTER_SECRET | Use admin routes for support-only visibility and overrides. Keep them out of the customer self-service surface. |
These examples match the current handler envelopes and are meant to be copied into your panel backend or ops tooling.
const res = await fetch('https://your-bitcall-host/api/tenant/provision', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Bitcall-Panel-Key': process.env.BITCALL_PANEL_KEY,
},
body: JSON.stringify({
tenantId: 'acme_corp',
displayName: 'Acme Support',
planId: 'pro',
apiKeyLabel: 'Default'
})
});{
"ok": true,
"data": {
"tenantId": "acme_corp",
"apiKey": "bk_live_xxxxxxxxxxxxxxxxxxxx",
"apiKeyMeta": {
"id": "key_abcd1234",
"label": "Default",
"prefix": "bk_live_"
},
"planId": "pro",
"plan": {
"planId": "pro",
"name": "Pro",
"status": "active",
"catalogStatus": "active",
"isDefaultProvision": false,
"billing": {
"price": 29,
"currency": "EUR",
"interval": "month"
}
}
}
}This endpoint uses X-Bitcall-Panel-Key only. No tenantId query param is needed because the tenant does not exist yet.
Store the returned apiKey securely. The plain key is not returned later from read endpoints.
const res = await fetch(
'https://your-bitcall-host/api/tenant?tenantId=acme_corp',
{
headers: {
'X-Bitcall-Panel-Key': process.env.BITCALL_PANEL_KEY,
}
}
);{
"ok": true,
"data": {
"tenantId": "acme_corp",
"enabled": true,
"plan": {
"planId": "pro",
"status": "active",
"entitlements": {
"customBranding": true,
"subdomainAccess": true,
"customDomainAccess": false,
"popupMode": true,
"audioClips": true,
"transfer": true,
"maxApiKeys": 3,
"maxSessions": 2
}
},
"branding": {
"displayName": "Acme Support",
"logoLight": "https://...",
"logoDark": "https://...",
"themeColor": "#14b8a6",
"headerMode": "both"
},
"domains": {
"subdomain": null,
"customDomain": null,
"customDomainStatus": null,
"customDomainTlsStatus": null
},
"apiKeys": [
{
"id": "key_legacy",
"label": "Default",
"prefix": "bk_live_",
"createdAt": "2026-03-14T12:00:00.000Z",
"lastUsedAt": null
}
],
"capabilities": {
"maxSessions": 2,
"transferBlind": true,
"popupMode": true,
"installable": true,
"audioClips": true
}
}
}Use this payload to prefill your panel forms. Do not fetch Mongo directly for panel screens.
The returned branding and capabilities are effective values, not just raw stored values.
const res = await fetch('https://your-bitcall-host/api/tenant/domains', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Bitcall-Panel-Key': process.env.BITCALL_PANEL_KEY,
},
body: JSON.stringify({
tenantId: 'acme_corp',
domain: 'phone.acme.com'
})
});{
"ok": true,
"data": {
"tenantId": "acme_corp",
"configured": true,
"created": true,
"domain": "phone.acme.com",
"status": "pending",
"tlsStatus": "pending",
"lastError": "",
"verifiedAt": null,
"checks": {
"lastCheckedAt": null
},
"automation": {
"phase": "dns_pending",
"paused": null,
"nextCheckAt": null,
"recheckAvailableAt": null
},
"nextAction": "Add the TXT record and DNS-only CNAME shown below, then click Recheck after DNS propagation.",
"txt": {
"host": "_bitcall-challenge.phone.acme.com",
"value": "token-value",
"matched": null,
"observed": []
},
"cname": {
"host": "phone.acme.com",
"value": "<shared-app-host>",
"matched": null,
"observed": []
}
}
}The CNAME should point to the shared Bitcall ingress host returned by the API, not to the tenant subdomain.
After the tenant updates DNS, call POST /api/tenant/domains/recheck for an immediate tenant-safe verification pass.
Use GET /api/tenant/domains for the latest stored status, diagnostics, and nextAction guidance between rechecks.
const res = await fetch('https://your-bitcall-host/api/tenant/api-keys', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Bitcall-Panel-Key': process.env.BITCALL_PANEL_KEY,
},
body: JSON.stringify({
tenantId: 'acme_corp',
label: 'Staging CRM'
})
});{
"ok": true,
"data": {
"id": "key_abcd1234",
"label": "Staging CRM",
"prefix": "bk_live_",
"apiKey": "bk_live_xxxxxxxxxxxxxxxxxxxx"
}
}Read-only endpoints never return the full plain key later, so copy/store it on creation.
Revoke with DELETE /api/tenant/api-keys/:id after downstream systems are updated.
const res = await fetch(
'https://your-bitcall-host/api/tenant/api-keys/key_abcd1234',
{
method: 'DELETE',
headers: {
'X-Bitcall-Panel-Key': process.env.BITCALL_PANEL_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
tenantId: 'acme_corp'
})
}
);{
"ok": true,
"data": {
"id": "key_abcd1234",
"revoked": true
}
}DELETE is still tenant-scoped, so your backend must include tenantId for panel auth.
If this would remove the last remaining credential, the API returns 409 LAST_API_KEY instead.
const res = await fetch(
'https://your-bitcall-host/api/admin/tenants?q=acme&enabled=true',
{
headers: {
Authorization: `Bearer ${process.env.APP_MASTER_SECRET}`
}
}
);{
"ok": true,
"data": {
"tenants": [
{
"tenantId": "acme_corp",
"enabled": true,
"planId": "pro",
"planStatus": "active",
"displayName": "Acme Support",
"subdomain": "acme",
"customDomain": "phone.acme.com",
"customDomainStatus": "active",
"customDomainTlsStatus": "active",
"apiKeyCount": 2
}
],
"total": 1
}
}Use GET /api/admin/tenants/:tenantId when support needs the full internal record.
Admin/internal routes require Authorization: Bearer APP_MASTER_SECRET, not the panel key.
High-signal flow order for your team. These are plain-text handoff diagrams, not abstract architecture notes.
Panel UI -> Panel Backend: create customer/account
Panel Backend -> Bitcall POST /api/tenant/provision: tenantId, displayName, planId
Bitcall -> Panel Backend: { ok: true, data: { tenantId, apiKey, apiKeyMeta, planId, plan } }
Panel Backend -> Panel DB: store tenantId + apiKey
Panel UI -> Panel Backend: load settings screen
Panel Backend -> Bitcall GET /api/tenant?tenantId=...
Bitcall -> Panel Backend: tenant profile
Panel Backend -> Panel UI: settings payload
Host App / CRM -> Widget Loader: BitcallWebphone.init({ key })
Widget Loader -> Bitcall GET /api/webphone/config: apiKey + origin
Bitcall -> Widget Loader: runtime configTenant UI -> Panel Backend: submit phone.acme.com
Panel Backend -> Bitcall POST /api/tenant/domains: { tenantId, domain }
Bitcall -> Panel Backend: { configured: true, created: true, txt, cname, status: pending, tlsStatus: pending, checks, automation, nextAction }
Panel Backend -> Tenant UI: show DNS instructions
Tenant -> DNS Provider: add TXT + CNAME
Tenant UI -> Panel Backend: click Recheck
Panel Backend -> Bitcall POST /api/tenant/domains/recheck: { tenantId }
Bitcall -> DNS: verify TXT + CNAME
Bitcall -> Mongo: update customDomainStatus / diagnostics / automation state
Bitcall -> TLS onboarding worker: attempt or retry certificate issuance
Panel UI -> Panel Backend: poll status
Panel Backend -> Bitcall GET /api/tenant/domains?tenantId=...
Bitcall -> Panel Backend: { configured, status, tlsStatus, verifiedAt, lastError, checks, automation, nextAction }Panel UI -> Panel Backend: create "Staging CRM" key
Panel Backend -> Bitcall POST /api/tenant/api-keys: { tenantId, label }
Bitcall -> Panel Backend: { id, label, prefix, apiKey }
Panel Backend -> Panel UI: show apiKey once and prompt user to copy/store it
Downstream system: update integration to new key
Panel UI -> Panel Backend: revoke old key
Panel Backend -> Bitcall DELETE /api/tenant/api-keys/:id
Bitcall -> Panel Backend: { ok: true, data: { id: "key_abcd1234", revoked: true } }These payloads are the exact shapes your settings forms should submit to your panel backend before your backend forwards them to Bitcall.
PATCH /api/tenant/config
{
"tenantId": "acme_corp",
"branding": {
"displayName": "Acme Support",
"logoLight": "https://cdn.acme.com/phone/logo-light.svg",
"logoDark": "https://cdn.acme.com/phone/logo-dark.svg",
"themeColor": "#0f766e",
"headerMode": "both"
}
}PATCH /api/tenant/config
{
"tenantId": "acme_corp",
"origins": {
"allowedOrigins": [
"https://crm.acme.com",
"https://support.acme.com"
]
}
}PATCH /api/tenant/config
{
"tenantId": "acme_corp",
"app": {
"popupMode": true,
"installable": false,
"audioClips": true
}
}PATCH /api/tenant/config
{
"tenantId": "acme_corp",
"behavior": {
"host": { "mode": "autologin" },
"sessionRestore": "auto",
"theme": { "start": "system", "lock": false }
}
}GET /api/tenant/subdomains/check then POST /api/tenant/subdomains
// availability
GET /api/tenant/subdomains/check?tenantId=acme_corp&value=acme
// assign
{
"tenantId": "acme_corp",
"subdomain": "acme"
}POST /api/tenant/domains
{
"tenantId": "acme_corp",
"domain": "phone.acme.com"
}POST /api/tenant/api-keys
{
"tenantId": "acme_corp",
"label": "Staging CRM"
}PATCH /api/tenant/plan
{
"tenantId": "acme_corp",
"planId": "enterprise",
"status": "active"
}POST /api/tenant/provision — create tenant + initial API keyGET /api/tenant — read the panel-safe tenant profileGET /api/tenant/live-calls — read current live usage for one tenantPATCH /api/tenant/config — update tenant-managed settingsGET /api/tenant/subdomains/check — availability lookupPOST /api/tenant/subdomains — assign subdomainDELETE /api/tenant/subdomains — remove subdomainPOST /api/tenant/domains — request custom domain + DNS instructionsGET /api/tenant/domains — read custom-domain lifecycle stateDELETE /api/tenant/domains — remove custom domain mappingPOST /api/tenant/api-keys — create tenant runtime keyDELETE /api/tenant/api-keys/:id — revoke tenant runtime keyPATCH /api/tenant/plan — sync planId / planStatus from billingGET /api/admin/settings/transport-defaults — read platform SIP/WSS/ICE defaultsPATCH /api/admin/settings/transport-defaults — update platform SIP/WSS/ICE defaultsGET /api/admin/tenants — list/search/filter tenantsGET /api/admin/tenants/:tenantId — read full internal tenant recordGET /api/admin/live-calls — read current live usage across tenantsPATCH /api/admin/tenants/:tenantId — trusted override pathPOST /api/admin/tenants/:tenantId/enable — enable tenantPOST /api/admin/tenants/:tenantId/disable — disable tenantDELETE /api/admin/tenants/:tenantId — admin-only tenant teardownPOST /api/admin/domains/sync — trigger domain verification sweepPOST /api/admin/domains/:domain/activate — manual emergency activatePOST /api/internal/domains/sync — verify TXT/CNAME/TLS state for custom domainsPOST /api/internal/certificates/sync — manual/admin trigger for certificate issuance, renewal, orphan-config cleanup, and final nginx reconciliationGET /api/webphone/config — loader/runtime config by tenant API key + allowed originGET /api/app/bootstrap — standalone app bootstrap by host or tenant queryGET /api/app/manifest.json — standalone app manifestPOST /api/auth/link — server-generated one-time launch linkPOST /api/auth/handoff — create handoff token for app routingPOST /api/auth/redeem — redeem handoff token inside standalone appCommon errors when your panel backend calls /api/tenant/*.
401 PANEL_UNAUTHORIZED — wrong or missing X-Bitcall-Panel-Key
400 MISSING_TENANT_ID — tenantId omitted on a tenant-scoped route
404 TENANT_NOT_FOUND — unknown tenantId
400 VALIDATION_ERROR — malformed request body or unsupported field
These are the errors your panel UI should handle explicitly in settings screens.
403 CUSTOM_DOMAIN_NOT_ALLOWED — plan/entitlement blocks custom domains
409 CUSTOM_DOMAIN_IN_USE — another tenant already owns that domain
409 API_KEY_LIMIT_REACHED — tenant hit the plan maxApiKeys limit
409 LAST_API_KEY — revoke blocked because at least one credential must survive
These are returned to the widget, standalone app, or auth-link consumers rather than your settings UI.
401 WEBPHONE_MISSING_KEY / Invalid API key paths
403 WEBPHONE_ORIGIN_FORBIDDEN — allowedOrigins rejected the caller origin
403 WEBPHONE_TENANT_DISABLED or APP_TENANT_DISABLED — tenant disabled
403 Feature disabled — auth-link gate is off for that tenant
const res = await fetch('https://your-bitcall-host/api/tenant/config', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Bitcall-Panel-Key': process.env.BITCALL_PANEL_KEY,
},
body: JSON.stringify({
tenantId: 'acme_corp',
app: { popupMode: true, installable: false, audioClips: true },
branding: { displayName: 'Acme Phones' },
origins: { allowedOrigins: ['https://crm.acme.com'] }
})
});
const payload = await res.json();const res = await fetch('https://your-bitcall-host/api/admin/tenants/acme_corp/disable', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.APP_MASTER_SECRET}`
}
});
const payload = await res.json();Keep BITCALL_PANEL_KEY and APP_MASTER_SECRET off the browser. These are backend-only secrets.
Use tenant API keys only for runtime/auth surfaces such as loader config and auth-link generation.
For custom domains, the tenant-facing DNS instructions come from POST /api/tenant/domains; do not hardcode the CNAME target in panel UI.
Capabilities & customization overview
The widget runs in an iframe on any web page. No framework dependencies. Works with React, Angular, Vue, vanilla HTML, or any CMS.
Each customer gets isolated configuration, storage, and branding. API keys control access. Origin validation prevents unauthorized embedding.
Dark/light themes, custom logos, accent colors, and branding — all controlled from the backend. Lock themes for brand consistency or let users choose.
Supports password and HA1 digest authentication. Auto-login for seamless CRM integration. Session restore for returning users.
Multi-call with hold, call recording, audio clips, caller ID management, DTMF, and call history are implemented. Transfer still needs full end-user UI/API wiring.
Users can install the webphone as a standalone app. Protocol handlers intercept tel: links, and the app uses browser notification APIs for incoming calls when permitted.
Quick reference guide
Type a number on the keypad and press the green call button. Or click any phone number link on the page.
Incoming calls show a notification with Answer and Decline buttons. If you are on another call, you can Hold & Answer or End & Answer.
Use the toolbar buttons to Mute your microphone, Hold the call with music, Record the conversation, play saved audio clips, or Send DTMF tones.
Tap "Calling as" on the dialer to see your caller IDs. You can add new ones, switch between them, or delete unused ones.
Open Settings to choose your microphone and speaker, and manage your saved audio clips. Changes take effect immediately — no need to restart the call.
Switch to the Recents tab to see past calls. Tap any entry to redial that number. Search to find specific calls.
What is actually tenant-controlled today in the current codebase.
| Control | Tenant Path | Applied In | Effect |
|---|---|---|---|
| enabled | tenant.enabled | /api/webphone/config + /api/app/bootstrap | When false, tenant config/bootstrap is rejected (service disabled). |
| allowedOrigins | tenant.allowedOrigins | tenantRepository.findTenantByKeyAndOrigin | Controls which host origins may call tenant-keyed config/handoff endpoints. |
| tenant id | tenant.tenantId | buildConfigPayload + /api/app/handoff + /api/app/auth-link | Canonical tenant identity. Returned in client payloads, resolved from API key in handoff/auth-link flows, and also used to derive storageNamespace. |
| uiUrl | tenant.uiUrl | buildConfigPayload -> loader config | Controls where iframe UI and standalone-app URLs are built from. |
| wss | settings.transport-defaults.wss + tenant.wss override | buildConfigPayload -> runtimeConfig.wsUrl | Platform-managed SIP WebSocket endpoint. Tenants inherit it in default mode or override it when custom WSS is enabled. |
| iceServers | settings.transport-defaults.iceServers[] + tenant.iceServers override | buildConfigPayload -> runtimeConfig.iceServers | Platform-managed STUN/TURN set. Tenants inherit it in default mode or override it when custom ICE is enabled. |
| sip defaults | settings.transport-defaults.sip + tenant.sip override | buildConfigPayload | Platform-managed SIP defaults merged with tenant overrides. DTMF can be tenant-controlled on any plan; SIP host overrides require entitlement. Host provides username/auth secrets. sip.server remains accepted only as a legacy alias. |
| host mode | tenant.behavior.host.mode | runtime behavior merge | Backend default onboarding mode (confirm | autologin | prefilled). Host init may override behavior.host.mode. |
| session restore | tenant.behavior.sessionRestore | runtime behavior merge | Backend default auto-restore policy for saved sessions. Host init may override behavior.sessionRestore. |
| theme start/lock | tenant.behavior.theme.{start,lock} | runtime behavior merge + UI | Backend theme policy. theme.start is a default and host may override it; theme.lock remains backend-controlled. |
| cookie mode | tenant.behavior.cookieMode | runtime behavior merge | Storage isolation mode policy. |
| branding | tenant.branding.{displayName,logoLight,logoDark,headerMode} | buildConfigPayload -> Header/Login UI | Tenant runtime branding inside widget and standalone app UI. |
| max sessions | tenant.plan.entitlements.maxSessions | buildConfigPayload -> SIP session guards | Plan-owned limit for concurrent active calls per running webphone client instance in the current implementation. |
| storage namespace | tenant.tenantId => payload.storageNamespace | loader/app bridge + StorageManager namespacing | Tenant-scoped session/channel/storage isolation. |
| Feature | Status | Scope | Notes |
|---|---|---|---|
| features.authLink | Implemented | /api/app/auth-link endpoint | Per-tenant gate for auth-link generation. |
| capabilities.installable | Implemented | standalone app payload/runtime installability gate | Enforced for tenant-resolved standalone app: manifest link omitted, install banner suppressed, and openApp({ install: true }) degrades to normal open when disabled. |
| capabilities.popupMode | Implemented | standalone app payload/runtime popup gate | Enforced for tenant-resolved runtime: widget popup button is hidden and openApp({ mode: 'popup' }) degrades to normal open when disabled. |
| capabilities.audioClips | Implemented | webphone settings + in-call clips runtime gate | Enforced in the webphone runtime: clip settings remain visible but disabled, the in-call Clips button is disabled, and programmatic clip playback is rejected when the effective capability is false. |
Note: host-provided uiUrl and branding passed to BitcallWebphone.init() are ignored by loader; backend tenant config is authoritative.
Copy this code to embed the widget in your application. Updates from the config above.
<script src="/webphone/loader.js"></script>
<script>
BitcallWebphone.init({
key: 'DEMO_KEY',
sip: {
username: 'agent1',
domain: 'sip.example.com',
// password: '***',
// HA1Password: '***'
},
interceptTelLinks: true,
phoneSelectors: [".phone-number"]
});
BitcallWebphone.on('callEnded', function (e) {
console.log('Call ended:', e);
});
BitcallWebphone.call('+15551234567', {
// callerId: '+15559876543'
});
</script>