OpenCall has two complementary APIs: a REST/HTTP API for authentication, invite links, call history, contact messages, diagnostics, and ICE configuration; and a real-time signaling API over Socket.io for placing, answering, and ending calls. Both are documented below, each with examples.
Conventions · Authentication · REST API (register · login · logout · me · api-key · invite · history · contact · client-log · ice-config) · Real-time calling (events · walkthrough · flow · errors)
https://opencall.siteContent-Type: application/json on any request with a body.{ "error": "message" }.ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (no ambiguous I/O/0/1) — pattern ^[A-HJ-NP-Z2-9]{8}$, e.g. K7XM2PQ9. Logged-in users keep one permanent Call ID; anonymous sessions get a fresh one each connection.Programmatic access is authenticated with a personal API key. Every account has one — get it from your dashboard. Use it on every request:
x-api-key: oc_… (or Authorization: Bearer oc_…).io(url, { auth: { apiKey: 'oc_…' } }).Keep the key secret — anyone holding it can act as you. Rotate it any time with POST /api/key/regenerate; the old key stops working immediately. Endpoints and events marked 🔒 Auth need the key, and it binds you to your permanent Call ID. REST endpoints return 401 { "error": "Not authenticated" } when it is missing.
The website signs you in with a session cookie, but that's internal to the browser app — programmatic clients always authenticate with the API key, so the examples below use x-api-key.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /api/auth/register | — | Create an account and start a session |
| POST | /api/auth/login | — | Log in to an existing account |
| POST | /api/auth/logout | — | End the current session |
| GET | /api/auth/me | — | Get the current session's user |
| POST | /api/key/regenerate | 🔒 | Rotate your API key |
| POST | /api/invite/generate | 🔒 | Create a one-time invite link |
| GET | /api/invite/:token | 🔒 | Look up an invite link |
| GET | /api/call-history | 🔒 | List the user's recent calls |
| POST | /api/contact | — | Submit a contact message |
| POST | /api/client-log | — | Ship browser logs to the server log file |
| GET | /api/ice-config | — | Get STUN/TURN servers for WebRTC |
Create an account. On success the session cookie is set and the user is logged in immediately.
| Field | Type | Rules |
|---|---|---|
username | string | 2–32 chars; letters, numbers, _ . - only |
email | string | Valid email; stored lowercased |
password | string | At least 6 characters |
curl -X POST https://opencall.site/api/auth/register \
-H 'Content-Type: application/json' \
-c cookies.txt \
-d '{"username":"alice","email":"alice@example.com","password":"secret123"}'
// Browser
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username: 'alice', email: 'alice@example.com', password: 'secret123' }),
});
200 OK — { "ok": true, "username": "alice" }
Errors: 400 (missing/invalid field) · 409 (username taken or email registered).
Log in to an existing account. Sets the session cookie on success.
curl -X POST https://opencall.site/api/auth/login \
-H 'Content-Type: application/json' \
-c cookies.txt \
-d '{"username":"alice","password":"secret123"}'
200 OK — { "ok": true, "username": "alice" }
Errors: 400 (missing field) · 401 { "error": "Invalid username or password" }.
Destroy the current session.
curl -X POST https://opencall.site/api/auth/logout -b cookies.txt
200 OK — { "ok": true }
Return the user behind the current session. Safe to call when logged out.
curl https://opencall.site/api/auth/me -b cookies.txt
200 OK (logged in)
{ "authenticated": true, "username": "alice", "email": "alice@example.com" }
200 OK (logged out) — { "authenticated": false }
Your API key is shown on your dashboard — that's where you get it. This endpoint issues a fresh key and immediately invalidates the old one.
curl -X POST https://opencall.site/api/key/regenerate -H 'x-api-key: oc_8f3c1d9e2a47b6035f1c8a90d4e2b71c'
200 OK — { "apiKey": "oc_…(new key)…" }
Create a one-time invite link pointing back to the caller. Valid for 24 hours. No request body.
curl -X POST https://opencall.site/api/invite/generate -H 'x-api-key: oc_8f3c1d9e2a47b6035f1c8a90d4e2b71c'
{
"token": "5f3c2b8e-6d41-4a90-bf2a-9e0c7f1d2a44",
"url": "https://opencall.site/invite/5f3c2b8e-6d41-4a90-bf2a-9e0c7f1d2a44"
}
Share url; the recipient lands on the call page with the creator's Call ID pre-filled.
Look up an invite link and check whether its creator is online.
curl https://opencall.site/api/invite/5f3c2b8e-6d41-4a90-bf2a-9e0c7f1d2a44 -H 'x-api-key: oc_8f3c1d9e2a47b6035f1c8a90d4e2b71c'
{
"creatorUsername": "alice",
"creatorCallId": "K7XM2PQ9",
"online": true,
"expiresAt": "2026-06-17T09:30:00.000Z"
}
creatorCallId is null when the creator is offline. Errors: 404 (not found) · 410 (expired).
Return the user's most recent calls (up to 50, newest first).
curl https://opencall.site/api/call-history -H 'x-api-key: oc_8f3c1d9e2a47b6035f1c8a90d4e2b71c'
{
"calls": [
{ "direction": "outgoing", "peer": "bob", "status": "answered",
"startedAt": "2026-06-16 09:12:44", "durationSeconds": 184 },
{ "direction": "incoming", "peer": "R4WTBN8C", "status": "missed",
"startedAt": "2026-06-15 21:03:10", "durationSeconds": null }
]
}
| Field | Type | Notes |
|---|---|---|
direction | "outgoing" | "incoming" | Relative to the requesting user |
peer | string | Other party's display name, username, or Call ID |
status | "answered" | "rejected" | "missed" | Outcome |
startedAt | string | UTC timestamp (no timezone suffix) |
durationSeconds | number | null | null unless answered |
Submit a contact message. No authentication required.
| Field | Type | Rules |
|---|---|---|
subject | string | ≥ 2 chars (truncated to 200) |
email | string | Valid email address |
message | string | ≥ 5 chars (truncated to 5000) |
curl -X POST https://opencall.site/api/contact \
-H 'Content-Type: application/json' \
-d '{"subject":"Hello","email":"me@example.com","message":"Great project!"}'
200 OK — { "ok": true }. Errors: 400 with a specific message for any missing/invalid field.
Ship buffered browser logs to the server so client and server events land in one file (logs/opencall.log, tagged [CLIENT <user>]). The browser client calls this automatically every 10 seconds and on page hide.
| Field | Type | Notes |
|---|---|---|
lines | string[] | Pre-formatted log lines. Max 200 per request; each truncated to 2000 chars |
curl -X POST https://opencall.site/api/client-log \
-H 'Content-Type: application/json' \
-H 'x-api-key: oc_8f3c1d9e2a47b6035f1c8a90d4e2b71c' \
-d '{"lines":["[2026-06-16T09:00:00.000Z] [ERROR] [webrtc] ICE failed"]}'
200 OK — { "ok": true, "received": 1 }. Errors: 400 { "error": "lines[] required" }.
Return the ICE servers the browser should use for WebRTC. Always includes a public STUN server; includes the self-hosted TURN server when the operator has configured one. Pass the whole object to new RTCPeerConnection(iceConfig).
curl https://opencall.site/api/ice-config
{
"iceServers": [
{ "urls": "stun:stun.l.google.com:19302" },
{ "urls": "turn:turn.opencall.site:3478", "username": "opencall", "credential": "…" },
{ "urls": "turn:turn.opencall.site:3478?transport=tcp", "username": "opencall", "credential": "…" }
]
}
Placing and answering calls is not done over REST — calls are real-time and peer-to-peer. The server relays the WebRTC handshake over Socket.io and then steps aside; audio flows directly between the two browsers (DTLS-SRTP encrypted).
https://opencall.site (Socket.io over WSS, HTTP long-polling fallback). All messages are JSON.io(url, { auth: { apiKey: 'oc_…' } }). Authenticated socket → permanent Call ID; no key → throwaway Call ID. An invalid key is rejected with connect_error.socket.io-client is served at /socket.io/socket.io.js, exposing the global io.registerRegister the current session and obtain a Call ID. displayName is optional (trimmed, capped at 32 chars; whitespace-only becomes null). Triggers registered.
socket.emit('register', { displayName: 'Alice' }); // payload optional
callInitiate a call by sending the target a WebRTC offer. Triggers incoming-call on the target, or call-error if the target is offline.
{ "targetCallId": "K7XM2PQ9", "offer": { "type": "offer", "sdp": "..." } }
answerSend a WebRTC answer back to the caller after accepting. Triggers call-answered on the caller.
{ "targetCallId": "R4WTBN8C", "answer": { "type": "answer", "sdp": "..." } }
rejectDecline an incoming call. Triggers call-rejected on the caller.
{ "targetCallId": "R4WTBN8C" }
hangupEnd an active call. Triggers call-ended on the remote party.
{ "targetCallId": "K7XM2PQ9" }
update-nameChange the display name for the current session without a new Call ID. Pass an empty/whitespace value to clear it.
{ "displayName": "Bob" }
ice-candidateRelay a trickled ICE candidate to the remote peer. Triggers ice-candidate on the target.
{
"targetCallId": "K7XM2PQ9",
"candidate": { "candidate": "candidate:...", "sdpMid": "0", "sdpMLineIndex": 0 }
}
registeredSent after a successful register. callId matches ^[A-HJ-NP-Z2-9]{8}$.
{ "callId": "R4WTBN8C" }
incoming-callSent to the callee when someone calls them. Hold offer until the user accepts or rejects.
{
"callerCallId": "R4WTBN8C",
"callerDisplayName": "Alice", // or null
"offer": { "type": "offer", "sdp": "..." }
}
call-answeredSent to the caller when the callee accepts. Apply answer with setRemoteDescription.
{ "answer": { "type": "answer", "sdp": "..." }, "calleeDisplayName": "Bob" }
call-rejectedSent to the caller when the callee rejects. Payload: empty object. Clean up and return Home.
call-endedSent to the remote party on hangup or mid-call disconnect. Payload: empty object.
ice-candidateRelayed ICE candidate from the remote peer. Call pc.addIceCandidate(candidate).
{ "candidate": { "candidate": "candidate:...", "sdpMid": "0", "sdpMLineIndex": 0 } }
call-errorSent to the caller when the target Call ID is not registered (offline or typo).
{ "message": "K9ZX4MT2 is not online" }
1 · Connect and register
// `io` is the socket.io-client global, served at /socket.io/socket.io.js
// (or `import { io } from 'socket.io-client'` in a bundler / Node.js client).
// Authenticate with your API key so you get your permanent Call ID.
const socket = io('https://opencall.site', {
auth: { apiKey: 'oc_8f3c1d9e2a47b6035f1c8a90d4e2b71c' },
});
let myCallId = null;
socket.on('connect', () => socket.emit('register', { displayName: 'Alice' }));
socket.on('registered', ({ callId }) => {
myCallId = callId; // e.g. "K7XM2PQ9" — share so others can call you
});
// Fetch ICE servers (STUN/TURN) once, before placing or answering a call.
const ICE = await fetch('/api/ice-config').then(r => r.json());
2 · Initiate a call
async function placeCall(targetCallId) {
// Capture the microphone and build the peer connection.
const localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
const pc = new RTCPeerConnection(ICE);
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
// Play remote audio; trickle our ICE candidates to the peer.
pc.ontrack = ({ streams }) => { remoteAudio.srcObject = streams[0]; };
pc.onicecandidate = ({ candidate }) => {
if (candidate) socket.emit('ice-candidate', { targetCallId, candidate });
};
// Create the offer and send it.
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
socket.emit('call', { targetCallId, offer });
// Apply the callee's answer when it comes back.
socket.on('call-answered', async ({ answer }) => {
await pc.setRemoteDescription(new RTCSessionDescription(answer));
});
socket.on('ice-candidate', ({ candidate }) =>
pc.addIceCandidate(new RTCIceCandidate(candidate)).catch(() => {}));
socket.on('call-error', ({ message }) => console.warn('Call failed:', message));
socket.on('call-rejected', () => console.log('Callee declined'));
return { pc, localStream };
}
const { pc, localStream } = await placeCall('R4WTBN8C');
3 · Answer an incoming call
socket.on('incoming-call', async ({ callerCallId, callerDisplayName, offer }) => {
// Show a ringing UI for `callerDisplayName`, then on "Accept":
const localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
const pc = new RTCPeerConnection(ICE);
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
pc.ontrack = ({ streams }) => { remoteAudio.srcObject = streams[0]; };
pc.onicecandidate = ({ candidate }) => {
if (candidate) socket.emit('ice-candidate', { targetCallId: callerCallId, candidate });
};
// Apply the offer, create an answer, send it back.
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
socket.emit('answer', { targetCallId: callerCallId, answer });
socket.on('ice-candidate', ({ candidate }) =>
pc.addIceCandidate(new RTCIceCandidate(candidate)).catch(() => {}));
});
// To decline instead: socket.emit('reject', { targetCallId: callerCallId });
4 · Hang up
function hangUp(remoteCallId, pc, localStream) {
socket.emit('hangup', { targetCallId: remoteCallId });
pc.close();
localStream.getTracks().forEach(t => t.stop());
}
// The other side receives the 'call-ended' event:
socket.on('call-ended', () => { /* close pc, stop tracks, reset UI */ });
Trickle-ICE ordering. Candidates can arrive before setRemoteDescription runs — a robust client queues them and flushes after the remote description is set (see flushPendingCandidates in the reference client).
Caller (Alice) Server Callee (Bob)
| | |
|-- register{Alice} ------->|<------------- register{Bob}|
|<- registered(R4WTBN8C) ---| registered(K7XM2PQ9) --->|
| | |
|-- call{K7XM2PQ9, offer} --> |
| |-- incoming-call{ |
| | callerCallId, |
| | callerDisplayName, |
| | offer} -------------->|
| |<-- answer{R4WTBN8C, ans} --|
|<- call-answered{ans, | |
| calleeDisplayName} ----| |
| | |
|-- ice-candidate --------->|-- ice-candidate --------->|
|<- ice-candidate ----------|<-- ice-candidate ---------|
| | |
| <========= P2P audio (WebRTC SRTP) ===================>|
| | |
|-- hangup{K7XM2PQ9} -------> |
| |-- call-ended ------------>|
| Scenario | Event received | Client action |
|---|---|---|
| Target Call ID not registered | call-error | Show message, return Home |
| Call rejected by callee | call-rejected | Show message, return Home |
| Remote party hung up | call-ended | Clean up, return Home |
| WebRTC connection fails | pc.connectionState === 'failed' | Clean up, return Home |
| Server disconnects mid-call | reconnect + new register | Effectively ends the call |