Skip to content

Search (POST)

POST
/v1/query
curl --request POST \
--url https://api.proofql.dev/v1/query \
--header 'Authorization: Bearer <token>' \
--header 'Cache-Control: no-cache' \
--header 'Content-Type: application/json' \
--header 'Origin: https://shop.example' \
--data '{ "q": "dental implants", "limit": 5, "mode": "excerpts", "filters": { "min_rating": 4, "source": [ "google" ], "metadata.location": "north", "since": "2025-01-01" } }'

Hybrid search over the project’s publishable reviews. Either key kind, in the Authorization header only — ?key= is a 401 here; it is the GET form’s affordance for the snippet. A publishable key must also come from a listed Origin (see CORS in the introduction). From a server, use a secret key.

  • q optional. Without it, results are the newest publishable reviews and every score is null. With it, hybrid search: the query is embedded at the edge (same model as the reviews), exact cosine over the project’s excerpt vectors is fused with a full-text rank, and candidates below the project’s relevance floor (similarity_floor, default 0.66 cosine, tunable per project) are dropped, except that an excerpt matching the query’s words (at least half of its words, not counting generic ones such as “dental”) passes at a lower tier, the floor minus 0.13. The endpoint returns results: [] rather than padding — empty beats irrelevant. Full-text-only hits with no vector proximity above that word-match tier are dropped too.
  • Policy is applied in the same SQL as the ranking: hidden reviews, reviews rated below the project’s min_rating (default 4), and unrated reviews classified negative never appear. A request’s filters.min_rating can only tighten the project’s policy (max(project.min_rating, filters.min_rating)), never loosen it.
  • mode: excerpts (default) returns the best-matching slice of each review, ideal for placement; reviews returns whole reviews, one per review, scored by the best excerpt, with review.text present.
  • score is the cosine similarity between the query and the returned excerpt, in [0, 1], already at or above the project’s floor, and comparable across queries. It is not the fused rank the results are ordered by, so it may be non-monotone when the full-text branch promoted a row. null without q.
  • excerpt is always a verbatim slice of the review’s text: nothing generates text.
  • highlight says where that slice sits in the whole review: { "start", "end" } as UTF-16 code-unit offsets into review.text, end exclusive, so review.text.slice(highlight.start, highlight.end) === excerpt in any JavaScript runtime, emoji and CJK included. Wrap that span in <mark> and the visitor sees the sentence that answered the query inside the untouched review. It is null when there is nothing to mark: without q, and when the match is the review as a whole (a full-review match highlights nothing).
  • include names optional response fields. ["text"] adds review.text to every result in mode=excerpts (it is always present in mode=reviews), which is what a highlighted render needs without switching modes. Part of the cache key.
  • fallback and match — the honest fallback. By default (fallback: "none") a query nothing clears the floor for returns results: []. With fallback: "recent" that empty result is replaced by the newest publishable reviews under the same policy and filters, and the response says so: match is "fallback" and every result has matched: false with score and highlight null, so a UI can change its heading from “What patients say about insurance” to “What patients say about working with us” instead of lying or rendering an empty box. Otherwise match is "query" (real matches, every matched: true), "none" (nothing cleared the floor, results empty), or "recent" (no q was sent). A response is all matches or all fallback, never a mix: only an empty result falls back; a short page is never topped up. Part of the cache key, and the verdict is cached with the results.
  • badge mirrors the project’s plan: true means the snippet must render the “Reviews by ProofQL” badge (free tier).
  • Cache. Results are cached server-side, keyed on project, environment, the normalized request, and the policy, and purged when a review is indexed, hidden, unhidden, edited, or deleted, or the policy changes. cached: true and x-cache: HIT mean results came from the cache (took_ms is still this request’s time). Send Cache-Control: no-cache to bypass the lookup (x-cache: BYPASS; the fresh result is stored when the cache accepts it). Cached answers are free against the monthly quota and are served even at quota. A repeat of a query is not guaranteed to be a HIT: the cache may decline to store a result (it keeps one-off queries out).
  • If the embedding service is unavailable the response is 503 embedding_unavailable — deliberately not a degraded full-text-only answer, which is exactly what the floor exists to prevent. Retry shortly.
Origin
string format: uri

