Push one review or a batch
const url = 'https://api.proofql.dev/v1/reviews';const options = { method: 'POST', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"external_id":"accounts/1/locations/2/reviews/abc","source":"google","rating":5,"text":"Dr. Patel did my implant and I honestly forgot it wasn\'t my own tooth within a week.","author_name":"Marcus T.","author_avatar_url":null,"occurred_at":"2026-03-14T18:20:00Z","url":"https://maps.google.com/?cid=123","language":"en","metadata":{"location":"north"}}'};
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/reviews \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "external_id": "accounts/1/locations/2/reviews/abc", "source": "google", "rating": 5, "text": "Dr. Patel did my implant and I honestly forgot it wasn'\''t my own tooth within a week.", "author_name": "Marcus T.", "author_avatar_url": null, "occurred_at": "2026-03-14T18:20:00Z", "url": "https://maps.google.com/?cid=123", "language": "en", "metadata": { "location": "north" } }'The push API. Body is one review object or an array of 1 to 100.
Upsert keyed on (project, environment, source, external_id), where
project and environment come from the key, never the body:
- new review → inserted with
status: "indexing"and queued for the pipeline (chunk, embed, sentiment); queryable within seconds. - existing,
textchanged (orratingremoved) → updated and re-indexed (statusback to"indexing"). - existing,
textunchanged → the other fields are updated;statusis unchanged.
Repeats of one (source, external_id) inside a batch collapse to the
last occurrence. The response lists the stored rows in request order.
The plan’s review cap (free: 5,000 per project) is checked before
anything is written, so a batch either lands whole or not at all
(422 review_limit_reached). The request body may be at most 1 MiB
(413 payload_too_large); 100 maximal reviews fit comfortably.
Indexing can be deferred. If the reviews were stored but could not be
queued for indexing (for example, the platform’s daily queue
allowance is spent), the response is still 200, with
indexing: "deferred". Those reviews report status: "indexing" and
are indexed automatically within minutes of the queue accepting work
again (at the latest shortly after 00:00 UTC). Do not resend them.
Authorizations
Section titled “Authorizations”Request Bodyrequired
Section titled “Request Bodyrequired”One review as the push API accepts it. Unknown keys are rejected.
object
The source’s own id for the review; the upsert key together with source.
Where the review came from. custom is the escape hatch for anything not listed.
Stars. null or omitted for sources without stars; sentiment is then classified at ingest.
The review text, verbatim. Trimmed; must be non-empty afterwards.
When the review was written. A full timestamp with Z or offset; a bare date is not enough to order reviews.
Where the review lives at the source.
BCP 47 tag, e.g. en, pt-BR.
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
One review as the push API accepts it. Unknown keys are rejected.
object
The source’s own id for the review; the upsert key together with source.
Where the review came from. custom is the escape hatch for anything not listed.
Stars. null or omitted for sources without stars; sentiment is then classified at ingest.
The review text, verbatim. Trimmed; must be non-empty afterwards.
When the review was written. A full timestamp with Z or offset; a bare date is not enough to order reviews.
Where the review lives at the source.
BCP 47 tag, e.g. en, pt-BR.
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
One review (scope.md §3 "Ingest")
{ "external_id": "accounts/1/locations/2/reviews/abc", "source": "google", "rating": 5, "text": "Dr. Patel did my implant and I honestly forgot it wasn't my own tooth within a week.", "author_name": "Marcus T.", "author_avatar_url": null, "occurred_at": "2026-03-14T18:20:00Z", "url": "https://maps.google.com/?cid=123", "language": "en", "metadata": { "location": "north" }}A batch of two
[ { "external_id": "r-1001", "source": "custom", "rating": 5, "text": "Painless cleaning, very gentle hygienist.", "author_name": "Dana K.", "occurred_at": "2026-02-01T09:00:00Z" }, { "external_id": "r-1002", "source": "custom", "rating": null, "text": "Parking behind the building was easy.", "author_name": "J. Alvarez", "occurred_at": "2026-01-15T17:45:00+01:00", "metadata": { "location": "south" } }]Responses
Section titled “Responses”Every review in the request was stored (inserted or updated), in
request order. status is indexing until the pipeline has
embedded it, then indexed. indexing: "deferred" means the
reviews were stored but indexing them was postponed; they are
indexed later without another request.
object
One entry per review in the request, in request order.
object
Where the review came from. custom is the escape hatch for anything not listed.
indexing until the pipeline has embedded the review (seconds), then indexed and queryable.
Present only when the reviews were stored but could not be queued
for indexing. They keep status: "indexing" and are indexed
automatically once the queue accepts work again (at the latest
shortly after 00:00 UTC); there is nothing to retry.
Examples
{ "reviews": [ { "id": "2f1c9e5a-3b7d-4c1e-9f0a-6d2b8e4a1c3f", "external_id": "accounts/1/locations/2/reviews/abc", "source": "google", "status": "indexing" } ]}Stored, indexing deferred
{ "reviews": [ { "id": "2f1c9e5a-3b7d-4c1e-9f0a-6d2b8e4a1c3f", "external_id": "accounts/1/locations/2/reviews/abc", "source": "google", "status": "indexing" } ], "indexing": "deferred"}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.
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 (pq_pk_…) on a route that requires a
secret key. Decided before the key is looked up, 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": "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.
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 (see the other routes), or review_limit_reached:
the batch would push the project past its plan’s review cap. Nothing
was written; the message says how many remain.
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": "validation_failed" }}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 this key’s per-minute limit. Wait Retry-After seconds.
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.
service_unavailable: the database refused or timed out a connection
(a connection storm, a paused compute; Retry-After: 1), or a daily
platform allowance is spent (Retry-After is the seconds until it
renews, at most a day). Nothing about the request was wrong; retry
after Retry-After seconds. 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": "service_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.
Whole seconds to wait. For rate_limited, until the current window
ends; for query_quota_exceeded, until the next UTC month begins.