# arzbridge calls — Complete Integration Guide

_Single-file edition. Share this with your developers, or read it online as separate files._


---

# arzbridge calls — Developer Integration Guide

Add real-time **video, voice, and data** to your app in minutes. arzbridge calls
is a managed WebRTC platform: your backend asks our API for a **join token**, and
your app (web, mobile, Flutter, etc.) connects directly to our media servers with
that token.

> You only integrate two things: **(1)** a couple of REST calls from your backend,
> and **(2)** a LiveKit client SDK in your app. That's it.

---

## The integration model (read this first)

```
            ┌─────────────┐        1. request token (API key+secret)
            │ YOUR BACKEND │ ───────────────────────────────────────►  arzbridge API
            │ Node/Laravel │ ◄───────────────────────────────────────  { token, ws_url }
            └──────┬──────┘        2. token returned
                   │ 3. your app asks your backend for a token
                   ▼
            ┌─────────────┐        4. connect with token
            │  YOUR APP    │ ─────────────────────────────────────►   arzbridge media
            │ Flutter/Web  │ ◄═══════════ live audio/video/data ═════►  (LiveKit)
            └─────────────┘
```

**Golden rule:** your **API key + secret live only on your backend**. Your app
(Flutter, web, mobile) never sees them — it only receives short-lived tokens from
your backend.

---

## What you'll need

| Thing | Where it comes from |
|-------|---------------------|
| **API key + secret** | Provided to you by arzbridge (one pair per project) |
| **API base URL** | `https://calls.arzbridge.com/api/v1` |
| **WebSocket URL** (for clients) | `wss://calls.arzbridge.com` (also returned in token responses) |

---

## Contents

| # | Guide | For |
|---|-------|-----|
| 01 | [Getting started](01-getting-started.md) | Concepts, auth, the token flow, roles |
| 02 | [API reference](02-api-reference.md) | Every endpoint, with examples |
| 03 | [Node.js backend](03-backend-nodejs.md) | Mint tokens & manage rooms from Node |
| 04 | [Laravel backend](04-backend-laravel.md) | Mint tokens & manage rooms from Laravel |
| 05 | [Receiving webhooks](08-receiving-webhooks.md) | Get signed event notifications on your backend |
| 06 | [Web — JavaScript / React](07-web-javascript-react.md) | Join calls from a website / web app |
| 07 | [Flutter app](05-flutter-app.md) | Join calls from a Flutter mobile app |
| 08 | [Security & best practices](06-security-best-practices.md) | Do it right in production |

## 60-second quickstart

```bash
# 1) Your backend gets a token (server-to-server)
curl -s https://calls.arzbridge.com/api/v1/tokens \
  -H "Authorization: Bearer YOUR_API_KEY:YOUR_API_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"room":"my-first-room","identity":"user-123","name":"Alice"}'
# -> { "data": { "token": "...", "ws_url": "wss://calls.arzbridge.com", ... } }
```

```dart
// 2) Your Flutter app joins with that token
final room = Room();
await room.connect('wss://calls.arzbridge.com', token);
await room.localParticipant?.setMicrophoneEnabled(true);
await room.localParticipant?.setCameraEnabled(true);
```

That's a working call. The rest of this guide makes it production-grade.


---

# 01 — Getting started

## Core concepts

| Term | Meaning |
|------|---------|
| **Room** | A call/meeting. Identified by a `name` you choose (e.g. `order-4821`, `support-call-9`). Rooms are isolated to your project. |
| **Identity** | A unique string for each participant within a room (e.g. your user id). Reusing it reconnects the same user. |
| **Token** | A short-lived JWT that authorizes one identity to join one room with specific permissions. Your backend mints it; your app uses it. |
| **Grants** | What a token allows: publish, subscribe, send data, admin, etc. (a.k.a. "role"). |

Room names must match `^[A-Za-z0-9][A-Za-z0-9._-]{1,63}$` (letters, digits, `.`,
`_`, `-`; 2–64 chars). They are **isolated per project** — your `support` never
clashes with another customer's `support`.

## Authentication (backend → API)

Every API call (except `/ping`) needs your API key + secret. Two equivalent ways:

```http
Authorization: Bearer YOUR_API_KEY:YOUR_API_SECRET
```
or
```http
X-Api-Key: YOUR_API_KEY
X-Api-Secret: YOUR_API_SECRET
```

- Send `Content-Type: application/json` for requests with a body.
- **Never put these credentials in a mobile/web app.** Only your backend uses them.

## The token flow (the only flow you need)

1. Your app (Flutter/web) asks **your** backend: "I want to join room X as user Y."
2. Your backend calls `POST /api/v1/tokens` with your API key, room, and identity.
3. arzbridge returns `{ token, ws_url, expires_in, ... }`.
4. Your backend returns `token` + `ws_url` to your app.
5. Your app connects a LiveKit SDK to `ws_url` using `token`. Done.

You decide who can join what — the token is created on your terms (your auth, your
rules), so arzbridge never needs to know about your users.

## Response & error format

Success:
```json
{ "data": { ... } }
```
Error:
```json
{ "error": { "code": 422, "message": "The room field is required.", "fields": { "room": ["..."] } } }
```

| HTTP | Meaning |
|------|---------|
| 200 / 201 | OK / created |
| 401 | Missing/invalid API credentials |
| 403 | Valid key, but missing a required scope, or quota reached |
| 404 | Room/participant/recording not found |
| 422 | Validation error (see `error.fields`) |
| 429 | Rate limit exceeded (see `Retry-After` header) |
| 5xx | Server/LiveKit issue — retry with backoff |