Set by the browser. Required with a publishable key and must be one of the project’s allowed origins (exact scheme, host, and port), else 403 forbidden. Ignored for secret keys (echoed if present).

Example
https://shop.example
Cache-Control
string

no-cache skips the result-cache lookup (the response says x-cache: BYPASS). Costs one uncached query.

Example
no-cache

Optional: an empty body is the same as {} (newest publishable reviews). filters accepts metadata either nested ("metadata": { "location": "north" }) or flat ("metadata.location": "north"); the two are merged.

Media typeapplication/json
object
q

The search text. Omit for the newest publishable reviews (every score is then null).

string
>= 1 characters <= 500 characters
limit
integer
default: 5 >= 1 <= 20
mode

excerpts: the best-matching slice per review. reviews: whole reviews, with review.text.

string
default: excerpts
Allowed values: excerpts reviews
include
One of:

An optional response field. text: review.text on every result (always present in mode=reviews).

string
Allowed values: text
fallback

What to return when q is present and nothing clears the floor: none (default) an empty list; recent the newest publishable reviews under the same policy and filters, with match: fallback.

string
default: none
Allowed values: none recent
filters

Narrow the candidates. metadata may be nested ("metadata": { "location": "north" }) or flat ("metadata.location": "north"); both are accepted and merged.

object
min_rating

Tightens the project’s min_rating for this request; cannot loosen it.

integer
>= 1 <= 5
source
One of:
string
>= 1 characters <= 64 characters
since
Any of:

YYYY-MM-DD (midnight UTC) or a full ISO 8601 timestamp with Z or offset.

string format: date
metadata

Flat string-to-string map the customer can filter on at query time. At most 32 entries; keys 1–64 characters, values up to 512. Nested objects, arrays, numbers, and booleans are rejected, not coerced.

object
<= 32 properties
key
additional properties
string
<= 512 characters
Examples

The scope document's example (flat metadata spelling)

{
"q": "dental implants",
"limit": 5,
"mode": "excerpts",
"filters": {
"min_rating": 4,
"source": [
"google"
],
"metadata.location": "north",
"since": "2025-01-01"
}
}

The matches in rank order — possibly none: below-floor candidates are dropped, never padded. match says what the list is: real matches, an honest fallback the caller asked for, nothing, or the newest reviews because no q was sent.

Media typeapplication/json
object
results
required

In rank order. Empty when nothing clears the floor and no fallback was requested. All matches or all fallback, never a mix.

Array<object>
object
score
required

Cosine similarity between the query and this excerpt, in [0, 1], already at or above the project’s relevance floor (default 0.66) — or, for an excerpt that matches the query’s words, at or above the word-match tier (the floor minus 0.13) — and comparable across queries. Not the fused rank the results are ordered by, so it may be non-monotone down the list. null when the request had no q and on fallback rows.

number | null
<= 1
matched
required

true for a real match (match: query); false on a fallback row and without q.

boolean
excerpt
required

A verbatim slice of the review’s text — the best-matching chunk.

string
excerpt_id
required

Stable id of the excerpt (chunk).

string format: uuid
highlight
required
One of:

Where excerpt sits in review.text, as UTF-16 code-unit offsets (the unit of String.prototype.slice), end exclusive: review.text.slice(start, end) === excerpt.

object
start
required
integer
end
required
integer
>= 1
review
required
object
id
required
string format: uuid
rating
required
integer | null
>= 1 <= 5
author_name
required
string | null
author_avatar_url
required
string | null
source
required

Where the review came from. custom is the escape hatch for anything not listed.

string
Allowed values: google yelp facebook trustpilot custom
occurred_at
required
string | null format: date-time
url
required
string | null
metadata
required

Flat string-to-string map the customer can filter on at query time. At most 32 entries; keys 1–64 characters, values up to 512. Nested objects, arrays, numbers, and booleans are rejected, not coerced.

object
<= 32 properties
key
additional properties
string
<= 512 characters
text

The whole review text. Present in mode=reviews, or in any mode with include: ["text"].

string
match
required

What results is: query real matches; fallback the newest reviews because nothing cleared the floor and fallback: recent was requested; none nothing cleared the floor and results is empty; recent no q was sent.

string
Allowed values: query fallback none recent
took_ms
required

Server time for this request, cached or not.

integer
cached
required

true when results were served from the result cache (x-cache: HIT).

boolean
badge
required

true means the snippet must render the “Reviews by ProofQL” badge (free tier). Mirrors the project’s plan on every response, cached or not.

boolean
Examples

`mode=excerpts` for `q=dental implants`

{
"results": [
{
"score": 0.83,
"matched": true,
"excerpt": "Dr. Patel did my implant and I honestly forgot it wasn't my own tooth within a week.",
"excerpt_id": "7c2e4b9a-1d3f-4a5b-8c6d-0e9f1a2b3c4d",
"highlight": {
"start": 26,
"end": 110
},
"review": {
"id": "2f1c9e5a-3b7d-4c1e-9f0a-6d2b8e4a1c3f",
"rating": 5,
"author_name": "Marcus T.",
"author_avatar_url": null,
"source": "google",
"occurred_at": "2026-03-14T18:20:00.000Z",
"url": "https://maps.google.com/?cid=123",
"metadata": {
"location": "north"
}
}
}
],
"match": "query",
"took_ms": 12,
"cached": false,
"badge": true
}
x-request-id
required
string
>= 1 characters <= 128 characters

This request’s id, on every response. Reuses the caller’s x-request-id when sent (up to 128 chars), else Cloudflare’s ray id, else a fresh UUID. Also inside every error envelope.

Cache-Control
required
string
Allowed values: private, max-age=0, must-revalidate no-store

private, max-age=0, must-revalidate on a successful GET /v1/query: the response varies by key, so a shared cache must never hold it, and a browser revalidates rather than reuses. no-store on POST, like every other response.

RateLimit-Policy
required
string
/^[0-9]+;w=[0-9]+$/

The key’s rate-limit policy as <limit>;w=<window seconds> (IETF draft ratelimit headers), e.g. 300;w=60 for a secret key, 120;w=60 for a publishable key. Present once the key is known.

RateLimit-Limit
required
integer
>= 1

The request limit per window for this key’s kind.

x-cache
required
string
Allowed values: HIT MISS BYPASS

Whether results came from the result cache: HIT (served from cache, cached: true), MISS (computed; stored when the cache accepts it), or BYPASS (the request sent Cache-Control: no-cache; computed).

Vary
required
string
Allowed value: Origin

Always Origin on /v1/query, so a shared cache never serves one origin’s CORS headers to another.

Access-Control-Allow-Origin
string

The request’s Origin, echoed when a publishable key’s project lists it or when the key is secret. Absent otherwise (the browser then blocks the response).

unauthorized: no Authorization header (and no ?key= where that is allowed), a header that is not Bearer <key>, a key that does not look like pq_(sk|pk)_(live|test)_…, a secret key in the URL, a ?key= on POST /v1/query, or an unknown or revoked key. Rate-limit headers are absent (the key was not resolved).

Media typeapplication/json
object
error
required
object
code
required

Stable error codes. Status by code: unauthorized 401, forbidden 403, not_found 404, payload_too_large 413, unsupported_media_type 415, validation_failed 422, review_limit_reached 422, rate_limited 429, query_quota_exceeded 429, internal 500, embedding_unavailable 503, service_unavailable 503.

string
Allowed values: unauthorized forbidden validation_failed review_limit_reached not_found payload_too_large unsupported_media_type rate_limited embedding_unavailable query_quota_exceeded service_unavailable internal
message
required

For humans; may change between releases. Switch on code.

string
doc_url
required

https://docs.proofql.dev/errors#<code>.

string format: uri
request_id
required

Same value as the x-request-id header.

string
details

Present for validation_failed only.

Array<object>
object
path
required

Dotted path to the offending field ("0.rating", "filters.since"); "" or "(body)" for the whole body.

string
message
required
string
error
required
object
code
Allowed value: unauthorized
Examples
Examplemissing
{
"error": {
"code": "unauthorized",
"message": "Missing Authorization header. Send `Authorization: Bearer <api key>`.",
"doc_url": "https://docs.proofql.dev/errors#unauthorized",
"request_id": "1b7c3d9e-2f4a-4b6c-8d0e-1f2a3b4c5d6e"
}
}
x-request-id
required
string
>= 1 characters <= 128 characters

This request’s id, on every response. Reuses the caller’s x-request-id when sent (up to 128 chars), else Cloudflare’s ray id, else a fresh UUID. Also inside every error envelope.

