> ## Documentation Index
> Fetch the complete documentation index at: https://docs.amber.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Pagination and errors

> Retrieve every record, preserve precision, and retry transient failures

Collections return `{ items, nextCursor, totalItems }`. `totalItems` counts the
full filtered set. The default `limit` is 25; the maximum is 100. Continue until
`nextCursor` is `null`, even when a page is smaller than expected.

Treat cursors as opaque. Keep the same brand, resource, and filters when you
continue. You may change `limit`. Ordering is specific to each collection;
do not infer chronological order from the first row. Pagination is a live read,
not a transaction-wide snapshot. For a repeatable export, avoid concurrent edits
and reconcile by stable IDs.

## Follow the next-page link

Collection responses with more records include a standard `Link` header:

```http theme={null}
Link: </api/public/v1/brands/acme/products?code=TEE-001&limit=1&cursor=opaque>; rel="next"
```

Resolve the target against the response URL and send the same API-key header.
It already contains your filters, page size, and next cursor. The header is
omitted on the last page. You can use either this link or the JSON `nextCursor`;
they identify the same continuation.

## Reusable Node.js client

The tutorials use this small helper. Save it as `amber-client.mjs` in the same
directory as the tutorial scripts. `readJson(apiUrl(""))` verifies access and returns
the authorized brand identity. Use Node.js 22 or later; no packages are
required. Set `AMBER_API_KEY` and `AMBER_BRAND` in your server environment before
running a script. The brand value is a slug, not a brand UUID.

```javascript amber-client.mjs theme={null}
const origin = "https://app.amber.ai";
const token = process.env.AMBER_API_KEY;
const brand = process.env.AMBER_BRAND;
if (!token || !brand) throw new Error("Set AMBER_API_KEY and AMBER_BRAND");
const brandPath = `/api/public/v1/brands/${encodeURIComponent(brand)}`;
const base = `${origin}${brandPath}/`;

export function apiUrl(resource, filters = {}) {
  const url = new URL(resource || `${origin}${brandPath}`, base);
  for (const [key, value] of Object.entries(filters)) {
    url.searchParams.set(key, String(value));
  }
  return url;
}

export async function readJson(input) {
  const url = new URL(input, origin);
  if (url.origin !== origin || (url.pathname !== brandPath && !url.pathname.startsWith(`${brandPath}/`))) {
    throw new Error("Expected an Amber API URL for the configured brand");
  }
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms + Math.random() * 250));
  for (let attempt = 0; attempt < 5; attempt++) {
    let response;
    let body;
    try {
      response = await fetch(url, {
        headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
        signal: AbortSignal.timeout(30_000),
        redirect: "manual",
      });
      if ([429, 500, 502, 503, 504].includes(response.status) && attempt < 4) {
        const retryAfter = response.headers.get("Retry-After");
        const requested = retryAfter === null ? NaN
          : /^\d+$/.test(retryAfter) ? Number(retryAfter) * 1000
          : Math.max(0, Date.parse(retryAfter) - Date.now());
        const delay = Number.isNaN(requested) ? 500 * 2 ** attempt : requested;
        if (delay <= 60_000) {
          await response.body?.cancel();
          await sleep(delay);
          continue;
        }
      }
      body = await response.json().catch((error) => {
        if (error instanceof SyntaxError) return null;
        throw error;
      });
    } catch (error) {
      const transient = error instanceof TypeError || ["TimeoutError", "AbortError"].includes(error.name);
      if (!transient || attempt === 4 || (response && !response.ok)) throw error;
      await sleep(500 * 2 ** attempt);
      continue;
    }
    if (!response.ok) {
      const error = new Error(`${response.status} ${body?.errorCode ?? "HTTP_ERROR"}: ${body?.error ?? response.statusText}`);
      error.status = response.status;
      error.errorCode = body?.errorCode;
      error.details = body?.details;
      error.retryAfter = response.headers.get("Retry-After");
      throw error;
    }
    if (body === null) throw new Error("Expected a JSON response from Amber");
    return body;
  }
}

function checkedPage(page) {
  if (!page || !Array.isArray(page.items) ||
      !(page.nextCursor === null || (typeof page.nextCursor === "string" && page.nextCursor.length > 0)) ||
      !Number.isInteger(page.totalItems) || page.totalItems < 0) {
    throw new Error("Expected an Amber collection page with items, nextCursor, and totalItems");
  }
  return page;
}

export async function* records(collectionUrl, cursor) {
  if (cursor === null) return;
  do {
    const url = new URL(collectionUrl, origin);
    url.searchParams.set("limit", "100");
    url.searchParams.delete("cursor");
    if (cursor) url.searchParams.set("cursor", cursor);
    const page = checkedPage(await readJson(url));
    if (cursor && page.nextCursor === cursor) throw new Error("Pagination cursor did not advance");
    yield* page.items;
    cursor = page.nextCursor;
  } while (cursor !== null);
}

export async function* includedRecords(page) {
  checkedPage(page);
  yield* page.items;
  yield* records(page.url, page.nextCursor);
}

export async function productGraph(productId, include = "all") {
  const { included, ...product } = await readJson(apiUrl(
    `products/${encodeURIComponent(productId)}`, { include, includeLimit: 100 },
  ));
  const relationships = {};
  for (const [name, page] of Object.entries(included)) {
    relationships[name] = [];
    for await (const record of includedRecords(page)) {
      relationships[name].push(record);
    }
  }
  return { product, relationships };
}
```

