# Guide to 100ms Webhooks: Features and Best Practices

100ms webhooks report what happens inside your live video and audio rooms. Every session produces a stream of events, such as peers joining and leaving, sessions opening and closing, recordings finishing and HLS streams starting, and 100ms posts each of them to your server. That's how you build attendance records, bill for usage, pick up finished recordings or react when a stream fails, without polling the 100ms API.

This guide covers how 100ms webhooks are configured, the events and payload format, how to authenticate requests with secret headers and the IP allowlist, how 100ms retries, and the practices that keep a 100ms receiver from losing events.

## What are 100ms webhooks?

Each 100ms workspace has its own webhook configuration, set in the **Developer** section of the 100ms dashboard. 100ms sends a JSON `POST` to that URL for every event in the workspace, and your endpoint must accept `POST` and answer `200 OK`.

100ms doesn't sign webhooks. Its documentation offers two ways to secure them:

* **Secret headers.** You set headers in the webhook configuration, and 100ms passes them "transparently to your webhook endpoint". Your endpoint checks the header name and value.
* **IP allowlisting.** 100ms publishes the addresses of its own NAT gateways, so a firewall can allow only those.

## 100ms webhook features

| Feature | Details |
| --- | --- |
| Configuration | One webhook per workspace, in the dashboard's **Developer** section |
| Method | `POST` with a JSON body |
| Event field | `type`, in the form `object.action.outcome` |
| Envelope | `version`, `id`, `app_id`, `account_id`, `template_id`, `timestamp`, `type`, `data` |
| Authentication | Secret headers you define. No signature or timestamp check |
| Source IPs | 61 published addresses of 100ms-owned NAT gateways |
| Success | HTTP `200` |
| Retries | Three, at 1 second, 3 seconds and 10 seconds after the previous attempt |
| Timeout | Not documented |

## Common events

| Area | `type` | Fires when |
| --- | --- | --- |
| Sessions | `session.open.success`, `session.close.success` | A session starts or ends |
| Peers | `peer.join.success`, `peer.leave.success`, `peer.join.failure`, `peer.leave.failure` | A peer joins or leaves a room, or fails to |
| Rooms and roles | `room.end.success`, `room.end.failure`, `role.change.success`, `role.change.failure` | A room is ended, or a peer's role changes |
| Recordings | `track.recording.started.success`, `track.recording.success`, `track.recording.failure`, `stream.recording.success`, `stream.recording.failure`, `recording.success`, `recording.failed` | Track, stream or composed recordings start, finish or fail |
| RTMP streaming and browser recording | `beam.started.success`, `beam.stopped.success`, `beam.recording.success`, `beam.failure` | Streaming or browser recording starts, stops, uploads or fails |
| HLS | `hls.started.success`, `hls.stopped.success`, `hls.recording.success`, `hls.failure` | HLS streaming or recording starts, stops, uploads or fails |
| Transcription | `transcription.started.success`, `transcription.success`, `transcription.failure` | Post-recording transcription starts, succeeds or fails |
| RTMP ingest | `ingest.start.success`, `ingest.end.success`, `ingest.start.failure`, `ingest.failure` | Incoming RTMP starts, ends or fails |
| SIP | `sip.start.success`, `sip.start.failure`, `sip.end.success`, `sip.end.failure` | A SIP call is answered, fails to connect, or ends |

Notice that the composed recording failure is `recording.failed`, not `recording.failure`. 100ms's summary table also leaves out `room.end.failure` and `role.change.failure`, which have their own sections further down the page.

Every event uses the same envelope, with the event's own fields in `data`. Here is 100ms's sample event:

```json
{
  "version": "2.0",
  "id": "6c75de35-b778-4fb0-88ae-114c28dc285f",
  "account_id": "60b8e13a6eb86d8101b57354",
  "timestamp": "2021-08-08T07:04:15Z",
  "type": "peer.join.success",
  "data": {
    "joined_at": "2021-08-08T07:04:15.001380432Z",
    "peer_id": "83b869e1-9a4b-4037-84b2-913cf76e4392",
    "role": "host",
    "room_id": "60b8e13a6eb86d8101b57354",
    "room_name": "test room",
    "session_id": "610f81ee870dde099a249948",
    "template_id": "66112497abcd52312556c4gg",
    "user_id": "user.001",
    "user_name": "test user"
  }
}

```

The envelope table also lists `app_id` and `template_id` at the top level, which this sample omits, so treat them as optional. The per-event samples in the docs have their IDs masked with asterisks, and the `track.recording.failure` sample shows `"type": "track.recording.success"`, so trust the section headings over the sample values.

## Setting up 100ms webhooks

1. Pick a header name, such as `X-100ms-Secret`, and generate a long random value with `openssl rand -hex 32`.
2. In the 100ms dashboard, open the **Developer** section and find your workspace's webhook configuration.
3. Enter your HTTPS endpoint URL.
4. Add the header name and value as a secret header.
5. Save, then start a test room to generate events.

If your firewall can block inbound traffic, allow the IP addresses listed on 100ms's [Secure Your Webhooks](https://www.100ms.live/docs/server-side/v2/how-to-guides/configure-webhooks/secure-webhooks) page as well. Keep the header check in place: the IP list is a second layer, not a replacement.

