Skip to main content

SDKs

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

LanguagePackageStatus
TypeScript / JavaScript@roamhq/sdkAvailable
Goro.am/roamhqAvailable
PythonroamhqAvailable

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 first.

Install

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.

go get ro.am/roamhq

Requires Go 1.22+. Source: WonderInventions/roam-sdk-go.

pip install roamhq

Requires Python 3.10+. Source: 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 for which one you want.

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

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:

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:

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():

MethodBody field
client.lobby.list()lobbies
client.calendar.list()events
client.userAuditLog.list()auditLogs
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:

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:

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 and 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 for the limits and response headers.

Tune it globally or per call:

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:

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

Or override a single call to try a different revision:

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

See API versioning.

Other request options

Every method takes an optional second argument:

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

To inspect response headers, use .withRawResponse():

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:

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:

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 scheme. The SDK verifies them for you, from a subpath export:

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:

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

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

A complete Express receiver:

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:

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 for the event catalog, payload shapes, and dual bodies for one event, and Unfurling links for the chat.link.shared flow specifically. The source of this SDK is not a public GitHub repository — report problems via support.

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:

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).

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

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:

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 }
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.

_, 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.

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:

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.

Python

The Python client is generated from the same spec as the TypeScript SDK. Install with pip install roamhq (see Install). Source: WonderInventions/roam-sdk-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:

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 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. The Go module source is WonderInventions/roam-sdk-go; the Python package source is WonderInventions/roam-sdk-python; the TypeScript package source is not a public GitHub repository.