Agent skill

Roam Webhooks Skill

Receive and verify Roam (ro.am virtual office, not Roam Research) webhooks. Use when setting up a Roam webhook handler, debugging Standard Webhooks signature verification (webhook-signature, whsec_ signing secret), or handling chat.message, chat.reaction, meeting.started, meeting.ended, lobby.booked and magicast.created events.

Install this skill

npx skills add hookdeck/webhook-skills --skill 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:

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 (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/, examples/nextjs/, 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.

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.messageA chat message is created, edited or deleted (data.version, data.contentType: "deleted")
chat.reactionA reaction is added to or removed from a message
meeting.startedA meeting has started
meeting.endedA meeting has ended; transcript and summary are ready
lobby.bookedA lobby meeting was booked
magicast.createdA magicast has been created
onair.event.createdAn 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

HeaderDescription
webhook-idThe event's eventId; same on every attempt and on both bodies of one event
webhook-timestampUnix seconds when sent (signed deliveries only)
webhook-signaturev1,<base64> entries, space-delimited (signed deliveries only)
Roam-Event-TypeEvent name, e.g. chat.message
Roam-Retry-Num1-3 on retries, absent on the first attempt

Environment Variables

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

Local Development

# 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 6, 2026

View on GitHub →