# SDKs

Official client libraries for the Roam API. They are generated from the same
OpenAPI specification that produces the [API Reference](/docs/api/api), so the
methods, types, and error codes here always match the documented endpoints.

| Language | Package | Status |
| --- | --- | --- |
| TypeScript / JavaScript | [`@roamhq/sdk`](https://www.npmjs.com/package/@roamhq/sdk) | Available |
| Go | [`ro.am/roamhq`](https://pkg.go.dev/ro.am/roamhq) | Available |
| Python | [`roamhq`](https://pypi.org/project/roamhq/) | Available |

You never have to use an SDK — the API is plain HTTPS with JSON, and every
reference page shows a `curl` example. The SDKs exist to save you from
re-implementing cursor pagination, retry-after handling, and webhook signature
verification in every project.

:::note
These SDKs target the **v1** API (`https://api.ro.am/v1`). If you are still on
v0, see [Migrating from v0 to v1](/docs/guides/migration-v0-to-v1) first.
:::

## Install

```bash
npm install @roamhq/sdk
```

Requires Node.js 18 or later (the SDK uses the built-in `fetch`). If you compile
from TypeScript source, you need **TypeScript 5.7 or later**. The package has
**no runtime dependencies**.

```bash
go get ro.am/roamhq
```

Requires **Go 1.22+**. Source: [`WonderInventions/roam-sdk-go`](https://github.com/WonderInventions/roam-sdk-go).

```bash
pip install roamhq
```

Requires **Python 3.10+**. Source: [`WonderInventions/roam-sdk-python`](https://github.com/WonderInventions/roam-sdk-python).

## Authenticate

Create a client with a token from **Roam Administration → Developer**. The same
constructor accepts an organization API key (`rmk-…`), a personal access token,
or an OAuth access token — see [Access models](/docs/guides/access-models) for
which one you want.

```ts
import { RoamClient } from "@roamhq/sdk";

const client = new RoamClient({
  token: process.env.ROAM_TOKEN!,
});
```

Never hardcode a token. Read it from the environment or a secret manager.

## Your first request

```ts
const result = await client.chat.post({
  groupId: "88bebce7-6cbb-4666-96f9-5c02d73e6661",
  text: "Build completed successfully!",
});

console.log(result.chatId, result.timestamp);
```

Methods are grouped by resource and mirror the endpoint names: `/chat.post`
becomes `client.chat.post(…)`, `/meeting.transcript` becomes
`client.meeting.transcript(…)`, `/group.list` becomes `client.group.list(…)`.

## Pagination

Most list endpoints return a page object that is also an async iterable.
Iterating it walks every page for you, fetching the next cursor as needed:

```ts
const messages = await client.chat.history({ chatId });

for await (const message of messages) {
  console.log(message.text);
}
```

If you want to control paging yourself — to stop early, or to persist a cursor
between runs — use the page directly:

```ts
let page = await client.group.list({ limit: 50 });

while (true) {
  for (const group of page.data) {
    console.log(group.id, group.name);
  }
  if (!page.hasNextPage()) break;
  page = await page.getNextPage();
}
```

`page.data` is the current page's items; `page.response` is the raw response
body, including `nextCursor`.

A few lists are **not paginated** and return the response body directly — they
have no `.data` array and no `hasNextPage()`:

| Method | Body field |
| --- | --- |
| `client.lobby.list()` | `lobbies` |
| `client.calendar.list()` | `events` |
| `client.userAuditLog.list()` | `auditLogs` |

```ts
const { lobbies } = await client.lobby.list();
for (const lobby of lobbies ?? []) {
  console.log(lobby.handle, lobby.url);
}
```

## Errors

Failed requests throw. Every error extends `RoamError`, which carries the status
code, the parsed body, and the request ID you should quote in a support request:

```ts
import { RoamClient, RoamError, Roam } from "@roamhq/sdk";

try {
  await client.chat.post({ groupId, text: "hello" });
} catch (err) {
  if (err instanceof Roam.UnauthorizedError) {
    // 401 — token is invalid, expired, or revoked
  } else if (err instanceof Roam.TooManyRequestsError) {
    // 429 — see "Retries" below; the SDK already retried this
  } else if (err instanceof RoamError) {
    console.error(err.statusCode, err.body, err.requestId);
  }
  throw err;
}
```

Typed subclasses exist for each documented status: `BadRequestError`,
`UnauthorizedError`, `ForbiddenError`, `NotFoundError`, `MethodNotAllowedError`,
`ConflictError`, `ContentTooLargeError`, `UnsupportedMediaTypeError`,
`TooManyRequestsError`, and `InternalServerError`.

For branching on *why* a call failed, prefer the machine-readable `error` field
in the body over the status code:

```ts
if (err instanceof RoamError) {
  const body = err.body as Roam.Error_;
  if (body.error === "missing_scope") {
    console.error("Token needs one of:", body.needed);
  }
}
```

The full catalog is in [Responses and errors](/docs/guides/responses-and-errors)
and [Error codes](/docs/guides/error-codes).

## Retries

The client retries failed requests **twice** by default, with exponential
backoff. It retries `408`, `429`, and any `5xx`.

On a `429` it honors the `Retry-After` header rather than guessing — so the
common case of tripping the rate limit is handled without any code from you. See
[Rate Limiting](/docs/api/api#rate-limiting) for the limits and response headers.

Tune it globally or per call:

```ts
const client = new RoamClient({ token, maxRetries: 5 });

await client.chat.post({ groupId, text: "hello" }, { maxRetries: 0 });
```

## Pinning an API version

Roam uses dated API versions. By default a request uses the version stamped on
your credential when it was created; the SDK does not override that.

To pin explicitly — recommended, so a later revision cannot change your
integration's behavior underneath you — set `roamVersion`:

```ts
const client = new RoamClient({ token, roamVersion: "2026-08-25" });
```

Or override a single call to try a different revision:

```ts
await client.chat.history({ chatId }, { roamVersion: "2026-08-25" });
```

See [API versioning](/docs/guides/api-versioning).

## Other request options

Every method takes an optional second argument:

```ts
await client.meeting.transcript(
  { id: meetingId },
  {
    timeoutInSeconds: 30,
    abortSignal: controller.signal,
    maxRetries: 1,
  },
);
```

To inspect response headers, use `.withRawResponse()`:

```ts
const { data, rawResponse } = await client.token.info().withRawResponse();
console.log(rawResponse.headers.get("Roam-Version"));
```

## Managing webhook subscriptions

Subscribe, list, and unsubscribe with typed methods on `client.webhook`. **Event
names are dotted** (`chat.message`, `lobby.booked`, `magicast.created`). Colon
names (`chat:message:dm`) are v0-only and return `Unrecognized event` on v1.
Optional `filter` limits deliveries; `{ chatType: "dm" }` is DMs only:

```ts
const sub = await client.webhook.subscribe({
  url: process.env.WEBHOOK_URL!,
  event: "chat.message",
  filter: { chatType: "dm" },
});

const { webhooks } = await client.webhook.list();
await client.webhook.unsubscribe({ id: sub.id });
```

`client.fetch` is the escape hatch for endpoints the SDK does not yet wrap.
Relative paths resolve against `https://api.ro.am/v1` — you do not need to pass
`environment` or `baseUrl`:

```ts
const res = await client.fetch("/token.info");
```

v1 unsubscribe is JSON `{"id"}`. v0 expects `application/x-www-form-urlencoded`
and rejects a JSON body with `id parameter required`.

## Verifying webhooks

Roam signs webhook deliveries with the
[Standard Webhooks](https://www.standardwebhooks.com/) scheme. The SDK verifies
them for you, from a subpath export:

```ts
import { verifyWebhook } from "@roamhq/sdk/webhooks";

const event = verifyWebhook(rawRequestBody, headers, process.env.ROAM_WEBHOOK_SECRET!);
```

`verifyWebhook` throws if the delivery cannot be trusted, and returns the parsed
event body if it can. Treat any throw as a `401` — never fall through to
processing the payload. If your payload is not JSON, or you want to run your own
parser, use `verifyWebhookSignature`, which checks the signature and returns
nothing.

The return type is `unknown` by default, so you narrow it before use. Pass a
type argument when you know the shape:

```ts
type RoamEvent = { type: string; data?: Record<string, unknown> };

const event = verifyWebhook<RoamEvent>(rawRequestBody, headers, secret);
```

A complete Express receiver:

```ts
import express from "express";
import { verifyWebhook } from "@roamhq/sdk/webhooks";

type RoamEvent = { type: string; data?: Record<string, unknown> };

const seen = new Set<string>(); // process-local; use Redis etc. in production

app.post(
  "/webhooks/roam",
  express.raw({ type: "application/json" }),
  (req, res) => {
    let event: RoamEvent;
    try {
      event = verifyWebhook<RoamEvent>(
        req.body.toString("utf8"),
        req.headers,
        process.env.ROAM_WEBHOOK_SECRET!,
      );
    } catch {
      return res.sendStatus(401);
    }

    // A single event can currently arrive twice (legacy tagged-id body +
    // v1 body) with the same webhook-id. See the Webhooks overview.
    const webhookId = req.get("webhook-id");
    if (webhookId) {
      if (seen.has(webhookId)) return res.sendStatus(200);
      seen.add(webhookId);
    }

    // Envelope (`2026-07-07`+): type is "chat.message", fields under data.
    // Baseline / dual body: type is "message", fields at the top level.
    const type = event.type;
    const data =
      type === "chat.message" && event.data != null ? event.data : event;

    // handle the event using type + data
    res.sendStatus(200);
  },
);
```

Two things account for most verification failures on deliveries that are
genuinely valid:

- **Verify the raw request body**, before any JSON parsing. The signature covers
  the exact bytes Roam sent, and re-serializing a parsed object will not
  reproduce them — key order, whitespace, and unicode escaping all shift. That
  is what `express.raw()` is doing above.
- **Pass the signing secret exactly as issued**, `whsec_` prefix included. The
  SDK strips the prefix and base64-decodes the rest to recover the key.

The verifier accepts multiple space-separated signatures, so a secret rotation
does not drop deliveries. It also enforces a 300-second replay window by
default. Roam signs an event once and reuses that signature across delivery
retries, and the retry ladder runs to roughly six minutes — so if you would
rather accept a late retry than drop it, widen the window:

```ts
verifyWebhook(rawBody, headers, secret, { toleranceInSeconds: 600 });
```

This part of the SDK runs on Node only; it uses `node:crypto` for constant-time
comparison. That is a builtin, so the package still has no dependencies.

See [Webhooks](/docs/webhooks/webhooks) for the event catalog, payload
shapes, and [dual bodies for one event](/docs/webhooks/webhooks#dual-bodies-for-one-event),
and [Unfurling links](/docs/guides/unfurling-links) for the
`chat.link.shared` flow specifically. The source of this SDK is not a public
GitHub repository — report problems via [support](https://ro.am/support/contact-us).

## Configuring the base URL

The client defaults to `https://api.ro.am/v1`. Override it only when you have
been told to — for example, to route through a proxy you control:

```ts
const client = new RoamClient({ token, baseUrl: "https://proxy.internal/v1" });
```

## Go

The Go client is generated from the same spec as the TypeScript SDK. Install
with `go get ro.am/roamhq` (see [Install](#install)).

Fern splits the client across sibling packages, so a call site imports two or
three of them:

```go
import (
    roamhq "ro.am/roamhq"          // request/response types
    "ro.am/roamhq/client"          // the client
    "ro.am/roamhq/option"          // constructor options
)

c := client.NewClient(option.WithToken(os.Getenv("ROAM_TOKEN")))

result, err := c.Chat.Post(ctx, &roamhq.PostChatRequest{
    GroupID: roamhq.String("88bebce7-6cbb-4666-96f9-5c02d73e6661"),
    Text:    roamhq.String("Build completed successfully!"),
})
```

Methods are grouped the same way as TypeScript: `/chat.post` is
`c.Chat.Post`, `/meeting.transcript` is `c.Meeting.Transcript`. Optional
fields are pointers; `roamhq.String` / `roamhq.Int` are the generated
helpers. Go names follow initialisms: `groupId` is `GroupID`, `url` is `URL`.

### Pagination

Paginated methods return a `Page`. Iterate items, or walk pages yourself:

```go
page, err := c.Chat.History(ctx, &roamhq.HistoryChatRequest{ChatID: roamhq.String(chatID)})
if err != nil { return err }

iter := page.Iterator()
for iter.Next(ctx) {
    msg := iter.Current()
    fmt.Println(msg.Text)
}
if err := iter.Err(); err != nil { return err }
```

```go
page, err := c.Group.List(ctx, &roamhq.ListGroupRequest{Limit: roamhq.Int(50)})
for page != nil {
    for _, group := range page.Results {
        fmt.Println(group.ID, group.Name)
    }
    page, err = page.GetNextPage(ctx)
    if errors.Is(err, core.ErrNoPages) {
        break
    }
    if err != nil { return err }
}
```

`page.Results` is the current page's items; `page.Response` is the raw
response body. `core.ErrNoPages` is the sentinel that means you are done —
import `ro.am/roamhq/core`.

Unpaginated lists (`Lobby.List`, `Calendar.List`, `UserAuditLog.List`) return
the response body directly, same as TypeScript.

### Errors, retries, versions

Failed requests return an error compatible with `errors.As`. Typed subclasses
exist for each documented status (`*roamhq.UnauthorizedError`,
`*roamhq.TooManyRequestsError`, …); the common envelope is `*core.APIError`.

```go
_, err := c.Chat.Post(ctx, req)
var unauthorized *roamhq.UnauthorizedError
var apiErr *core.APIError
if errors.As(err, &unauthorized) {
    // 401
} else if errors.As(err, &apiErr) {
    fmt.Println(apiErr.StatusCode)
}
```

Retries default to two attempts on `408`, `429`, and `5xx`, honoring
`Retry-After`. Tune with `option.WithMaxAttempts`; disable with
`option.WithoutRetries()` (`WithMaxAttempts(0)` falls through to the default
of 2). Pin a dated API version with `option.WithRoamVersion(roamhq.String("2026-08-20"))`
(it takes a `*string`), on the client or a single call. Timeouts are the
request `context`.

```go
c := client.NewClient(
    option.WithToken(token),
    option.WithMaxAttempts(5),
    option.WithRoamVersion(roamhq.String("2026-08-20")),
    option.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
)
```

Webhook *management* is on this client (`c.Webhook.Subscribe`, `List`,
`Unsubscribe`). Signature verification is a hand-written package, because
the generated helper rejects genuine `whsec_` deliveries — same reason as
the TypeScript `verifyWebhook`:

```go
import "ro.am/roamhq/webhooks"

event, err := webhooks.Verify(body, r.Header, os.Getenv("ROAM_WEBHOOK_SECRET"), nil)
if err != nil {
    http.Error(w, "unauthorized", http.StatusUnauthorized)
    return
}
```

`body` must be the raw request bytes. See [Webhooks](/docs/webhooks/webhooks).

## Python

The Python client is generated from the same spec as the TypeScript SDK.
Install with `pip install roamhq` (see [Install](#install)). Source:
[`WonderInventions/roam-sdk-python`](https://github.com/WonderInventions/roam-sdk-python).

```python
from roamhq import RoamClient

client = RoamClient(token=os.environ["ROAM_TOKEN"])
result = client.chat.post(
    group_id="88bebce7-6cbb-4666-96f9-5c02d73e6661",
    text="Build completed successfully!",
)
```

An async client is also generated (`AsyncRoamClient`). Methods take
snake_case keyword arguments.

List methods return the response body. Paginate by passing `cursor=` from
`next_cursor`:

```python
page = client.group.list(limit=50)
for group in page.groups:
    print(group.id, group.name)
while page.next_cursor:
    page = client.group.list(limit=50, cursor=page.next_cursor)
```

Retries default to two retries on `408`, `429`, and `5xx`, honoring
`Retry-After`. Tune with `RoamClient(token=..., max_retries=5)` or
`request_options={"max_retries": 0}`. Pin a dated API version with
`RoamClient(token=..., roam_version="2026-08-20")`. Per-request override
is `request_options={"additional_headers": {"Roam-Version": "2026-08-20"}}`.

Webhook *management* is `client.webhook.subscribe` / `list` /
`unsubscribe`. Signature verification is
`from roamhq.webhooks import verify_webhook` — hand-written, same reason
as TypeScript and Go.

## Reporting problems

The SDKs are generated, so a wrong type or a missing field is almost always a
bug in the [OpenAPI spec](https://developer.ro.am/chat-v1.json) rather than in
hand-written code — which means fixing it fixes this site and every SDK at once.
Include the `requestId` from the error when you
[contact support](https://ro.am/support/contact-us). The Go module source is
[`WonderInventions/roam-sdk-go`](https://github.com/WonderInventions/roam-sdk-go);
the Python package source is
[`WonderInventions/roam-sdk-python`](https://github.com/WonderInventions/roam-sdk-python);
the TypeScript package source is not a public GitHub repository.