Skip to content
API PlatformDevelopers

Filtering contacts

Find, count and save contact segments with the same filter the contacts page uses.

Search contacts (POST /v1/contacts/search), Count contacts by filter (POST /v1/contacts/count) and Check a contact filter (POST /v1/contacts/filters/validate) take one filter object. It uses the same format as the contacts page in the app, so a filter you build here can be saved and then opened in the app as it is, and a filter saved in the app runs here as it is.

curl -X POST "https://mcp.wa-api.cloud/v1/contacts/search" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "filter": {
      "and": [
        { "attributes": { "some": { "attributeName": { "eq": "tier" }, "stringValue": { "eq": "gold" } } } },
        { "createdAt": { "gte": "-7d" } }
      ]
    },
    "sort": { "field": "createdAt", "direction": "desc" },
    "limit": 50
  }'

The request body uses snake_case like the rest of the API (saved_filter_id, next_cursor). The keys inside filter stay camelCase (phoneNumber, attributeName, createdAt), exactly as the app stores them.

The format

A filter is an object of { field: { operator: value } }. Several fields in one object must all match. Combine conditions with:

  • and: a list of filters that must all match.
  • or: a list of filters where at least one must match.
  • not: one filter that must not match.
{
  "or": [
    { "dndEnabled": { "equals": true } },
    { "not": { "groups": { "some": { "id": { "eq": "6" } } } } }
  ]
}

Fields and operators

FieldKindOperators
idideq neq in notIn gt gte lt lte is
name, phoneNumber, whatsappUserIdtexteq neq in contains startsWith endsWith like ilike regex iregex gt gte lt lte is
createdAt, updatedAtdateeq neq in gt gte lt lte is
dndEnabled, isStarredtrue/falseequals
searchname or number{ "term": "ali" }; optional caseSensitive, exactMatch, fields: ["name", "phoneNumber"]
groups, assignedStaffrelationsome every none is isEmpty
conversationTags, attributes, broadcastsrelationsome every none isEmpty
savedFiltersaved filtereq or in (every one must match)
  • is takes "NULL" (no value) or "NOT_NULL" (has a value).
  • contains, startsWith, endsWith and search match the text literally: % and _ are ordinary characters. like and ilike are patterns: % is any run of characters, _ one character.
  • Ids are text: "42". Ids sent as numbers are accepted and turned into text; the answer's notes say so.
  • Phone numbers are stored as digits without `+`: 15555550123. Spaces, dashes, brackets and a leading + in a phoneNumber value are removed before comparing (not in like or regex patterns).
  • isStarred means the contact has a starred conversation, as on the contacts page (not the starred filter of List contacts).
  • whatsappUserId is set for customers who use a WhatsApp username and whose number WhatsApp withholds.

Relations

A relation holds a filter for its items:

  • some: at least one item matches.
  • none: no item matches (contacts with no items at all match too).
  • every: the contact has at least one item, and every item matches. "In both group A and group B" is not every: write one some per group inside and.
  • is (groups and assignedStaff only): the same as every, exactly the matching ones.
  • isEmpty: { "equals": true }: no items at all (false: at least one).
RelationItem keys
groupsid, name, emoji
conversationTagsid, name — the tags on the contact's conversations
assignedStaffid — staff assigned to the contact's conversations
broadcastsid (required), message_status, response { responseName, responseValue }
attributesattributeName plus one value key (below); and / or / not inside

Contact tags are not part of this filter. For contacts with a tag, use tag_ids on List contacts (GET /v1/contacts); it cannot be combined with a filter.

message_status is one of delivered, read, failed, responded, not_responded, with eq, neq or in. response works under some and none, not under every.

Custom fields

Name the field by its key in attributeName (a text filter, usually { "eq": "<key>" }), and put the value under the key that matches the field's type:

Field typeValue key
text, select, radio, email, url, phone, number, multi-selectstringValue
textareatextValue
checkbox, booleanbooleanValue
datedateValue (calendar days)
datetimedateTimeValue
intintValue
float, decimalcannot be filtered

List custom fields gives each field's key and type. The wrong value key is refused with the right one named, and an unknown key is refused too. A number field is stored as text: use eq / in, because gt / lt compare text ("9" > "10"); use an int field for numeric ranges. A multi-select field is stored as a JSON list: match one option with { "contains": "\"gold\"" } (the quotes included).

A field with no value: { "attributes": { "none": { "attributeName": { "eq": "city" } } } }.

Each attributes condition tests one field. For two fields, write two attributes conditions and join them with the filter's and.

Dates

A date value is an ISO date (2026-09-01), a datetime (2026-09-01T10:30:00Z), or relative:

  • now, today, yesterday, tomorrow
  • start_of_week (Monday), start_of_month, start_of_year
  • offsets from now: -30min, -24h, -7d, -2w, -3mo, +1d

Relative dates, and dates without a timezone, are worked out in your workspace's timezone. d, w and mo count calendar days and months, so a change to or from daylight saving time does not shift them. Every answer lists what they became in resolved_dates:

"resolved_dates": [{ "path": "filter.and.1.createdAt.gte", "input": "-7d", "value": "2026-09-23T08:00:00.000Z" }]

Sorting and pages

sort is { "field": "createdAt" | "updatedAt" | "name" | "phoneNumber" | "id", "direction": "asc" | "desc" }. Without it, the newest contacts come first.

Search pages like every list (see Pagination): limit 1–100 (default 25), and pass next_cursor back as cursor with the same filter, saved_filter_id, sort and detail. A cursor for another search is refused. Relative dates keep the moment of the first page, so page 2 of -7d is the same set of contacts as page 1.

  • total and approximate come with the first page only (null after that).
  • approximate: true means the number is an estimate, which can happen on very large workspaces. Count contacts by filter answers count and approximate the same way.
  • detail: "full" adds each contact's groups.

Limits

LimitValue
Nesting of and / or / not and relations8 levels
Conditions in one filter60
Items in one list (in, notIn)500
One text value500 characters
A regex / iregex pattern200 characters; must be a valid pattern; no repeated groups that repeat themselves, like (a+)+
The whole filter16 KB of JSON
search.term1–100 characters
Saved filters in one request10, nested at most 5 deep
A saved filter's definition32 KB (its panel settings too)
A saved filter's name1–255 characters
A saved filter's descriptionup to 1000 characters

A filter that takes too long to run (about 8 seconds) is stopped: 504 timeout with reason: query_timeout. Narrow it (fewer or branches, fewer custom-field or broadcast conditions, a date range) and send it again.

Validation errors

A filter is checked completely before it runs. Every problem is one errors[] entry whose pointer leads to the exact spot, with how to fix it. Messages name the request's own fields (tag_ids, saved_filter_id); the filter keeps its own camelCase keys (phoneNumber, attributeName).

{
  "status": 400,
  "code": "invalid_input",
  "errors": [
    { "pointer": "/filter/and/0/attributes/some/intValue", "detail": "\"tier\" is a select field: use stringValue (not intValue)" },
    { "pointer": "/filter/phone", "detail": "\"phone\" is not a contact filter key: use phoneNumber. Allowed: and, or, not, id, name, phoneNumber, …" }
  ]
}

