Search recipes

Copy-paste Kustomer search queries for common tasks, plus patterns for exporting beyond the 100-page limit, incremental sync and counting.

Ready-to-use bodies for POST /v1/customers/search. See Search for the full syntax and every attribute.

Conversations

JSON
{
  "queryContext": "conversation",
  "and": [
    { "conversation_status": { "equals": "open" } },
    { "conversation_priority": { "gte": 4 } }
  ]
}

Customers

JSON
{
  "queryContext": "customer",
  "and": [{ "customer_email": { "contains": "@acme.example" } }]
}

Messages and custom objects

JSON
{
  "queryContext": "message",
  "and": [
    { "message_body": { "contains": "refund" } },
    { "message_direction": { "equals": "in" } },
    { "message_created_at": { "gte": "2026-10-01" } }
  ]
}

Check attribute names against the standard attributes tables. A misspelled attribute returns an error or no results.

Export more than the page limit

Search stops at 10,000 results per query: a request where page × pageSize is over 10,000 returns 400, so with pageSize=500 the last page you can read is 20. To export everything, split the query into time windows on updated_at and page through each window. Make each window small enough to stay under the limit.

Node.js
async function* exportConversations(from: Date, to: Date, windowHours = 24) {
  for (let start = from; start < to; start = new Date(start.getTime() + windowHours * 3_600_000)) {
    const end = new Date(Math.min(start.getTime() + windowHours * 3_600_000, to.getTime()))
    const body = {
      queryContext: 'conversation',
      and: [
        { conversation_updated_at: { gte: start.toISOString() } },
        { conversation_updated_at: { lt: end.toISOString() } },
      ],
    }
    for (let page = 1; ; page++) {
      const res = await kfetch(`/v1/customers/search?page=${page}&pageSize=500`, { method: 'POST', body: JSON.stringify(body) })
      const { data, meta } = await res.json()
      yield* data
      if (page >= (meta.totalPages ?? 0)) break
      if (page * 500 >= 10_000) throw new Error(`Window ${start.toISOString()} has more than 10,000 results; use a smaller window`)
    }
  }
}

UTC timestamps (with Z) are used exactly, so adjacent windows with gte and lt never overlap or leave gaps.

Incremental sync

After a full export, only fetch what changed since your last run:

Node.js
const since = await store.get('lastRun') // ISO timestamp
const runStartedAt = new Date().toISOString()

for await (const c of exportConversations(new Date(since), new Date(runStartedAt), 6)) await upsertLocal(c)

await store.set('lastRun', runStartedAt)

Store the time the run started, not finished, so records updated during the run are picked up next time. Search updates asynchronously, so overlap windows by a few minutes if you can tolerate duplicates.

Count without fetching

Use pageSize=1 and read meta.total:

Shell
curl -X POST "https://acme.api.kustomerapp.com/v1/customers/search?pageSize=1" \
  -H "Authorization: Bearer $KUSTOMER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "queryContext": "conversation", "and": [{ "conversation_status": { "equals": "open" } }] }' \
  | jq '.meta.total'

IDs only

For large syncs where you'll fetch records separately, add ?idsOnly=true to return only matching IDs.

Records older than two years

Search only covers records updated in the last two years. Send the same body to archive search for older data.

Remember the limits

  • Machine users can make 100 search requests per minute.
  • Sorting only works on customer attributes.
  • Results reflect the search index, which can lag writes by a moment.