URL: /docs/recording

---
title: Recording
description: Optional start/stop. Per-participant MP4 on S3.
icon: circle-dot
---

Recording is a **lossy fork**, not a third peer. Rill copies live RTP (and reassembled data messages) to `rill-record` with a non-blocking `try_send`. The sidecar writes a disk buffer, muxes MP4, uploads S3, and POSTs your app. The live SFU loop does not wait on FFmpeg or S3.

If `rill-record` is omitted or down, **the call continues**. `create({ recording: true })` still returns 201 with `recording: false`. Later `recordingStart()` returns `recording_unavailable`.

Still two people. Destroying the call stops an active recording. The public demo (`compose.site.yaml`) does not run recording.

```
Rill SFU  --try_send-->  rill-record  -->  /recordings (buffer)
                              |                 |
                              |                 +-- FFmpeg mux
                              |                 +-- S3 PUT
                              +-- HMAC POST recording.stopped
```

## Create vs start/stop

{/* example:recording */}
```ts
import { Rill } from "@rill/server";

const rill = new Rill({
  url: "https://rill.example.com",
  apiKey: process.env.RILL_API_KEY!,
  apiSecret: process.env.RILL_API_SECRET!,
});

const call = await rill.calls.create({ recording: true });
if (!call.recording) {
  // Sidecar down. The call still exists.
}

await call.recordingStart();
await call.recordingStop();
await call.end();
```
{/* /example:recording */}

```sh
curl -s -X POST http://127.0.0.1:7880/v1/calls/$CALL/recording/start \
  -H "Authorization: Bearer devkey"

curl -s -X POST http://127.0.0.1:7880/v1/calls/$CALL/recording/stop \
  -H "Authorization: Bearer devkey"
```

`docker compose up` already starts `rill-record` and Garage. HMAC for `recording.stopped` is configured on the sidecar (`RILL_RECORD_WEBHOOK_SECRET`), not the SFU. Same header scheme as join/leave — [Webhooks](/webhooks).

### Local compose

Contributors can run `mise run record-demo` (or `bun docs/examples/recording-local.ts`) after compose is up. Signed GET URLs use Garage at `http://127.0.0.1:3900`. The try-it site on `:3000` does not start recording.

## Files

After stop, each participant with camera+mic is **one MP4** (`H.264` + `AAC`) that plays in Chrome and desktop Safari `<video>`. Timing follows the live media clocks, not a fake 30 fps. Audio-only is an AAC MP4. Screen share is `{participant}-screen.mp4`. Eligible data labels are `{participant}-{label}.jsonl` (`application/x-ndjson`); empty files are omitted. Dots in labels stay dots (`alice-a.b.jsonl` ≠ `alice-a_b.jsonl`). Each complete data-channel message is one JSONL line (`t` in microseconds, `encoding` `utf8` or `base64`, `payload`). Chunk rrweb snapshots in the app (≤128 KiB, 60 KiB is safe) so live relay and JSONL stay in sync. Labels starting with `_` are SDK-internal and are not recorded. No composite of both people. No RTMP. No download HTTP on the SFU.

Objects land in Garage (local) or S3 (production). Disk under `/recordings` is a buffer and is cleared after upload. Dumps are not the playable format. If the sidecar restarts mid-call, it muxes leftover dumps on startup and may POST `recording.stopped` again with the same `id` (`{callId}-{startedAt}`); tolerate a duplicate webhook if the crash happened after mux but before the first POST.

## Webhook

Set on **rill-record** (not the SFU): `RILL_RECORD_WEBHOOK_URL` and `RILL_RECORD_WEBHOOK_SECRET`. Same HMAC as Rill join/leave: `X-Rill-Timestamp` + `X-Rill-Signature`. Signed GET URLs last 24 hours. Rill’s join/leave webhook set does not include recording events.

```json
{
  "type": "recording.stopped",
  "callId": "<ulid>",
  "startedAt": "20260820T120000Z",
  "stoppedAt": "20260820T121500Z",
  "files": [
    {
      "participantId": "alice",
      "kind": "av",
      "bucket": "rill",
      "key": "rill/<call>/<started>/alice.mp4",
      "url": "http://127.0.0.1:3900/rill/...",
      "contentType": "video/mp4"
    },
    {
      "participantId": "alice",
      "kind": "data",
      "label": "chat",
      "bucket": "rill",
      "key": "rill/<call>/<started>/alice-chat.jsonl",
      "url": "http://127.0.0.1:3900/rill/...",
      "contentType": "application/x-ndjson"
    }
  ]
}
```

`kind` is `av`, `screen`, or `data`. Data files include `label`. If mux fails, that entry includes `error` and may omit `url`. The live call does not wait on S3.

## Production

Point the sidecar at real S3. Keep bucket keys in the recorder env, never the SFU `.env`. Socket must match: `RILL_RECORD_SOCKET` on both processes (`127.0.0.1:7420` or a unix path).

| Variable | Role |
|----------|------|
| `RILL_RECORD_SOCKET` | Must match the SFU |
| `RILL_RECORD_DIR` | Disk buffer |
| `RILL_RECORD_S3_BUCKET` | Bucket |
| `RILL_RECORD_S3_PREFIX` | Object prefix (default `rill/`) |
| `RILL_RECORD_S3_ENDPOINT` | Garage/R2 origin; omit for AWS |
| `RILL_RECORD_S3_REGION` | Default `us-east-1` (or `auto` with an endpoint) |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Sidecar only |
| `RILL_RECORD_WEBHOOK_URL` / `RILL_RECORD_WEBHOOK_SECRET` | Completion POST |

If this service is down, `POST .../recording/start` fails; the call does not.
