Webhooks
Join, leave, call ended, and recording.stopped.
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.
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;
}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
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
import hashlib, hmac
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256
).hexdigest()
hmac.compare_digest(expected, signature.lower())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.
{
"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.
{
"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.
{
"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.
{
"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/..."
}
]
}