# 100ms Webhooks

100ms (live video SDK) sends a JSON `POST` to your endpoint when something happens in a room:
a peer joins or leaves, a session opens or closes, a recording or HLS stream starts or finishes.

100ms webhooks are not signed. Add a secret header to the webhook in the 100ms dashboard and enter it here.
Your endpoint authenticates each request by comparing that header with the value you stored.

## When to Use This Skill

* How do I receive 100ms webhooks?
* Does 100ms sign webhook payloads? (No: use a secret header)
* How do I check the secret header on a 100ms webhook?
* How do I handle `peer.join.success`, `session.close.success` or `recording.success` events?
* Why does my 100ms webhook endpoint return 401?

## Verification (core)

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

// Hash both sides: equal-length buffers, so timingSafeEqual never throws.
function safeEqual(received, expected) {
  const a = crypto.createHash('sha256').update(Buffer.from(received, 'latin1')).digest();
  const b = crypto.createHash('sha256').update(Buffer.from(expected, 'utf8')).digest();
  return crypto.timingSafeEqual(a, b);
}

function verify100msRequest(headers, secret = process.env.HUNDRED_MS_WEBHOOK_SECRET) {
  if (!secret) return false; // never accept when unconfigured
  const name = (process.env.HUNDRED_MS_WEBHOOK_HEADER || 'Authorization').toLowerCase();
  const received = headers[name]; // Node lowercases header names
  return typeof received === 'string' && received !== '' && safeEqual(received, secret);
}

```

* The header **name** is whatever you chose in the dashboard (`HUNDRED_MS_WEBHOOK_HEADER`,
  default `Authorization`), looked up case-insensitively.
* Compare the **whole** value (`HUNDRED_MS_WEBHOOK_SECRET`), including any `Bearer ` prefix you typed.
  Do not strip or parse the prefix.
* Python: `hmac.compare_digest` on bytes (encode both sides; never pass `str`, which raises on non-ASCII).
* Return `401` for a missing header or mismatch, `400` for a body that isn't a JSON object with a `type`,
  and `200` once handled. 100ms retries three times on any other status.
* Read the body raw (`express.raw({ type: '*/*' })`) and authenticate before parsing it.
* A static shared credential authenticates the sender, not the body, and gives no replay protection
  (no signature, no timestamp). Use HTTPS and rotate the credential if it leaks. Optionally also
  allowlist the 100ms IP addresses (listed in their docs).

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/100ms-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/100ms-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/100ms-webhooks/examples/fastapi/).

## Event Types

The body is `{version, id, app_id, account_id, template_id, timestamp, type, data}`. `type` is
`<object>.<action>.<outcome>`; dispatch on it and dedupe on `id`. The webhook must accept `POST`.

| Event | Sent When |
| --- | --- |
| `session.open.success`, `session.close.success` | A session starts or ends |
| `peer.join.success`, `peer.leave.success` | A peer joins or leaves a room |
| `peer.join.failure`, `peer.leave.failure`, `room.end.failure`, `role.change.failure` | A peer join or leave, room end or role change fails |
| `room.end.success` | A room ends |
| `role.change.success` | A peer's role is updated |
| `recording.success`, `recording.failed` | An SFU recording ends |
| `track.recording.started.success`, `track.recording.success`, `track.recording.failure` | A track recording starts, ends or fails |
| `stream.recording.success`, `stream.recording.failure` | A stream recording ends or fails |
| `beam.started.success`, `beam.stopped.success`, `beam.recording.success`, `beam.failure` | RTMP streaming or browser recording starts, stops, ends or fails |
| `hls.started.success`, `hls.stopped.success`, `hls.recording.success`, `hls.failure` | HLS streaming or recording starts, stops, ends or fails |
| `transcription.started.success`, `transcription.success`, `transcription.failure` | Transcription starts, ends or fails |
| `ingest.start.success`, `ingest.end.success`, `ingest.start.failure`, `ingest.failure` | RTMP ingestion starts, ends or fails |
| `sip.start.success`, `sip.start.failure`, `sip.end.success`, `sip.end.failure` | An inbound or outbound SIP call connects, fails or ends |

## Environment Variables

```bash
HUNDRED_MS_WEBHOOK_HEADER=Authorization        # header name you chose (default: Authorization)
HUNDRED_MS_WEBHOOK_SECRET=your-secret-value    # exact, whole header value

```

The prefix is `HUNDRED_MS` because an environment variable name can't start with a digit.

## Local Development

100ms can't reach `localhost`. Tunnel with the Hookdeck CLI (no account required):

```bash
npx hookdeck-cli listen 3000 100ms --path /webhooks/100ms

```

Use the printed URL as the webhook URL in the 100ms dashboard.

## Resources

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/100ms-webhooks/references/overview.md) - What 100ms webhooks are, events and payload shape
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/100ms-webhooks/references/setup.md) - Configure the webhook and secret header in the dashboard
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/100ms-webhooks/references/verification.md) - Secret header check details, gotchas, debugging 401s
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/100ms-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests