# Roam Webhooks

Roam (ro.am) is a virtual office with chat, meetings, lobbies and On-Air events. Its v1 Webhooks API posts a JSON envelope (`type`, `eventId`, `timestamp`, `apiVersion`, `data`) to your URL when something happens in a workspace. This skill is about Roam HQ, not Roam Research.

## When to Use This Skill

* How do I receive Roam webhooks?
* How do I verify Roam webhook signatures (`webhook-signature`)?
* How do I handle `chat.message`, `meeting.ended` or `lobby.booked` events?
* Where do I get the Roam webhook signing secret (`whsec_...`)?
* Why do I receive each Roam event twice, and why does verification fail on retries?

## Verification (core)

Roam signs per Standard Webhooks with three headers: `webhook-id`, `webhook-timestamp` (Unix seconds) and `webhook-signature` (space-delimited `v1,<base64>` entries). The signature is base64 HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the **base64-decoded** part of the `whsec_...` secret. Roam's own example uses the `standardwebhooks` library:

```javascript
const { Webhook } = require('standardwebhooks');   // Python: from standardwebhooks.webhooks import Webhook
const event = new Webhook(process.env.ROAM_WEBHOOK_SIGNING_SECRET).verify(rawBody, {
  'webhook-id': req.headers['webhook-id'],
  'webhook-timestamp': req.headers['webhook-timestamp'],
  'webhook-signature': req.headers['webhook-signature'],
});

```

Verify against the **raw body** before parsing JSON (in Express use `express.raw({ type: '*/*' })` so any content type verifies).

**Timestamp window and Hookdeck.** The `standardwebhooks` and `svix` libraries always enforce a fixed 5-minute timestamp window. Hookdeck retries and replays resend the original `webhook-timestamp`, so the library rejects them once they are more than 5 minutes old. If you receive through Hookdeck and need late retries to pass, use the manual verifier in [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/roam-webhooks/references/verification.md) (HMAC-SHA256 over `{id}.{timestamp}.{raw body}`, key = base64-decode of the secret after `whsec_`, every space-separated `v1,<base64>` entry compared in constant time), where the window can be dropped or widened.

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

## Setup

Configure a **signing secret** for the Roam webhook (API Key or OAuth App → **Webhooks** tab → Signing Secret, `whsec_...`). Without one, deliveries are unsigned and fail verification, so always set it, and also when you receive through Hookdeck. See [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/roam-webhooks/references/setup.md).

## Duplicate Deliveries: One Event, Two POSTs

One Roam event can arrive as **two POSTs with the same `webhook-id`**: a legacy tagged-id body (`type: "message"`, ids like `B-...`) and the v1 body (envelope with `type: "chat.message"`, untagged UUIDs). Treat them as one event: dedupe on `webhook-id`, process the v1 envelope and skip the legacy body. The `webhook-id` is also identical across retries (Roam retries 429, 5xx and timeouts about 1 second, +1 minute and +5 minutes later).

## Common Event Types

| Event (`type`) | Sent when |
| --- | --- |
| `chat.message` | A chat message is created, edited or deleted (`data.version`, `data.contentType: "deleted"`) |
| `chat.reaction` | A reaction is added to or removed from a message |
| `meeting.started` | A meeting has started |
| `meeting.ended` | A meeting has ended; transcript and summary are ready |
| `lobby.booked` | A lobby meeting was booked |
| `magicast.created` | A magicast has been created |
| `onair.event.created` | An On-Air event was created |

More: `chat.link.shared`, `user.status.update`, `onair.event.updated`, `onair.event.canceled`, `onair.guest.rsvp`, `onair.guest.added`, `token.revoked`, `app.uninstalled`. Event names are dotted; the colon names (`chat:message:dm`) belong to the legacy v0 API.

## Important Headers

| Header | Description |
| --- | --- |
| `webhook-id` | The event's `eventId`; same on every attempt and on both bodies of one event |
| `webhook-timestamp` | Unix seconds when sent (signed deliveries only) |
| `webhook-signature` | `v1,<base64>` entries, space-delimited (signed deliveries only) |
| `Roam-Event-Type` | Event name, e.g. `chat.message` |
| `Roam-Retry-Num` | `1`-`3` on retries, absent on the first attempt |

## Environment Variables

```bash
ROAM_WEBHOOK_SIGNING_SECRET=whsec_xxxxx   # Webhooks tab of your API Key or OAuth App

```

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 roam --path /webhooks/roam

```

Use the printed URL as the Webhook URL in Roam (HTTPS required) and click **Test** to send a sample delivery. Respond with a 2xx within 3 seconds, and do not return `410 Gone` unless you want Roam to delete the subscription.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/roam-webhooks/references/overview.md) - What Roam webhooks are, events, envelope, delivery behaviour
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/roam-webhooks/references/setup.md) - Configure a webhook and signing secret in Roam
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/roam-webhooks/references/verification.md) - Library and manual verification, timestamp window, gotchas