forbidden: a publishable key with no Origin header, or with an Origin that is not in the project’s allowed origins. The message names the origin and where to add it. (A publishable key never gets a 403 for the key kind here — both kinds may query.)

Media typeapplication/json
object
error
required
object
code
required

Stable error codes. Status by code: unauthorized 401, forbidden 403, not_found 404, payload_too_large 413, unsupported_media_type 415, validation_failed 422, review_limit_reached 422, rate_limited 429, query_quota_exceeded 429, internal 500, embedding_unavailable 503, service_unavailable 503.

string
Allowed values: unauthorized forbidden validation_failed review_limit_reached not_found payload_too_large unsupported_media_type rate_limited embedding_unavailable query_quota_exceeded service_unavailable internal
message
required

For humans; may change between releases. Switch on code.

string
doc_url
required

https://docs.proofql.dev/errors#<code>.

string format: uri
request_id
required

Same value as the x-request-id header.

string
details

Present for validation_failed only.

Array<object>
object
path
required

Dotted path to the offending field ("0.rating", "filters.since"); "" or "(body)" for the whole body.

string
message
required
string
error
required
object
code
Allowed value: forbidden
Example
{
"error": {
"code": "forbidden"
}
}
x-request-id
required
string
>= 1 characters <= 128 characters

This request’s id, on every response. Reuses the caller’s x-request-id when sent (up to 128 chars), else Cloudflare’s ray id, else a fresh UUID. Also inside every error envelope.

RateLimit-Policy
required
string
/^[0-9]+;w=[0-9]+$/

The key’s rate-limit policy as <limit>;w=<window seconds> (IETF draft ratelimit headers), e.g. 300;w=60 for a secret key, 120;w=60 for a publishable key. Present once the key is known.

RateLimit-Limit
required
integer
>= 1

The request limit per window for this key’s kind.

Vary
required
string
Allowed value: Origin

Always Origin on /v1/query, so a shared cache never serves one origin’s CORS headers to another.

payload_too_large: the body exceeds the route’s limit (1 MiB for POST /v1/reviews, 64 KiB for PATCH, 16 KiB for POST /v1/query). Checked before authentication, so rate-limit headers are absent.

Media typeapplication/json
object
error
required
object
code
required

Stable error codes. Status by code: unauthorized 401, forbidden 403, not_found 404, payload_too_large 413, unsupported_media_type 415, validation_failed 422, review_limit_reached 422, rate_limited 429, query_quota_exceeded 429, internal 500, embedding_unavailable 503, service_unavailable 503.

string
Allowed values: unauthorized forbidden validation_failed review_limit_reached not_found payload_too_large unsupported_media_type rate_limited embedding_unavailable query_quota_exceeded service_unavailable internal
message
required

For humans; may change between releases. Switch on code.

string
doc_url
required

https://docs.proofql.dev/errors#<code>.

string format: uri
request_id
required

Same value as the x-request-id header.

string
details

Present for validation_failed only.

Array<object>
object
path
required

Dotted path to the offending field ("0.rating", "filters.since"); "" or "(body)" for the whole body.

string
message
required
string
error
required
object
code
Allowed value: payload_too_large
Example
{
"error": {
"code": "payload_too_large"
}
}
x-request-id
required
string
>= 1 characters <= 128 characters

This request’s id, on every response. Reuses the caller’s x-request-id when sent (up to 128 chars), else Cloudflare’s ray id, else a fresh UUID. Also inside every error envelope.

unsupported_media_type: the request has a body whose Content-Type is not application/json (or has none). Refused before the body is read and before authentication, so rate-limit headers are absent. A request with no body at all (an empty POST /v1/query asking for the newest reviews) needs no Content-Type.

Media typeapplication/json
object
error
required
object
code
required

Stable error codes. Status by code: unauthorized 401, forbidden 403, not_found 404, payload_too_large 413, unsupported_media_type 415, validation_failed 422, review_limit_reached 422, rate_limited 429, query_quota_exceeded 429, internal 500, embedding_unavailable 503, service_unavailable 503.

string
Allowed values: unauthorized forbidden validation_failed review_limit_reached not_found payload_too_large unsupported_media_type rate_limited embedding_unavailable query_quota_exceeded service_unavailable internal
message
required

