LevelChatLevelChatDocs
Webhooks

Guides

Webhooks

Reliable, signed event delivery.

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):

text
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/json

The 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

TypeScript
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

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

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:

EventWhenUse it for
room.endedLast participant left or host endedBilling rollups, ticket close
recording.readyCompression + HLS doneEmail notification, CDN mirror
participant.leftPer-participant disconnectAttendance, session reconciliation
qos.alertLoss storm / low bitrate / high RTTPager, customer support tooling
license.revokedOn-prem license invalidatedForce-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.