## Roles via grants

Choose grants when minting a token (full list in
[02-api-reference.md](02-api-reference.md)):

| Role | Token options |
|------|---------------|
| **Full participant** (default) | *(none — publish + subscribe + data are on)* |
| **Viewer / webinar attendee** | `{ "can_publish": false }` |
| **Broadcaster only** | `{ "can_subscribe": false }` |
| **Moderator** | `{ "room_admin": true }` (can mute/remove others via the API) |
| **Hidden bot / recorder** | `{ "hidden": true }` |

## Token lifetime (TTL)

A token only needs to live long enough to **connect**; the call continues after it
expires. Default TTL is 6 hours; set `ttl` (seconds, 30–86400) to taste. Re-issue
on demand from your backend.

## Rate limits

Default **120 requests/minute per API key** (token minting + management calls).
A `429` includes `Retry-After`. Need more? Ask arzbridge to raise your limit.

Next: pick your backend — [Node.js](03-backend-nodejs.md) or
[Laravel](04-backend-laravel.md) — then wire up your [Flutter app](05-flutter-app.md).


---

# 02 — API reference

- **Base URL:** `https://calls.arzbridge.com/api/v1`
- **Auth:** `Authorization: Bearer KEY:SECRET` (or `X-Api-Key`/`X-Api-Secret`)
- **Success:** `{ "data": ... }` · **Error:** `{ "error": { code, message } }`

In the examples below:
```bash
KEY=YOUR_API_KEY; SECRET=YOUR_API_SECRET
AUTH="Authorization: Bearer $KEY:$SECRET"
BASE=https://calls.arzbridge.com/api/v1
```

---

## Health & account

### `GET /ping` — liveness (no auth)
```bash
curl -s $BASE/ping       # { "pong": true, "time": "..." }
```

### `GET /me` — your project profile, quotas, usage
```bash
curl -s -H "$AUTH" $BASE/me
```

### `GET /health` — deep health (app, db, media)
```bash
curl -s -H "$AUTH" $BASE/health
```

---

## Tokens

### `POST /tokens` — issue a join token  *(the main endpoint)*
Also available as `POST /rooms/{room}/tokens` (room from the path).

**Body**

| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `room` | string | — (required) | Room name |
| `identity` | string | — (required) | Unique per participant in the room |
| `name` | string | – | Display name |
| `ttl` | int (sec) | 21600 | 30–86400 |
| `metadata` | object | – | Attached to participant, visible to others |
| `attributes` | object | – | Key/value attributes |
| `can_publish` | bool | true | May send audio/video |
| `can_subscribe` | bool | true | May receive others |
| `can_publish_data` | bool | true | May send data messages |
| `hidden` | bool | false | Join invisibly |
| `room_admin` | bool | false | Server-side admin rights |

```bash
curl -s -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"room":"team-sync","identity":"user-42","name":"Alice","ttl":3600}' \
  $BASE/tokens
```
**Response**
```json
{ "data": {
  "token": "<jwt>",
  "ws_url": "wss://calls.arzbridge.com",
  "room": "team-sync",
  "identity": "user-42",
  "expires_in": 3600
}}
```

---

## Rooms

### `GET /rooms` — list your active rooms
```bash
curl -s -H "$AUTH" $BASE/rooms
```

### `POST /rooms` — create a room (optional; tokens auto-create too)
| Field | Type | Notes |
|-------|------|-------|
| `name` | string (required) | Room name |
| `display_name` | string | Friendly label |
| `max_participants` | int | Cap room size |
| `empty_timeout` | int (sec) | Auto-close after empty (default 300) |
| `metadata` | object | Arbitrary metadata |
| `recording` | bool | Hint flag |

```bash
curl -s -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"name":"webinar-1","max_participants":500}' $BASE/rooms
```

### `GET /rooms/{name}` — room details + live participants
### `DELETE /rooms/{name}` — close/end a room (disconnects everyone)
```bash
curl -s -X DELETE -H "$AUTH" $BASE/rooms/webinar-1
```

---

## Participants

### `GET /rooms/{room}/participants` — who's in the room
### `GET /rooms/{room}/participants/{identity}` — one participant
### `DELETE /rooms/{room}/participants/{identity}` — remove (kick)
```bash
curl -s -X DELETE -H "$AUTH" $BASE/rooms/team-sync/participants/user-42
```

### `POST /rooms/{room}/participants/{identity}/mute` — mute a track
```bash
curl -s -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"track_sid":"TR_xxx","muted":true}' \
  $BASE/rooms/team-sync/participants/user-42/mute
```

### `POST /rooms/{room}/data` — send a data message to the room
```bash
curl -s -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"data":"{\"type\":\"hand_raised\"}","topic":"signals"}' \
  $BASE/rooms/team-sync/data
```
Optional `destination_identities: ["user-42"]` to target specific participants.

---

## Recordings

Room-composite recording to **MP4**. Requires the `recordings.write` scope on your
API key (listing needs only a valid key).

### `GET /recordings` — list recordings (filter `?room=`)
### `POST /rooms/{room}/recordings` — start recording (`layout`: `grid`|`speaker`|`single-speaker`)
### `DELETE /recordings/{egressId}` — stop recording
### `GET /recordings/{egressId}/download` — download the finished file
```bash
# start
curl -s -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"layout":"grid"}' $BASE/rooms/team-sync/recordings

# download once status = complete (follows the 302 to a signed URL)
curl -L -H "$AUTH" -o recording.mp4 $BASE/recordings/EG_xxx/download
```