For humans; may change between releases. Switch on code.

string
doc_url
required

https://docs.proofql.dev/errors#<code>.

string format: uri
request_id
required

Same value as the x-request-id header.

string
details

Present for validation_failed only.

Array<object>
object
path
required

Dotted path to the offending field ("0.rating", "filters.since"); "" or "(body)" for the whole body.

string
message
required
string
error
required
object
code
Allowed value: unsupported_media_type
Example
{
"error": {
"code": "unsupported_media_type",
"message": "Request bodies must be JSON: send `Content-Type: application/json`.",
"doc_url": "https://docs.proofql.dev/errors#unsupported_media_type",
"request_id": "1b7c3d9e-2f4a-4b6c-8d0e-1f2a3b4c5d6e"
}
}
x-request-id
required
string
>= 1 characters <= 128 characters

This request’s id, on every response. Reuses the caller’s x-request-id when sent (up to 128 chars), else Cloudflare’s ray id, else a fresh UUID. Also inside every error envelope.

validation_failed: the body or query string is not valid JSON, does not match the schema, or carries an unknown field. details lists every issue with a dotted path ("0.rating", "filters.limt"; "" or "(body)" for the body as a whole).

Media typeapplication/json
object
error
required
object
code
required

Stable error codes. Status by code: unauthorized 401, forbidden 403, not_found 404, payload_too_large 413, unsupported_media_type 415, validation_failed 422, review_limit_reached 422, rate_limited 429, query_quota_exceeded 429, internal 500, embedding_unavailable 503, service_unavailable 503.

string
Allowed values: unauthorized forbidden validation_failed review_limit_reached not_found payload_too_large unsupported_media_type rate_limited embedding_unavailable query_quota_exceeded service_unavailable internal
message
required

For humans; may change between releases. Switch on code.

string
doc_url
required

https://docs.proofql.dev/errors#<code>.

string format: uri
request_id
required

Same value as the x-request-id header.

string
details

Present for validation_failed only.

Array<object>
object
path
required

Dotted path to the offending field ("0.rating", "filters.since"); "" or "(body)" for the whole body.

string
message
required
string
error
required
object
code
Allowed value: validation_failed
details
required
array
Examples
ExampleunknownField
{
"error": {
"code": "validation_failed",
"message": "Invalid request: limt is not a recognized field.",
"doc_url": "https://docs.proofql.dev/errors#validation_failed",
"request_id": "1b7c3d9e-2f4a-4b6c-8d0e-1f2a3b4c5d6e",
"details": [
{
"path": "limt",
"message": "is not a recognized field"
}
]
}
}
x-request-id
required
string
>= 1 characters <= 128 characters

This request’s id, on every response. Reuses the caller’s x-request-id when sent (up to 128 chars), else Cloudflare’s ray id, else a fresh UUID. Also inside every error envelope.

RateLimit-Policy
required
string
/^[0-9]+;w=[0-9]+$/

The key’s rate-limit policy as <limit>;w=<window seconds> (IETF draft ratelimit headers), e.g. 300;w=60 for a secret key, 120;w=60 for a publishable key. Present once the key is known.

RateLimit-Limit
required
integer
>= 1

The request limit per window for this key’s kind.

rate_limited (over the key’s per-minute limit; Retry-After is seconds to the window end) or query_quota_exceeded (the project is at its monthly quota of uncached queries; Retry-After is seconds to the next UTC month). Cached answers are still served at quota.

Media typeapplication/json
object
error
required
object
code
required

Stable error codes. Status by code: unauthorized 401, forbidden 403, not_found 404, payload_too_large 413, unsupported_media_type 415, validation_failed 422, review_limit_reached 422, rate_limited 429, query_quota_exceeded 429, internal 500, embedding_unavailable 503, service_unavailable 503.

string
Allowed values: unauthorized forbidden validation_failed review_limit_reached not_found payload_too_large unsupported_media_type rate_limited embedding_unavailable query_quota_exceeded service_unavailable internal
message
required

For humans; may change between releases. Switch on code.

string
doc_url
required

https://docs.proofql.dev/errors#<code>.

string format: uri
request_id
required

Same value as the x-request-id header.

string
details

Present for validation_failed only.

Array<object>
object
path
required

