# Guide to Roam Webhooks: Features and Best Practices

Roam is a virtual office: chat, meetings, lobbies for booking time, Magicasts and On-Air events, all in one workspace. Its webhooks let your app react to what happens there, such as a direct message to your bot, a meeting ending with its transcript and summary ready, a lobby booking or a guest's RSVP, without polling the API.

This guide covers how Roam webhooks are configured and signed, the events and their payloads, Roam's delivery behaviour, and the practices that keep a Roam receiver reliable. It is about Roam at ro.am, not Roam Research.

## What are Roam webhooks?

Roam webhooks belong to an individual app: an API Key, an OAuth App or a personal access token. You can configure them statically in the app's settings, or dynamically through the `/v1/webhook.subscribe` endpoint, which is the only option for personal access tokens. Each event requires a scope, which you enable on the app's **Permissions** tab.

When a subscribed event happens, Roam POSTs a JSON envelope to your URL. If the app has a signing secret, the request is signed under the Standard Webhooks specification, with `webhook-id`, `webhook-timestamp` and `webhook-signature` headers. Without a signing secret, Roam sends the `webhook-id` header but no timestamp or signature.

Roam expects a `2xx` within 3 seconds and retries transient failures only three times over about six minutes. Those retries live in memory, so this is best-effort delivery.

## Roam webhook features

| Feature | Details |
| --- | --- |
| Configuration | App settings (**Webhooks** tab) or `POST /v1/webhook.subscribe` |
| Events | 15 dotted v1 names, each needing a scope |
| Filters | Optional, per subscription, on dynamic subscriptions |
| Payload | `{ type, eventId, timestamp, apiVersion, data }` on API version `2026-07-07` and later |
| Signature headers | `webhook-id`, `webhook-timestamp`, `webhook-signature` |
| Scheme | Standard Webhooks: `v1,<base64 HMAC-SHA256>` |
| Signed content | `{webhook-id}.{webhook-timestamp}.{raw body}` |
| Secret | `whsec_...`; the key is the base64-decoded part after the prefix |
| Other headers | `Roam-Event-Type`, `Roam-Version`, `Roam-Retry-Num`, `Roam-Retry-Reason` |
| Timeout | `2xx` within 3 seconds |
| Retries | Connection errors, timeouts, `429` and `5xx`: about +1 second, +1 minute, +5 minutes |
| Replay window | 300 seconds in Roam's OpenAPI spec |
| Failed delivery log | `GET /v1/webhook.deliveries`, about 30 days |

Roam doesn't publish source IPs for webhooks.

## Common events

Roam's v1 catalogue has 15 events. Their names are dotted. The colon names you may see in older code, such as `chat:message:dm`, are the v0 catalogue, and `/v1/webhook.subscribe` rejects them.

| Area | Events | Fires when |
| --- | --- | --- |
| Chat | `chat.message`, `chat.reaction`, `chat.link.shared` | A message is created, edited or deleted, a reaction is added or removed, or a message links to one of your app's registered domains |
| Meetings | `meeting.started`, `meeting.ended` | A meeting starts, or ends with its transcript and summary ready |
| Scheduling and media | `lobby.booked`, `magicast.created` | A lobby meeting is booked, or a Magicast is created |
| Presence | `user.status.update` | A user checks in or out, or their Will Return or Out of Roam status changes |
| On-Air | `onair.event.created`, `onair.event.updated`, `onair.event.canceled`, `onair.guest.rsvp`, `onair.guest.added` | An On-Air event is created, updated or cancelled, or its guests change |
| App lifecycle | `token.revoked`, `app.uninstalled` | An OAuth token for your app is revoked, or your app is uninstalled |

Dynamic subscriptions can narrow what they receive with a `filter`. For example, `chat.message` accepts `chatType` (`dm` or `group`) and `mention`, `chat.reaction` accepts `names`, `meeting.ended` accepts `hasVideo: true`, and the On-Air events accept `eventId`. An empty filter, or one that doesn't apply to the event, returns `400`.

Every v1 delivery shares one envelope. Here it is for `meeting.ended`, trimmed:

```json
{
  "type": "meeting.ended",
  "eventId": "0197f9a5-4d84-7dd4-be5f-6a7b8c9d0e1f",
  "timestamp": "2026-04-21T17:52:18.000000Z",
  "apiVersion": "2026-07-07",
  "data": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "title": "Weekly Team Standup",
    "start": "2026-04-21T17:00:00Z",
    "participantCount": 8,
    "hasVideo": true,
    "host": { "type": "member", "id": "709b8a57-70bc-427a-b6f0-b16ba5297f8c", "name": "Alex Chen" },
    "summary": "The team reviewed Q2 priorities and assigned next-sprint ...",
    "actionItems": [
      { "id": "f1e2d3c4-b5a6-7890-abcd-ef1234567890", "title": "Update API documentation", "complete": false }
    ]
  }
}

```

