Customer Webhooks

Customer webhooks deliver RTC lifecycle events to a customer's HTTPS endpoint.

Supported events

Current event sources:

  • room.started
  • room.ended
  • participant.joined
  • participant.left
  • usage.finalized
  • credit.low
  • credit.exhausted
  • credit.restored
  • payment.succeeded
  • payment.failed
  • payment.expired
  • payment.canceled
  • payment.refunded

Create an endpoint

POST /v1/webhook-endpoints
Authorization: Bearer sk_live_xxx
Content-Type: application/json
{
  "url": "https://customer.example.com/webhooks/rtc",
  "events": [
    "room.started",
    "room.ended",
    "participant.joined",
    "participant.left",
    "usage.finalized"
  ]
}

The create response contains a signing secret such as:

whsec_xxxxxxxxx

Store it securely. The API does not expose the secret again.

Delivery envelope

{
  "id": "evt_xxx",
  "type": "participant.joined",
  "created_at": "2026-09-28T16:00:00Z",
  "project_id": "prj_xxx",
  "data": {
    "room_id": "room_xxx",
    "participant_id": "part_xxx"
  }
}

Headers

Content-Type: application/json
User-Agent: Kallo-Webhooks/0.1
X-RTC-Webhook-Id: evt_xxx
X-RTC-Timestamp: 1790611200
X-RTC-Signature: v1=<hex-hmac>

The signed content is:

X-RTC-Timestamp + "." + raw_request_body

Algorithm:

HMAC-SHA256(webhook_secret, signed_content)

Use the exact raw request body bytes. Do not parse and re-serialize JSON before verification.

Node.js verification

import { verifyWebhookSignature } from "@kallolive/server";

const valid = await verifyWebhookSignature({
  secret: process.env.KALLO_WEBHOOK_SECRET!,
  timestamp: req.headers["x-rtc-timestamp"] as string,
  signature: req.headers["x-rtc-signature"] as string,
  body: rawBody,
});

if (!valid) {
  throw new Error("invalid webhook signature");
}

The default replay tolerance is five minutes.

Go verification

err := kallo.VerifyWebhookSignature(
    os.Getenv("KALLO_WEBHOOK_SECRET"),
    r.Header.Get("X-RTC-Timestamp"),
    r.Header.Get("X-RTC-Signature"),
    rawBody,
    5*time.Minute,
    time.Now(),
)
if err != nil {
    http.Error(w, "invalid webhook", http.StatusUnauthorized)
    return
}

Retry policy

Failed deliveries are retried approximately at:

10 seconds
1 minute
5 minutes
30 minutes
2 hours
6 hours
24 hours

After the retry window is exhausted, the delivery moves to dead.

Customers must process webhook events idempotently using the event id.

Success response

Any HTTP status from 200 through 299 marks the delivery successful.

Redirects are not followed.

Security

Outbound webhook delivery:

  • requires HTTPS
  • rejects localhost/private/link-local destinations
  • resolves DNS immediately before connecting
  • rejects hostnames resolving to a private/local address
  • does not use environment HTTP proxies
  • limits response body capture
  • uses a delivery timeout
  • signs every request

These protections reduce SSRF risk from customer-configured callback URLs.

Reliability

Media lifecycle events are generated transactionally with state/usage changes where possible.

The reconciler can recover missing LiveKit join/leave lifecycle events and still generate:

  • room start
  • participant join
  • participant leave
  • finalized usage

Manual room termination also finalizes open usage sessions before producing room.ended.