LevelChat delivers events as outbound HTTPS POSTs. Receivers must be idempotent — we deliver
at-least-once and retry on backoff: 0s, 10s, 1m, 5m, 15m, 1h, 4h then dead-letter
(7 attempts total, ±20% jitter, ~5h 19m wall-clock window). HTTP 410 Gone terminates the
schedule immediately; every other 4xx/5xx response is retried.
Register an endpoint
curl -X POST $LC_API_URL/v1/webhooks/endpoints \
-H "Authorization: Bearer lc_pk_xxx.yyy" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.example/lc-events",
"events": ["room.ended","recording.ready","qos.alert"]
}'The response includes a one-time-readable secret you'll use to verify the HMAC signature.
Headers we send
Every delivery carries these headers (defined in services/webhooks/internal/delivery/sign.go):
X-LC-Webhook-Id: evt_01HF... # unique delivery id — dedupe on this
X-LC-Webhook-Type: room.ended # event name
X-LC-Webhook-Ts: 2026-04-25T17:00:00.123Z # RFC-3339Nano, bound into the signature
X-LC-Webhook-Sig: base64(HMAC-SHA-256(secret, ts + "." + body))
X-LC-Webhook-Kid: v1 # signing key id; supports rotation
X-LC-Webhook-Attempt: 1 # 1, 2, 3 … on retry
Content-Type: application/jsonThe signature canonical string is ts + "." + body — the raw request body bytes
prepended with the timestamp and a literal dot. This is the same shape Stripe uses, so
copy-paste from existing verifier code works with one substitution: hex → base64
(base64.RawStdEncoding — no = padding).
Dedupe on X-LC-Webhook-Id. The same event can be delivered more than once if your
endpoint fails a previous attempt.
Verify a delivery
Reject anything older than 5 minutes — replay protection is your responsibility.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(headers: Headers, rawBody: string, secret: string): boolean {
const sig = headers.get('x-lc-webhook-sig');
const ts = headers.get('x-lc-webhook-ts');
if (!sig || !ts) return false;
// Replay window: ±5 minutes.
const age = Math.abs(Date.now() - Date.parse(ts));
if (Number.isNaN(age) || age > 5 * 60 * 1000) return false;
// base64( HMAC-SHA-256(secret, ts + "." + body) ), no padding.
const expected = createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('base64')
.replace(/=+$/, '');
const got = sig.replace(/=+$/, '');
if (expected.length !== got.length) return false;
return timingSafeEqual(Buffer.from(expected), Buffer.from(got));
}Python
import base64
import hmac
import hashlib
from datetime import datetime, timezone
def verify(headers, raw_body: bytes, secret: bytes) -> bool:
sig = headers.get("X-LC-Webhook-Sig")
ts = headers.get("X-LC-Webhook-Ts")
if not sig or not ts:
return False
# Replay window: +/- 5 minutes.
sent = datetime.fromisoformat(ts.replace("Z", "+00:00"))
age = abs((datetime.now(timezone.utc) - sent).total_seconds())
if age > 300:
return False
canonical = ts.encode("utf-8") + b"." + raw_body
mac = hmac.new(secret, canonical, hashlib.sha256).digest()
# base64.RawStdEncoding — strip "=" padding.
expected = base64.b64encode(mac).rstrip(b"=").decode("ascii")
return hmac.compare_digest(expected, sig.rstrip("="))Go
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"net/http"
"strings"
"time"
)
// Verify reports whether the request signature matches `secret` and was
// issued within the ±5-minute replay window.
func Verify(r *http.Request, body []byte, secret []byte) bool {
sig := r.Header.Get("X-LC-Webhook-Sig")
ts := r.Header.Get("X-LC-Webhook-Ts")
if sig == "" || ts == "" {
return false
}
parsed, err := time.Parse(time.RFC3339Nano, ts)
if err != nil {
return false
}
if delta := time.Since(parsed); delta > 5*time.Minute || delta < -5*time.Minute {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(ts + "." + string(body)))
expected := base64.RawStdEncoding.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(strings.TrimRight(sig, "=")), []byte(expected))
}Event catalog
The full list lives in the API reference. The high-traffic ones:
| Event | When | Use it for |
|---|---|---|
room.ended | Last participant left or host ended | Billing rollups, ticket close |
recording.ready | Compression + HLS done | Email notification, CDN mirror |
participant.left | Per-participant disconnect | Attendance, session reconciliation |
qos.alert | Loss storm / low bitrate / high RTT | Pager, customer support tooling |
license.revoked | On-prem license invalidated | Force-stop on tenants you've offboarded |
Key rotation
A tenant may have multiple active signing secrets (kid v1, kid v2). We always sign with
the active kid; the X-LC-Webhook-Kid header tells your verifier which secret to use. Keep
old kids loaded until every in-flight delivery has drained — typically 24 hours after
rotation.