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
| Field | Kind | Operators |
|---|---|---|
id | id | eq neq in notIn gt gte lt lte is |
name, phoneNumber, whatsappUserId | text | eq neq in contains startsWith endsWith like ilike regex iregex gt gte lt lte is |
createdAt, updatedAt | date | eq neq in gt gte lt lte is |
dndEnabled, isStarred | true/false | equals |
search | name or number | { "term": "ali" }; optional caseSensitive, exactMatch, fields: ["name", "phoneNumber"] |
groups, assignedStaff | relation | some every none is isEmpty |
conversationTags, attributes, broadcasts | relation | some every none isEmpty |
savedFilter | saved filter | eq or in (every one must match) |
istakes"NULL"(no value) or"NOT_NULL"(has a value).contains,startsWith,endsWithandsearchmatch the text literally:%and_are ordinary characters.likeandilikeare patterns:%is any run of characters,_one character.- Ids are text:
"42". Ids sent as numbers are accepted and turned into text; the answer'snotessay so. - Phone numbers are stored as digits without `+`:
15555550123. Spaces, dashes, brackets and a leading+in aphoneNumbervalue are removed before comparing (not inlikeorregexpatterns). isStarredmeans the contact has a starred conversation, as on the contacts page (not thestarredfilter of List contacts).whatsappUserIdis 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 notevery: write onesomeper group insideand.is(groupsandassignedStaffonly): the same asevery, exactly the matching ones.isEmpty: { "equals": true }: no items at all (false: at least one).
| Relation | Item keys |
|---|---|
groups | id, name, emoji |
conversationTags | id, name — the tags on the contact's conversations |
assignedStaff | id — staff assigned to the contact's conversations |
broadcasts | id (required), message_status, response { responseName, responseValue } |
attributes | attributeName 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 type | Value key |
|---|---|
| text, select, radio, email, url, phone, number, multi-select | stringValue |
| textarea | textValue |
| checkbox, boolean | booleanValue |
| date | dateValue (calendar days) |
| datetime | dateTimeValue |
| int | intValue |
| float, decimal | cannot 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,tomorrowstart_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.
totalandapproximatecome with the first page only (nullafter that).approximate: truemeans the number is an estimate, which can happen on very large workspaces. Count contacts by filter answerscountandapproximatethe same way.detail: "full"adds each contact's groups.
Limits
| Limit | Value |
|---|---|
Nesting of and / or / not and relations | 8 levels |
| Conditions in one filter | 60 |
Items in one list (in, notIn) | 500 |
| One text value | 500 characters |
A regex / iregex pattern | 200 characters; must be a valid pattern; no repeated groups that repeat themselves, like (a+)+ |
| The whole filter | 16 KB of JSON |
search.term | 1–100 characters |
| Saved filters in one request | 10, nested at most 5 deep |
| A saved filter's definition | 32 KB (its panel settings too) |
| A saved filter's name | 1–255 characters |
| A saved filter's description | up 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" }] }reason | Status | What it means |
|---|---|---|
filter_too_deep | 400 | More than 8 levels of and / or / not and relations (or saved filters nested more than 5 deep). |
filter_too_many_conditions | 400 | More than 60 conditions. |
filter_list_too_long | 400 | An in / notIn list with more than 500 items. |
filter_string_too_long | 400 | A text value over 500 characters. |
filter_regex_too_long | 400 | A regex / iregex pattern over 200 characters. |
filter_regex_invalid | 400 | A pattern that does not compile, repeats a group that itself repeats (like (a+)+), or is too complex for the database to run. |
filter_too_large | 400 | The filter is over 16 KB of JSON. |
filter_value_invalid | 400 | A value cannot be compared (for example a date outside the years 1000-9999). |
saved_filter_cycle | 400 | A saved filter would contain itself, directly or through other saved filters. |
saved_filter_unsupported_operator | 400 | savedFilter with an operator other than eq or in. |
saved_filter_not_found | 404 | A 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_statuswithis,responseunderbroadcasts.every, andand/or/notinsideresponse. 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, optionaldescription,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 oneandlist ({ "and": [{ "name": … }, { "or": [ … ] }] }), which matches the same contacts. What the builder cannot show (a condition on the contactid, a relation'sisEmptyoris) 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'sresolved_datesshows 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}) changesname,description(nullclears it) or replacesfilter. Fields you leave out stay as they are. The answer'schangeslists 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=trueon 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: nullon a create (the id on an update),updated_at: null, anddry_run: true. Nothing is saved. A create preview answers201like the real call. - Count with the preview. Add
count=true(a query parameter, withdry_run=trueonly; without it the call is400 invalid_input) to also getcountandapproximate: the contacts it would match now. If counting fails, the preview still answers, withcount: nullandcount_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 forbiddenwithreason: not_ownerand theowner_staff_id. Save a copy instead. - Use one by id:
saved_filter_idon search, count and check, or{ "savedFilter": { "eq": "7" } }inside a filter. Withfilteras well, both must match. An unknown id is404 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. Usesearch, other fields, orphoneNumber: {"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
| Status | code | reason | What to do |
|---|---|---|---|
| 400 | invalid_input | none, or a filter_* / saved_filter_cycle class (above) | Fix each errors[] entry. |
| 403 | forbidden | phone_hidden | Filter or sort without comparing phone numbers. |
| 403 | forbidden | not_owner | Only owner_staff_id can change or delete this saved filter; save a copy. |
| 403 | forbidden | token_exchange_required | The API cannot reach the contact search for this key. Contact support. |
| 404 | not_found | saved_filter_not_found | A saved filter it names does not exist in your workspace. |
| 502 | upstream_error | filter_failed | The contacts service could not run this filter (for example a regular expression its engine gives up on). Simplify it: the same filter fails again. |
| 504 | timeout | query_timeout | Narrow the filter and send it again. |
| 503 | service_unconfigured | advanced_filter_disabled | Advanced 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
- Check a contact filter with
count: true: fix anyissues, and readcountandwarnings. - Count contacts by filter right before the change, and stop if the number is not what you expect.
- 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.