Errors & pagination
Errors
Every non-2xx response is thrown as a ForumulateError. It mirrors the API's stable error envelope:
class ForumulateError extends Error {
readonly status: number; // HTTP status
readonly code: string; // stable machine-readable code
// message is inherited from Error
}The wire envelope every error serializes to is:
{ "error": { "code": "validation.failed", "message": "Validation failed.", "details": { "slug": ["Required"] } } }details is present only on validation errors — a map of field path to messages. Branch on code, never on message (messages are human-facing and may change).
import { ForumulateError } from '@forumulate/sdk';
try {
await forumulate.orgs.create({ name: '', slug: '' });
} catch (err) {
if (err instanceof ForumulateError) {
if (err.code === 'validation.failed') {
// 422 — a field failed validation
} else if (err.status === 401) {
// token missing/expired
}
console.error(err.status, err.code, err.message);
} else {
throw err; // network / non-API error
}
}Common codes
(The full set of codes is defined in the API's error-code list; these are the ones integrations hit most.)
Pagination
List endpoints follow one collection envelope so an integrator writes a single parser. There are two forms:
The bare list (default)
Small collections that the API builds whole — an org's spaces, a space's tags, an org's webhooks — return the plain list by default:
const { spaces } = await forumulate.spaces.list(orgId); // { spaces: Space[] }For these, the bare array is the complete list; there is nothing more to page through.
The cursor-paginated envelope
Large collections (notably a space's items) page at the source and return an envelope with a page block:
{
"items": [ /* ... */ ],
"page": {
"limit": 50,
"has_more": true,
"next_cursor": "eyJ2IjoxLC...", // opaque; store it, send it back, never parse it
"total": 1280
}
}Walk every page by passing next_cursor back as cursor until has_more is false (next_cursor is null on the last page):
let cursor: string | undefined;
const all = [];
do {
const res = await forumulate.typed('get', '/orgs/{orgId}/spaces/{spaceId}/items', {
params: { orgId, spaceId },
query: { limit: '100', cursor, fields: 'id,title,updatedAt' },
});
all.push(...res.items);
cursor = res.page?.next_cursor ?? undefined;
} while (cursor);Key properties of the cursor:
It is opaque and base64url-encoded — never construct or parse it. It names the exact row the walk stopped after (an
(updatedAt, id)position), so writes that land mid-walk can't skip or duplicate rows.An invalid/foreign cursor is rejected with
422(validation.failed) rather than silently misread — restart the walk without it.
/items query parameters
GET /orgs/{orgId}/spaces/{spaceId}/items accepts:
When any of cursor, updated_since, include, fields, or include_deleted=true is present, the response is the enveloped, cursor-paginated form { items, page, sync }. With none of them, /items returns the bare { items } (up to limit). The convenience helper forumulate.items.list(orgId, spaceId, limit) uses the bare form; use client.typed(...) (as above) when you need to page or sync.
Note on the version header. The API can also switch a collection into the enveloped form via a
Forumulate-Versionrequest header. The SDK sends onlyAuthorizationandContent-Typeand cannot set that header, so with the SDK you opt into the envelope through the query parameters shown above.