Recording is **asynchronous**. `POST` returns immediately with the job (`status`
`starting`/`active`). When it finishes you learn the result either by **polling**
`GET /recordings` (job `status` → `complete`, `output.files[]` lists the produced
file with `filename`/`size`/`duration`) or via a **webhook** subscribed to
`egress_ended` (its `egress.files[]` carries the same details). A recording only
starts capturing once at least one participant is publishing.

**Download** the file with `GET /recordings/{egressId}/download`: it redirects
(`302`) to a short-lived (~15 min) signed URL, so `curl -L` or a browser fetches
the MP4 directly — no storage credentials required. Add `?format=json` for the
signed URL in the body (`{"data":{"url","expires_in":900}}`). `409` until the
recording is `complete`.

---

## Webhook endpoints — see [08](08-receiving-webhooks.md)

Register URLs to receive signed event notifications on your backend.

| Method | Path | Scope | Description |
|--------|------|-------|-------------|
| GET | `/webhook-endpoints` | – | List your endpoints (secret omitted) |
| POST | `/webhook-endpoints` | `webhooks.write` | Create an endpoint. Body: `url`, `events?[]`, `description?`. Returns `secret` **once** |
| DELETE | `/webhook-endpoints/{id}` | `webhooks.write` | Delete an endpoint |
| POST | `/webhook-endpoints/{id}/test` | `webhooks.write` | Send a `ping` test event |

```bash
curl -s -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"url":"https://api.yourapp.com/webhooks/calls","events":["participant_joined","participant_left"]}' \
  $BASE/webhook-endpoints
```

---

## Notes

- All operations are **scoped to your project** automatically — you only ever see
  and affect your own rooms.
- For real-time server-side notifications, register a webhook endpoint (above) and
  verify signatures — full guide in [08-receiving-webhooks.md](08-receiving-webhooks.md).


---

# 03 — Node.js backend integration

Your Node backend is responsible for **authenticating your users** and then
**minting LiveKit tokens** for them via the arzbridge API. Below is a clean,
production-ready setup (no extra SDK needed — just `fetch`, built into Node 18+).

## 1. Configuration

Keep credentials in environment variables — never in code or the frontend.

```bash
# .env
ARZBRIDGE_BASE_URL=https://calls.arzbridge.com/api/v1
ARZBRIDGE_API_KEY=YOUR_API_KEY
ARZBRIDGE_API_SECRET=YOUR_API_SECRET
```

## 2. A reusable client (`arzbridge.js`)

```js
// arzbridge.js  — a tiny, dependency-free client for the arzbridge calls API
const BASE = process.env.ARZBRIDGE_BASE_URL;
const AUTH = `Bearer ${process.env.ARZBRIDGE_API_KEY}:${process.env.ARZBRIDGE_API_SECRET}`;

async function call(method, path, body) {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: { Authorization: AUTH, 'Content-Type': 'application/json' },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok) {
    const msg = json?.error?.message || `arzbridge ${res.status}`;
    const err = new Error(msg);
    err.status = res.status;
    err.body = json;
    throw err;
  }
  return json.data;
}

export const arzbridge = {
  // Mint a join token for a participant.
  createToken: (opts) => call('POST', '/tokens', opts),
  // Rooms
  listRooms: () => call('GET', '/rooms'),
  createRoom: (opts) => call('POST', '/rooms', opts),
  getRoom: (name) => call('GET', `/rooms/${encodeURIComponent(name)}`),
  deleteRoom: (name) => call('DELETE', `/rooms/${encodeURIComponent(name)}`),
  // Participants
  listParticipants: (room) => call('GET', `/rooms/${encodeURIComponent(room)}/participants`),
  removeParticipant: (room, identity) =>
    call('DELETE', `/rooms/${encodeURIComponent(room)}/participants/${encodeURIComponent(identity)}`),
  // Data message
  sendData: (room, data, opts = {}) =>
    call('POST', `/rooms/${encodeURIComponent(room)}/data`, { data, ...opts }),
};
```

## 3. Expose a token endpoint to your app (Express)

```js
// server.js
import express from 'express';
import { arzbridge } from './arzbridge.js';

const app = express();
app.use(express.json());

// Your app calls THIS endpoint (with your own auth) to join a call.
app.post('/api/calls/join', async (req, res) => {
  try {
    // 1) Authenticate the user with YOUR system (session, JWT, etc.)
    const user = req.user;                       // however you auth
    if (!user) return res.status(401).json({ error: 'unauthenticated' });

    // 2) Decide which room they may join + their role (your business rules)
    const { roomId } = req.body;

    // 3) Mint a token scoped to that user + room
    const { token, ws_url } = await arzbridge.createToken({
      room: `room-${roomId}`,
      identity: `user-${user.id}`,
      name: user.name,
      ttl: 3600,
      // role example: a viewer in a webinar
      // can_publish: false,
      metadata: { role: user.role },
    });

    // 4) Hand the token to your app
    res.json({ token, wsUrl: ws_url });
  } catch (e) {
    res.status(e.status || 500).json({ error: e.message });
  }
});

app.listen(3000, () => console.log('listening on :3000'));
```

Your Flutter/web app calls `POST /api/calls/join` and gets back `{ token, wsUrl }`.

## 4. Server-side controls

```js
// Kick a user
await arzbridge.removeParticipant('room-42', 'user-99');

// End a call for everyone
await arzbridge.deleteRoom('room-42');

// Broadcast a signal (e.g. "quiz started") to all participants
await arzbridge.sendData('room-42', JSON.stringify({ type: 'quiz_started' }), { topic: 'signals' });

// See who's connected
const people = await arzbridge.listParticipants('room-42');
```

