# 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`](/docs/webhooks/chat-message) events.

## What you will build

1. Subscribe to `chat.message`
2. Verify Standard Webhooks signatures
3. Reply with `chat.typing` + `chat.post`

## 1. Create credentials

1. **Roam Administration → Developer → Add ApiClient**
2. Prefer **OAuth** for multi-tenant apps, or **API key** for a single-workspace bot
3. Scopes: `chat:read`, `chat:history`, `chat:send_message`, `webhook:write`
4. Copy the **API key / access token** and the **webhook signing secret** (`whsec_…`)

```bash
export ROAM_TOKEN='…'
export ROAM_WEBHOOK_SECRET='whsec_…'
```

Full OAuth code flow: [OAuth & Authentication](/docs/guides/oauth).

## 2. Expose a local HTTPS URL

Roam must reach your server. For local development, use a tunnel:

```bash
# example: cloudflared
cloudflared tunnel --url http://localhost:3000
# → https://random.trycloudflare.com
```

```bash
export WEBHOOK_URL='https://random.trycloudflare.com/webhooks/roam'
```

## 3. Minimal webhook server (Node.js)

```bash
npm init -y
npm i express standardwebhooks
```

```js
// 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"));
```

```bash
ROAM_WEBHOOK_SECRET="$ROAM_WEBHOOK_SECRET" ROAM_TOKEN="$ROAM_TOKEN" node server.js
```

## 4. Subscribe to `chat.message`

```bash
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`](/docs/webhooks/webhook-subscribe) and
[`chat.message`](/docs/webhooks/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

```bash
curl -sS -H "Authorization: Bearer $ROAM_TOKEN" \
  "https://api.ro.am/v1/webhook.deliveries" | jq .
```

See [Webhooks overview](/docs/webhooks/webhooks) for the envelope, signing
headers, and verification details.

## Next steps

- Private help text: [`chat.postEphemeral`](/docs/api/chat-post-ephemeral)
- Streaming AI replies: `chat.startStream` / `appendStream` / `stopStream`
- [Access Models](/docs/guides/access-models) — Org bot vs Personal bot
- [OpenClaw](/docs/integrations/openclaw) / [Hermes](/docs/integrations/hermes) if you want a packaged agent runtime