* `type` is the event, also sent as `Roam-Event-Type`. Route on it.
* `eventId` is the same on every attempt and equals the `webhook-id` header. Deduplicate on it.
* `timestamp` is when the event happened, as RFC 3339 with microseconds. It is separate from the signed `webhook-timestamp` header, which is in Unix seconds.
* `apiVersion` is the version the body is rendered in, also sent as `Roam-Version`.

Subscriptions on API version `2026-06-01` get a bare body instead, with a short `type` such as `"message"` and the fields at the top level. Pin `apiVersion` to `2026-07-07` or later when you subscribe if you want the envelope.

## Setting up Roam webhooks

For an API Key or OAuth App:

1. Open **Roam Administration > Developer** and stay on the **API Clients** tab.
2. Under **API Keys** or **OAuth Apps**, click **Edit** next to your app, or create a new one.
3. Open the app's **Webhooks** tab. Copy the **Signing Secret** (`whsec_...`) and store it server-side, for example as `ROAM_WEBHOOK_SIGNING_SECRET`.
4. Enter your HTTPS endpoint as the **Webhook URL**. API Keys can add more URLs; OAuth Apps have one.
5. Toggle the **Subscribed Events** you need, and enable each event's scope on the **Permissions** tab.
6. Click **Save**, then **Test** to send a sample delivery.

For a personal access token, subscribe through the API. The signing secret is shown when you create the token, and **Reveal Token** in **User Settings > Developer** shows it again:

```bash
curl -X POST "https://api.ro.am/v1/webhook.subscribe" \
  -H "Authorization: Bearer $ROAM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/webhooks/roam", "event": "chat.message", "filter": {"chatType": "dm"}, "apiVersion": "2026-07-07"}'

```

**Configure a signing secret for the Roam webhook.** Without one, Roam sends no `webhook-timestamp` or `webhook-signature` header, so deliveries are unsigned and fail verification in your handler and in a Hookdeck Roam source.

If you receive Roam webhooks through Hookdeck, use the Hookdeck source URL as the Webhook URL (or the subscription `url`) and paste the `whsec_...` signing secret into the Roam source.

## Securing Roam webhooks

Join `webhook-id`, `webhook-timestamp` and the raw request body with dots. Take the part of the secret after `whsec_`, base64-decode it, and use those bytes as the HMAC-SHA256 key. Base64-encode the result and compare it in constant time with each `v1,` entry in the space-separated `webhook-signature` header.

Roam's docs show the official `standardwebhooks` libraries, and the Roam skill's examples use them too. Those libraries always reject a `webhook-timestamp` more than 5 minutes from your clock, and the window can't be turned off. Hookdeck retries and replays resend the original `webhook-timestamp`, so a delivery that reaches your code later than that fails even though its signature is valid. If you receive through Hookdeck, use a manual verifier like the one below, or the one in the skill's `references/verification.md`, where the window is optional and off by default.

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

function verifyRoamSignature(rawBody, headers, secret, toleranceSeconds = 0) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatureHeader = headers['webhook-signature'];
  if (!id || !timestamp || !signatureHeader || !secret || !secret.startsWith('whsec_')) return false;
  if (!/^\d+$/.test(timestamp)) return false;

  // Optional replay check, off by default.
  if (toleranceSeconds > 0 && Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
    return false;
  }

  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.`).update(rawBody).digest();

  return signatureHeader.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'base64');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/roam', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.ROAM_WEBHOOK_SIGNING_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyRoamSignature(req.body, req.headers, secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on req.headers['webhook-id'], skip legacy bodies, route on event.type.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import binascii
import hashlib
import hmac
import time

def verify_roam_signature(raw_body: bytes, headers, secret: str, tolerance_seconds: int = 0) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signature_header = headers.get("webhook-signature")
    if not (msg_id and timestamp and signature_header and secret and secret.startswith("whsec_")):
        return False
    if not timestamp.isdigit():
        return False
    # Optional replay check, off by default.
    if tolerance_seconds > 0 and abs(time.time() - int(timestamp)) > tolerance_seconds:
        return False

    try:
        key = base64.b64decode(secret[len("whsec_"):])
    except (binascii.Error, ValueError):
        return False
    signed = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())

    for entry in signature_header.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode("utf-8"), expected):
            return True
    return False

# In FastAPI: raw_body = await request.body()
# verify_roam_signature(raw_body, request.headers, os.environ["ROAM_WEBHOOK_SIGNING_SECRET"])

```

To check your verifier, use the secret `whsec_cm9hbS1ndWlkZS10ZXN0LXNpZ25pbmcta2V5LTMyYiE=`, the ID `0197f9a5-4d84-7dd4-be5f-6a7b8c9d0e1f`, the timestamp `1761400000` and this exact body:

```
{"type":"meeting.started","eventId":"0197f9a5-4d84-7dd4-be5f-6a7b8c9d0e1f","timestamp":"2026-10-25T13:46:40.000000Z","apiVersion":"2026-07-07","data":{}}

```

The `webhook-signature` value should be:

```
v1,ssdnhohUqyJGjNEBRkgso2P1rLQwAZ5XH+jP0wrFulM=

```

> **Make Roam webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies Roam's Standard Webhooks signature on every delivery, answers inside Roam's 3-second window, and holds each chat, meeting and On-Air event until your service has processed it.

## Roam webhook limitations and pain points

### Retries are short and not durable

**The Problem:** Events are lost when your endpoint is down for more than a few minutes.

**Why It Happens:** Roam retries connection errors, timeouts, `429` and `5xx` three times, at about 1 second, 1 minute and 5 minutes. Pending retries are held in memory, so a restart on Roam's side drops them, and under heavy load Roam can shed them. Other `4xx` responses aren't retried at all.

**Workarounds:**

* Return a `2xx` within 3 seconds and process in the background.
* After an outage, list what failed with `GET /v1/webhook.deliveries` (kept for about 30 days) and re-fetch the affected resources from the API.

**How Hookdeck Can Help:** Hookdeck answers Roam immediately and stores each event, then delivers to your service with [retries](/docs/retries) you configure. Your downtime no longer has to fit inside six minutes.

### One event, two POSTs

**The Problem:** Your handler processes the same message twice, once in an unfamiliar shape.

**Why It Happens:** Roam can currently send a single event as two POSTs with the same `webhook-id`: a legacy body with a short `type` such as `"message"` and prefixed IDs, and the v1 body. Roam's guidance is to treat them as one event and prefer the v1 body.

**Workarounds:**

* Deduplicate on `webhook-id`, but choose which body to keep rather than keeping whichever arrived first.
* Process only bodies whose `type` is the dotted event name you subscribed to, such as `chat.message`, and acknowledge the rest without acting on them.

**How Hookdeck Can Help:** A Hookdeck [filter](/docs/filters) can drop the legacy bodies by requiring a dotted `type`, so only v1 bodies reach your service. If you also use [deduplication](/docs/deduplication), key it on `webhook-id` and `type` together so a legacy body can never displace the v1 one.

### Failures pause or delete subscriptions

**The Problem:** Deliveries stop arriving after a bad deploy.

**Why It Happens:** After 24 hours of consecutive terminal failures, Roam pauses a subscription and sends only one real event per day. After 14 days, dynamic subscriptions are deleted. A `410 Gone` response deletes any subscription immediately.

**Workarounds:**

* Never return `410` unless you mean to unsubscribe.
* Check `disabledAt` and `failStreakStartedAt` from `/v1/webhook.list`, and resubscribe or save the static configuration to resume.

**How Hookdeck Can Help:** Because Hookdeck accepts every delivery, Roam sees a healthy destination even while your service is failing, and you deal with the backlog from Hookdeck instead.

### The library's fixed 5-minute window

**The Problem:** Genuine events fail verification when they are retried or replayed late.

**Why It Happens:** The `standardwebhooks` libraries reject any `webhook-timestamp` more than 5 minutes from the current time, and a retry or replay from Hookdeck keeps the original timestamp because it is part of the signed content.

**Workarounds:**

* Verify manually and choose your own window, or drop the window and deduplicate on `webhook-id`.
* Run verification as soon as the request arrives, before it is queued.

**How Hookdeck Can Help:** Hookdeck verifies the signature when the request arrives and applies no timestamp window, so retrying or replaying an event from Hookdeck doesn't fail verification at the source.

## Best practices

### Verify against the raw body

The signature covers the exact bytes. Use `express.raw({ type: '*/*' })` in Express, `await request.text()` in a Next.js route handler and `await request.body()` in FastAPI, and parse only after verification.

### Pin an API version

Set `apiVersion` when you subscribe so the body shape doesn't change when the credential's default moves. Use `2026-07-07` or later to get the envelope.

### Prevent bot loops

If your app replies to chat messages, it can end up replying to itself. Message and reaction events carry the actor's principal type inline, so check it and ignore your own bot's messages without another API lookup.

### Acknowledge fast and process asynchronously

Verify, store, return `200`, and do the work afterwards. Roam's 3-second limit leaves no time for API calls or model inference inside the request. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Roam signs webhooks with Standard Webhooks once you configure a signing secret. Set one, verify `webhook-signature` against the raw body, deduplicate on `webhook-id` while preferring the v1 body, and answer within 3 seconds, because Roam's in-memory retries give up after about six minutes.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Roam signature, and holds each event until your service has processed it.

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