## 5. Error handling & retries

- `401/403` → fix credentials/scopes (config issue, not transient).
- `422` → bad input; inspect `err.body.error.fields`.
- `429` → you're rate-limited; honor `Retry-After` and back off.
- `5xx` → retry with exponential backoff (2–3 tries).

```js
async function withRetry(fn, tries = 3) {
  for (let i = 0; i < tries; i++) {
    try { return await fn(); }
    catch (e) {
      if (e.status && e.status < 500 && e.status !== 429) throw e; // don't retry client errors
      if (i === tries - 1) throw e;
      await new Promise(r => setTimeout(r, 300 * 2 ** i));
    }
  }
}
```

## (Optional) Node as a call client

If you also have a **web** frontend, use the browser SDK `livekit-client` with the
token from your backend — see the snippet in the
[main README](README.md#60-second-quickstart) and
[LiveKit JS docs](https://docs.livekit.io/client-sdk-js/). For mobile, see
[Flutter](05-flutter-app.md).


---

# 04 — Laravel backend integration

Mint tokens and manage rooms from your Laravel app using the built-in HTTP client
(no extra package required).

## 1. Configuration

```bash
# .env
ARZBRIDGE_BASE_URL=https://calls.arzbridge.com/api/v1
ARZBRIDGE_API_KEY=YOUR_API_KEY
ARZBRIDGE_API_SECRET=YOUR_API_SECRET
```

```php
// config/services.php
'arzbridge' => [
    'base_url' => env('ARZBRIDGE_BASE_URL', 'https://calls.arzbridge.com/api/v1'),
    'key'      => env('ARZBRIDGE_API_KEY'),
    'secret'   => env('ARZBRIDGE_API_SECRET'),
],
```

## 2. A service class (`app/Services/ArzbridgeCalls.php`)

```php
<?php

namespace App\Services;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;

class ArzbridgeCalls
{
    protected function client(): PendingRequest
    {
        return Http::baseUrl(config('services.arzbridge.base_url'))
            ->withToken(config('services.arzbridge.key').':'.config('services.arzbridge.secret'))
            ->acceptJson()
            ->asJson()
            ->retry(3, 300, throw: false);
    }

    /** Mint a join token for a participant. */
    public function createToken(array $opts): array
    {
        return $this->client()->post('/tokens', $opts)->throw()->json('data');
    }

    public function createRoom(array $opts): array
    {
        return $this->client()->post('/rooms', $opts)->throw()->json('data');
    }

    public function listParticipants(string $room): array
    {
        return $this->client()->get("/rooms/".rawurlencode($room)."/participants")->throw()->json('data');
    }

    public function removeParticipant(string $room, string $identity): array
    {
        return $this->client()
            ->delete("/rooms/".rawurlencode($room)."/participants/".rawurlencode($identity))
            ->throw()->json('data');
    }

    public function endRoom(string $room): array
    {
        return $this->client()->delete("/rooms/".rawurlencode($room))->throw()->json('data');
    }

    public function sendData(string $room, string $data, array $opts = []): array
    {
        return $this->client()->post("/rooms/".rawurlencode($room)."/data", ['data' => $data] + $opts)
            ->throw()->json('data');
    }
}
```

## 3. A controller your app calls

```php
<?php

namespace App\Http\Controllers;

use App\Services\ArzbridgeCalls;
use Illuminate\Http\Request;

class CallController extends Controller
{
    public function __construct(protected ArzbridgeCalls $calls) {}

    // POST /api/calls/join  — protect with your own auth (Sanctum, session, etc.)
    public function join(Request $request)
    {
        $data = $request->validate([
            'room_id' => ['required', 'string'],
        ]);

        $user = $request->user();   // your authenticated user

        $result = $this->calls->createToken([
            'room' => 'room-'.$data['room_id'],
            'identity' => 'user-'.$user->id,
            'name' => $user->name,
            'ttl' => 3600,
            'metadata' => ['role' => $user->role ?? 'member'],
            // 'can_publish' => false,   // e.g. a webinar viewer
        ]);

        return response()->json([
            'token'  => $result['token'],
            'wsUrl'  => $result['ws_url'],
        ]);
    }
}
```

```php
// routes/api.php
use App\Http\Controllers\CallController;

Route::middleware('auth:sanctum')->post('/calls/join', [CallController::class, 'join']);
```

Your Flutter/web app calls `POST /api/calls/join` and receives `{ token, wsUrl }`.

## 4. Server-side controls

```php
$calls = app(\App\Services\ArzbridgeCalls::class);

$calls->removeParticipant('room-42', 'user-99');             // kick
$calls->endRoom('room-42');                                  // end for everyone
$calls->sendData('room-42', json_encode(['type' => 'start']), ['topic' => 'signals']);
$people = $calls->listParticipants('room-42');               // who's connected
```

## 5. Error handling

`->throw()` raises `Illuminate\Http\Client\RequestException` on non-2xx. Inspect:

```php
use Illuminate\Http\Client\RequestException;

try {
    $token = $this->calls->createToken([...]);
} catch (RequestException $e) {
    $status = $e->response->status();          // 401/403/422/429/5xx
    $error  = $e->response->json('error');     // { code, message, fields? }
    // 422 -> validation; 429 -> back off (Retry-After); 5xx -> already retried 3x
    report($e);
    return response()->json(['error' => $error['message'] ?? 'call service error'], $status);
}
```

The client above already retries transient failures (`->retry(3, 300)`).


---

# 08 — Receiving webhooks

Get real-time **server-side notifications** when things happen in your calls —
rooms start/finish, participants join/leave, recordings finish. arzbridge POSTs a
signed JSON event to an endpoint you register, so your backend can react (update
your DB, notify users, trigger workflows) without polling.

> Webhooks are **server-to-server**. Register an endpoint on **your backend**, not
> in a mobile/web app.

## 1. Register an endpoint

Via the API (needs the `webhooks.write` scope) — the **signing secret is returned
once**, store it:

```bash
curl -s https://calls.arzbridge.com/api/v1/webhook-endpoints \
  -H "Authorization: Bearer $KEY:$SECRET" -H "Content-Type: application/json" \
  -d '{
        "url": "https://api.yourapp.com/webhooks/calls",
        "description": "production",
        "events": ["participant_joined","participant_left","room_finished"]
      }'
# -> { "data": { "id": 7, "secret": "whsec_…", "active": true, ... } }
```
Omit `events` (or pass `[]`) to receive **all** event types. Manage endpoints with
`GET/DELETE /api/v1/webhook-endpoints` and `POST /api/v1/webhook-endpoints/{id}/test`.
(Or ask arzbridge to add it for you.)

> Your URL must be **public HTTPS** — private/loopback addresses are rejected.

## 2. The request we send

`POST` to your URL, `Content-Type: application/json`, with headers:

| Header | Example | Meaning |
|--------|---------|---------|
| `X-Arzbridge-Event` | `participant_joined` | The event type |
| `X-Arzbridge-Delivery` | `4821` | Unique delivery id (for your logs) |
| `X-Arzbridge-Signature` | `t=1751280000,v1=9f86d0…` | HMAC signature (verify this!) |

Body (room names are **your** names, never the internal ones):
```json
{
  "id": "EV_8s2…",
  "event": "participant_joined",
  "created_at": 1751280000,
  "room": "support-42",
  "participant": { "identity": "user-1", "name": "Alice", "sid": "PA_…" },
  "egress": {
    "egress_id": "EG_…",
    "status": 3,
    "files": [
      { "filename": "…mp4", "location": "…", "size": 1048576, "duration": 42000000000 }
    ]
  }
}
```
`participant` is present on participant/track events; `egress` on recording events.
On `egress_ended` (recording finished, `status: 3`) `egress.files[]` lists the
produced file(s) — `filename`, `location`, `size` (bytes), `duration` (ns).
(`files` is omitted until the recording completes.)

## 3. Verify the signature (always do this)

The signature is `HMAC-SHA256( "<t>.<raw-body>", secret )`, hex-encoded.
**Use the raw request body** (not a re-serialized object) and compare in constant
time. Optionally reject timestamps older than ~5 min to prevent replay.

### Node.js (Express)
```js
const crypto = require('crypto');
const SECRET = process.env.ARZ_WEBHOOK_SECRET;

// IMPORTANT: capture the RAW body for this route
app.post('/webhooks/calls', express.raw({ type: '*/*' }), (req, res) => {
  const m = (req.header('X-Arzbridge-Signature') || '').match(/t=(\d+),v1=([a-f0-9]+)/);
  if (!m) return res.sendStatus(400);
  const [, t, v1] = m;
  const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${req.body}`).digest('hex');
  const ok = expected.length === v1.length &&
             crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
  if (!ok) return res.sendStatus(401);
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(401); // replay guard

  const event = JSON.parse(req.body.toString());
  // ...handle event.event, event.room, event.participant...
  res.sendStatus(200); // ACK fast (within ~10s)
});
```

### Laravel
```php
// routes/api.php  (api routes are CSRF-exempt)
use Illuminate\Http\Request;