When the problem belongs to a class your code can branch on, the error also carries it as reason (the first classified problem's):

{ "status": 400, "code": "invalid_input", "reason": "filter_regex_invalid", "errors": [{ "pointer": "/filter/name/regex", "detail": "is not a valid regular expression" }] }
reasonStatusWhat it means
filter_too_deep400More than 8 levels of and / or / not and relations (or saved filters nested more than 5 deep).
filter_too_many_conditions400More than 60 conditions.
filter_list_too_long400An in / notIn list with more than 500 items.
filter_string_too_long400A text value over 500 characters.
filter_regex_too_long400A regex / iregex pattern over 200 characters.
filter_regex_invalid400A pattern that does not compile, repeats a group that itself repeats (like (a+)+), or is too complex for the database to run.
filter_too_large400The filter is over 16 KB of JSON.
filter_value_invalid400A value cannot be compared (for example a date outside the years 1000-9999).
saved_filter_cycle400A saved filter would contain itself, directly or through other saved filters.
saved_filter_unsupported_operator400savedFilter with an operator other than eq or in.
saved_filter_not_found404A saved filter it names does not exist in your workspace. Nothing is read.

Problems without a class (an unknown key, a wrong value key, an empty operator) have no reason: read errors[]. New classes can appear, so treat an unknown reason like a missing one.

What is refused, and why:

  • Unknown keys and operators, with the likely one (phone → phoneNumber, created_at → createdAt, ne → neq).
  • Conditions that would match more than you asked: an empty filter or an operator object with nothing in it, a broadcast condition without id, message_status with is, response under broadcasts.every, and and / or / not inside response. Instead of being ignored, these are refused.
  • Custom fields: a key that does not exist (the known keys are listed) or the wrong value key for its type.
  • Limits (above) and values in the wrong form (dates, ids, is). Dates must fall between the years 1000 and 9999 ("-99999mo" is refused).

Check a contact filter runs the same checks without searching. An invalid filter is an answer there, not an error: valid: false and every problem in issues[] with its path, its message and, when it has one, its reason (the classes above):

{ "valid": false, "issues": [{ "path": "filter.name.regex", "message": "regular expression is too long (201 characters; at most 200)", "reason": "filter_regex_too_long" }], "filter": null, "count": null }

A valid filter comes back normalised, exactly as Create a saved filter would store it, with resolved_dates, warnings, the number of conditions, its depth, the saved filters it uses and the timezone. Send "count": true in the body (not as a query parameter) to also get how many contacts match.

warnings are { "path", "message" } objects on every operation (search, count, check and the saved-filter writes; path may be missing when a warning is about the whole filter). They point out conditions that are valid but may not mean what they look like, for example custom-field conditions inside or, or isStarred, which means the contact has a starred conversation:

"warnings": [{ "path": "filter.isStarred", "message": "isStarred means the contact has a starred conversation (as on the contacts page)" }]

Saved filters

  • Create a saved filter (POST /v1/saved-filters, name, optional description, filter) checks the filter first, like Check a contact filter.
  • Saved filters open in the contacts page's filter builder. A condition written next to and / or / not ({ "name": …, "or": [ … ] }) is stored as one and list ({ "and": [{ "name": … }, { "or": [ … ] }] }), which matches the same contacts. What the builder cannot show (a condition on the contact id, a relation's isEmpty or is) is kept and runs, with a warning: editing that filter in the app and saving it again drops it.
  • A saved filter stores resolved dates, not relative ones. "-7d" saved on 30 September is stored as the instant of 23 September, and it keeps meaning 23 September later. The answer's resolved_dates shows what was stored. To always mean "the last 7 days", send the relative date with each search instead.
  • Update a saved filter (PATCH /v1/saved-filters/{saved_filter_id}) changes name, description (null clears it) or replaces filter. Fields you leave out stay as they are. The answer's changes lists the fields whose saved value differs (name, description, filter; empty when it changes nothing). What an update keeps is held to the same limits as what it changes: a filter saved before a limit existed may need a new name, description or filter before it can be saved again.
  • Preview a save first. ?dry_run=true on create or update runs every check the save runs (ownership included) and answers the saved filter exactly as it would be stored: your name, your filter with its relative dates made absolute, id: null on a create (the id on an update), updated_at: null, and dry_run: true. Nothing is saved. A create preview answers 201 like the real call.
  • Count with the preview. Add count=true (a query parameter, with dry_run=true only; without it the call is 400 invalid_input) to also get count and approximate: the contacts it would match now. If counting fails, the preview still answers, with count: null and count_error ({ "code", "message" }): the save checks all passed.
  • A preview does not use up your `Idempotency-Key`. Send the same key with the preview and then with the real call: the real call saves. Previews are charged to your key's read limit, not its change limit.
  • Delete a saved filter needs Api-Confirm: delete. Contacts are not touched. To learn whether a delete would be refused without confirming it, send it with ?dry_run=true: an unknown id (404), someone else's filter (403 not_owner) or a conversation filter (400) is refused then too, and nothing is deleted.
  • Every saved filter is visible to your whole workspace. Only the staff member who saved it can change or delete it; anyone else gets 403 forbidden with reason: not_owner and the owner_staff_id. Save a copy instead.
  • Use one by id: saved_filter_id on search, count and check, or { "savedFilter": { "eq": "7" } } inside a filter. With filter as well, both must match. An unknown id is 404 not_found: nothing is read, and you never get "every contact" by mistake. A filter that contains itself (directly or through other saved filters) is refused.
  • Conversation filters from the inbox are not contact filters: they are refused here.

