Loupe Search API (Developer Guide)
Loupe Search exposes a REST API that lets you build your own search UI (theme template, JS widget, Gutenberg block, React app, etc.) on top of the same index Loupe Search uses internally.
This guide is written to be read top-to-bottom the first time: start with Quick start, then add one capability at a time in Building up a search. Use Reference once you know what you need.
Contents
Section titled “Contents”- Quick start — first working request in a few minutes
- Core concepts — endpoints, permissions, index readiness, allowlisting
- Building up a search — pagination, filters, sorting, facets, highlighting, geo
- Worked examples — Gutenberg block and server-side PHP
- Preparing fields — schema hooks for filter/sort/facet/geo fields
- Reference — full request/response schema, filter AST, errors
Quick start
Section titled “Quick start”Step 1 — Prerequisites
Section titled “Step 1 — Prerequisites”- WordPress 6.9+ and Loupe Search activated.
- At least one post type selected in Settings → Loupe Search.
- A built index. If you have never indexed, run:
wp loupe-search reindexStep 2 — Confirm the index is ready
Section titled “Step 2 — Confirm the index is ready”Search fails with HTTP 400 if nothing is indexed, so check first. This endpoint requires an administrator (manage_options), so run it logged in:
curl -s https://example.test/wp-json/loupe-search/v1/index-status \ --user 'admin:application-password'{ "items": [ { "postType": "post", "label": "Posts", "ready": true, "reason": null, "published": 42 }, { "postType": "page", "label": "Pages", "ready": false, "reason": "missing", "published": 7 } ]}Every post type you intend to search should have "ready": true. If not, reindex.
Step 3 — Your first search
Section titled “Step 3 — Your first search”The smallest possible request is a q and nothing else. Searching is public — no authentication needed:
curl -s https://example.test/wp-json/loupe-search/v1/search \ -H 'Content-Type: application/json' \ -d '{ "q": "wordpress" }'Step 4 — Read the response
Section titled “Step 4 — Read the response”{ "hits": [ { "id": 123, "post_type": "post", "post_type_label": "Post", "title": "Getting started with WordPress", "excerpt": "…", "url": "https://example.test/getting-started", "_score": 12.345 } ], "pagination": { "total": 42, "per_page": 10, "current_page": 1, "total_pages": 5 }, "tookMs": 8}That is enough to render a results list: loop hits, link url, print title. Everything that follows is additive — you opt into filters, facets, and highlighting only when you need them.
Core concepts
Section titled “Core concepts”Endpoints
Section titled “Endpoints”| Method | Route | Access | Purpose |
|---|---|---|---|
| POST | /wp-json/loupe-search/v1/search | Public | Recommended. JSON filters, facets, geo, sorting, highlighting |
| GET | /wp-json/loupe-search/v1/search?q=… | Public | Legacy. Query only, no filters/facets/geo |
| GET | /wp-json/loupe-search/v1/index-status | manage_options | Per-post-type index health |
Authentication & permissions
Section titled “Authentication & permissions”- Search is public. Both search endpoints use
__return_trueas their permission callback, so anonymous visitors can query them. They only ever return published content. - Admin endpoints (
/index-status,/post-type-fields/…,/create-database,/delete-database) require themanage_optionscapability. - From the browser, use
@wordpress/api-fetch, which attaches the REST nonce automatically. With plainfetch()you do not need a nonce for search, but sendX-WP-Nonceif you want the request to run as the logged-in user. - From outside WordPress, use an application password for the admin endpoints.
Index readiness
Section titled “Index readiness”Search requires a ready index per post type.
- When
postTypes: "all"is used, Loupe Search only searches post types that have a ready index. - If no configured post type has a ready index, the API returns HTTP 400 (
wp_loupe_no_indexed_post_types). - If you name a post type explicitly and its index is missing or stale, you get
wp_loupe_index_missingorwp_loupe_index_needs_reindex.
Allowlisted fields
Section titled “Allowlisted fields”Filtering, sorting, facets, and geo operations are restricted to fields that are explicitly enabled in Settings → Loupe Search.
- Filter fields must be enabled as Filterable
- Sort fields must be enabled as Sortable
- Facet fields must be enabled as Filterable (terms facet)
- Geo requires a dedicated geo-point field:
- Geo radius filtering requires the field to be Filterable
- Geo distance sorting (
geo.sort) requires the field to be Sortable
If you request an operation on a non-allowlisted field, the API returns HTTP 400 (wp_loupe_unallowlisted_field) with the offending field in data.details.field.
Need to enable a field that is not in the UI, or set this up in code? See Preparing fields.
Building up a search
Section titled “Building up a search”Each step below adds one capability to the request from Quick start. All of them are optional and can be combined.
Add pagination
Section titled “Add pagination”{ "q": "wordpress", "page": { "number": 2, "size": 20 } }sizemust be 1–100 (default 10);numberis 1-based (default 1).- Results are capped at 1000 total scanned documents:
(number - 1) × size + sizemust stay ≤ 1000. Deeper requests returnwp_loupe_pagination_limit. - Use
pagination.total_pagesfrom the response to render a pager.
Restrict to post types
Section titled “Restrict to post types”{ "q": "wordpress", "postTypes": [ "post", "product" ] }Omit postTypes (or use "all") to search every post type that has a ready index.
Filter results
Section titled “Filter results”Filters use a small JSON tree (see Filter AST for the full grammar). Start with one predicate:
{ "q": "wordpress", "filter": { "type": "pred", "field": "category", "op": "eq", "value": "news" }}Combine predicates with and / or / not:
{ "q": "wordpress", "filter": { "type": "and", "items": [ { "type": "pred", "field": "category", "op": "in", "value": [ "news", "events" ] }, { "type": "pred", "field": "post_date", "op": "gte", "value": "2025-01-01" } ] }}Gotcha: every
fieldhere must be allowlisted as Filterable, or the request fails withwp_loupe_unallowlisted_field.
Sort results
Section titled “Sort results”{ "q": "wordpress", "sort": [ { "by": "_score", "order": "desc" }, { "by": "post_date", "order": "desc" } ]}- Sorting is applied in order, so the example sorts by relevance and breaks ties by date.
_scoreis always available. Any other field must be allowlisted as Sortable.- Omitting
sortsorts by relevance.
Add facets
Section titled “Add facets”Facets return counts per value so you can build filter UI. Only terms facets are supported:
{ "q": "wordpress", "facets": [ { "type": "terms", "field": "category", "size": 10, "minCount": 1 } ]}The response gains a facets object:
{ "facets": { "category": { "type": "terms", "buckets": [ { "value": "news", "count": 12 }, { "value": "events", "count": 4 } ] } }}A typical UI renders each bucket as a checkbox, then sends the checked values back as an in filter on the same field.
Highlight matches and build snippets
Section titled “Highlight matches and build snippets”Highlighting is opt-in: ask for it and each hit gains a _formatted object.
{ "q": "wordpress", "attributesToHighlight": [ "post_title", "post_content" ], "highlightStartTag": "<mark>", "highlightEndTag": "</mark>", "attributesToCrop": [ "post_content" ], "cropLength": 30}{ "_formatted": { "post_title": "Getting started with <mark>WordPress</mark>", "post_content": "…install <mark>WordPress</mark> and pick a theme…" }}Gotcha:
_formattedkeys are the underlying Loupe field names (post_title,post_content), not the friendly top-level keys (title,excerpt). Readhit._formatted.post_titleand fall back tohit.title.
Details:
- Use
["*"]to cover every indexed field. Unknown field names are dropped silently. - Start/end tags are sanitized to an allowlist of inline tags (
mark,em,strong,span,b,i, each allowingclass), so the API never emits script-capable markup. Indexed content is stored as plain text, so_formattedcontains only those tags plus matched text — safe to render. cropLengthdefaults to 50 and is clamped to 10–500.cropMarkerdefaults to….
Search by location
Section titled “Search by location”{ "q": "cafe", "geo": { "field": "location", "near": { "lat": 59.9139, "lon": 10.7522 }, "radiusMeters": 5000, "sort": { "order": "asc" }, "includeDistance": true }}fieldmust be a geo-point field;nearis required (lngis accepted as an alias forlon).radiusMetersis optional — omit it to sort by distance without filtering.includeDistance: trueadds_distanceMetersto each hit.- Radius filtering needs the field Filterable;
geo.sortneeds it Sortable.
Worked examples
Section titled “Worked examples”Gutenberg block (client-side search)
Section titled “Gutenberg block (client-side search)”A minimal block that searches as you type. Note the debounce — without it every keystroke fires a request.
If you build blocks with @wordpress/scripts you already have @wordpress/api-fetch, @wordpress/element, and @wordpress/components. Using apiFetch also means the REST nonce is attached for you.
import apiFetch from '@wordpress/api-fetch';import { useEffect, useState } from '@wordpress/element';import { TextControl, Spinner } from '@wordpress/components';
export default function Edit() { const [ query, setQuery ] = useState( '' ); const [ isLoading, setIsLoading ] = useState( false ); const [ hits, setHits ] = useState( [] );
useEffect( () => { if ( ! query.trim() ) { setHits( [] ); return; }
const controller = new AbortController();
// Wait 300ms after the last keystroke before querying. const timer = setTimeout( () => { setIsLoading( true );
apiFetch( { path: '/loupe-search/v1/search', method: 'POST', signal: controller.signal, data: { q: query, postTypes: 'all', page: { number: 1, size: 10 }, attributesToHighlight: [ 'post_title' ], highlightStartTag: '<mark>', highlightEndTag: '</mark>', }, } ) .then( ( res ) => setHits( res?.hits || [] ) ) .catch( ( error ) => { if ( 'AbortError' !== error?.name ) { setHits( [] ); } } ) .finally( () => setIsLoading( false ) ); }, 300 );
// Cancel the pending timer and request when the query changes. return () => { clearTimeout( timer ); controller.abort(); }; }, [ query ] );
return ( <div className="loupe-search-block"> <TextControl label="Search" value={ query } onChange={ setQuery } placeholder="Type to search…" /> { isLoading ? <Spinner /> : null } <ul> { hits.map( ( hit ) => ( <li key={ hit.id }> <a href={ hit.url }>{ hit.title }</a> </li> ) ) } </ul> </div> );}Rendering
_formattedvalues requiresdangerouslySetInnerHTML. That is safe here because the API sanitizes highlight tags and indexes plain text, but only do it with values that came from_formatted.
PHP (server-side call)
Section titled “PHP (server-side call)”$response = wp_remote_post( rest_url( 'loupe-search/v1/search' ), [ 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( [ 'q' => 'wordpress', 'postTypes' => 'all', 'page' => [ 'number' => 1, 'size' => 10 ], ] ), ]);
if ( is_wp_error( $response ) ) { return;}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
foreach ( $body['hits'] ?? [] as $hit ) { printf( '<a href="%s">%s</a>', esc_url( $hit['url'] ), esc_html( $hit['title'] ) );}Preparing fields (schema hooks)
Section titled “Preparing fields (schema hooks)”Loupe Search’s REST API only lets clients filter/sort/facet/geo on fields that are enabled in the schema.
Most sites will configure this in Settings → Loupe Search → Field Settings. If you’re building an integration or need to enforce fields programmatically, use the schema hooks below.
Fields for facets (terms)
Section titled “Fields for facets (terms)”Terms facets require the field to be:
- indexed (
indexable: true) - allowlisted as filterable (
filterable: true) - stored as a string or an array of strings
Example: add a facet field backed by post meta.
// 1) Allowlist the field in the schema.add_filter( 'loupe_search_schema_post', function ( array $schema ): array { $schema['audience'] = [ 'weight' => 1.0, 'indexable' => true, 'filterable' => true, // enables filtering + terms facets 'sortable' => false, 'sort_direction' => 'desc', ]; return $schema;} );
// 2) Store the value in post meta as string or array of strings.// (Arrays become multi-valued facets.)add_action( 'save_post', function ( int $post_id ) { // Example: multi-valued facet. update_post_meta( $post_id, 'audience', [ 'beginner', 'developer' ] );} );Fields for geo (radius filtering + distance sorting)
Section titled “Fields for geo (radius filtering + distance sorting)”Geo requires a dedicated geo-point field stored as an array:
// Stored as post meta:// [ 'lat' => 59.9139, 'lng' => 10.7522 ]// (or use 'lon' instead of 'lng')To enable geo features:
- Geo radius filtering requires the field to be Filterable.
- Geo distance sorting (
geo.sort) requires the field to be Sortable.
Example:
// 1) Allowlist the geo field.add_filter( 'loupe_search_schema_post', function ( array $schema ): array { $schema['location'] = [ 'weight' => 1.0, 'indexable' => true, 'filterable' => true, // required for geo radius filtering 'sortable' => true, // required for geo.sort distance ordering 'sort_direction' => 'asc', ]; return $schema;} );
// 2) Store the geo-point in post meta.add_action( 'save_post', function ( int $post_id ) { update_post_meta( $post_id, 'location', [ 'lat' => 59.9139, 'lng' => 10.7522, ] );} );If your geo field is stored as post meta and you need to override “meta sortability” decisions, you can use:
add_filter( 'loupe_search_is_safely_sortable_meta_post', function ( bool $is_sortable, string $field_name ): bool { if ( 'location' === $field_name ) { return true; } return $is_sortable;}, 10, 2 );Fields for sorting (non-geo)
Section titled “Fields for sorting (non-geo)”Sorting requires the field to be:
- indexed (
indexable: true) - allowlisted as sortable (
sortable: true) - stored as a scalar (string/number) for post meta fields
Example:
add_filter( 'loupe_search_schema_post', function ( array $schema ): array { $schema['rating'] = [ 'weight' => 1.0, 'indexable' => true, 'filterable' => false, 'sortable' => true, 'sort_direction' => 'desc', ]; return $schema;} );
add_action( 'save_post', function ( int $post_id ) { update_post_meta( $post_id, 'rating', 4.7 );} );Note: if you already have data in meta, you typically only need the schema hook + a reindex (Settings → Loupe Search → Reindex, or wp loupe-search reindex).
Reference
Section titled “Reference”POST /search request body
Section titled “POST /search request body”{ "q": "search text", "postTypes": "all", "page": { "number": 1, "size": 10 }, "filter": { "type": "and", "items": [ { "type": "pred", "field": "category", "op": "eq", "value": "news" }, { "type": "pred", "field": "post_author", "op": "eq", "value": 123 } ] }, "sort": [ { "by": "_score", "order": "desc" }, { "by": "post_date", "order": "desc" } ], "facets": [ { "type": "terms", "field": "category", "size": 10, "minCount": 1 } ], "geo": { "field": "location", "near": { "lat": 59.9139, "lon": 10.7522 }, "radiusMeters": 5000, "sort": { "order": "asc" }, "includeDistance": true }, "attributesToHighlight": [ "post_title", "post_content" ], "highlightStartTag": "<mark>", "highlightEndTag": "</mark>", "attributesToCrop": [ "post_content" ], "cropLength": 50, "cropMarker": "…"}Top-level properties
Section titled “Top-level properties”q(string, required): the search query.postTypes("all"| string[], optional, default"all"): which post types to search."all"resolves to the subset of configured post types that have a ready index.
page.number(int, optional, default 1): 1-based page.page.size(int, optional, default 10): page size (1–100).filter(object, optional): JSON filter AST (see below).sort(array, optional): sorting instructions.facets(array, optional): terms facets.geo(object, optional): geo radius + geo sorting.attributesToHighlight(string[], optional): field names to wrap matched terms in. Use["*"]for every indexed field.highlightStartTag/highlightEndTag(string, optional, default<em>/</em>): markup wrapped around matches.attributesToCrop(string[], optional): field names to return as a short snippet around the match. Use["*"]for every indexed field.cropLength(int, optional, default 50, clamped 10–500): approximate snippet length in words.cropMarker(string, optional, default…): marker inserted where text is cropped.
Notes:
- Fields used in
filter,sort,facets, andgeomust be allowlisted in Settings (see Allowlisted fields above). - For geo,
geo.near.lonis supported;geo.near.lngis also accepted for convenience.
Highlighting & cropping
Section titled “Highlighting & cropping”See Highlight matches and build snippets for a walkthrough. In short: _formatted appears only when attributesToHighlight and/or attributesToCrop are supplied, unknown field names are dropped, ["*"] expands to every indexed field, tags are sanitized to an inline allowlist, and _formatted keys are Loupe field names (post_title) rather than the friendly keys (title).
Response
Section titled “Response”{ "hits": [ { "id": 123, "post_type": "post", "post_type_label": "Post", "title": "Example title", "excerpt": "…", "url": "https://example.test/example", "_score": 12.345, "_distanceMeters": 3210, "_formatted": { "post_title": "Example <mark>title</mark>", "post_content": "…matched <mark>title</mark> in context…" } } ], "facets": { "category": { "type": "terms", "buckets": [ { "value": "news", "count": 12 }, { "value": "events", "count": 4 } ] } }, "pagination": { "total": 42, "per_page": 10, "current_page": 1, "total_pages": 5 }, "tookMs": 8}Notes:
_scoreis always included._distanceMetersis included only whengeo.includeDistanceistrue._formattedis included only whenattributesToHighlightand/orattributesToCropare supplied.
Filter AST (JSON)
Section titled “Filter AST (JSON)”Loupe Search accepts a structured JSON filter. The server translates it into the underlying Loupe filter syntax.
Groups
Section titled “Groups”{ "type": "and", "items": [ <expr>, <expr>, ... ] }{ "type": "or", "items": [ <expr>, <expr>, ... ] }{ "type": "not", "item": <expr> }
Predicates
Section titled “Predicates”Predicates use a single shape:
{ "type": "pred", "field": "fieldName", "op": "eq", "value": "example" }Supported op values:
eq,nein,nin(value must be a non-empty array)lt,lte,gt,gtebetween(value must be[min, max]or{ "min": ..., "max": ... })exists(value must be boolean)
Literal values
Section titled “Literal values”The API accepts:
- strings
- numbers
- booleans
null- dates as either:
- date-only
YYYY-MM-DD - ISO-8601 timestamp (e.g.
2025-12-18T10:11:12Z)
- date-only
GET /search (legacy)
Section titled “GET /search (legacy)”Kept for backward compatibility. It supports a query and pagination only — no filters, facets, geo, sorting, or highlighting. Prefer POST for anything else.
curl -s 'https://example.test/wp-json/loupe-search/v1/search?q=wordpress&post_type=post&per_page=10&page=1'| Parameter | Type | Default | Description |
|---|---|---|---|
q | string | — | Required. Search query. |
post_type | string | all | A single post type slug, or all. |
per_page | int | 10 | Results per page. |
page | int | 1 | 1-based page number. |
The response contains hits and pagination with the same shape as POST, but never facets, tookMs, _distanceMeters, or _formatted.
GET /index-status
Section titled “GET /index-status”Requires the manage_options capability. Returns one entry per configured post type:
{ "items": [ { "postType": "post", "label": "Posts", "ready": true, "reason": null, "published": 42 } ]}ready— whether the post type can be searched.reason— why it is not ready (e.g.missing, or a schema-drift reason);nullwhen ready.published— number of published posts of that type.
Errors
Section titled “Errors”Errors are returned as standard WordPress REST errors. Validation problems use HTTP 400; unknown post types on admin routes use 404; indexing failures use 500.
{ "code": "wp_loupe_missing_query", "message": "Missing or empty query parameter \"q\".", "data": { "status": 400 }}Some errors add a data.details object naming the offending value — for example details.field for allowlist failures and details.postType for index problems.
| Code | Cause | Fix |
|---|---|---|
wp_loupe_invalid_payload | Body was not valid JSON | Send valid JSON and Content-Type: application/json |
wp_loupe_missing_query | q missing or empty | Always send a non-empty q |
wp_loupe_invalid_page_size | page.size outside 1–100 | Clamp page size to 1–100 |
wp_loupe_pagination_limit | Page too deep (offset + size > 1000) | Lower page.number/page.size, or narrow the query |
wp_loupe_no_indexed_post_types | No post type has a ready index | Run wp loupe-search reindex |
wp_loupe_index_missing | Named post type has no index | Index that post type; see details.postType |
wp_loupe_index_needs_reindex | Index schema is stale after a settings change | Reindex the post type |
wp_loupe_invalid_post_types | postTypes not "all" or a non-empty array | Send "all" or an array of slugs |
wp_loupe_invalid_post_type | Unknown slug in postTypes | Check details.unknown |
wp_loupe_unallowlisted_field | Field not enabled as Filterable/Sortable | Enable it in Settings or via schema hooks; see details.field |
wp_loupe_invalid_filter | Malformed filter node, operator, or value | Check the Filter AST grammar |
wp_loupe_invalid_sort | Malformed sort entry or bad order | Use { "by": …, "order": "asc"|"desc" } |
wp_loupe_invalid_facets | facets not an array, or non-terms type | Only { "type": "terms", … } is supported |
wp_loupe_invalid_facet_field | Facet field name is not a valid attribute name | Use a valid indexed field name |
wp_loupe_invalid_geo | Bad geo object, near, radius, or sort order | See Search by location |
wp_loupe_invalid_geo_field | Geo field name is not a valid attribute name | Use a valid geo-point field |
wp_loupe_invalid_highlight | attributesToHighlight not an array | Send an array of field names or ["*"] |
wp_loupe_invalid_crop | attributesToCrop not an array | Send an array of field names or ["*"] |
wp_loupe_reindex_failed | Indexing threw an error (HTTP 500) | Check the message and PHP error log |
These error codes keep the
wp_loupe_prefix for backward compatibility, even though the REST namespace is nowloupe-search/v1.
📦 Source: soderlind/loupe-search · Edit on GitHub