Route::post('/webhooks/calls', function (Request $r) {
    $sig = $r->header('X-Arzbridge-Signature', '');
    if (! preg_match('/t=(\d+),v1=([a-f0-9]+)/', $sig, $m)) abort(400);
    [, $t, $v1] = $m;

    $expected = hash_hmac('sha256', $t.'.'.$r->getContent(), config('services.arzcalls.webhook_secret'));
    abort_unless(hash_equals($expected, $v1), 401);
    abort_if(abs(time() - (int) $t) > 300, 401);          // replay guard

    $event = $r->json()->all();
    // ...handle $event['event'], $event['room'], $event['participant']...

    return response()->noContent();                        // 200/204
})->name('calls.webhook');
```

## 4. Event types

`room_started`, `room_finished`, `participant_joined`, `participant_left`,
`track_published`, `track_unpublished`, `egress_started`, `egress_updated`,
`egress_ended`. Subscribe to a subset via `events`, or receive all.

## 5. Delivery, retries & best practices

- **Respond `2xx` quickly** (within ~10s). Do heavy work asynchronously — just
  acknowledge first.
- **Retries:** non-2xx / timeouts are retried up to **5 times** with backoff
  (~10s, 30s, 2m, 5m, 15m). An endpoint that keeps failing is auto-disabled.
- **Idempotency:** the same delivery may arrive more than once (e.g. after a
  retry). De-duplicate on the event `id`.
- **Ordering** is not guaranteed; use `created_at` if you need sequence.
- **Keep the secret safe**; rotate by deleting and re-creating the endpoint.
- Test anytime with `POST /api/v1/webhook-endpoints/{id}/test` (sends a `ping`).

That's it — your backend now stays in sync with every call in real time.


---

# 07 — Web (JavaScript / React)

Add calls to a website or web app. Like every client, your web app gets a **token
from your backend** (see [Node](03-backend-nodejs.md) / [Laravel](04-backend-laravel.md)),
then connects with LiveKit's browser SDK.

> Your API key/secret stay on your backend. The browser only ever sees a token.
> **HTTPS is required** for camera/mic (browsers only allow `getUserMedia` on
> secure origins — `https://` or `localhost`).

