Pagination and filtering

Cursor traversal plus the live hybrid query contract
View as Markdown

The live collection endpoints use cursor pagination with a hybrid query model:

  • GET /v2/contacts
  • GET /v2/companies
  • GET /v2/users
  • GET /v2/tags
  • GET /v2/engagements
  • Query params: limit, cursor, search when supported, structured filter[...], and order_by

Use the canonical parameter names exactly as documented. The API does not accept legacy aliases such as company_id, top-level processing_status, sort, or order.

Query parameters

ParameterMeaning
limitNumber of records to return. Defaults to 25, maximum 100.
cursorKeyset cursor from a previous list response. See How cursors work.
searchOptional top-level free-text search on resources that support it.
filter[updated_at][gt]Incremental sync filter for records updated strictly after a checkpoint.
order_bySort order. Most list endpoints support updated_at:asc or updated_at:desc. GET /v2/engagements also supports start_at:asc and start_at:desc. Keep the same field and direction throughout a cursor walk.

Response fields

FieldMeaning
dataCurrent page of contact objects
has_moretrue if another page is available
next_cursorCursor to use for the next request, or null

Example

curl --request GET \
--url 'https://app.askelephant.ai/api/v2/contacts?limit=2&search=ada&filter[updated_at][gt]=2026-03-01T00:00:00.000Z&order_by=updated_at:desc' \
--header 'Authorization: sk-apik_<id>.<secret>'
{
"object": "list",
"data": [
{
"object": "contact",
"id": "cnt_01HQY3JMS2QAXJGX6X7CH7CM6X",
"first_name": "Ada",
"last_name": "Lovelace",
"created_at": "2026-03-01T12:00:00Z",
"updated_at": "2026-03-04T18:25:00Z"
}
],
"has_more": true,
"next_cursor": "pa_uac_1_eyJpZCI6ImNudF8wMUhRWTNKTVMyUUFYSkdYNlg3Q0g3Q002WCIsInVwZGF0ZWRBdCI6IjIwMjYtMDMtMDRUMTg6MjU6MDAuMDAwWiJ9"
}

To request the next page:

curl --request GET \
--url 'https://app.askelephant.ai/api/v2/contacts?limit=2&cursor=pa_uac_1_eyJpZCI6ImNudF8wMUhRWTNKTVMyUUFYSkdYNlg3Q0g3Q002WCIsInVwZGF0ZWRBdCI6IjIwMjYtMDMtMDRUMTg6MjU6MDAuMDAwWiJ9' \
--header 'Authorization: sk-apik_<id>.<secret>'

Engagement filters

GET /v2/engagements supports these engagement-specific filters on top of the shared list query contract:

ParameterMeaning
filter[engagement_type][eq|in]Filter by engagement type.
filter[processing_status][eq|in]Filter by processing status.
filter[company_ids][eq|in]Filter by AskElephant company IDs.
filter[contact_ids][eq|in]Filter by AskElephant contact IDs.
filter[crm_associations][eq]Filter by CRM associations using indexed bracket syntax or a JSON-encoded array, for example company, contact, or deal.
filter[start_at][gt|gte|lt|lte]Filter by engagement start time.
filter[updated_at][gt|gte|lt|lte]Filter by engagement update time.
expandInclude additional engagement fields such as companies, contacts, owner, signals, or transcript.

Engagement filtering follows MCP-style bucket semantics:

  • company_ids and CRM associations that resolve to companies share one company bucket.
  • contact_ids and CRM associations that resolve to contacts share one participant bucket.
  • CRM associations that resolve to deals/projects populate a project bucket.

Values within the same bucket use OR semantics. Populated buckets combine with AND semantics.

For readability, docs examples use indexed bracket syntax. One CRM company association looks like:

filter[crm_associations][eq][0][id]=12345
filter[crm_associations][eq][0][object_type]=company

When using raw filter[...] query params with curl, add --globoff so curl does not interpret [] as URL globbing syntax.

That corresponds to this object:

{ "id": "12345", "object_type": "company" }

For GET /v2/engagements, the common CRM object filters are:

  • CRM company ID: { "id": "12345", "object_type": "company" }
  • CRM contact ID: { "id": "67890", "object_type": "contact" }
  • CRM deal ID: { "id": "deal_abc123", "object_type": "deal" }

Equivalent JSON-array form:

filter[crm_associations][eq]=[{"id":"123","object_type":"deal"},{"id":"456","object_type":"contact"}]

filter[company_ids][in] and filter[contact_ids][in] support up to 25 values each. filter[crm_associations][eq] supports up to 20 objects.

CRM source is inferred from the workspace’s connected CRM state. The API resolves local CRM links first, and unresolved CRM associations do not erase otherwise valid direct company_ids or contact_ids filters in the same bucket.

For a full walkthrough, see Filter engagements by CRM object IDs.

Engagement example

curl --request GET \
--globoff \
--url 'https://app.askelephant.ai/api/v2/engagements?filter[company_ids][in]=cmp_01HQXVB4Y9Y0Q6P0QH5GX2CT7T,cmp_01HQXVB4Y9Y0Q6P0QH5GX2CT7U&filter[contact_ids][eq]=cnt_01HQY3JMS2QAXJGX6X7CH7CM6X&filter[crm_associations][eq][0][id]=deal_123&filter[crm_associations][eq][0][object_type]=deal&filter[updated_at][gt]=2026-03-01T00:00:00.000Z&order_by=updated_at:desc' \
--header 'Authorization: sk-apik_<id>.<secret>'

How cursors work

List cursors are keyset (seek) pagination tokens, not offset tokens and not a point-in-time snapshot of the collection.

Each cursor encodes the sort key and record id tie-breaker from the last row on the previous page:

order_by fieldCursor prefixEncoded payload
updated_at (default for most list endpoints)pa_uac_1_{ "id": "<resource_id>", "updatedAt": "<ISO-8601 UTC>" }
start_at (GET /v2/engagements only)pa_sac_1_{ "id": "<engagement_id>", "startAt": "<ISO-8601 UTC or null>" }

The prefix plus base64url-encoded JSON is opaque to callers — treat next_cursor as an opaque token and pass it back unchanged. Do not construct cursors manually unless you are debugging.

Cursors are bound to the order_by field, not its direction. Passing an updated_at cursor with order_by=start_at:asc (or the reverse) returns 400 Invalid Query Parameter. Reusing a cursor with the opposite direction for the same field is accepted, but the cursor boundary is then interpreted in that opposite direction and can skip or repeat records. Keep both field and direction unchanged during a traversal.

Live pagination, not a snapshot

Keyset pagination reads the live dataset as you walk pages. There is no snapshot isolation:

  • Duplicate delivery (upsert-safe): If a row’s sort key moves forward relative to your walk direction while you paginate (for example, updated_at increases during an order_by=updated_at:asc export), that row can appear again on a later page. Design sync jobs to upsert by id so duplicates are safe.
  • Possible skip: If a row’s sort key moves backward relative to your walk direction (for example, updated_at decreases during an order_by=updated_at:asc export), that row may be skipped relative to an in-progress cursor walk.

For forward-moving sync jobs, combine filter[updated_at][gte]=<checkpoint> with order_by=updated_at:asc and upsert by id. See Bulk exports and incremental syncs for checkpoint guidance.

Why cursor pagination

Cursor pagination is the live pattern because it is more stable than offset pagination for large, frequently changing datasets.

For incremental sync, keep the last seen updated_at checkpoint and continue walking pages with next_cursor until has_more is false.