08 โ€” Receiving webhooks

Get real-time server-side notifications when things happen in your calls โ€” rooms start/finish, participants join/leave, recordings finish. arzbridge POSTs a signed JSON event to an endpoint you register, so your backend can react (update your DB, notify users, trigger workflows) without polling.

Webhooks are server-to-server. Register an endpoint on your backend, not in a mobile/web app.

1. Register an endpoint

Via the API (needs the webhooks.write scope) โ€” the signing secret is returned once, store it:

curl -s https://calls.arzbridge.com/api/v1/webhook-endpoints \
  -H "Authorization: Bearer $KEY:$SECRET" -H "Content-Type: application/json" \
  -d '{
        "url": "https://api.yourapp.com/webhooks/calls",
        "description": "production",
        "events": ["participant_joined","participant_left","room_finished"]
      }'
# -> { "data": { "id": 7, "secret": "whsec_โ€ฆ", "active": true, ... } }

Omit events (or pass []) to receive all event types. Manage endpoints with GET/DELETE /api/v1/webhook-endpoints and POST /api/v1/webhook-endpoints/{id}/test. (Or ask arzbridge to add it for you.)

Your URL must be public HTTPS โ€” private/loopback addresses are rejected.

2. The request we send

POST to your URL, Content-Type: application/json, with headers:

Header Example Meaning
X-Arzbridge-Event participant_joined The event type
X-Arzbridge-Delivery 4821 Unique delivery id (for your logs)
X-Arzbridge-Signature t=1751280000,v1=9f86d0โ€ฆ HMAC signature (verify this!)

Body (room names are your names, never the internal ones):

{
  "id": "EV_8s2โ€ฆ",
  "event": "participant_joined",
  "created_at": 1751280000,
  "room": "support-42",
  "participant": { "identity": "user-1", "name": "Alice", "sid": "PA_โ€ฆ" },
  "egress": {
    "egress_id": "EG_โ€ฆ",
    "status": 3,
    "files": [
      { "filename": "โ€ฆmp4", "location": "โ€ฆ", "size": 1048576, "duration": 42000000000 }
    ]
  }
}

participant is present on participant/track events; egress on recording events. On egress_ended (recording finished, status: 3) the egress.files[] array lists the produced file(s) โ€” filename, location, size (bytes) and duration (nanoseconds). (files is omitted until the recording completes.)

3. Verify the signature (always do this)

The signature is HMAC-SHA256( "<t>.<raw-body>", secret ), hex-encoded. Use the raw request body (not a re-serialized object) and compare in constant time. Optionally reject timestamps older than ~5 min to prevent replay.

Node.js (Express)

const crypto = require('crypto');
const SECRET = process.env.ARZ_WEBHOOK_SECRET;

// IMPORTANT: capture the RAW body for this route
app.post('/webhooks/calls', express.raw({ type: '*/*' }), (req, res) => {
  const m = (req.header('X-Arzbridge-Signature') || '').match(/t=(\d+),v1=([a-f0-9]+)/);
  if (!m) return res.sendStatus(400);
  const [, t, v1] = m;
  const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${req.body}`).digest('hex');
  const ok = expected.length === v1.length &&
             crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
  if (!ok) return res.sendStatus(401);
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(401); // replay guard

  const event = JSON.parse(req.body.toString());
  // ...handle event.event, event.room, event.participant...
  res.sendStatus(200); // ACK fast (within ~10s)
});

Laravel

// routes/api.php  (api routes are CSRF-exempt)
use Illuminate\Http\Request;

Route::post('/webhooks/calls', function (Request $r) {
    $sig = $r->header('X-Arzbridge-Signature', '');
    if (! preg_match('/t=(\d+),v1=([a-f0-9]+)/', $sig, $m)) abort(400);
    [, $t, $v1] = $m;

    $expected = hash_hmac('sha256', $t.'.'.$r->getContent(), config('services.arzcalls.webhook_secret'));
    abort_unless(hash_equals($expected, $v1), 401);
    abort_if(abs(time() - (int) $t) > 300, 401);          // replay guard

    $event = $r->json()->all();
    // ...handle $event['event'], $event['room'], $event['participant']...

    return response()->noContent();                        // 200/204
})->name('calls.webhook');

4. Event types

room_started, room_finished, participant_joined, participant_left, track_published, track_unpublished, egress_started, egress_updated, egress_ended. Subscribe to a subset via events, or receive all.

5. Delivery, retries & best practices

  • Respond 2xx quickly (within ~10s). Do heavy work asynchronously โ€” just acknowledge first.
  • Retries: non-2xx / timeouts are retried up to 5 times with backoff (~10s, 30s, 2m, 5m, 15m). An endpoint that keeps failing is auto-disabled.
  • Idempotency: the same delivery may arrive more than once (e.g. after a retry). De-duplicate on the event id.
  • Ordering is not guaranteed; use created_at if you need sequence.
  • Keep the secret safe; rotate by deleting and re-creating the endpoint.
  • Test anytime with POST /api/v1/webhook-endpoints/{id}/test (sends a ping).

That's it โ€” your backend now stays in sync with every call in real time.