---

## Option A — React (fastest, recommended)

Prebuilt components handle layout, rendering, and controls for you.

### Install
```bash
npm install @livekit/components-react@^2.9 @livekit/components-styles@^1.2 livekit-client@^2.20
```
Requires React ≥ 18.

### A complete call component
```tsx
import '@livekit/components-styles';
import {
  LiveKitRoom, GridLayout, ParticipantTile,
  RoomAudioRenderer, ControlBar, useTracks,
} from '@livekit/components-react';
import { Track } from 'livekit-client';

function Conference() {
  // Camera + screenshare tracks from everyone (incl. you).
  const tracks = useTracks(
    [
      { source: Track.Source.Camera, withPlaceholder: true },
      { source: Track.Source.ScreenShare, withPlaceholder: false },
    ],
    { onlySubscribed: false },
  );

  return (
    <GridLayout tracks={tracks} style={{ height: 'calc(100vh - var(--lk-control-bar-height))' }}>
      <ParticipantTile />
    </GridLayout>
  );
}

export default function CallRoom({ token }: { token: string }) {
  return (
    <LiveKitRoom
      token={token}
      serverUrl="wss://calls.arzbridge.com"
      connect={true}
      video={true}
      audio={true}
      data-lk-theme="default"
      style={{ height: '100vh' }}
      onDisconnected={() => console.log('left the call')}
    >
      <Conference />
      <RoomAudioRenderer />   {/* plays remote audio */}
      <ControlBar />          {/* mic/cam/screenshare/leave buttons */}
    </LiveKitRoom>
  );
}
```

### Get the token, then render
```tsx
import { useEffect, useState } from 'react';
import CallRoom from './CallRoom';

export function CallPage({ roomId }: { roomId: string }) {
  const [token, setToken] = useState<string | null>(null);

  useEffect(() => {
    fetch('/api/calls/join', {                         // YOUR backend endpoint
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include',                          // your own auth
      body: JSON.stringify({ room_id: roomId }),
    })
      .then((r) => r.json())
      .then((d) => setToken(d.token));                 // { token, wsUrl }
  }, [roomId]);

  if (!token) return <div>Joining…</div>;
  return <CallRoom token={token} />;
}
```

> **Next.js:** components-react is client-side. Put `'use client'` at the top of the
> file, or import the call component with `dynamic(() => import('./CallRoom'), { ssr: false })`.

---

## Option B — Vanilla JavaScript (no framework)

Full control with the core `livekit-client` SDK.

### Install (bundler)
```bash
npm install livekit-client@^2.20
```
…or load straight from a CDN (no build step):
```html
<script type="module">
  import { Room, RoomEvent, Track }
    from 'https://cdn.jsdelivr.net/npm/livekit-client@2.20.0/+esm';
  // ... code below ...
</script>
```

### Connect, publish, render
```js
import { Room, RoomEvent, Track } from 'livekit-client';

async function joinCall(roomId) {
  // 1) token from YOUR backend
  const res = await fetch('/api/calls/join', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    credentials: 'include',
    body: JSON.stringify({ room_id: roomId }),
  });
  const { token, wsUrl } = await res.json();

  // 2) set up the room
  const room = new Room({ adaptiveStream: true, dynacast: true });

  room
    .on(RoomEvent.TrackSubscribed, (track) => {
      if (track.kind === Track.Kind.Video || track.kind === Track.Kind.Audio) {
        document.getElementById('remote').appendChild(track.attach());
      }
    })
    .on(RoomEvent.TrackUnsubscribed, (track) => track.detach().forEach((el) => el.remove()))
    .on(RoomEvent.Disconnected, () => console.log('disconnected'));

  // 3) connect + publish your camera & mic
  await room.connect(wsUrl, token);
  await room.localParticipant.enableCameraAndMicrophone();

  // 4) show your own camera
  const cam = room.localParticipant.getTrackPublication(Track.Source.Camera);
  if (cam?.videoTrack) {
    document.getElementById('local').appendChild(cam.videoTrack.attach());
  }

  return room;
}
```

```html
<div id="local"></div>
<div id="remote"></div>
```

### Controls
```js
await room.localParticipant.setMicrophoneEnabled(false); // mute
await room.localParticipant.setCameraEnabled(false);     // camera off
await room.localParticipant.setScreenShareEnabled(true); // share screen
await room.disconnect();                                  // leave
```

### Data messages
```js
const enc = new TextEncoder(), dec = new TextDecoder();

// send
await room.localParticipant.publishData(
  enc.encode(JSON.stringify({ type: 'reaction', emoji: '👏' })),
  { reliable: true, topic: 'signals' },
);

// receive
room.on(RoomEvent.DataReceived, (payload, participant, _kind, topic) => {
  const msg = JSON.parse(dec.decode(payload));
  // handle msg, participant?.identity, topic
});
```

---

## Notes & gotchas

- **HTTPS only** for camera/mic (or `localhost` in dev).
- **Audio autoplay:** browsers may block audio until a user gesture. With React,
  `<RoomAudioRenderer />` plus a "Join" click handles this; in vanilla JS, start
  `joinCall()` from a click. You can also call `room.startAudio()` after a click.