All the changes support ?dry_run=true (see Dry runs).

Hidden phone numbers

When your workspace hides phone numbers from the key's staff member, contacts come back with masked numbers (phone_masked: true), as in the app. A filter that compares the number itself (any phoneNumber operator but is), or a sort by phoneNumber, cannot be used then, since it would reveal the number piece by piece. It is refused with 403 forbidden and reason: phone_hidden:

Phone numbers are hidden from the key's staff member in this workspace, so a filter or sort cannot compare them. Use search, other fields, or phoneNumber: {"is": "NOT_NULL"}.

search and phoneNumber: { "is": "NULL" | "NOT_NULL" } still work. This is the only visibility rule: every staff member sees every contact of the workspace, as in the app. Check a contact filter reports it as an issue (valid: false) instead.

Refusals to branch on

StatuscodereasonWhat to do
400invalid_inputnone, or a filter_* / saved_filter_cycle class (above)Fix each errors[] entry.
403forbiddenphone_hiddenFilter or sort without comparing phone numbers.
403forbiddennot_ownerOnly owner_staff_id can change or delete this saved filter; save a copy.
403forbiddentoken_exchange_requiredThe API cannot reach the contact search for this key. Contact support.
404not_foundsaved_filter_not_foundA saved filter it names does not exist in your workspace.
502upstream_errorfilter_failedThe contacts service could not run this filter (for example a regular expression its engine gives up on). Simplify it: the same filter fails again.
504timeoutquery_timeoutNarrow the filter and send it again.
503service_unconfiguredadvanced_filter_disabledAdvanced filters are switched off here: use the filters of List contacts.

Recipes

Contacts added in the last 7 days whose conversation is tagged "new-lead"

{
  "filter": {
    "and": [
      { "conversationTags": { "some": { "name": { "eq": "new-lead" } } } },
      { "createdAt": { "gte": "-7d" } }
    ]
  }
}

For a contact tag, use List contacts with tag_ids, and check created_at on the results.

Custom field "tier" is gold

{ "filter": { "attributes": { "some": { "attributeName": { "eq": "tier" }, "stringValue": { "eq": "gold" } } } } }

Not in the "VIP customers" group

{ "filter": { "groups": { "none": { "id": { "eq": "6" } } } } }

Contacts in no group at all: { "groups": { "isEmpty": { "equals": true } } }.

Replied to broadcast 301

{ "filter": { "broadcasts": { "some": { "id": { "eq": "301" }, "message_status": { "eq": "responded" } } } } }

Received it but did not reply: "message_status": { "eq": "not_responded" } under some (none with responded would also match everyone who never received it). responded means a recorded answer to the broadcast, not any later message. Pressed one button: "response": { "responseValue": { "eq": "Yes, interested" } }.

A saved filter plus one more condition

{ "saved_filter_id": "7", "filter": { "dndEnabled": { "equals": false } } }

Preview a saved filter, then save it

POST /v1/saved-filters?dry_run=true&count=true
Idempotency-Key: 5f1c…

{ "name": "New this month", "filter": { "createdAt": { "gte": "start_of_month" } } }

Show the stored filter and the count, then send the same request without dry_run and count (the same key is fine).

Count before a bulk change

  1. Check a contact filter with count: true: fix any issues, and read count and warnings.
  2. Count contacts by filter right before the change, and stop if the number is not what you expect.
  3. Page through Search contacts and collect the ids first, then change them in batches of up to 500 (Add tags to contacts, Add contacts to a group). Contacts that start or stop matching while you work are then neither touched twice nor missed.