Gareth Wilson Gareth Wilson

Guide to Roam Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationApp settings (Webhooks tab) or POST /v1/webhook.subscribe
Events15 dotted v1 names, each needing a scope
FiltersOptional, per subscription, on dynamic subscriptions
Payload{ type, eventId, timestamp, apiVersion, data } on API version 2026-07-07 and later
Signature headerswebhook-id, webhook-timestamp, webhook-signature
SchemeStandard Webhooks: v1,<base64 HMAC-SHA256>
Signed content{webhook-id}.{webhook-timestamp}.{raw body}
Secretwhsec_...; the key is the base64-decoded part after the prefix
Other headersRoam-Event-Type, Roam-Version, Roam-Retry-Num, Roam-Retry-Reason
Timeout2xx within 3 seconds
RetriesConnection errors, timeouts, 429 and 5xx: about +1 second, +1 minute, +5 minutes
Replay window300 seconds in Roam's OpenAPI spec
Failed delivery logGET /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.

AreaEventsFires when
Chatchat.message, chat.reaction, chat.link.sharedA message is created, edited or deleted, a reaction is added or removed, or a message links to one of your app's registered domains
Meetingsmeeting.started, meeting.endedA meeting starts, or ends with its transcript and summary ready
Scheduling and medialobby.booked, magicast.createdA lobby meeting is booked, or a Magicast is created
Presenceuser.status.updateA user checks in or out, or their Will Return or Out of Roam status changes
On-Aironair.event.created, onair.event.updated, onair.event.canceled, onair.guest.rsvp, onair.guest.addedAn On-Air event is created, updated or cancelled, or its guests change
App lifecycletoken.revoked, app.uninstalledAn 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:

{
  "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:

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.

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:

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 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 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 can drop the legacy bodies by requiring a dotted type, so only v1 bodies reach your service. If you also use 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 and our guide to 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 sits in front of your receiver, checks every Roam signature, and holds each event until your service has processed it.

Get started with Hookdeck for free and handle Roam webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.