- **Token expiry:** if a connection is rejected, re-fetch a fresh token from your
  backend (the call itself continues after a token expires).
- **Roles:** ask your backend to mint a viewer token (`can_publish: false`) for
  webinar attendees, etc. — see [01-getting-started.md](01-getting-started.md).
- **Mobile web** works too, but for native apps prefer the
  [Flutter SDK](05-flutter-app.md) (or the native iOS/Android SDKs).

Reference: [LiveKit JS SDK](https://docs.livekit.io/client-sdk-js/) ·
[React components](https://docs.livekit.io/reference/components/react/).
arzbridge runs standard LiveKit, so all official SDKs work unchanged.


---

# 05 — Flutter app integration

Your Flutter app is the **call client**. It gets a token from *your* backend (see
[Node](03-backend-nodejs.md) / [Laravel](04-backend-laravel.md)) and connects to
arzbridge media with the official `livekit_client` SDK.

> The app never holds your API key/secret — only the short-lived token your
> backend returns.

## 1. Add dependencies

```yaml
# pubspec.yaml
dependencies:
  livekit_client: ^2.8.1     # latest stable
  permission_handler: ^12.0.0
  http: ^1.2.0               # to call your backend for a token
```
```bash
flutter pub get
```
> Requires Flutter 3.3.0+ (recommended). Tested against `livekit_client` 2.8.x.

## 2. Platform setup (required for camera/mic)

### iOS — `ios/Runner/Info.plist`
```xml
<key>NSCameraUsageDescription</key>
<string>$(PRODUCT_NAME) needs the camera for video calls</string>
<key>NSMicrophoneUsageDescription</key>
<string>$(PRODUCT_NAME) needs the microphone for calls</string>
<!-- keep audio alive when the app is backgrounded during a call -->
<key>UIBackgroundModes</key>
<array>
  <string>audio</string>
  <string>voip</string>
</array>
```
Set the iOS deployment target to **12.1+** (`ios/Podfile`: `platform :ios, '12.1'`).

### Android — `android/app/src/main/AndroidManifest.xml`
```xml
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.microphone" />

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
```
Set `minSdkVersion 21` (or higher) in `android/app/build.gradle`.

## 3. Request runtime permissions

```dart
import 'package:permission_handler/permission_handler.dart';

Future<bool> ensureCallPermissions() async {
  final statuses = await [Permission.camera, Permission.microphone].request();
  return statuses.values.every((s) => s.isGranted);
}
```

## 4. Get a token from YOUR backend

```dart
import 'dart:convert';
import 'package:http/http.dart' as http;

Future<({String token, String wsUrl})> fetchCallToken(String roomId) async {
  final res = await http.post(
    Uri.parse('https://your-backend.example.com/api/calls/join'),
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer <YOUR_APP_SESSION_TOKEN>', // your own auth
    },
    body: jsonEncode({'room_id': roomId}),
  );
  if (res.statusCode != 200) {
    throw Exception('Failed to get call token: ${res.body}');
  }
  final data = jsonDecode(res.body);
  return (token: data['token'] as String, wsUrl: data['wsUrl'] as String);
}
```

## 5. A complete call screen

```dart
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';

class CallScreen extends StatefulWidget {
  final String roomId;
  const CallScreen({super.key, required this.roomId});
  @override
  State<CallScreen> createState() => _CallScreenState();
}

class _CallScreenState extends State<CallScreen> {
  Room? _room;
  EventsListener<RoomEvent>? _listener;
  bool _micOn = true, _camOn = true;
  CameraPosition _cameraPos = CameraPosition.front;

  @override
  void initState() {
    super.initState();
    _connect();
  }

  Future<void> _connect() async {
    if (!await ensureCallPermissions()) return;

    final creds = await fetchCallToken(widget.roomId);

    final room = Room(
      roomOptions: const RoomOptions(adaptiveStream: true, dynacast: true),
    );
    final listener = room.createListener();

    listener
      ..on<TrackSubscribedEvent>((_) => setState(() {}))
      ..on<TrackUnsubscribedEvent>((_) => setState(() {}))
      ..on<ParticipantConnectedEvent>((_) => setState(() {}))
      ..on<ParticipantDisconnectedEvent>((_) => setState(() {}))
      ..on<RoomDisconnectedEvent>((e) {
        if (mounted) Navigator.of(context).maybePop();
      });

    await room.connect(creds.wsUrl, creds.token);
    await room.localParticipant?.setMicrophoneEnabled(true);
    await room.localParticipant?.setCameraEnabled(true);

    setState(() { _room = room; _listener = listener; });
  }

  Future<void> _toggleMic() async {
    _micOn = !_micOn;
    await _room?.localParticipant?.setMicrophoneEnabled(_micOn);
    setState(() {});
  }

  Future<void> _toggleCam() async {
    _camOn = !_camOn;
    await _room?.localParticipant?.setCameraEnabled(_camOn);
    setState(() {});
  }

  Future<void> _flipCamera() async {
    final pubs = _room?.localParticipant?.videoTrackPublications ?? [];
    final track = pubs.isNotEmpty ? pubs.first.track : null;
    if (track is LocalVideoTrack) {
      _cameraPos = _cameraPos == CameraPosition.front
          ? CameraPosition.back
          : CameraPosition.front;
      await track.setCameraPosition(_cameraPos);
    }
  }

  @override
  void dispose() {
    _listener?.dispose();
    _room?.dispose();   // disconnects + releases camera/mic
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final room = _room;
    if (room == null) {
      return const Scaffold(body: Center(child: CircularProgressIndicator()));
    }

    // Collect all video tracks (local + remote) to render.
    final tracks = <VideoTrack>[];
    final localPubs = room.localParticipant?.videoTrackPublications ?? [];
    final localCam = localPubs.isNotEmpty ? localPubs.first.track : null;
    if (localCam is VideoTrack) tracks.add(localCam);
    for (final p in room.remoteParticipants.values) {
      for (final pub in p.videoTrackPublications) {
        final t = pub.track;
        if (t is VideoTrack) tracks.add(t);
      }
    }

    return Scaffold(
      backgroundColor: Colors.black,
      body: SafeArea(
        child: GridView.count(
          crossAxisCount: tracks.length <= 1 ? 1 : 2,
          children: [
            for (final t in tracks)
              Container(
                margin: const EdgeInsets.all(2),
                color: Colors.grey[900],
                child: VideoTrackRenderer(t),
              ),
          ],
        ),
      ),
      bottomNavigationBar: BottomAppBar(
        color: Colors.black87,
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            IconButton(icon: Icon(_micOn ? Icons.mic : Icons.mic_off, color: Colors.white), onPressed: _toggleMic),
            IconButton(icon: Icon(_camOn ? Icons.videocam : Icons.videocam_off, color: Colors.white), onPressed: _toggleCam),
            IconButton(icon: const Icon(Icons.cameraswitch, color: Colors.white), onPressed: _flipCamera),
            IconButton(icon: const Icon(Icons.call_end, color: Colors.red), onPressed: () => _room?.disconnect()),
          ],
        ),
      ),
    );
  }
}
```

## 6. Data messages (optional)

Send and receive arbitrary messages over the same connection (needs
`import 'dart:convert';`):

```dart
// send
await room.localParticipant?.publishData(
  utf8.encode(jsonEncode({'type': 'reaction', 'emoji': '👏'})),
  reliable: true, topic: 'signals',
);

// receive
listener.on<DataReceivedEvent>((e) {
  final msg = jsonDecode(utf8.decode(e.data));
  // handle msg, e.topic, e.participant
});
```

## 7. Lifecycle tips

- Always `dispose()` the listener and room when leaving the screen (releases the
  camera/mic and disconnects).
- `RoomOptions(adaptiveStream: true, dynacast: true)` saves bandwidth/CPU.
- The SDK auto-reconnects on network blips; listen to `RoomDisconnectedEvent` for a
  permanent drop.
- For audio-only calls, just skip `setCameraEnabled(true)`.

## 8. Troubleshooting

| Symptom | Fix |
|---------|-----|
| Black video / no camera | Permissions not granted; check `Info.plist`/manifest + runtime request |
| Connects then immediately drops | Token expired or wrong `ws_url`; re-fetch from your backend |
| No remote video | The other side hasn't published, or `can_subscribe:false` on the token |
| Works on Wi-Fi, not cellular | Network blocks UDP — the SDK falls back to TCP automatically; ensure latest SDK |
| iOS build fails | Set deployment target ≥ 12.1; run `pod install` |

Reference: [LiveKit Flutter SDK docs](https://docs.livekit.io/client-sdk-flutter/).
The SDK is fully compatible — arzbridge runs standard LiveKit.


---

# 06 — Security & best practices

## Protect your credentials

- **API key + secret belong on your backend only.** Never ship them in a mobile
  app, web bundle, or public repo — anyone with them can create unlimited calls on
  your account.
- Store them in environment variables / a secrets manager, not in source code.
- Rotate immediately if leaked (ask arzbridge to issue a new pair and revoke the
  old one).

## Mint tokens server-side, per user

- Always create tokens on your backend **after** authenticating the user with your
  own system. Apply your own rules (who can join which room, with what role).
- Set a sensible `ttl` — long enough to connect, not forever. The call continues
  after the token expires; you don't need long-lived tokens.
- Use a **stable, unique `identity`** per user (e.g. your user id). Don't reuse one
  identity for two simultaneous people in the same room — the later one displaces
  the earlier.

## Use least-privilege grants

- Give viewers `can_publish: false`; give broadcasters `can_subscribe: false` if
  they shouldn't see others; reserve `room_admin: true` for moderators only.
- Don't hand out `room_admin` to normal participants.

## Validate inputs

- Room names must match `^[A-Za-z0-9][A-Za-z0-9._-]{1,63}$`. Generate them from
  your own ids (e.g. `order-{id}`) rather than user free-text.
- Treat `metadata`/`attributes` as visible to other participants — don't put
  secrets there.

## Handle limits & failures gracefully

- Respect `429` + `Retry-After`; back off rather than hammering.
- Retry only `5xx`/`429` (transient). Never auto-retry `401/403/422`.
- Show users a friendly "reconnecting…" state; the client SDK auto-reconnects on
  brief network drops.

## Privacy & compliance

- You control recording. If you enable it, tell your users and store recordings in
  your own private storage.
- Tokens are bearer credentials — deliver them to your app over HTTPS only, and
  don't log them.

## Production checklist

- [ ] API key/secret only on the backend, in env/secrets manager
- [ ] Token endpoint behind your own authentication
- [ ] Sensible `ttl` and least-privilege grants per role
- [ ] Unique `identity` per user
- [ ] Retry/backoff on `429`/`5xx`
- [ ] HTTPS everywhere; tokens never logged
- [ ] Client SDK kept up to date

## Support

Contact arzbridge for: raising rate limits, enabling recording, enabling webhook
forwarding to your backend, additional API keys, or any integration help.

