API Documentation

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.

On this page

Conventions · Authentication · REST API (register · login · logout · me · api-key · invite · history · contact · client-log · ice-config) · Real-time calling (events · walkthrough · flow · errors)

Conventions

Authentication

Programmatic access is authenticated with a personal API key. Every account has one — get it from your dashboard. Use it on every request:

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.

REST API

MethodPathAuthPurpose
POST/api/auth/registerCreate an account and start a session
POST/api/auth/loginLog in to an existing account
POST/api/auth/logoutEnd the current session
GET/api/auth/meGet 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/contactSubmit a contact message
POST/api/client-logShip browser logs to the server log file
GET/api/ice-configGet STUN/TURN servers for WebRTC
POST/api/auth/register

Create an account. On success the session cookie is set and the user is logged in immediately.

FieldTypeRules
usernamestring2–32 chars; letters, numbers, _ . - only
emailstringValid email; stored lowercased
passwordstringAt 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).

POST/api/auth/login

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" }.

POST/api/auth/logout

Destroy the current session.

curl -X POST https://opencall.site/api/auth/logout -b cookies.txt

200 OK{ "ok": true }

GET/api/auth/me

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 }

POST/api/key/regenerate🔒 Auth

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)…" }

POST/api/invite/generate🔒 Auth

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.

GET/api/invite/:token🔒 Auth

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).

GET/api/call-history🔒 Auth

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 }
  ]
}
FieldTypeNotes
direction"outgoing" | "incoming"Relative to the requesting user
peerstringOther party's display name, username, or Call ID
status"answered" | "rejected" | "missed"Outcome
startedAtstringUTC timestamp (no timezone suffix)
durationSecondsnumber | nullnull unless answered
POST/api/contact

Submit a contact message. No authentication required.

FieldTypeRules
subjectstring≥ 2 chars (truncated to 200)
emailstringValid email address
messagestring≥ 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.

POST/api/client-log

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.

FieldTypeNotes
linesstring[]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" }.

GET/api/ice-config

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": "…" }
  ]
}

Real-time calling (Socket.io)

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).

Client → Server events

EMIT register

Register 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

EMIT call

Initiate 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": "..." } }

EMIT answer

Send a WebRTC answer back to the caller after accepting. Triggers call-answered on the caller.

{ "targetCallId": "R4WTBN8C", "answer": { "type": "answer", "sdp": "..." } }

EMIT reject

Decline an incoming call. Triggers call-rejected on the caller.

{ "targetCallId": "R4WTBN8C" }

EMIT hangup

End an active call. Triggers call-ended on the remote party.

{ "targetCallId": "K7XM2PQ9" }

EMIT update-name

Change the display name for the current session without a new Call ID. Pass an empty/whitespace value to clear it.

{ "displayName": "Bob" }

EMIT ice-candidate

Relay a trickled ICE candidate to the remote peer. Triggers ice-candidate on the target.

{
  "targetCallId": "K7XM2PQ9",
  "candidate": { "candidate": "candidate:...", "sdpMid": "0", "sdpMLineIndex": 0 }
}

Server → Client events

ON registered

Sent after a successful register. callId matches ^[A-HJ-NP-Z2-9]{8}$.

{ "callId": "R4WTBN8C" }

ON incoming-call

Sent 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": "..." }
}

ON call-answered

Sent to the caller when the callee accepts. Apply answer with setRemoteDescription.

{ "answer": { "type": "answer", "sdp": "..." }, "calleeDisplayName": "Bob" }

ON call-rejected

Sent to the caller when the callee rejects. Payload: empty object. Clean up and return Home.

ON call-ended

Sent to the remote party on hangup or mid-call disconnect. Payload: empty object.

ON ice-candidate

Relayed ICE candidate from the remote peer. Call pc.addIceCandidate(candidate).

{ "candidate": { "candidate": "candidate:...", "sdpMid": "0", "sdpMLineIndex": 0 } }

ON call-error

Sent to the caller when the target Call ID is not registered (offline or typo).

{ "message": "K9ZX4MT2 is not online" }

Calling walkthrough (code examples)

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).

Call flow diagram

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 ------------>|

Error handling

ScenarioEvent receivedClient action
Target Call ID not registeredcall-errorShow message, return Home
Call rejected by calleecall-rejectedShow message, return Home
Remote party hung upcall-endedClean up, return Home
WebRTC connection failspc.connectionState === 'failed'Clean up, return Home
Server disconnects mid-callreconnect + new registerEffectively ends the call