## Search contacts **post** `/v2/contacts/search` Search contacts (customers and leads) with a structured filter AST, sorting, and cursor pagination. ### Structured query AST Each clause has the shape `{ field, operator, value }`. You can pass a single clause or wrap up to 15 in a top-level `AND` group: ```json { "query": { "operator": "AND", "value": [ { "field": "type", "operator": "=", "value": "customer" }, { "field": "lastActivity", "operator": ">", "value": 1735689600 } ] }, "sort": "lastActivity:desc", "limit": 20 } ``` Top-level `OR` groups and nested groups are not supported in this version. ### Supported fields and operators Field names are camelCase, matching the rest of the v2 public API. Snake_case names that this endpoint originally shipped with (`external_id`, `company_ids`, `posts_created`, `comments_created`, `last_activity`, `created_at`) are still accepted for back-compat but are deprecated — please migrate to camelCase in new code. | Field | Type | Operators | | ----------------- | -------------------------------- | --------------------------- | | `externalId` | string (caller-provided user id) | `=`, `!=`, `IN`, `NIN` | | `email` | string | `=`, `!=`, `IN`, `NIN` | | `name` | string | `=`, `!=`, `IN`, `NIN`, `~` | | `type` | enum (`customer`, `lead`) | `=`, `!=`, `IN`, `NIN` | | `companyIds` | id (Featurebase company `id`) | `=`, `!=`, `IN`, `NIN` | | `postsCreated` | number | `=`, `!=`, `>`, `<` | | `commentsCreated` | number | `=`, `!=`, `>`, `<` | | `lastActivity` | unix seconds | `=`, `!=`, `>`, `<` | | `createdAt` | unix seconds | `=`, `!=`, `>`, `<` | The `name ~ "..."` operator runs a word-aware substring search via the underlying text index. Other string operators (`!~`, `^`, `$`) are reserved for future use and currently return `400`. A query consisting only of `!=` or `NIN` clauses on unbounded fields is rejected with `query_too_broad` to prevent full-org scans. Combine the negation with at least one positive clause (`=`, `IN`, `>`, `<`) instead. The closed-enum field `type` is exempt from this guard. Contacts that have been merged into another contact (lead-to-customer rollup) are always excluded from results. ### Sort Allowed values: - `lastActivity:desc` (default), `lastActivity:asc` - `createdAt:desc`, `createdAt:asc` - `postsCreated:desc`, `postsCreated:asc` - `commentsCreated:desc`, `commentsCreated:asc` Snake_case sort axes (`last_activity:desc`, `created_at:asc`, etc.) are accepted for back-compat — the cursor encodes the canonical camelCase identity, so a caller can switch naming conventions mid-pagination without restarting. The chosen sort axis is encoded in the cursor; switching `sort` mid-pagination returns `400 invalid_cursor`. Restart pagination without a cursor when changing sort. ### Pagination Cursor-based, `limit` between 1 and 100 (default 10). `totalCount` is approximate and capped at 5000; `totalCountCapped` is `true` when the real count may be higher. ### Response Returns a standard list envelope with contact rows identical to `GET /v2/contacts/{id}`. ### Version Availability This endpoint is only available in API version 2026-01-01.nova and newer. ### Header Parameters - `"Featurebase-Version": optional "2026-01-01.nova" or "2025-12-12.clover"` - `"2026-01-01.nova"` - `"2025-12-12.clover"` ### Body Parameters - `cursor: optional string` An opaque cursor for pagination. Use the `nextCursor` value from a previous response to fetch the next page of results. - `limit: optional number` A limit on the number of objects to be returned, between 1 and 100. - `query: optional object { field, operator, value } or object { operator, value }` Structured filter AST. Either a single filter clause or one top-level AND group (max 15 clauses). Top-level OR groups are not yet supported. - `SearchFilter object { field, operator, value }` - `field: string` Field name to filter on (e.g. `state`, `tag_ids`, `created_at`) - `operator: "=" or "!=" or "IN" or 9 more` Comparison operator - `"="` - `"!="` - `"IN"` - `"NIN"` - `">"` - `"<"` - `">="` - `"<="` - `"~"` - `"!~"` - `"^"` - `"$"` - `value: string or number or boolean or array of string or number` Value to compare against (primitive or array of primitives) - `string` - `number` - `boolean` - `array of string or number` - `string` - `number` - `SearchFilterGroup object { operator, value }` - `operator: "AND" or "OR"` Group operator: AND (all match) or OR (any match) - `"AND"` - `"OR"` - `value: array of object { field, operator, value }` Array of filter clauses (1-15 entries) - `field: string` Field name to filter on (e.g. `state`, `tag_ids`, `created_at`) - `operator: "=" or "!=" or "IN" or 9 more` Comparison operator - `"="` - `"!="` - `"IN"` - `"NIN"` - `">"` - `"<"` - `">="` - `"<="` - `"~"` - `"!~"` - `"^"` - `"$"` - `value: string or number or boolean or array of string or number` Value to compare against (primitive or array of primitives) - `string` - `number` - `boolean` - `array of string or number` - `string` - `number` - `sort: optional "createdAt:desc" or "createdAt:asc" or "lastActivity:desc" or 13 more` Sort field + direction. Defaults to `lastActivity:desc` (matches the dashboard's default contact ordering). The chosen sort axis is encoded in the cursor; switching `sort` mid-pagination returns `400 invalid_cursor`. Snake_case names (`last_activity:desc`, `posts_created:asc`, …) are accepted for back-compat — prefer camelCase in new code. - `"createdAt:desc"` - `"createdAt:asc"` - `"lastActivity:desc"` - `"lastActivity:asc"` - `"postsCreated:desc"` - `"postsCreated:asc"` - `"commentsCreated:desc"` - `"commentsCreated:asc"` - `"created_at:desc"` - `"created_at:asc"` - `"last_activity:desc"` - `"last_activity:asc"` - `"posts_created:desc"` - `"posts_created:asc"` - `"comments_created:desc"` - `"comments_created:asc"` ### Returns - `data: array of object { id, name, object, 16 more }` Array of search results - `id: string` Unique identifier - `name: string` Contact display name - `object: "contact"` Object type identifier - `"contact"` - `type: "customer" or "lead"` Type of contact - `"customer"` - `"lead"` - `commentsCreated: optional number` Number of comments created - `companies: optional array of Company` Companies the contact belongs to - `id: string` Featurebase internal ID - `companyId: string` External company ID from your system - `companySize: number` Company employee headcount - `createdAt: string` ISO date when company was created - `industry: string` Industry - `lastActivity: string` ISO date of last activity - `linkedUsers: number` Number of users linked to this company - `monthlySpend: number` Monthly spend - `name: string` Company name - `object: "company"` Object type identifier - `"company"` - `plan: string` Plan or tier name - `updatedAt: string` ISO date when company was last updated - `website: string` Company website URL - `customFields: optional map[unknown]` Custom field values - `customFields: optional map[unknown]` Custom field values on the contact - `description: optional string` Contact description/bio - `email: optional string` Contact email - `lastActivity: optional string` Last activity ISO timestamp - `locale: optional string` Contact locale - `manuallyOptedOutFromChangelog: optional boolean` Whether manually opted out from changelog - `organizationId: optional string` Organization ID the contact belongs to - `postsCreated: optional number` Number of posts created - `profilePicture: optional string` Profile picture URL - `roles: optional array of string` Contact roles - `subscribedToChangelog: optional boolean` Whether subscribed to changelog - `userId: optional string` External user ID from SSO - `verified: optional boolean` Whether email is verified - `nextCursor: string` Cursor for fetching the next page (null if no more results) - `object: "list"` Object type identifier - `"list"` - `totalCount: optional number` Total number of contacts matching the query, capped at 5000. When the actual total is at or above the cap, `totalCountCapped` is true and the value is exactly the cap. - `totalCountCapped: optional boolean` True when `totalCount` is exactly the cap and the real count may be higher. ### Example ```http curl https://do.featurebase.app/v2/contacts/search \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $FEATUREBASE_API_KEY" \ -d '{ "cursor": "eyJpZCI6IjUwN2YxZjc3YmNmODZjZDc5OTQzOTAxMSJ9", "limit": 10, "sort": "lastActivity:desc" }' ``` #### Response ```json { "data": [ { "id": "676f0f6765bdaa7d7d760f88", "name": "John Steezy", "object": "contact", "type": "customer", "commentsCreated": 0, "companies": [ { "id": "507f1f77bcf86cd799439011", "companyId": "comp_12345", "companySize": 250, "createdAt": "2025-01-01T12:00:00.000Z", "industry": "Technology", "lastActivity": "2025-01-15T00:00:00.000Z", "linkedUsers": 15, "monthlySpend": 5000, "name": "Acme Inc", "object": "company", "plan": "enterprise", "updatedAt": "2025-01-10T15:30:00.000Z", "website": "https://acme.com", "customFields": { "location": "bar", "priority": "bar" } } ], "customFields": { "foo": "bar" }, "description": "", "email": "john@example.com", "lastActivity": "2025-01-03T21:42:30.181Z", "locale": "en", "manuallyOptedOutFromChangelog": false, "organizationId": "5febde12dc56d60012d47db6", "postsCreated": 0, "profilePicture": "https://fb-usercontent.fra1.cdn.digitaloceanspaces.com/anon_23.png", "roles": [ "string" ], "subscribedToChangelog": true, "userId": "676f0f673dbb299c8a4f3057", "verified": true } ], "nextCursor": "eyJpZCI6IjUwN2YxZjc3YmNmODZjZDc5OTQzOTAxMSJ9", "object": "list", "totalCount": 42, "totalCountCapped": false } ```