Errors & pagination

Embed
Preview of the card
<a href="https://forumulate.com/reference/errors-and-pagination"><img src="https://forumulate.com/reference/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/8042f004-e3d9-445f-b7a4-b6c1f86836e6/pages/b1adfcbf-c5ad-4d17-b25e-de6018dc18ac/social-card.svg?v=3e7ae33eec8b" alt="Errors &amp; pagination" width="600" height="315"></a>

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-Version request header. The SDK sends only Authorization and Content-Type and cannot set that header, so with the SDK you opt into the envelope through the query parameters shown above.

The Forumulate teamUpdated