**With Hookdeck.** 100ms webhooks are not signed. Add a secret header to the webhook in the 100ms dashboard, then create a Hookdeck source with the **100ms** type and enter the same name in **API Key Header** and the exact value in **API Key**. Hookdeck finds the header case-insensitively and compares the whole value, so for `Authorization: Bearer abc123` enter `Bearer abc123`. Use the source URL as the webhook URL in 100ms. The source accepts `POST` only, which is what 100ms sends.

## Securing 100ms webhooks

Read the header you configured, compare its whole value with the stored one in constant time, then parse the body.

```javascript
const crypto = require('crypto');
const express = require('express');

const HEADER = (process.env.HUNDRED_MS_WEBHOOK_HEADER || 'X-100ms-Secret').toLowerCase();
const SECRET = process.env.HUNDRED_MS_WEBHOOK_SECRET; // the exact, whole header value

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/100ms', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get(HEADER);
  if (!SECRET || typeof received !== 'string' || !safeEqual(received, SECRET)) {
    return res.sendStatus(401);
  }

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // Deduplicate on event.id, queue by event.type, then answer with exactly 200.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

HEADER = os.environ.get("HUNDRED_MS_WEBHOOK_HEADER", "X-100ms-Secret")
SECRET = os.environ.get("HUNDRED_MS_WEBHOOK_SECRET", "")

def verify_100ms_request(headers) -> bool:
    if not SECRET:
        return False  # fail closed when unconfigured
    received = headers.get(HEADER)  # Starlette and Flask headers are case-insensitive
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), SECRET.encode("utf-8"))

```

A static header authenticates the sender, not the body. It can't detect a modified payload, and with no signed timestamp a captured request can be replayed and still pass. Use HTTPS, a long random value, and the IP allowlist where your infrastructure supports it.

> **Make 100ms webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks your secret header on every 100ms event, answers with a `200` straight away, and delivers each event to your service with retries you control.

## 100ms webhook limitations and pain points

### Three retries in 14 seconds

**The Problem:** A deploy, a restart or a short outage loses events for good.

**Why It Happens:** 100ms retries a failed webhook three times: one second after the original attempt, three seconds after the first retry and ten seconds after the second. That is about 14 seconds in total, and 100ms documents no further attempts.

**Workarounds:**

* Keep the receiver highly available, and answer before doing any slow work.
* Reconcile important state, such as recordings, against the 100ms API rather than relying on every webhook arriving.

**How Hookdeck Can Help:** Hookdeck answers 100ms with a `200` as soon as the event is stored, then delivers it to your service with [retries](/docs/retries) over as long a window as you configure. Events that arrive while your service is down wait in Hookdeck instead of disappearing.

### Only `200` counts

**The Problem:** Your endpoint returns `202 Accepted` or `204 No Content`, and you receive the event again.

**Why It Happens:** 100ms says it retries "if your server does not return an HTTP 200 status code". It doesn't say other `2xx` codes count as success.

**Workarounds:**

* Return exactly `200`.
* Deduplicate on the event `id` in case a retry gets through anyway.

**How Hookdeck Can Help:** Hookdeck answers 100ms with a `200`, whatever your own service returns.

### High event volume from busy rooms

**The Problem:** A large session floods your endpoint with peer events, and slow responses turn into retries.

**Why It Happens:** Every peer join and leave is its own webhook, and they all go to the workspace's single URL.

**Workarounds:**

* Queue events and process them asynchronously.
* Ignore event types you don't use rather than doing work for each.

**How Hookdeck Can Help:** Hookdeck absorbs the burst and delivers to your service at a [max delivery rate](/docs/destinations#set-a-max-delivery-rate) you set. [Filters](/docs/filters) can drop the event types you don't need before they reach you.

### One header for the whole workspace

**The Problem:** A wrong header value after a rotation means every event in the workspace fails.

**Why It Happens:** There is one webhook configuration per workspace, and the secret header is a static value.

**Workarounds:**

* Rotate by accepting both values in your receiver, updating the dashboard, then removing the old value.

**How Hookdeck Can Help:** Hookdeck's 100ms source compares the header you name with the exact value you enter. Hookdeck answers 100ms with a `200` either way, then records a request without the right header as failed verification, so it never reaches your destination, and you can see which headers arrived.

## Best practices

### Route on `type`

Switch on `type` and acknowledge anything you don't recognise, so an event type you haven't seen yet doesn't fail and get retried.

### Deduplicate on `id`

Each event has an `id`. Store it before processing and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Order by `timestamp`, not arrival

Peer and session events from the same room can arrive close together. Use `timestamp` and the fields in `data`, such as `joined_at`, to order them.

### Acknowledge fast

With about 14 seconds of retries, speed matters more than usual. Verify, store, return `200`, and process afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

100ms sends one JSON `POST` per room, session, recording or streaming event, with the event name in `type` and no signature. Add a secret header and compare its whole value, allow 100ms's IP addresses where you can, deduplicate on `id`, and answer with exactly `200` quickly, because three retries over about 14 seconds are all you get.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks the header, answers 100ms at once, and holds every event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle 100ms webhooks reliably in minutes.