wats.sh
Reference

Pagination Reference

paginate and paginateAll over cursor-paginated Graph list endpoints, with AbortSignal support and a typed error union.

active · reviewed 2026-04-22

paginate and paginateAll iterate cursor-paginated Graph list endpoints (phone numbers, message templates, etc.) without hand-rolled cursor plumbing. Both work over any defineEndpoint callable whose response conforms to PaginatedPage<T>.

import {
  GraphClient,
  defineEndpoint,
  paginate,
  paginateAll,
  PaginationError,
  type PaginatedPage
} from "@wats/graph";

// 1. Define a list endpoint whose response conforms to PaginatedPage<T>.
interface PhoneNumberEntry {
  readonly id: string;
  readonly display_phone_number?: string;
}

const listPhoneNumbers = defineEndpoint<
  { wabaId: string; after?: string; limit?: string },
  never,
  PaginatedPage<PhoneNumberEntry>
>({
  method: "GET",
  pathTemplate: "/{wabaId}/phone_numbers",
  params: {
    wabaId: { in: "path", required: true },
    after: { in: "query", required: false },
    limit: { in: "query", required: false }
  }
});

const client = new GraphClient({
  accessToken: "...",
  apiVersion: "v25.0"
});

// 2. Stream items one at a time (recommended for large result sets).
for await (const phone of paginate(
  client,
  listPhoneNumbers,
  { wabaId: "1234567890" },
  { maxPages: 50, pageSize: 100 }
)) {
  console.log("saw phone number:", phone.id);
}

// 3. Or accumulate into a flat list via paginateAll.
const result = await paginateAll(
  client,
  listPhoneNumbers,
  { wabaId: "1234567890" },
  { maxPages: 10 }
);
console.log("total items:", result.items.length);
console.log("pages consumed:", result.pagesConsumed);
console.log("cap hit?", result.pageLimitReached);

paginate is an async generator yielding items one at a time. paginateAll drives the generator to completion and returns a PaginatedResult<T> summary with the flat items list.

PaginationOptions

FieldTypeDefaultNotes
maxPagesnumber1000Positive integer cap on pages consumed. NaN/Infinity/0.5 rejected.
pageSizenumberPositive integer; merged into first request's query under limit.
signalAbortSignalDuck-typed signal (aborted, addEventListener, removeEventListener).

All three fields are validated at the first .next() tick. Invalid inputs throw PaginationError with a typed .code — the generator never silently swallows bad options.

PaginatedResult<T>

Every run returns a frozen summary regardless of how it ended:

  • items: readonly T[] — flat list of every yielded item (the same items the for await loop observed).
  • pagesConsumed: number — pages that actually hit the wire.
  • pageLimitReached: booleantrue when maxPages stopped iteration; false on natural exhaustion.
  • aborted: booleantrue when an AbortSignal interrupted iteration.

Cursor extraction from paging.next

Graph list responses carry a paging.next field — an absolute URL with an after query parameter. The primitive parses paging.next as a URL, reads after, and merges it into the next iteration's params as { ..., after: <cursor> }.

If paging.next is absent, empty, unparseable, or lacks after, iteration terminates cleanly — no fabricated cursor, no infinite loop.

Error taxonomy

PaginationError is a plain Error subclass (not a TypeError) with a stable .code union:

.codeWhen it fires
invalid_endpointFirst arg is not an EndpointCallable (missing .definition brand).
invalid_max_pagesmaxPages is not a positive integer (0, negatives, NaN, Infinity, 0.5 etc.).
invalid_page_sizepageSize is not a positive integer.
invalid_signalsignal is not a duck-typed AbortSignal.
abortedReserved for programmatic abort-path signalling (currently conveyed via the result flag).
page_fetch_failedUnderlying endpoint threw mid-stream; original error attached as .cause.

Endpoint errors always re-throw as PaginationError(page_fetch_failed, ..., { cause }). Narrow via instanceof PaginationError, branch on .code, or follow .cause back to the underlying GraphApiError / transport failure.

Streaming iteration

paginate yields each item as it arrives, then fetches the next page once the current one is exhausted. Page bodies never accumulate in memory; the only growing structure is the items array on PaginatedResult. For very large result sets, iterate with for await and skip paginateAll to avoid holding the flat list.

AbortSignal semantics

  • Pre-aborted signal — iteration returns an empty result with aborted: true without touching the transport (zero requests).
  • Abort between pages — the current page's items still flush to the caller; the next fetch is skipped; the final result carries aborted: true and pagesConsumed reflects pages actually fetched.
  • The signal is also forwarded to the underlying endpoint's EndpointInvokeOptions.signal, so in-flight fetches cancel at the transport layer.

maxPages cap

Default DEFAULT_MAX_PAGES = 1000 — large enough for any realistic list endpoint, small enough to stop a runaway loop against a misbehaving cursor. On cap:

  • Iteration stops after the Nth page's items have been yielded.
  • The final result carries pageLimitReached: true and pagesConsumed === maxPages.

Set maxPages: 1 for first-page-only semantics; raise it for long exports. The default is a safety net, not a tight bound.

Message template cursor handling

listMessageTemplates(...) accepts both after and before cursor parameters and forwards them to Graph /{wabaId}/message_templates. Graph code 131059 maps to InvalidTemplateCursorError when Meta rejects a template cursor.

WATS performs no hidden retry. If your workflow can safely restart from the first page, catch InvalidTemplateCursorError and retry without before/after as an explicit opt-in:

try {
  await waba.listMessageTemplates({ after: savedCursor });
} catch (error) {
  if (error instanceof InvalidTemplateCursorError) {
    // opt-in: discard stale cursor and retry without before/after
    await waba.listMessageTemplates({ limit: "25" });
  }
}

Related: whatsapp_business_manager_messaging_limit and messaging_limit_tier appear on WABA/phone-number inventory responses. They are not pagination inputs, but they often shape how operators schedule template and campaign work.

Non-goals

Pagination does not:

  • persist cursor state across process restarts (save result.items or re-iterate);
  • implement cross-envelope pagination — the Graph shape is the only shape;
  • provide bidirectional iteration (no previous cursor walk);
  • retry or back off transient page-fetch failures (they surface as PaginationError(page_fetch_failed); wrap with your own retry policy);
  • resume streams across process boundaries;
  • cache: each run hits the transport.

On this page