Dotted path to the offending field ("0.rating", "filters.since"); "" or "(body)" for the whole body.

string
message
required
string
error
required
object
code
Allowed values: rate_limited query_quota_exceeded
Example
{
"error": {
"code": "rate_limited"
}
}
x-request-id
required
string
>= 1 characters <= 128 characters

This request’s id, on every response. Reuses the caller’s x-request-id when sent (up to 128 chars), else Cloudflare’s ray id, else a fresh UUID. Also inside every error envelope.

RateLimit-Policy
required
string
/^[0-9]+;w=[0-9]+$/

The key’s rate-limit policy as <limit>;w=<window seconds> (IETF draft ratelimit headers), e.g. 300;w=60 for a secret key, 120;w=60 for a publishable key. Present once the key is known.

RateLimit-Limit
required
integer
>= 1

The request limit per window for this key’s kind.

Retry-After
required
integer
>= 1

Whole seconds to wait. For rate_limited, until the current window ends; for query_quota_exceeded, until the next UTC month begins.

internal: something unexpected. The message carries the request id and nothing about the cause.

Media typeapplication/json
object
error
required
object
code
required

Stable error codes. Status by code: unauthorized 401, forbidden 403, not_found 404, payload_too_large 413, unsupported_media_type 415, validation_failed 422, review_limit_reached 422, rate_limited 429, query_quota_exceeded 429, internal 500, embedding_unavailable 503, service_unavailable 503.

string
Allowed values: unauthorized forbidden validation_failed review_limit_reached not_found payload_too_large unsupported_media_type rate_limited embedding_unavailable query_quota_exceeded service_unavailable internal
message
required

For humans; may change between releases. Switch on code.

string
doc_url
required

https://docs.proofql.dev/errors#<code>.

string format: uri
request_id
required

Same value as the x-request-id header.

string
details

Present for validation_failed only.

Array<object>
object
path
required

Dotted path to the offending field ("0.rating", "filters.since"); "" or "(body)" for the whole body.

string
message
required
string
error
required
object
code
Allowed value: internal
Example
{
"error": {
"code": "internal"
}
}
x-request-id
required
string
>= 1 characters <= 128 characters

This request’s id, on every response. Reuses the caller’s x-request-id when sent (up to 128 chars), else Cloudflare’s ray id, else a fresh UUID. Also inside every error envelope.

Retryable, never a degraded answer: embedding_unavailable (q could not be embedded; no full-text-only fallback) or service_unavailable (the database refused or timed out a connection, Retry-After: 1; or a daily platform allowance is spent, Retry-After until it renews).

Media typeapplication/json
object
error
required
object
code
required

Stable error codes. Status by code: unauthorized 401, forbidden 403, not_found 404, payload_too_large 413, unsupported_media_type 415, validation_failed 422, review_limit_reached 422, rate_limited 429, query_quota_exceeded 429, internal 500, embedding_unavailable 503, service_unavailable 503.

string
Allowed values: unauthorized forbidden validation_failed review_limit_reached not_found payload_too_large unsupported_media_type rate_limited embedding_unavailable query_quota_exceeded service_unavailable internal
message
required

For humans; may change between releases. Switch on code.

string
doc_url
required

https://docs.proofql.dev/errors#<code>.

string format: uri
request_id
required

Same value as the x-request-id header.

string
details

Present for validation_failed only.

Array<object>
object
path
required

Dotted path to the offending field ("0.rating", "filters.since"); "" or "(body)" for the whole body.

string
message
required
string
error
required
object
code
Allowed values: embedding_unavailable service_unavailable
Example
{
"error": {
"code": "embedding_unavailable"
}
}
x-request-id
required
string
>= 1 characters <= 128 characters

This request’s id, on every response. Reuses the caller’s x-request-id when sent (up to 128 chars), else Cloudflare’s ray id, else a fresh UUID. Also inside every error envelope.

RateLimit-Policy
string
/^[0-9]+;w=[0-9]+$/

Present on embedding_unavailable (the key was resolved); absent when the database was unreachable before auth.

RateLimit-Limit
string
/^[0-9]+$/

As RateLimit-Policy.

Retry-After
integer
>= 1

Present on service_unavailable only.

Vary
string

Origin, on a response that set a CORS allow-origin.