Search (POST)
const url = 'https://api.proofql.dev/v1/query';const options = { method: 'POST', headers: { Origin: 'https://shop.example', 'Cache-Control': 'no-cache', Authorization: 'Bearer <token>', 'Content-Type': 'application/json' }, body: '{"q":"dental implants","limit":5,"mode":"excerpts","filters":{"min_rating":4,"source":["google"],"metadata.location":"north","since":"2025-01-01"}}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}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.
qoptional. Without it, results are the newest publishable reviews and everyscoreisnull. 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 returnsresults: []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 classifiednegativenever appear. A request’sfilters.min_ratingcan 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;reviewsreturns whole reviews, one per review, scored by the best excerpt, withreview.textpresent.scoreis 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.nullwithoutq.excerptis always a verbatim slice of the review’s text: nothing generates text.highlightsays where that slice sits in the whole review:{ "start", "end" }as UTF-16 code-unit offsets intoreview.text,endexclusive, soreview.text.slice(highlight.start, highlight.end) === excerptin 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 isnullwhen there is nothing to mark: withoutq, and when the match is the review as a whole (a full-review match highlights nothing).includenames optional response fields.["text"]addsreview.textto every result inmode=excerpts(it is always present inmode=reviews), which is what a highlighted render needs without switching modes. Part of the cache key.fallbackandmatch— the honest fallback. By default (fallback: "none") a query nothing clears the floor for returnsresults: []. Withfallback: "recent"that empty result is replaced by the newest publishable reviews under the same policy and filters, and the response says so:matchis"fallback"and every result hasmatched: falsewithscoreandhighlightnull, 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. Otherwisematchis"query"(real matches, everymatched: true),"none"(nothing cleared the floor,resultsempty), or"recent"(noqwas 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.badgemirrors the project’s plan:truemeans 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: trueandx-cache: HITmeanresultscame from the cache (took_msis still this request’s time). SendCache-Control: no-cacheto 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 aHIT: 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.
Authorizations
Section titled “Authorizations”Parameters
Section titled “Parameters”Header Parameters
Section titled “Header Parameters”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.exampleno-cache skips the result-cache lookup (the response says x-cache: BYPASS). Costs one uncached query.
Example
no-cacheRequest Body
Section titled “Request Body”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.
object
The search text. Omit for the newest publishable reviews (every score is then null).
excerpts: the best-matching slice per review. reviews: whole reviews, with review.text.
An optional response field. text: review.text on every result (always present in mode=reviews).
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.
Narrow the candidates. metadata may be nested
("metadata": { "location": "north" }) or flat
("metadata.location": "north"); both are accepted and merged.
object
Tightens the project’s min_rating for this request; cannot loosen it.
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
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" }}Nested metadata, whole reviews
{ "q": "gentle hygienist", "limit": 3, "mode": "reviews", "filters": { "metadata": { "location": "south" } }}Excerpts with the whole text, for a highlighted render
{ "q": "dental implants", "limit": 3, "include": [ "text" ]}Newest reviews instead of an empty list, labelled
{ "q": "roofing", "limit": 3, "fallback": "recent"}No `q` — the newest publishable reviews
{ "limit": 3}Responses
Section titled “Responses”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.
object
In rank order. Empty when nothing clears the floor and no fallback was requested. All matches or all fallback, never a mix.
object
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.
true for a real match (match: query); false on a fallback row and without q.
A verbatim slice of the review’s text — the best-matching chunk.
Stable id of the excerpt (chunk).
object
Where the review came from. custom is the escape hatch for anything not listed.
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
The whole review text. Present in mode=reviews, or in any mode with include: ["text"].
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.
Server time for this request, cached or not.
true when results were served from the result cache (x-cache: HIT).
true means the snippet must render the “Reviews by ProofQL” badge (free tier). Mirrors the project’s plan on every response, cached or not.
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}`include=text`: the whole review with the span to mark
{ "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" }, "text": "Three visits, zero drama. Dr. Patel did my implant and I honestly forgot it wasn't my own tooth within a week. The whole team remembered my name." } } ], "match": "query", "took_ms": 14, "cached": false, "badge": true}Nothing above the floor
{ "results": [], "match": "none", "took_ms": 9, "cached": false, "badge": true}`fallback=recent` for `q=roofing`: nothing cleared the floor, so the newest reviews, labelled
{ "results": [ { "score": null, "matched": false, "excerpt": "Three visits, zero drama. Dr. Patel did my implant and I honestly forgot it wasn't my own tooth within a week. The whole team remembered my name.", "excerpt_id": "0a1b2c3d-4e5f-4a6b-8c7d-8e9f0a1b2c3d", "highlight": null, "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": "fallback", "took_ms": 11, "cached": false, "badge": true}Headers
Section titled “Headers”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.
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.
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.
The request limit per window for this key’s kind.
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).
Always Origin on /v1/query, so a shared cache never serves one origin’s CORS headers to another.
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).
object
object
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.
For humans; may change between releases. Switch on code.
https://docs.proofql.dev/errors#<code>.
Same value as the x-request-id header.
Present for validation_failed only.
object
Dotted path to the offending field ("0.rating", "filters.since"); "" or "(body)" for the whole body.
object
Examples
{ "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" }}Headers
Section titled “Headers”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.)
object
object
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.
For humans; may change between releases. Switch on code.
https://docs.proofql.dev/errors#<code>.
Same value as the x-request-id header.
Present for validation_failed only.
object
Dotted path to the offending field ("0.rating", "filters.since"); "" or "(body)" for the whole body.
object
Example
{ "error": { "code": "forbidden" }}Headers
Section titled “Headers”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.
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.
The request limit per window for this key’s kind.
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.
object
object
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.
For humans; may change between releases. Switch on code.
https://docs.proofql.dev/errors#<code>.
Same value as the x-request-id header.
Present for validation_failed only.
object
Dotted path to the offending field ("0.rating", "filters.since"); "" or "(body)" for the whole body.
object
Example
{ "error": { "code": "payload_too_large" }}Headers
Section titled “Headers”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.
object
object
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.
For humans; may change between releases. Switch on code.
https://docs.proofql.dev/errors#<code>.
Same value as the x-request-id header.
Present for validation_failed only.
object
Dotted path to the offending field ("0.rating", "filters.since"); "" or "(body)" for the whole body.
object
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" }}Headers
Section titled “Headers”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).
object
object
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.
For humans; may change between releases. Switch on code.
https://docs.proofql.dev/errors#<code>.
Same value as the x-request-id header.
Present for validation_failed only.
object
Dotted path to the offending field ("0.rating", "filters.since"); "" or "(body)" for the whole body.
object
Examples
{ "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" } ] }}Headers
Section titled “Headers”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.
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.
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.
object
object
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.
For humans; may change between releases. Switch on code.
https://docs.proofql.dev/errors#<code>.
Same value as the x-request-id header.
Present for validation_failed only.
object
Dotted path to the offending field ("0.rating", "filters.since"); "" or "(body)" for the whole body.
object
Example
{ "error": { "code": "rate_limited" }}Headers
Section titled “Headers”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.
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.
The request limit per window for this key’s kind.
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.
object
object
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.
For humans; may change between releases. Switch on code.
https://docs.proofql.dev/errors#<code>.
Same value as the x-request-id header.
Present for validation_failed only.
object
Dotted path to the offending field ("0.rating", "filters.since"); "" or "(body)" for the whole body.
object
Example
{ "error": { "code": "internal" }}Headers
Section titled “Headers”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).
object
object
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.
For humans; may change between releases. Switch on code.
https://docs.proofql.dev/errors#<code>.
Same value as the x-request-id header.
Present for validation_failed only.
object
Dotted path to the offending field ("0.rating", "filters.since"); "" or "(body)" for the whole body.
object
Example
{ "error": { "code": "embedding_unavailable" }}Headers
Section titled “Headers”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.
Present on embedding_unavailable (the key was resolved); absent when the database was unreachable before auth.
As RateLimit-Policy.
Present on service_unavailable only.
Origin, on a response that set a CORS allow-origin.