Getting started
This guide takes you from a new BitCall account to a metered consultation between two browsers. It uses the production API at https://api.bitcall.stream. All request and response bodies are JSON.
What you need
- A BitCall account. Sign up at https://bitcall.stream/portal/.
- A sandbox API key (
ck_test_…). Signup creates a sandbox application and shows its key once. - A backend you control (any language). The examples use
curland Node 22 (fetchis built in). - Two browsers, or one browser with two tabs, each with a microphone.
Where keys live:
| Key | Starts with | Who holds it | Used for |
|---|---|---|---|
| Sandbox API key | ck_test_ |
Your backend | Free development. Capped at 200 relayed minutes per month, never invoiced, events tagged "mode":"test". |
| Live API key | ck_live_ |
Your backend | Production. Billed per relayed minute. Needs live approval (Step 6). |
| Peer token | JWT | One end user, one room | Opening the call WebSocket and fetching ICE servers. |
| Consultant presence token | JWT | One consultant’s app | Going online and receiving consultation requests. |
Never ship an API key to a browser or mobile app. Your backend mints short-lived peer tokens and hands those to clients. Keys are stored hashed and cannot be recovered; if one leaks, rotate it in the portal.
Step 1: Create an application and get a key
-
Sign up at https://bitcall.stream/portal/. The portal creates a sandbox app and shows its
ck_test_…key once. Copy it into your secret store. -
To add another app later, use Create app in the portal. Mode
Sandboxis available immediately; modeLiveis enabled after approval (Step 6). Each account can hold up to 10 apps. Rotate key on an app issues a new key and invalidates the old one at once. -
Verify the key from your terminal.
GET /v1/mereturns the authenticated application.
curl https://api.bitcall.stream/v1/me \
-H "Authorization: Bearer ck_test_…"
Response: 200 { "app": { "id": "app_…", "name": "My App", "mode": "test", "key_prefix": "ck_test_…", "settings": { "max_peers_per_room": 4, "max_rooms": 50, "token_ttl_seconds": 3600, "monthly_minutes": 200, ... } } }. A wrong or missing key returns 401 {"error":{"code":401,"message":"invalid or missing API key"}}.
Step 2: Create a room and mint peer tokens from your backend
A room is a place two or more peers meet. A peer token lets one client join one room under one peer id.
- Create a room.
curl -X POST https://api.bitcall.stream/v1/rooms \
-H "Authorization: Bearer ck_test_…" -H "Content-Type: application/json" \
-d '{"name":"demo-1","max_peers":2,"ttl_seconds":3600,"metadata":{"order":"o_1"}}'
| Field | Type | Notes |
|---|---|---|
name |
string | Optional label. |
max_peers |
int | Optional. Defaults to the app setting (4 sandbox, 8 live) and cannot exceed it. |
ttl_seconds |
int | Optional. The room is removed after this time. |
metadata |
object of strings | Optional. Returned on GET /v1/rooms/{id}. |
Response: 201 { "room": { "id": "room_…", "app_id": "app_…", "name": "demo-1", "max_peers": 2, "created_at": "…", "expires_at": "…" } }.
- Mint one token per participant.
curl -X POST https://api.bitcall.stream/v1/tokens \
-H "Authorization: Bearer ck_test_…" -H "Content-Type: application/json" \
-d '{"room_id":"room_…","peer_id":"alice","name":"Alice","scope":"peer","ttl_seconds":900}'
| Field | Type | Notes |
|---|---|---|
room_id |
string | Required. Must belong to your app, otherwise 404. |
peer_id |
string | Required. Must match ^[A-Za-z0-9_.:@-]{1,64}$ and be unique in the room. A second connection with the same id replaces the first. |
name |
string | Optional display name, shown to other peers. |
scope |
string | peer (default, can place calls) or subscriber (receive only). |
ttl_seconds |
int | Optional. Defaults to the app’s token_ttl_seconds (3600). Values above that setting are ignored and the default applies. |
Response: 201 { "token": "<jwt>", "expires_at": "…", "ws_url": "wss://api.bitcall.stream/v1/ws?token=…", "room_id": "room_…", "peer_id": "alice" }.
- The same two calls from Node, as a function your API can expose to a logged-in user.
const BASE = 'https://api.bitcall.stream';
const KEY = process.env.BITCALL_API_KEY; // ck_test_… or ck_live_…, server side only
async function bitcall(method, path, body) {
const res = await fetch(BASE + path, {
method,
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + KEY },
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`${res.status} ${json.error?.message || ''}`);
return json;
}
async function tokensForCall(userA, userB) {
const { room } = await bitcall('POST', '/v1/rooms', { max_peers: 2, ttl_seconds: 3600 });
const a = await bitcall('POST', '/v1/tokens', { room_id: room.id, peer_id: userA, ttl_seconds: 900 });
const b = await bitcall('POST', '/v1/tokens', { room_id: room.id, peer_id: userB, ttl_seconds: 900 });
return { roomId: room.id, tokens: { [userA]: a.token, [userB]: b.token } };
}
Return each token only to the user it was minted for.
Step 3: Connect two browsers with the JS SDK
The SDK is one file served from the API host. It exposes CallingClient (rooms) and PresenceClient (consultants).
- Load the SDK.
<script src="https://api.bitcall.stream/sdk/calling-client.js"></script>
- Callee page (Bob). Accept the first incoming call with the local microphone.
<audio id="remote" autoplay></audio>
<script>
const token = /* Bob's peer token from your backend */;
const client = new CallingClient({ baseUrl: 'https://api.bitcall.stream', token });
client.on('incoming', async ({ from, name, accept }) => {
const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
await accept(mic);
});
client.on('track', ({ peerId, stream }) => { document.getElementById('remote').srcObject = stream; });
client.on('hangup', ({ peerId }) => console.log('call ended with', peerId));
client.connect().then(info => console.log('joined', info.room_id, 'as', info.peer_id));
</script>
- Caller page (Alice). Connect, then call Bob by his peer id.
<audio id="remote" autoplay></audio>
<button id="call">Call Bob</button>
<button id="end">Hang up</button>
<script>
const token = /* Alice's peer token from your backend */;
const client = new CallingClient({ baseUrl: 'https://api.bitcall.stream', token });
client.on('track', ({ stream }) => { document.getElementById('remote').srcObject = stream; });
client.on('rejected', ({ peerId }) => console.log(peerId, 'rejected the call'));
await client.connect();
document.getElementById('call').onclick = async () => {
const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
await client.call('bob', mic);
};
document.getElementById('end').onclick = () => { client.hangup('bob'); client.disconnect(); };
</script>
Notes:
getUserMedianeeds a secure context (https://orlocalhost) and, in most browsers, a user gesture. Request media after a click, as the caller page does.connect()fetches ICE servers (STUN plus time-limited TURN credentials) with the peer token, opens the WebSocket, and resolves with thewelcomepayload{ peer_id, room_id, call_id, peers }. PassiceServersin the constructor to skip the fetch.- Constructor options:
baseUrl,token(required),iceServers,statsIntervalMs(default 5000; call quality is reported to BitCall at this interval). - Events:
connected,peer-joined,peer-left,incoming,track,connection-state,hangup,rejected,data,meter,consultation-ended,stats,chat,typing,read,error,disconnected. Methods:connect(),call(peerId, stream),hangup(peerId),disconnect(),sendData(payload, to), plus the chat methods in features/chat.md. - A call is the lifetime of a room from its first peer joining to its last peer leaving. That is the time BitCall meters.
Step 4: Metered consultations
Consultations are 1:1 sessions with a server-side time cap. Your backend converts a user’s balance to max_seconds; BitCall warns, extends and hard-stops the call. BitCall never sees money.
Consultant presence
- Register the consultant from your backend (idempotent).
curl -X PUT https://api.bitcall.stream/v1/consultants/astro-1 \
-H "Authorization: Bearer ck_test_…" -H "Content-Type: application/json" \
-d '{"name":"Pandit Sharma","modes":["audio","video","chat"],"metadata":{"price_per_minute":"25"}}'
Response: 200 { "consultant": { "id": "astro-1", "status": "offline", "modes": [...], ... } }.
- Mint a presence token and send it to the consultant’s app.
curl -X POST https://api.bitcall.stream/v1/consultants/astro-1/token \
-H "Authorization: Bearer ck_test_…" -H "Content-Type: application/json" \
-d '{"ttl_seconds":86400}'
Response: 201 { "token": "<jwt>", "expires_at": "…", "presence_url": "wss://api.bitcall.stream/v1/presence/ws?token=…" }. The default TTL is 24 hours.
- In the consultant’s app, open the presence socket. Connecting sets the consultant
online; disconnecting setsofflineabout 10 seconds later.
const presence = new PresenceClient({ baseUrl: 'https://api.bitcall.stream', token: presenceToken });
presence.on('request', req => {
// req is the consultation: { id, user_id, user_name, mode, max_seconds, ... }
if (confirm(`${req.user_name} wants a ${req.mode} session. Accept?`)) presence.accept(req.id);
else presence.decline(req.id);
});
presence.on('accepted', async ({ consultant_token, room_id, consultation }) => {
const call = new CallingClient({ baseUrl: 'https://api.bitcall.stream', token: consultant_token });
call.on('incoming', async ({ accept }) => accept(await navigator.mediaDevices.getUserMedia({ audio: true })));
call.on('track', ({ stream }) => { remoteAudio.srcObject = stream; });
call.on('meter', m => showRemaining(m.remaining_seconds));
call.on('consultation-ended', m => console.log('ended:', m.reason, 'billed', m.billed_seconds, 's'));
await call.connect();
});
presence.on('ended', m => console.log('consultation ended', m));
await presence.connect();
You can also set status from the backend with POST /v1/consultants/{id}/status {"status":"online"|"offline"|"busy"}, and list who is available with GET /v1/consultants?status=online.
Request, accept, tokens
- When a user taps “Call”, your backend converts their balance to seconds and requests the consultation.
curl -X POST https://api.bitcall.stream/v1/consultations \
-H "Authorization: Bearer ck_test_…" -H "Content-Type: application/json" \
-d '{"consultant_id":"astro-1","user_id":"user-42","user_name":"Asha","mode":"audio",
"max_seconds":600,"warn_at_seconds":60,"metadata":{"order_id":"o_123"}}'
| Field | Type | Notes |
|---|---|---|
consultant_id, user_id |
string | Required. Same pattern as peer_id. |
user_name |
string | Optional, shown to the consultant. |
mode |
string | audio (default), video or chat. The consultant must offer it. |
max_seconds |
int | Hard cap. 0 means uncapped. |
warn_at_seconds |
int | Remaining seconds at which the warning fires. Default 60. |
request_timeout_seconds |
int | How long the consultant has to answer. Default 45. |
connect_timeout_seconds |
int | How long both sides have to join after accept. Default 60. |
metadata |
object of strings | Optional, returned with the consultation. |
Response: 201 { "consultation": { "id": "cons_…", "status": "requested", ... } }. You get 409 if the consultant is offline, busy, already has a pending request, or does not offer mode; 402 if the app’s monthly quota is used up.
- The consultant accepts on the presence socket (
presence.accept(id)) or your backend accepts on their behalf.
curl -X POST https://api.bitcall.stream/v1/consultations/cons_…/accept \
-H "Authorization: Bearer ck_test_…" -H "Content-Type: application/json" -d '{"by":"astro-1"}'
Response: 200 { "consultation", "room_id", "user_token", "consultant_token", "ws_url", "tokens_expire_at" }. Tokens last 2 hours.
-
Deliver
user_tokento the user’s app. Both the accept response and theconsultation.acceptedwebhook (data.user_token,data.consultant_token,data.room_id,data.ws_url) carry it, so you can push it from your webhook handler. The consultant receivesconsultant_tokenon the presence socket. If a token is lost,POST /v1/consultations/{id}/tokensre-issues both. -
The user joins the room with
CallingClientand calls the consultant. Room peer ids are fixed: the user isu:<user_id>and the consultant isc:<consultant_id>.
const call = new CallingClient({ baseUrl: 'https://api.bitcall.stream', token: userToken });
call.on('track', ({ stream }) => { remoteAudio.srcObject = stream; });
call.on('meter', m => showRemaining(m.remaining_seconds));
call.on('consultation-ended', m => showSummary(m.reason, m.billed_seconds));
await call.connect();
const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
await call.call('c:astro-1', mic);
The meter starts when both peers are connected, not when the consultation is accepted.
The meter message
Both peers receive a WebSocket frame of type meter when the session starts, every 15 seconds, at the warning threshold, after an extension, and at the end. The SDK emits it as the meter event; state: "ended" also fires consultation-ended.
{ "type": "meter", "payload": {
"consultation_id": "cons_…", "state": "warning",
"elapsed_seconds": 540, "remaining_seconds": 60, "max_seconds": 600 } }
| Field | Notes |
|---|---|
state |
started, active, warning, extended or ended. |
elapsed_seconds |
Seconds since both peers connected. |
remaining_seconds |
Seconds left. -1 when max_seconds is 0. |
max_seconds |
Current cap, including extensions. |
billed_seconds |
Present on ended. Whole seconds between start and end, capped at max_seconds. Bill from this, not from your own clock. |
reason |
Present on ended. See below. |
To add time after a top-up: POST /v1/consultations/{id}/extend {"add_seconds":120,"by":"user-42"}. Both peers get a meter with state: "extended".
What ends a call
| End | Status | reason |
Notes |
|---|---|---|---|
| Cap reached | ended |
balance_exhausted |
BitCall disconnects both peers. |
| User’s socket closes | ended |
user_hangup |
Includes hangup(), tab close and network loss. |
| Consultant’s socket closes | ended |
consultant_hangup |
|
POST /v1/consultations/{id}/end |
ended |
closed_by_api |
Works in any non-terminal state. |
Nobody joined within connect_timeout_seconds |
ended |
connect_timeout |
billed_seconds is 0. |
Consultant did not answer within request_timeout_seconds |
expired |
request_timeout |
|
| Consultant declined / user cancelled | declined / cancelled |
declined / cancelled |
POST …/decline, POST …/cancel. |
Read the final state any time with GET /v1/consultations/{id} (status, end_reason, ended_by, billed_seconds, duration_ms).
Step 5: Receive webhooks
Webhooks tell your backend what happened without polling. Every delivery is signed and retried.
-
In the portal, set the app’s webhook URL (saved with
PATCH /v1/account/apps/{id}{"webhook_url":"https://your.app/webhooks/bitcall"}). -
Open https://bitcall.stream/portal/webhooks.html, select the app and click Rotate secret. The secret is shown once; store it next to your API key. Use Send test to deliver a
webhook.testevent. -
Verify the signature over the raw body, check the timestamp, and deduplicate by
X-Webhook-Id. Plain Node, no framework:
import http from 'node:http';
import crypto from 'node:crypto';
const SECRET = process.env.BITCALL_WEBHOOK_SECRET;
const seen = new Set(); // use a unique index on webhook_id in your database
http.createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/webhooks/bitcall') { res.writeHead(404).end(); return; }
const chunks = []; for await (const c of req) chunks.push(c);
const raw = Buffer.concat(chunks);
const ts = req.headers['x-webhook-timestamp'] || '';
const sig = req.headers['x-webhook-signature'] || '';
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) { res.writeHead(400).end('stale'); return; }
const expected = 'v1=' + crypto.createHmac('sha256', SECRET).update(ts + '.').update(raw).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(sig);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { res.writeHead(401).end('bad signature'); return; }
const id = req.headers['x-webhook-id'];
if (!seen.has(id)) { seen.add(id); handle(JSON.parse(raw)); }
res.writeHead(200).end();
}).listen(4000);
Respond within 5 seconds. Non-2xx responses other than 4xx are retried at 2 s, 10 s, 1 m, 5 m and 30 m; a 4xx other than 429 stops retries.
- Payload shape and the events most integrations handle:
{ "type": "consultation.ended", "app_id": "app_…", "mode": "test", "room_id": "room_…", "call_id": "call_…",
"consultation_id": "cons_…", "timestamp": "2026-10-27T10:00:01Z",
"data": { "reason": "balance_exhausted", "ended_by": "system", "billed_seconds": 600, "duration_ms": 600412,
"max_seconds": 600, "mode": "audio", "consultant_id": "astro-1", "user_id": "user-42", "status": "ended" } }
| Event | Use it to |
|---|---|
consultation.accepted |
Push data.user_token and data.room_id to the user’s device. |
consultation.started |
Mark the session active; the meter is running. |
consultation.warning |
Prompt the user to top up (data.remaining_seconds). |
consultation.extended |
Confirm the top-up (data.add_seconds, data.max_seconds). |
consultation.ended |
Debit data.billed_seconds. The billing truth. |
consultation.declined, .cancelled, .expired |
Release the hold, nothing charged. |
consultant.status |
Update your directory (data.consultant_id, data.status, data.source). |
call.started, peer.joined, peer.left, call.ended |
Plain room calls; call.ended carries data.duration_seconds. |
usage.quota_reached |
Fires once when the app’s monthly minutes are used up. New rooms and consultations return 402. |
The delivery log, retries and replay are in the portal and in features/webhooks.md.
Step 6: Go live
-
Request live approval. New accounts are sandbox only. On the portal home page click Request live access, describe your app and expected call volume, and send it. The page shows Live access requested while BitCall reviews it (usually within one business day) and live enabled once approved; if a request is declined, the reason is shown and you can request again. Creating a live app before approval returns
403 "live mode is not enabled for this account yet; contact support for approval". -
Once approved, create an app with mode Live in the portal and store its
ck_live_…key. Live apps default tomax_peers_per_room: 8,max_rooms: 1000,monthly_minutes: 0(no cap) and your account’sprice_per_minute,included_minutesandcurrency. Set the live app’s webhook URL and rotate its secret; secrets are per app. -
Quotas. When an app with a
monthly_minutescap reaches it,POST /v1/roomsandPOST /v1/consultationsreturn402 "monthly minute quota exhausted for this application"andusage.quota_reachedfires once. Calls in progress are not cut. Sandbox apps are capped at 200 minutes. Watch usage withGET /v1/account(this_monthper app) or the portal’s Usage card. -
Invoices. Each live app gets a monthly statement:
billed_minutes = ceil(used_seconds / 60),billable = max(0, billed − included_minutes),amount = billable × price_per_minute. Read it in the portal or with a portal session token:
curl "https://api.bitcall.stream/v1/account/apps/app_…/invoice?month=2026-10" \
-H "Authorization: Bearer <session_token from POST /v1/login>"
GET /v1/account/apps/{id}/usage?month=YYYY-MM returns the usage summary together with the invoice. Sandbox apps always show amount: 0.
Next steps
- API reference: every endpoint, field and WebSocket frame.
- Errors, limits and conventions: status codes, rate limits, quotas, TTLs.
- Webhooks: headers, retries, delivery log, replay.
- Chat: history, typing, read receipts on the same socket.
- Call recording: consent-gated server-side recording.
- Scheduled consultations: availability, bookings, auto-start.
- Group calls: rooms with more than a few participants.
- Mobile SDKs: Kotlin and Swift clients with the same events.
- Sample integration:
examples/astro-platform/in the BitCall repository is a complete wallet-to-meter backend in one Node file.