Quickstart: Interactive Bot
Receive chat messages via webhooks and reply with the v1 API. This path uses an
OAuth app or API key with Organization access, a public HTTPS endpoint, and
chat.message events.
What you will build
- Subscribe to
chat.message - Verify Standard Webhooks signatures
- Reply with
chat.typing+chat.post
1. Create credentials
- Roam Administration → Developer → Add ApiClient
- Prefer OAuth for multi-tenant apps, or API key for a single-workspace bot
- Scopes:
chat:read,chat:history,chat:send_message,webhook:write - Copy the API key / access token and the webhook signing secret (
whsec_…)
export ROAM_TOKEN='…'
export ROAM_WEBHOOK_SECRET='whsec_…'
Full OAuth code flow: OAuth & Authentication.
2. Expose a local HTTPS URL
Roam must reach your server. For local development, use a tunnel:
# example: cloudflared
cloudflared tunnel --url http://localhost:3000
# → https://random.trycloudflare.com
export WEBHOOK_URL='https://random.trycloudflare.com/webhooks/roam'
3. Minimal webhook server (Node.js)
npm init -y
npm i express standardwebhooks
// server.js
import express from "express";
import { Webhook } from "standardwebhooks";
const app = express();
const wh = new Webhook(process.env.ROAM_WEBHOOK_SECRET);
app.post(
"/webhooks/roam",
express.raw({ type: "application/json" }),
async (req, res) => {
const raw = req.body.toString("utf8");
let payload;
try {
payload = wh.verify(raw, {
"webhook-id": req.get("webhook-id"),
"webhook-timestamp": req.get("webhook-timestamp"),
"webhook-signature": req.get("webhook-signature"),
});
} catch {
return res.sendStatus(401);
}
// Acknowledge quickly; do heavy work async in production
res.sendStatus(200);
// Envelope (`2026-07-07`+): type is "chat.message", fields under data.
// A single event can currently also arrive as a legacy tagged-id body
// with the same webhook-id — de-dupe on that header and prefer the v1 body.
if (payload.type === "chat.message" || payload.type === "message") {
const data = payload.data ?? payload;
const { chatId, text, userType } = data;
if (!chatId) return; // skip the legacy tagged-id twin (`chat` / `sender`)
// Ignore your own bot to avoid loops
if (userType === "bot") return;
await fetch("https://api.ro.am/v1/chat.typing", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ROAM_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ chatId }),
});
await fetch("https://api.ro.am/v1/chat.post", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ROAM_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
chatId,
text: `You said: ${text ?? "(non-text message)"}`,
}),
});
}
}
);
app.listen(3000, () => console.log("listening on :3000"));
ROAM_WEBHOOK_SECRET="$ROAM_WEBHOOK_SECRET" ROAM_TOKEN="$ROAM_TOKEN" node server.js
4. Subscribe to chat.message
curl -sS -X POST https://api.ro.am/v1/webhook.subscribe \
-H "Authorization: Bearer $ROAM_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"event\": \"chat.message\",
\"url\": \"$WEBHOOK_URL\",
\"apiVersion\": \"2026-08-20\"
}" | jq .
Use the dotted v1 name (chat.message). Colon names (chat:message:dm)
are v0-only and return Unrecognized event on /v1/webhook.subscribe.
Pinning apiVersion gives you the envelope (type: "chat.message", fields
under data) regardless of the credential's default.
The subscription is created immediately — Roam does not probe $WEBHOOK_URL
first, so the next matching event is your endpoint's first delivery.
Optional filters (e.g. mentions only) are documented on
webhook.subscribe and
chat.message.
5. Talk to the bot
- Groups: add the organization app as a member (Group Settings → Add Members). @-mention does not join the group. Personal bots hear chats the owner is in; pass
{ "mention": true }on subscribe for @-only. - DMs: message the app bot from Roam
You should see a typing indicator and an echo reply.
Debug deliveries
curl -sS -H "Authorization: Bearer $ROAM_TOKEN" \
"https://api.ro.am/v1/webhook.deliveries" | jq .
See Webhooks overview for the envelope, signing headers, and verification details.
Next steps
- Private help text:
chat.postEphemeral - Streaming AI replies:
chat.startStream/appendStream/stopStream - Access Models — Org bot vs Personal bot
- OpenClaw / Hermes if you want a packaged agent runtime