Pagination and filtering

Every list endpoint pages the same way: ask for up to 100 items, get back a cursor, send the cursor to get the next batch. There is no page number and no total count, and both omissions are deliberate — this page explains what you get in exchange and how to traverse a list safely.

The two parameters

ParameterDefaultDescription
limit25Items per page. Accepts 1–100. Values outside that range are rejected, not clamped.
cursornoneAn opaque token from the previous response. Omit it to start at the beginning.

First page

GET /timers?limit=25&sort=-created_at

{
  "data": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "name": "Product launch",
      "type": "fixed",
      "status": "published"
    }
  ],
  "page": {
    "has_more": true,
    "next_cursor": "eyJ2YWx1ZSI6Ii4uLiJ9"
  },
  "request_id": "7b82b7f7-4d13-497b-9f20-58d46fd7a510"
}

Next page

GET /timers?limit=25&sort=-created_at&cursor=eyJ2YWx1ZSI6Ii4uLiJ9

# Carry every filter and the sort forward unchanged.
# Changing them mid-traversal invalidates the cursor.

Stop when page.next_cursor is null. The companion field page.has_more says the same thing as a boolean; use whichever reads better in your loop, but do not infer the end from a short page — a full page can still be the last one.

The cursor is opaque. It encodes the sort field and position, and its format is not part of the contract — do not parse it, store it long-term, or construct one by hand. Treat it the way you would treat a session token.

Why there is no page number

Offset paging — ?page=3 or ?offset=50 — is easier to explain and quietly wrong on any list that changes while you read it.

Sort timers newest-first, read page one, and then create a timer before you read page two. Everything shifts down by one position, so the item that was last on page one is now first on page two: you read it twice. Delete one instead and an item moves up past the boundary, so you never see it at all. Neither failure raises an error, and both corrupt a sync job in ways that surface days later.

A cursor encodes a position in the sort order rather than a count of skipped rows, so inserts and deletes elsewhere in the list do not move your place. In exchange you give up jumping to an arbitrary page and you give up a total count — computing one means scanning the whole table on every request, which gets slower exactly as an account gets larger.

If you need a total for a dashboard, filter to what you actually want to count and page through it once on a schedule. If you need “jump to page 40”, you almost certainly want a filter instead — nobody scans to page 40 looking for something.

Filtering and sorting timers

GET /timers accepts these alongside the paging parameters. Filters combine with AND.

ParameterAccepted valuesNotes
typefixed · duration · recurring · personalizedRestrict to one timer type.
statusdraft · published · archivedArchived timers are excluded unless you ask for them.
sortcreated_at · -created_at · updated_at · -updated_atA leading minus means descending. Defaults to -created_at.
Filter server-side rather than fetching everything and filtering in your own code. Asking for ?status=published&type=fixed may turn a twenty-page traversal into a single request, and it counts as one call against your rate limit instead of twenty.

Keep parameters stable across a traversal

The cursor was issued for one specific query. Changing sort or any filter while paging produces a cursor mismatch, and the sensible response to that is to start the traversal again rather than to guess.

Reading an entire list

A generator is the right shape here: the caller sees a flat sequence, memory stays flat regardless of account size, and the paging stays in one place.

Node.js — async generator

async function* allTimers(filters = {}) {
  let cursor = undefined;

  do {
    const query = new URLSearchParams({ limit: "100", ...filters });
    if (cursor) query.set("cursor", cursor);

    const { data, page } = await call(`/timers?${query}`);

    // Yield as you go. Accumulating 10,000 timers into one array
    // just to return it is how a sync job runs out of memory.
    for (const timer of data) yield timer;

    cursor = page.next_cursor ?? undefined;
  } while (cursor);
}

// Usage
for await (const timer of allTimers({ status: "published" })) {
  await reconcile(timer);
}

Python — generator

def all_timers(session, **filters):
    cursor = None
    while True:
        params = {"limit": 100, **filters}
        if cursor:
            params["cursor"] = cursor

        payload = session.get(f"{BASE}/timers", params=params).json()
        yield from payload["data"]

        cursor = payload["page"]["next_cursor"]
        if not cursor:
            return

Use limit=100 for bulk traversal. It is four times fewer requests than the default and the response is not meaningfully slower, which matters when the per-minute rate limit is the constraint.

A full traversal of 10,000 timers at 100 per page is 100 requests. In Production that fits comfortably inside one minute; in Sandbox, at 30 requests per minute, it does not. Expect a 429 there and honour Retry-After — see rate limits.

The other paginated endpoints

The same limit and cursor mechanics apply everywhere. Only the filters differ.

EndpointReturnsUseful filters
GET /timersTimers on the accounttype, status, sort
GET /timers/{id}/activityChanges and control actions for one timer
GET /webhook-deliveriesDelivery attempts across destinationsstatus
GET /webhook-destinationsRegistered destinations
GET /timers/{id}/webhook-rulesRules attached to one timer
GET /usage/requestsYour API request logstatus, route, key, request ID
GET /timezonesAccepted IANA identifierssearch
GET /webhook-deliveries?status=failed is the one to reach for first when a webhook integration misbehaves. It shows what we attempted, what your endpoint answered, and when the next retry is due — see retries and replay.