Skip to content

Push one review or a batch

POST
/v1/reviews
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, text changed (or rating removed) → updated and re-indexed (status back to "indexing").
  • existing, text unchanged → the other fields are updated; status is 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.

Media typeapplication/json
One of:

One review as the push API accepts it. Unknown keys are rejected.

object
external_id
required

The source’s own id for the review; the upsert key together with source.

string
>= 1 characters <= 512 characters
source
required

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

string
Allowed values: google yelp facebook trustpilot custom
rating

Stars. null or omitted for sources without stars; sentiment is then classified at ingest.

integer | null
>= 1 <= 5
text
required

The review text, verbatim. Trimmed; must be non-empty afterwards.

string
>= 1 characters <= 20000 characters
author_name
required
string
>= 1 characters <= 256 characters
author_avatar_url
string | null format: uri
occurred_at
required

When the review was written. A full timestamp with Z or offset; a bare date is not enough to order reviews.

string format: date-time
url

Where the review lives at the source.

string | null format: uri
language

BCP 47 tag, e.g. en, pt-BR.

string
>= 2 characters <= 35 characters
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

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"
}
}

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.

Media typeapplication/json
object
reviews
required

One entry per review in the request, in request order.

Array<object>
object
id
required
string format: uuid
external_id
required
string
source
required

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

string
Allowed values: google yelp facebook trustpilot custom
status
required

indexing until the pipeline has embedded the review (seconds), then indexed and queryable.

string
Allowed values: indexing indexed
indexing

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.

string
Allowed values: deferred
Examples
{
"reviews": [
{
"id": "2f1c9e5a-3b7d-4c1e-9f0a-6d2b8e4a1c3f",
"external_id": "accounts/1/locations/2/reviews/abc",
"source": "google",
"status": "indexing"
}
]
}
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.

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 (pq_pk_…) on a route that requires a secret key. Decided before the key is looked up, 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: 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.

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 (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.

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: validation_failed review_limit_reached
Example
{
"error": {
"code": "validation_failed"
}
}
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 this key’s per-minute limit. Wait Retry-After seconds.

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: rate_limited
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.

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.

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: service_unavailable
Example
{
"error": {
"code": "service_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.

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.