# Pagination and filtering

Cursor paging with exact page edges, filters that all run in SQL, and one sort parameter.

Source: https://docs.leadarc.io/pagination.html

## Cursor pagination

Cursors only. There is no offset paging. New replies arrive while you page, and an offset would skip or repeat rows.

| Parameter | Meaning |
| --- | --- |
| `limit` | 1 to 200. Defaults to 50. Higher values are clamped. |
| `cursor` | The `next_cursor` from the last page. Opaque. Do not build one. |
| `order` | `desc` (default) or `asc`. Flips the endpoint's one sort key. There is no `sort_by`. |

Every list returns the same envelope:

```json
{
  "object": "list",
  "data": [
    "…"
  ],
  "has_more": true,
  "next_cursor": "MjAyNi0wNy0yN1QwOToxNDoyMi4wMDBafDk5MTIwNDE"
}
```

```bash
cursor=""
while :; do
  page=$(curl -sS "https://api.leadarc.io/v1/workspaces/airpay/leads?limit=100${cursor:+&cursor=$cursor}" \
    -H "Authorization: Bearer $LEADARC_API_KEY")
  echo "$page" | jq -c '.data[]'
  [ "$(echo "$page" | jq -r '.has_more')" = "true" ] || break
  cursor=$(echo "$page" | jq -r '.next_cursor')
done
```

> **Cursors use two columns, not one.**
>
> The lead feed pages on `last_message_at` plus the reply id. Two leads can share a timestamp to the millisecond, and a one-column cursor would drop one of them at the page edge.

No list returns a total count. Counting is too expensive. Page until `has_more` is false.

## Filters

**Every filter runs in the database.** Nothing is filtered after loading, so a filtered page is never a partial answer.

An unknown query parameter is always [`400 unsupported_filter`](errors.html#status-400), and the error names it. A typo like `?statuses=interested` is reported instead of ignored.

`GET /v1/workspaces/{ws}/leads` filters:

| Param | Values | SQL |
|---|---|---|
| `status` | CSV of `interested`, `not_interested`, `automated`, `unsubscribed`, `untagged`, `booked`, `disqualified` | Translates to a `last_action` predicate for the first five and a `booked_at`/`disqualified_at` predicate for the last two |
| `campaign_id` | one `cmp_` id | `responses_index.campaign_id` |
| `assignee_id` | CSV of `usr_` ids, or the literal `none` | `EXISTS` / `NOT EXISTS` on `lead_assignments` |
| `email` | exact address, case-insensitive | `lower(lead_email) = ?` |
| `has_open_task` | `true` \| `false` | `EXISTS` on `tasks` scoped to the key owner. See the note. |
| `task_due_before` | `YYYY-MM-DD` | `tasks.due_date < ?`, same scoping |
| `meeting_outcome` | CSV of `showed`, `no_show`, `no_show_host`, `cancelled`, `rescheduled`, `none` | `responses_index.meeting_outcome` |
| `direction` | `sent` \| `received` | `last_message_direction` |
| `updated_since` | RFC3339 | `last_message_at > ?`, forward order |
| `updated_before` | RFC3339 | `last_message_at < ?` |

Inside the app, `INTERESTED` and friends live on `last_action`, while `booked` and `disqualified` live on the `feed` axis backed by `booked_at` and `disqualified_at`. the app deliberately excludes `DISQUALIFIED` from its tag allow-list for that reason. An agent reading a docs table that lists seven lifecycle states will send all seven to one param, so the API accepts all seven on one param and routes them internally.

Note that `DNC` is surfaced as `unsubscribed`, because `STATUS_META.DNC.label` in the app is already "Unsubscribed". `dnc` is accepted as an alias on input and never returned.

**`updated_since` is not mutually exclusive with `cursor`.** Inside the app, `fetchInboxRows` treats `cursor` and `since` as exclusive with `since` winning silently, which makes "every lead that replied in June" impossible to express. The API allows a bounded window plus a cursor inside it, which is what a reconciliation job needs.

There is no `q=` full-text search. `responses_index` has `responses_index_lead_email_idx` and no trigram or tsvector index. A substring search over `body_preview` is a sequential scan on the largest table in the database. Exact `email=` is offered instead. Real search needs an index first, and that is a separate decision.

## Sorting

One param, `order`, with two values: `desc` (default) and `asc`. It flips the endpoint's single fixed sort key. There is no `sort_by`.

Fixed sort keys: leads by `(last_message_at, eb_reply_id)`. Meetings by `(booked_at, id)`. Tasks by `(due_date, id)`. Notes, templates, assets, sequences by `(order_index, id)` or `(created_at, id)` as noted per endpoint.

## Expansions

`?expand=a,b` inlines related objects. Maximum three per request. More is [`400 invalid_request`](errors.html#status-400).

`expand=messages` and `expand=thread` are **not available on any list endpoint**, at any limit. Building a thread calls `buildLeadThreadMessages` in the app, which on a cache miss makes up to 14 EmailBison round trips per lead (the anchor reply's conversation thread, up to `MAX_SIBLINGS = 12` sibling threads, and the lead's `/sent-emails`). One hundred leads is up to 1,400 upstream calls in a single HTTP request. That would time out, exhaust the pool while writes queue behind it, and get the whole agency rate-limited by EmailBison, which breaks the Master Inbox for every human SDR. Threads are single-lead only, on `GET /v1/workspaces/{ws}/leads/{lead}/messages`.

## Dates and times

| Kind | Looks like | Used for |
| --- | --- | --- |
| Instant | `2026-07-27T09:14:22.000Z` | Every timestamp. Always UTC with a `Z`. |
| Day | `2026-07-29` | `tasks.due_date`, `next_due_at`, daily insights. A day, not an instant. |
| Wall clock | `10:30` | `tasks.due_time`. 24 hour local time, no timezone. |
| Unix seconds | `1785312060` | The `x-ratelimit-reset` header only. |

`updated_since`, `booked_before`, `from` and `to` take a full date-time. `task_due_before` and `due_after` take `YYYY-MM-DD` and compare whole days, so no timezone shifts them.

---

Base URL `https://api.leadarc.io/v1`. Generated from the API source.
