URL: /docs/webhooks

---
title: "Webhooks"
description: "Join, leave, call ended, and recording.stopped."
icon: webhook
---

Rill POSTs JSON to `RILL_WEBHOOK_URL` with `X-Rill-Timestamp` (unix seconds) and `X-Rill-Signature` (hex HMAC-SHA256 of `timestamp + "." + raw body` using `RILL_API_SECRET`). Rill does not follow HTTP redirects — set `RILL_WEBHOOK_URL` and `RILL_RECORD_WEBHOOK_URL` to the final HTTPS endpoint.

The call never waits on your URL. Same person replacing a connection does not emit leave then join.

Recording completion is a **sidecar** POST (`RILL_RECORD_WEBHOOK_URL` / `RILL_RECORD_WEBHOOK_SECRET`). Same HMAC headers. Join/leave events never include recording.

Verify with `verifyWebhook` from `@rill/server` on the **raw** POST body. Default skew is five minutes (`maxSkewSeconds: 300`). After HMAC succeeds, `JSON.parse` the body and persist `id` — `recording.stopped` uses `{callId}-{startedAt}`; treat duplicate `id` as idempotent. See `examples/webhook-receiver/` for a tiny Node sample.

{/* example:verify-webhook */}
```ts
import { verifyWebhook } from "@rill/server";

export function handleWebhook(
  rawBody: string,
  headers: { timestamp?: string; signature?: string },
) {
  const result = verifyWebhook({
    secret: process.env.RILL_API_SECRET!,
    timestamp: headers.timestamp ?? "",
    signature: headers.signature ?? "",
    body: rawBody,
  });
  if (!result.ok) {
    throw new Error(result.code);
  }
  const event = JSON.parse(rawBody) as { id: string; type: string };
  // Persist event.id. recording.stopped uses {callId}-{startedAt}.
  return event;
}
```
{/* /example:verify-webhook */}

### Other languages

Signature is hex HMAC-SHA256 of `"{timestamp}.{raw_body}"`. Compare with a constant-time check. Reject if `|now - timestamp|` exceeds 300 seconds.

**Go**

```go
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
hmac.Equal([]byte(expected), []byte(strings.ToLower(signature)))
```

**Python**

```python
import hashlib, hmac

expected = hmac.new(
    secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256
).hexdigest()
hmac.compare_digest(expected, signature.lower())
```

**Rust**

```rust
use hmac::{Hmac, Mac};
use sha2::Sha256;

let mut mac = Hmac::<Sha256>::new_from_slice(secret)?;
mac.update(timestamp.as_bytes());
mac.update(b".");
mac.update(body);
let expected = hex::encode(mac.finalize().into_bytes());
```

Gold vector (secret `secret`, timestamp `1700000000`):

```
{"id":"01ARZ3NDEKTSV4RRFFQ69G5FAV","type":"participant.left","createdAt":"2023-11-14T22:13:20Z","callId":"01ARZ3NDEKTSV4RRFFQ69G5FAV","participantId":"user-123"}
```

Signature: `1bd40059accd4f28c5366b0f9647533d3b6d02276792f17c8f94eaf7c7977343`

## `participant.joined`

A new participant id is in the call.

```json
{
  "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
  "type": "participant.joined",
  "createdAt": "2023-11-14T22:13:20Z",
  "callId": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
  "participantId": "alice"
}
```

## `participant.left`

That id is gone (including kick). Last-writer-wins replace does not emit leave then join.

```json
{
  "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
  "type": "participant.left",
  "createdAt": "2023-11-14T22:13:20Z",
  "callId": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
  "participantId": "alice"
}
```

## `call.ended`

Call destroyed. `reason` is `empty`, `join_timeout`, `lifetime`, `ended`, or `shutdown`.

```json
{
  "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
  "type": "call.ended",
  "createdAt": "2023-11-14T22:13:20Z",
  "callId": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
  "reason": "empty"
}
```

## `recording.stopped`

Sidecar completion POST. Same HMAC headers. Configure on rill-record, not the SFU. The sidecar retries like SFU join/leave webhooks; `id` is `{callId}-{startedAt}` — treat duplicate `id` as idempotent.

```json
{
  "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV-20260820T120000Z",
  "type": "recording.stopped",
  "createdAt": "2026-08-20T12:15:00Z",
  "callId": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
  "startedAt": "20260820T120000Z",
  "stoppedAt": "20260820T121500Z",
  "files": [
    {
      "participantId": "alice",
      "kind": "av",
      "contentType": "video/mp4",
      "bucket": "rill",
      "key": "rill/01ARZ3NDEKTSV4RRFFQ69G5FAV/20260820T120000Z/alice.mp4",
      "url": "http://127.0.0.1:3900/rill/..."
    }
  ]
}
```