Try it by saving and running this second file:

```javascript list-products.mjs theme={null}
import { apiUrl, records } from "./amber-client.mjs";

for await (const product of records(apiUrl("products"))) {
  console.log(product.id, product.name);
}
```

```bash theme={null}
node list-products.mjs
```

`records()` starts from the first page when no cursor is supplied. Passing
`null` performs no request. `includedRecords()` emits the initial embedded page
and follows its continuation URL without fetching that first page again.
Filters stay on the URL across pages; do not change them mid-export. Malformed
pages or a non-advancing cursor stop the export instead of silently restarting it.
`productGraph(id)` returns a complete product packet after following every
included collection cursor. Pass a comma-separated include list as its second
argument to retrieve selected relationships. It collects one product in memory.

The client retries the listed HTTP failures up to five attempts, with a timeout
per request. It reports non-JSON gateway errors without trying to parse them as
API data. Authentication, validation, and not-found responses fail immediately.
HTTP redirects are never followed or retried.
Transient network failures and timeouts, including interrupted successful response
bodies, use the same retry budget. If `Retry-After` exceeds 60 seconds, the client
throws with `error.retryAfter` so your job scheduler can resume later. HTTP errors
also preserve `error.details` for parameter validation messages. API-key headers are sent only to the configured brand's API
paths. Use a separate request for signed storage URLs, as shown in
[Download documents](/tutorials/download-documents).

## Plan a recurring export

The current public API has no general `updatedSince` filter, deletion feed,
snapshot token, or public change-subscription endpoint. Paginate each collection
you need and reconcile by stable IDs. A child record can change independently
of its Parent Product, so the parent's `updatedAt` is not a graph-wide watermark.

Write each run to staging and publish it only after every required collection
finishes. A failed run or an empty filtered page does not prove a record was
deleted. Even a completed run is a live read; reconcile missing IDs carefully
when users edit data during the export. For large catalogs, read child
collections brand-wide and join locally instead of calling `include=all` for
every Parent Product.

## Data conventions

Detailed source records preserve exact decimal values as strings, such as
`"12.5000"`. Use a decimal library when calculating money or quantities. Compact
summary fields can use numbers; check the response schema. IDs are strings; most
are UUIDs. Document IDs can also be opaque `legacy:...` identifiers. Countries use
their country code.
JSON specifications and custom fields preserve nested values. Timestamps are
serialized as strings; nullable source timestamps remain null.

## Errors

Errors contain `error` and `errorCode`. Validation errors may include additional
field details. Match the code instead of parsing message text.

| Status | Action |
| - | - |
| `400` | Correct the parameter, include name, or cursor/filter combination. |
| `401` | Check the key, expiry, revocation, and requested brand. |
| `404` | Check the entity ID and visibility; foreign entities are not disclosed. |
| `429` | Wait for `Retry-After`, then retry. |
| `500`, `502`, `503`, `504` | Retry transient failures with a bounded backoff; report persistent failures. |

Use only filters listed for that endpoint in the API Reference. Collections reject unknown filters with `400`, including Parent Products,
Quotes, Orders, and Suppliers. Correct misspelled parameters before retrying.
Business resources support GET reads. Key-management operations require a
separate administrator session.
