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.endedorlobby.bookedevents? - 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.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
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
- references/overview.md - What Roam webhooks are, events, envelope, delivery behaviour
- references/setup.md - Configure a webhook and signing secret in Roam
- references/verification.md - Library and manual verification, timestamp window, gotchas