## Search posts **post** `/v2/posts/search` Search feedback posts with a structured filter AST, full-text search, sorting, and cursor pagination. ### Two modes (combinable) - **Filter mode** — pass a structured `query` AST (and/or `sort`) and the endpoint returns posts matching the filter, ordered by `sort` (default `createdAt:desc`). Same shape as `/v2/conversations/search`, `/v2/companies/search`, `/v2/contacts/search`. - **Search mode** — pass a top-level `search` string and the endpoint runs a hybrid full-text + vector search (BM25 + embeddings, semanticRatio 0.5) over post `title` and `content`, returning matches ranked by relevance. Optional: combine with `query` to constrain the search to a board / status / time window, and/or `sort` to override relevance ranking with chronological order. ```json { "search": "mobile dark mode", "query": { "operator": "AND", "value": [ { "field": "boardId", "operator": "IN", "value": ["507f1f77bcf86cd799439011"] } ] }, "limit": 20 } ``` `search` is server-managed: no operators, no field selection, no special syntax — same plain-text contract as `/v2/conversations/search`. The string must be 1–500 characters; up to ~1024 BM25-tokenizer characters are forwarded to Turbopuffer (longer queries are truncated at a word boundary). ### 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": "boardId", "operator": "IN", "value": ["507f1f77bcf86cd799439011"] }, { "field": "upvotes", "operator": ">", "value": 10 } ] }, "sort": "upvotes:desc", "limit": 20 } ``` To list every post a contact has upvoted (the inverse of `feedback.posts.voters.list(postId)`): ```json { "query": { "field": "voterId", "operator": "=", "value": "507f1f77bcf86cd799439011" }, "sort": "upvotes:desc", "limit": 100 } ``` 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 (`board_id`, `status_id`, `tag_id`, `author_id`, `voter_id`, `assignee_id`, `company_id`, `created_at`, `updated_at`, `comment_count`, `monthly_spend`, `opportunity_amount`, `in_review`, `is_pinned`) are still accepted for back-compat but are deprecated — please migrate to camelCase in new code. | Field | Type | Operators | | -------------------------------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `boardId` | id (categoryId) | `=`, `!=`, `IN`, `NIN` | | `statusId` | id (postStatus) | `=`, `!=`, `IN`, `NIN` | | `tagId` | id (postTags) | `=`, `!=`, `IN`, `NIN` | | `authorId` | string (user id) | `=`, `!=`, `IN`, `NIN` | | `voterId` | id (customer) | `=`, `IN` (use to list every post a contact has upvoted; only matches `type: customer` upvoters — guests/admins are not searchable here) | | `assigneeId` | id or null (admin) | `=`, `!=`, `IN`, `NIN` (use `null` for unassigned) | | `companyId` | string (external company id) | `=`, `!=`, `IN`, `NIN` | | `createdAt`, `updatedAt`, `eta` | unix seconds | `=`, `!=`, `>`, `<`, `>=`, `<=` | | `upvotes`, `commentCount`, `monthlySpend`, `opportunityAmount` | number | `=`, `!=`, `>`, `<`, `>=`, `<=` | | `inReview`, `isPinned` | boolean | `=`, `!=` | Use the top-level `search` field for full-text search across `title` and `content` (see "Search mode" above). The clause-level operators `~`, `!~`, `^`, `$` are reserved for future use and currently return `400`. A filter-mode query (no `search`) 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. Boolean fields (`inReview`, `isPinned`) are bounded so a standalone negation on them is allowed. Search mode (with `search` set) relaxes this — the FTS query itself acts as the positive constraint. ### Sort Allowed values: - `createdAt:desc` (default), `createdAt:asc` - `updatedAt:desc`, `updatedAt:asc` - `upvotes:desc`, `upvotes:asc` - `eta:desc`, `eta:asc` - `monthlySpend:desc`, `monthlySpend:asc` (sum of upvoters' monthly spend) - `opportunityAmount:desc`, `opportunityAmount:asc` (linked HubSpot/Salesforce value) - `commentCount:desc`, `commentCount:asc` Snake_case sort axes (`created_at:desc`, `monthly_spend: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. When `search` is set, results are ranked by relevance unless `sort` is also set, in which case the matching posts are returned in the requested order. The chosen sort axis (or relevance) is encoded in the cursor; switching `sort` (or toggling `search` on/off) mid-pagination returns `400 invalid_cursor`. Restart pagination without a cursor when changing the sort or search shape. ### Pagination Cursor-based, `limit` between 1 and 100 (default 10). In filter mode, `totalCount` is approximate and capped at 5000; `totalCountCapped` is `true` when the real count may be higher. In search mode, `totalCount` reflects the matching survivor set after the AST filter and is capped at 200 (the Turbopuffer top-K). ### Server-side guards These exclusions are always applied and CANNOT be disabled via the AST: - Spam posts (`isSpam: true`) are excluded. - Merged posts (those rolled into another post) are excluded — the canonical row is what's returned. - Support-board (ticket) categories are excluded — tickets have their own `/v2/tickets` surface. ### Response Returns a standard list envelope with post rows identical to `GET /v2/posts/{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` - `search: optional string` Plain-text full-text search across post `title` and `content`. Server-managed (hybrid BM25 + vector) — no operators, no field selection. When `search` is set, results are ranked by relevance unless `sort` is also set, in which case the matching posts are returned in the requested order. Combine with `query` to constrain the search to a board / status / time window. - `sort: optional "createdAt:desc" or "createdAt:asc" or "updatedAt:desc" or 21 more` Sort field + direction. Defaults to `createdAt:desc`. The chosen sort axis is encoded in the cursor; switching `sort` mid-pagination returns `400 invalid_cursor`. Snake_case names (`created_at:desc`, `monthly_spend:asc`, …) are accepted for back-compat — prefer camelCase in new code. - `"createdAt:desc"` - `"createdAt:asc"` - `"updatedAt:desc"` - `"updatedAt:asc"` - `"upvotes:desc"` - `"upvotes:asc"` - `"eta:desc"` - `"eta:asc"` - `"monthlySpend:desc"` - `"monthlySpend:asc"` - `"opportunityAmount:desc"` - `"opportunityAmount:asc"` - `"commentCount:desc"` - `"commentCount:asc"` - `"created_at:desc"` - `"created_at:asc"` - `"updated_at:desc"` - `"updated_at:asc"` - `"monthly_spend:desc"` - `"monthly_spend:asc"` - `"opportunity_amount:desc"` - `"opportunity_amount:asc"` - `"comment_count:desc"` - `"comment_count:asc"` ### Returns - `data: array of object { id, access, assigneeId, 20 more }` Array of search results - `id: string` Unique identifier - `access: object { companyExternalIds, userIds }` - `companyExternalIds: array of string` External company IDs explicitly granted access to this post. Empty array means no company-level restrictions. Non-empty means only users belonging to these companies can see the post. - `userIds: array of string` User IDs explicitly granted access to this post. Empty array means no user-level restrictions (post uses board/org visibility). Non-empty means only these users (plus admins) can see the post. - `assigneeId: string` ID of the admin assigned to this post, null if unassigned - `author: object { id, email, name, 2 more }` - `id: string` Author unique identifier - `email: string` Author email (if available) - `name: string` Author display name - `profilePicture: string` Author profile picture URL - `type: "admin" or "customer" or "guest" or 3 more` Type of user who authored the post - `"admin"` - `"customer"` - `"guest"` - `"integration"` - `"bot"` - `"lead"` - `boardId: string` Board (category) ID this post belongs to - `commentCount: number` Total number of comments - `content: string` Post content in HTML format - `createdAt: string` ISO 8601 timestamp when created - `customFields: map[unknown]` Custom field values keyed by field ID - `eta: string` Estimated completion time as ISO 8601 timestamp, null if not set - `features: object { commentsEnabled }` - `commentsEnabled: boolean` Whether comments are allowed on this post - `inReview: boolean` Whether the post is pending moderation review - `integrations: object { clickup, devops, github, 4 more }` Third-party integration links associated with this post - `clickup: array of object { id, title, url }` - `id: string` ClickUp task ID - `title: string` ClickUp task title - `url: string` URL to the ClickUp task - `devops: array of object { id, projectId, projectName, 2 more }` - `id: number` Azure DevOps work item ID - `projectId: string` Azure DevOps project ID - `projectName: string` Azure DevOps project name - `title: string` Work item title - `url: string` URL to the work item - `github: array of object { id, number, repositoryFullName, 3 more }` - `id: string` GitHub issue ID - `number: string` GitHub issue number - `repositoryFullName: string` Full repository name (owner/repo) - `repositoryName: string` Repository name - `title: string` GitHub issue title - `url: string` URL to the GitHub issue - `hubspot: array of object { dealAmount, dealClosed, objectId, type }` - `dealAmount: number` Deal amount (for DEAL type) - `dealClosed: boolean` Whether the deal is closed (for DEAL type) - `objectId: number` HubSpot object ID - `type: "TICKET" or "DEAL" or "CONTACT"` HubSpot object type - `"TICKET"` - `"DEAL"` - `"CONTACT"` - `jira: array of object { issueId, issueUrl }` - `issueId: string` Jira issue ID - `issueUrl: string` URL to the Jira issue - `linear: array of object { issueId, issueUrl }` - `issueId: string` Linear issue ID - `issueUrl: string` URL to the Linear issue - `salesforce: array of object { amount, isClosed, objectId, objectType }` - `amount: number` Opportunity amount (for Opportunity type) - `isClosed: boolean` Whether the opportunity is closed (for Opportunity type) - `objectId: string` Salesforce record ID - `objectType: "Opportunity" or "Case"` Salesforce object type - `"Opportunity"` - `"Case"` - `isPinned: boolean` Whether the post is pinned to the top - `object: "post"` Object type identifier - `"post"` - `opportunityAmount: number` Total opportunity amount from linked HubSpot deals and Salesforce opportunities - `postUrl: string` Full URL to view the post - `slug: string` URL-friendly slug - `status: PostStatus` - `id: string` Unique identifier - `color: string` Color for UI display - `isDefault: boolean` Whether this is the default status for new posts - `name: string` Display name - `object: "post_status"` Object type identifier - `"post_status"` - `type: "reviewing" or "unstarted" or "active" or 2 more` The workflow stage this status represents - `"reviewing"` - `"unstarted"` - `"active"` - `"completed"` - `"canceled"` - `tags: array of object { id, color, name }` Tags attached to this post - `id: string` Tag unique identifier - `color: string` Tag color hex code - `name: string` Tag name - `title: string` Post title - `updatedAt: string` ISO 8601 timestamp when last modified - `upvotes: number` Total number of upvotes - `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 posts 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. UI can render as e.g. "5000+". ### Example ```http curl https://do.featurebase.app/v2/posts/search \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $FEATUREBASE_API_KEY" \ -d '{ "cursor": "eyJpZCI6IjUwN2YxZjc3YmNmODZjZDc5OTQzOTAxMSJ9", "limit": 10, "search": "mobile dark mode", "sort": "upvotes:desc" }' ``` #### Response ```json { "data": [ { "id": "507f1f77bcf86cd799439011", "access": { "companyExternalIds": [ "string" ], "userIds": [ "string" ] }, "assigneeId": "507f1f77bcf86cd799439013", "author": { "id": "507f1f77bcf86cd799439011", "email": "john@example.com", "name": "John Doe", "profilePicture": "https://cdn.example.com/avatars/john.png", "type": "customer" }, "boardId": "507f1f77bcf86cd799439011", "commentCount": 5, "content": "
It would be great to have a dark mode option for the dashboard.
", "createdAt": "2023-12-12T00:00:00.000Z", "customFields": { "cf_priority": "bar", "cf_effort": "bar" }, "eta": "2025-01-01T00:00:00.000Z", "features": { "commentsEnabled": true }, "inReview": false, "integrations": { "clickup": [ { "id": "86a1b2c3d", "title": "Add dark mode support", "url": "https://app.clickup.com/t/86a1b2c3d" } ], "devops": [ { "id": 1234, "projectId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "projectName": "My Project", "title": "Add dark mode support", "url": "https://dev.azure.com/org/project/_workitems/edit/1234" } ], "github": [ { "id": "1234567890", "number": "42", "repositoryFullName": "acme/backend", "repositoryName": "backend", "title": "Add dark mode support", "url": "https://github.com/acme/backend/issues/42" } ], "hubspot": [ { "dealAmount": 5000, "dealClosed": false, "objectId": 123456789, "type": "TICKET" } ], "jira": [ { "issueId": "10042", "issueUrl": "https://myteam.atlassian.net/browse/PROJ-123" } ], "linear": [ { "issueId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "issueUrl": "https://linear.app/team/issue/ENG-123" } ], "salesforce": [ { "amount": 25000, "isClosed": false, "objectId": "006Dn00000Abcdef", "objectType": "Opportunity" } ] }, "isPinned": false, "object": "post", "opportunityAmount": 30000, "postUrl": "https://feedback.example.com/p/add-dark-mode-support", "slug": "add-dark-mode-support", "status": { "id": "507f1f77bcf86cd799439011", "color": "Blue", "isDefault": false, "name": "In Progress", "object": "post_status", "type": "active" }, "tags": [ { "id": "507f1f77bcf86cd799439011", "color": "#FF5722", "name": "bug" } ], "title": "Add dark mode support", "updatedAt": "2023-12-13T00:00:00.000Z", "upvotes": 42 } ], "nextCursor": "eyJpZCI6IjUwN2YxZjc3YmNmODZjZDc5OTQzOTAxMSJ9", "object": "list", "totalCount": 42, "totalCountCapped": false } ```