Global search
GET /v1/search is a single, free-text endpoint that searches across customers, products, invoices, promotions, credit notes, and plans at once. Use it to power a "find anything" search bar in your dashboard, autocomplete inputs, or support tooling that needs to locate records by name, code, or number.
It's a separate read path from the per-resource list endpoints (GET /v1/customers, GET /v1/invoices, …) - see Search vs cursor lists below for when to use which.
Quick example
Code
Code
Each hit carries a stable entity_type discriminator and a renderable
display_name + secondary_text pair, so a UI can show all results in a
single list without per-entity rendering logic.
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
q | string | "" | Free-text query. Empty q returns the most-recent records (sorted by created_at desc) - useful as a "recents" feed when the user opens the search bar with no input. Free-text matches the entity's display name, secondary fields, and any tag names attached to the entity, so typing vip finds both customers named "VIP Inc" and customers tagged vip. |
types | string | (all) | Comma-separated entity types to include. Allowed: customer, product, invoice, promotion, credit_note, plan. Unknown values return 400. |
tag | string | (none) | Repeatable. Narrows hits to entities carrying every supplied tag (AND semantics). Accepts either a tag id (UUID) or a tag name - names are resolved to ids per request, so renames don't break saved URLs. If a name doesn't resolve, the response is empty rather than a silently widened filter. Mirrors the ?tag= filter on /v1/customers. |
limit | integer | 50 | Max hits to return (capped at 200). |
cursor | string | (none) | Opaque cursor from a previous response. Omit for the first page. |
Code
How matching works
The query runs as a multi_match with bool_prefix semantics. In practice:
display_nameis boosted so a hit on the primary user-facing label outranks a hit on the secondary fields.- Prefix-matches the last word - typing
acmfindsAcme GmbHbefore the user finishes typing. - Diacritic-insensitive - searching
cafematchescafé. - Case-insensitive -
ACMEandacmeare equivalent.
Per-entity searchable fields:
| Entity | Display name | Also searchable |
|---|---|---|
customer | name | email, external_id, tag names |
product | name | external_id, description, tag names |
invoice | invoice_number | tag names |
promotion | name | code, description |
credit_note | credit_note_number | reason, tag names |
plan | name | external_id, description, tag names |
Tag names are denormalized into the search index as a snapshot. They stay
in sync via tag.applied / tag.removed events (immediate) and
tag.renamed / tag.deleted (sweeping update - see
eventual consistency). Promotions
are not in the polymorphic taggings set and therefore never carry tag
names in their searchable fields.
Pagination
Search uses the same cursor / has_more envelope as the cursor list
endpoints. The cursor encodes Elasticsearch search_after state internally,
but you treat it as opaque - feed pagination.cursor from one response back
as the cursor query parameter on the next request:
Code
The cursor format is not interchangeable with cursors from list endpoints. Use cursors only with the endpoint that produced them.
Response shape
| Field | Type | Description |
|---|---|---|
data[].id | string | Entity UUID. Use it with GET /v1/{entity}/{id} to fetch the full record. |
data[].entity_type | string | One of customer, product, invoice, promotion, credit_note, plan. Drives your detail-page routing. |
data[].display_name | string | Primary label. Always present, never empty. |
data[].secondary_text | string | Optional context line (e.g. ACME-001 · billing@acme.example). Empty for entities with no secondary metadata. |
data[].score | number | Relevance score. Use only for debugging - don't display it. |
data[].tags[].id | string | Tag UUID. Use it for tag-detail navigation or when constructing a ?tag= filter to follow up. |
data[].tags[].name | string | Tag display name (snapshot). Renames propagate asynchronously via tag.renamed. |
data[].data | object | Optional entity-specific payload (e.g. invoice currency + total). Schema differs by entity_type; treat as best-effort. |
pagination.cursor | string | Opaque cursor for the next page. null when there are no more results. |
pagination.has_more | boolean | true if a next page exists. |
Search vs cursor lists
The search endpoint does not replace the per-resource list endpoints - they coexist and serve different purposes.
GET /v1/search | GET /v1/customers, /invoices, … | |
|---|---|---|
| Use case | "Find anything" search bar, autocomplete | Tables, exports, paginated drill-down |
| Query shape | Free-text + optional entity filter | Categorical filters (status, tags, custom fields, …) |
| Strong consistency | ❌ Eventually consistent (~1 s lag after writes) | ✅ Read-after-write consistent |
| Sort | Relevance, then created_at desc | created_at desc (or ?sort= where supported) |
| Cross-entity hits | ✅ One call returns mixed entity_types | ❌ One endpoint = one entity |
| Filter by entity field | Limited - search-relevant fields only | ✅ Full filter set per entity |
Use the search endpoint when:
- You're building a header search bar / command palette / autocomplete.
- You want one API call for "anything matching
acme". - A 1 s indexing lag is acceptable for the user (typical for search UI).
Use a cursor list endpoint when:
- You need read-after-write consistency (e.g. a customer just created via
POST /customersmust appear in the table immediately). - You're running an export or batch job that needs a deterministic, complete result set.
- You need to filter on fields the search index doesn't expose (custom fields, tags, status combinations).
- Billing-critical reads where freshness matters more than fuzziness.
A common pattern is to use both in the same UI: the search bar at the top
calls /v1/search for instant fuzzy lookup; the data table below it calls
the cursor list endpoint for the strict-filter view.
Eventual consistency
The search index is derived from PostgreSQL via the transactional outbox - typical lag in production is well under one second. Practical implications:
- A record created via
POSTis not guaranteed to appear in/v1/searchin the same request cycle. The detail endpoint (GET /v1/{entity}/{id}) returns it immediately. - A record updated via
PUTmay surface old values in search briefly. The updated values are visible immediately on the per-resource endpoints. - Tag changes (
POST/DELETE /v1/{entity}/{id}/tags,PUT/DELETE /v1/tags/{id}) propagate via the same outbox path; renaming a tag updates every doc carrying the tag id within seconds.
If you're scripting an automated workflow that depends on a record being immediately searchable (rare), prefer the cursor list endpoint and filter client-side, or poll with a short backoff.
Multi-tenancy
Every search query is automatically scoped to the organization that owns the API key. There is no public way to query across organizations.
Errors
Errors follow the RFC 9457 Problem Details shape with these code values:
| Status | code | When |
|---|---|---|
400 | VALIDATION | Unknown value in types=; malformed cursor |
401 | UNAUTHORIZED | Missing or invalid Authorization: Bearer header |
429 | RATE_LIMIT | Per-org read rate limit exceeded |
500 | INTERNAL | Search backend unavailable - retry with exponential backoff |
When the search backend is briefly unavailable (rare), /v1/search returns 500 rather than falling back to a Postgres scan, so you can distinguish "index lag" from "search outage". The cursor list endpoints continue to serve normally during a search outage.
Related
- Pagination, filtering & sorting - cursor pagination + filter operators on the per-resource list endpoints
- Errors - full Problem Details shape and error-code catalog
- Authentication - how the
Authorization: Bearerheader resolves to an organization scope