# Storefront API

> The endpoints called by the store page and the store's server.

Source: /en/reference/storefront-api/

Version `v1`. The Sayproof API address to put in front of the endpoint paths is shown on the **Integration** screen in the panel.

## Authentication

| Caller | Method |
|---|---|
| The browser (the store page) | The site key: the `?key=` parameter for read requests, the `X-Sayproof-Site-Key` header for write requests. The request must come from a **verified** domain: the domain is read from the `Origin` header, or from the `Referer` header when `Origin` is missing. |
| The store's server | The `Authorization: Bearer <server key>` header and the `?channel=<channel code>` parameter |

No endpoint uses the browser session (cookies); requests are sent without cookies.

## Common rules

- Request and response bodies are JSON.
- The error response format is the same on all endpoints: `{"error": "<code>", "detail": "<code>"}`. `detail` is also a code, not a sentence; convert it into the text shown to users on your side. For the list of codes, see [Error codes](/en/reference/errors.md).
- The language is determined, in order, from the `language` field of the body (write requests), the `?lang=tr|en` parameter or the channel's language. The `Accept-Language` header is not taken into account. If an unsupported language is requested, the channel's language is used.
- Every response carries the `X-Request-Id` header; include this value in support requests.
- For problems with the key, channel or domain, a `403 refused` response is returned and `detail` gives the reason (for example `origin_unverified: www.yourbrand.com`, `unknown_site_key`, `channel_inactive`). The `origin_unverified` detail carries the domain the request came from. When the rate limit is exceeded, `429 rate_limited` is returned.

## Review list

`GET /v1/reviews/list` — returns the published reviews, the summary and the required elements.

| Parameter | Default | Rule |
|---|---|---|
| `key` | | The site key |
| `product` | | Required; up to 128 characters |
| `scope` | `variant` | `variant` or `group` |
| `sort` | The market's default | A value outside the permitted list receives a `400 sort_not_allowed` response |
| `page`, `page_size` | `1`, `10` | `page` 1–1000, `page_size` 1–50; out-of-range values are moved to the nearest limit |
| `rating` | | 1–5; an out-of-range value receives a `400 invalid / rating` response |
| `verified` | | `1`: only reviews with proof of purchase |
| `media` | | `1`: only reviews containing a photo or video |
| `q` | | Search in the title and text; up to 100 characters. All words must appear. Letter case and the Turkish ı/i distinction are ignored. |
| `topic` | | A single word (up to 40 letters); reviews containing a word that starts with this word (`comfort` → `comfortable`) |
| `lang` | The channel's language | `tr` or `en` |
| `via` | | The script sends `script`. If you read reviews with your own code, do not send it; the panel's **Store appearance** screen counts those pages as your site's own design. |

Response:

```json
{
  "reviews": [
    {
      "id": "…", "rating": 5, "title": "…", "body": "…", "author": "Jane D.", "anonymous": false,
      "language": "en", "published_at": "…", "verified": true, "verification_method": "tokenized_invite",
      "helpful_count": 3, "unhelpful_count": 1,
      "aspects": [{ "key": "size", "label": "Size", "scale": "fit", "value": 2, "position": 0.5, "step_label": "Adequate" }],
      "aspect_scores": { "size": 2 }, "aspect_template": "footwear",
      "media": [{ "id": "…", "kind": "image", "url": "…", "srcset": "… 320w, … 640w, … 1280w", "width": 1280, "height": 960, "alt_text": "", "poster_url": "" }],
      "seller_response": { "body": "…", "published_at": "…" },
      "resolution": { "body": "…", "created_at": "…" },
      "origin": "own", "origin_market": "", "experience": "product", "translations": {}
    }
  ],
  "total": 200, "page": 1, "pages": 20, "page_size": 10, "sort": "date", "shared": 0,
  "ai_summary": null,
  "sorts": [{ "code": "date", "label": "Newest" }, { "code": "rating", "label": "By rating" }, { "code": "seller", "label": "With a seller response" }],
  "aggregate": { "count": 200, "average": 4.1, "stars_filled": 4, "distribution": { "1": 5, "5": 120 }, "verified_count": 200 },
  "aspects": [{ "key": "size", "label": "Size", "scale": "fit", "steps": ["Low", "Adequate", "High"], "average": 2.1, "position": 0.55, "step_label": "Adequate", "count": 48 }],
  "topics": [{ "word": "comfortable", "count": 31 }],
  "gallery": [{ "id": "…", "kind": "image", "url": "…", "srcset": "…", "width": 1280, "height": 960, "alt_text": "", "poster_url": "", "review_id": "…", "rating": 5, "author": "Jane D.", "anonymous": false }],
  "media_reviews": 14, "style": "cards", "theme": "warm",
  "applied": { "q": "", "topic": "", "rating": null, "media": false, "verified": false },
  "core": {
    "method_statement": { "url": "…", "label": "How we collect reviews", "required": true },
    "sort": { "allowed": ["date", "rating", "seller"], "default": "date", "locked": true },
    "verification_badge": { "label": "Verified buyer", "required": true },
    "source_label_required": true, "streams_together": true, "resolution_inline": true,
    "aggregate": { "…": "…" }, "policy_version": 1, "market": "TR"
  },
  "language": "en",
  "words": { "verified": "Verified buyer", "…": "…" },
  "report_reasons": [{ "code": "suspected_fake", "label": "…" }],
  "questions_enabled": false,
  "privacy_url": "https://yourbrand.com/privacy-notice",
  "limits": { "media_count": 6, "title_length": 200, "body_length": 5000, "name_length": 120, "alt_text_length": 300, "image_bytes": 12582912, "video_bytes": 209715200, "video_seconds": 60, "reviews_per_call": 20, "order_reference_length": 128, "email_length": 254, "report_note_length": 1000 }
}
```

- `aggregate.stars_filled`: the number of filled stars; halves are rounded up (4.5 → 5).
- `aspects` (top level): the averages of rating topics with a stepped scale. `position` is between 0 and 1, and `step_label` is the label of the nearest step. Filters do not change these values.
- `topics`: up to 5 words that appear most often in reviews, and the number of reviews each appears in. Words must have at least 4 letters and appear in at least 2 reviews; the values can be used directly in the `topic` parameter.
- `gallery`: up to 12 fully processed photos or videos attached to the product's published reviews, starting from the newest review. Each item carries the `media` fields and the `review_id`, `rating`, `author` and `anonymous` values of the review it belongs to. Filters do not change this list.
- `media_reviews`: the number of published reviews containing a photo or video; the number of reviews the `media=1` filter returns on its own. Filters do not change this value.
- `style`: the layout chosen for the channel in the panel, `cards` or `sidebar`.
- `theme`: the design chosen for the channel in the panel: `warm`, `trust`, `editorial`, `minimal`, `soft`, `bold`, `natural` or `night`.
- `applied`: the filters applied.
- `experience`: `product` (a product review) or `process` (the experience, up to that point, of a customer whose order was cancelled or who returned the item). `process` reviews must be shown with the `words.experience_process` label; the default component shows this label itself.
- `limits`: the limits the review form applies: the number of files (`media_count`), text lengths (`title_length`, `body_length`, `name_length`, `alt_text_length`), file sizes in bytes (`image_bytes`, `video_bytes`), the video length in seconds (`video_seconds`), the number of products per call (`reviews_per_call`), the order number and email lengths (`order_reference_length`, `email_length`) and the report note length (`report_note_length`).
- `core.method_statement.url`: the address of the method statement that applies to the request's channel and language (your own page linked for the channel, or Sayproof's page).
- The response carries the `Cache-Control: private, max-age=30` header.
- `questions_enabled`: whether the brand's questions and answers are on; see [Questions and answers](#questions-and-answers).
- `privacy_url`: the privacy notice link set in the panel for the request's channel; empty if not set. The script shows this address below the review, question and answer forms as a **Privacy notice on the processing of personal data** link (`words.privacy_notice`).
- `origin`, `origin_market`: `own` is the brand's own review in this market. If cross-market sharing is on for your brand (**Settings › Publishing › Reviews from other markets**), the list also carries proof-of-purchase reviews of the same product from your other markets: on these rows `origin` is `syndicated` and `origin_market` is the market they came from. They do not count in the average, the rating distribution or the structured data; `shared` says how many there are in the list.
- `origin_label`, `origin_url`: the ready label text on every review whose source is not `own` (the server writes it in the visitor's language with the right suffixes; the script only shows it). On a review imported from a marketplace (`origin: "imported"`) the label reads like "Bought on Trendyol · Imported automatically from Trendyol", `origin_url` is the address of the review at the source (empty if unknown), `verified` is always `false` and `verification_method` is empty. These reviews are left out of the `verified_only` filter, count in the average, the rating breakdown and the number of reviews, and are left out of the structured data and of the Google product reviews file. For details see [Marketplace reviews](/en/features/marketplace-reviews.md).
- `aggregate.imported_count`, `aggregate.imported_label`: the number of imported reviews and the ready text of the summary line ("3 of 4 reviews imported from Trendyol"); `0` and an empty text when nothing is imported. `aggregate.verified_count` does not count imported reviews.
- `translations` (arrives with v2; currently an empty object `{}` on every review): if translation is on in the AI package, the review's translations into other languages, keyed by language code: `{"en": {"title", "body"}}`. When a review is not in the visitor's language, the script shows the translation and adds a link to show the original.
- `ai_summary` (arrives with v2; currently always `null`): if review summaries are on in the AI package and the product has enough reviews, the summary: `text`, the number of reviews it is based on (`review_count`), when it was made (`created_at`) and the main topics (`topics[]`: `topic`, `sentiment`, `count`); otherwise `null`. The default component shows the summary with its label; the summary section cannot be hidden.

## Server-side rendering (SSR)

`GET /v1/ssr` — returns the ready-made HTML, the `state` field with the same content as the list response, and the structured data. Parameters: `product`, `channel`, `scope`, `name`, `lang`, `sort`, `style` (`cards` or `sidebar`; any other value receives a `400 invalid / style` response), `theme` (`warm`, `trust`, `editorial`, `minimal`, `soft`, `bold`, `natural` or `night`; any other value receives a `400 invalid / theme` response). For usage and the response fields, see [Server-side rendering (SSR)](/en/integrate/server-rendering.md).

- The response carries the `ETag` and `Cache-Control` headers: `private, max-age=60` for a call made with a server key, `public, max-age=60` for a call made with a site key. For a request sent with `If-None-Match`, `304` is returned if the content has not changed.
- If the channel has no site key in use, `409 refused / no_site_key` is returned.

## Signed-in customers

`GET /v1/viewer` — can only be called with a server key. The customer's email address is sent in the `X-Sayproof-Customer` header. Parameters: `product`, `scope`, `lang`, `channel`. The `state` value in the response is `can_write`, `written` or `none`; the response also contains `reviews`, `limits`, the `token` and `products` fields if the customer can write a review, and the `asker` field (`token`, `name`) if the brand has turned on questions and answers. The name shown with a question is sent in the optional `X-Sayproof-Customer-Name` header, percent-encoded as UTF-8. The response carries the `Cache-Control: no-store` header. For details, see [Signed-in customers](/en/integrate/signed-in-customers.md).

## Reviews on the order page

`GET /v1/viewer/order` — returns the items of the order and the review state of each one, for the signed-in customer's order detail page. It can only be called with a server key; a call from the browser with a site key receives a `403 refused / server_only` response. The customer's email address is sent in the `X-Sayproof-Customer` header. Parameters: `order` (the order number, up to 128 characters), `channel`, `lang`.

```json
{
  "order": "100245",
  "token": "…",
  "lines": [
    { "line_reference": "1", "variant_key": "PRODUCT-38", "group_key": "PRODUCT", "title": "Running shoe", "aspects": [{ "key": "size", "label": "Size", "kind": "product", "required": false, "scale": "fit", "steps": ["Low", "Adequate", "High"] }], "state": "can_write" },
    { "line_reference": "2", "variant_key": "SOCK-2", "group_key": "SOCK", "title": "Running socks", "aspects": [], "state": "pending", "rating": 4 }
  ],
  "limits": { "media_count": 6, "body_length": 5000, "…": "…" },
  "language": "en",
  "theme": "warm",
  "privacy_url": "https://yourbrand.com/privacy-notice",
  "words": { "order_title": "Rate your items", "…": "…" }
}
```

- `lines[].state`: `can_write`, `pending`, `published`, `rejected`, `removed`, `waiting` or `closed`. For their meanings, see [Signed-in customers › Order detail page](/en/integrate/signed-in-customers.md#order-detail-page).
- `privacy_url`: the channel's privacy notice link; empty if not set. The script shows the link below the forms.
- `token`: a single writing token for the lines that can be reviewed; empty if no line can be reviewed. `aspects` is filled only on `can_write` lines; `rating` is present only on lines that have been reviewed.
- If the order is not found or belongs to another customer, the response is still `200` and `lines` is empty; the response does not reveal whether the order exists.
- If `order` is not sent, `400 invalid / order` is returned; if the `X-Sayproof-Customer` header is missing or is not a valid address, `400 invalid / customer`.
- The response carries the `Cache-Control: no-store` header. The rate limit is shared with `GET /v1/viewer`.

## "Helpful" vote

`POST /v1/reviews/<id>/helpful` — body: `{"vote": "up" | "down" | "none", "visitor_key": "…"}`.

```json
{ "vote": "down", "counted": true, "helpful_count": 3, "unhelpful_count": 1 }
```

- Each visitor can cast one vote per review. If a vote in the same direction is cast again, `counted: false` is returned; if the direction changes, the vote moves to the new direction; `none` withdraws the vote (`vote: null`).
- `visitor_key` is only used to remove duplicates; the rate limit is applied per IP address.

## Reporting a review

`POST /v1/reviews/<id>/report` — body: `{"reason": "…", "note": "…", "visitor_key": "…"}`. The reason is chosen from the `report_reasons` field of the list response; a value outside the list receives a `400 invalid_reason` response. The note can be up to 1000 characters. A successful request returns `201 {"id"}`. Only published reviews can be reported. If the same visitor (`visitor_key`, or the IP address if it is not sent) already has an open report on the same review, no new record is created; the response returns the `id` of the existing report.

## Product views

`POST /v1/views?key=…` — the body is JSON sent with the `text/plain` content type: `{"product": "…", "scope": "variant" | "group"}`. The script also reports how the reviews were shown: `shown` (`default` for the default component, `coded` when the look is chosen in code, `headless`), `style` and `theme` when chosen in code, and the review area's width in pixels as `width`. Sayproof only keeps the width as one of four ranges; when these fields are missing or invalid, only the view is counted. A successful request returns `204` without a body. The script sends this request itself; in headless use too, you do not need to send it separately as long as the script is loaded on the page. Views from automated clients (bots, link preview tools) and prefetch requests are not counted. In-app browsers (for example Pinterest, Telegram, Slack, Discord) and phone brands whose name contains "bot", such as CUBOT, are counted. On a prerendered page, the script reports the view when the page is actually shown.

## Method statement

`GET /v1/method-statement?key=…&lang=tr|en` — a public page; it does not require domain verification (it opens in a new tab). With the `format=json` parameter, it is returned in a machine-readable format. The page shows the statement of the site key's channel; the text is built from that channel's rules and publishing method and is the same text the panel shows for that channel.

## Review writing flow endpoints

The script uses the endpoints below. Because the review writing flow stays in the script in headless use as well, you do not need to call these endpoints directly.

| Endpoint | Purpose |
|---|---|
| `POST /v1/eligibility` | Checks eligibility with the order number and email address. If the customer is eligible, a single-use writing token is returned; otherwise a single, general refusal is returned. |
| `POST /v1/eligibility/token` | Verifies the token in an invitation, shopping experience invitation or correction link. The `mode` value in the response is `write`, `feedback` or `revise`. The products in a `feedback` answer contain no rating topics. A `revise` answer contains the review to be corrected, the name the writer entered (`review.author_name`), whether the name is hidden on the site (`review.hide_name`) and the files attached to it (`review.media`: `id`, `kind`). If the link is opened on another market's channel, `403 token_refused / token_foreign_market` is returned. |
| `POST /v1/media` | Uploads a photo or video (jpeg, png, webp: up to 12 MB; mp4, mov: up to 200 MB and 60 seconds). The file goes through a security scan; location and device information is removed. A video longer than the limit is refused at upload: `422 refused / video_too_long: 60`. A video larger than 3840×2160 (2160×3840 portrait), with more than 60 frames per second or not encoded with H.264, HEVC, MPEG-4, VP9 or AV1 is also refused. The answer carries no address for the file; the file is at a public address only while the review it is added to is published. |
| `POST /v1/reviews` | Submits reviews (up to 20 products per call). Accepts product invitation and shopping experience invitation tokens; a review written with a shopping experience invitation token is saved as `experience: "process"`. Each row can carry the optional `author_name` (at most 120 characters) and `hide_name` (`true` or `false`; default `false`) fields. With `hide_name: true` the name shows on the site, in structured data and in the Google file as the first letter of every word followed by `***` (`A*** K***` instead of `Ayşe Kaya`); the full name is visible only in the brand's panel. A `hide_name` that is not a boolean gets `400 invalid / hide_name`. |
| `POST /v1/reviews/revise` | Submits a rejected review again in its corrected form. `kept_media` in the body is the list of IDs of the existing files that stay on the review; if the field is not sent, all existing files stay. Kept and new files together can be at most 6. `author_name` and `hide_name`, when sent, replace the values on the review (an empty `author_name` removes the name); when omitted, the current values stay. |

For the rate limit values, see [Limits](/en/reference/limits.md).

## Questions and answers

Available once the brand turns on **Settings › Publishing › Questions and answers** in the panel; while it is off, all three endpoints return `403 refused / questions_disabled` and `questions_enabled` in the list response is `false`. The script loads the section only when this value is `true`.

| Endpoint | Purpose |
|---|---|
| `GET /v1/questions` | Returns the product's published questions and each question's published answers. Parameters: `key`, `product` (required), `scope` (`variant` or `group`), `page` (1–1000), `page_size` (default 5, at most 20), `lang`. Can also be read with the server key and the `channel` parameter. |
| `POST /v1/questions` | Saves a visitor's question. Body: `product`, `scope`, `name` (optional, at most 120 characters), `email` (optional; without it no email goes out when an answer arrives), `body` (required, at most 1000 characters), `hide_name` (optional boolean; with `true` the name shows on the site as the first letter of every word followed by `***`), `language`. For a signed-in customer, the `asker.token` value from the `GET /v1/viewer` response is sent in the `asker` field instead of `name` and `email`; the name and email address are read from the token. No purchase is needed. A successful call returns `201 {"id", "state"}`. |
| `POST /v1/questions/<id>/answers` | Saves the answer of a customer who bought the item. The writing token is sent in the `X-Sayproof-Token` header; a token from an invitation or from order verification is accepted, and having used it to write a review does not stop it from being used to answer. Body: `body` (required, at most 2000 characters), `name` (optional), `hide_name` (optional boolean; with `true` the name shows on the site as the first letter of every word followed by `***`), `language`. A successful call returns `201 {"id", "state"}`. |

`GET /v1/questions` response:

```json
{
  "questions": [
    {
      "id": "…", "author": "Sam", "body": "Does it fit wide feet?", "published_at": "…",
      "answers": [
        { "id": "…", "kind": "brand", "author": "Sample Brand", "verified": false, "body": "…", "published_at": "…" },
        { "id": "…", "kind": "buyer", "author": "Alex", "verified": true, "body": "…", "published_at": "…" }
      ]
    }
  ],
  "total": 4, "page": 1, "pages": 1, "page_size": 5,
  "limits": { "question_length": 1000, "answer_length": 2000, "name_length": 120, "email_length": 254 },
  "language": "en"
}
```

- Questions are pooled by market: questions asked on the channels of the same market are shown together. The most recently published question comes first; answers are listed in the order they went live.
- `kind`: `brand` (the brand's answer; `author` is the channel's brand name) or `buyer` (the answer of a customer who bought the item; `verified: true`, shown with the "Verified buyer" label). The response never carries the email address of a question or an answer.
- A question and a customer's answer are approved before they go live. Text with personal information, swearing or a health claim is not refused; it waits for approval. If the publishing method is Automatic or Hybrid and the text passes these checks, it goes live by itself.
- Missing details return `400 invalid` with the reason in `detail`: `body_required`, `body_too_long`, `email_invalid`, `email_too_long`, `name_too_long`, `asker_too_long`, `product_required`, `product_too_long`.
- If the `asker` token has expired (after 1 hour), `403 token_refused / asker_expired` is returned; if it is not valid or belongs to another brand, `403 token_refused / asker_invalid`.
- If the token's order does not contain the question's product (the same variant or the same product group), `403 token_refused / not_a_buyer` is returned. Without a token `401 token_required` is returned; if the question is not live or belongs to another market, `404 not_found` is returned.
- When an answer goes live and the question has an email address, the person who asked gets an email in the brand's name. If the address is on the brand's do-not-email list, no email is sent.
- The `GET /v1/questions` response carries the `Cache-Control: private, max-age=30` header.

## Example requests

Trying the review list from the command line (the `Origin` header is added as if the request came from a verified domain):

```bash
curl -s "https://api.sayproof.example/v1/reviews/list?key=YOUR-SITE-KEY&product=PRODUCT-38&sort=date&page=1&lang=en" \
  -H "Origin: https://www.yourbrand.com"
```

Reporting a review from the browser:

```js
export async function reportReview(reviewId, reason, note) {
  const answer = await fetch(`https://api.sayproof.example/v1/reviews/${encodeURIComponent(reviewId)}/report`, {
    method: "POST",
    credentials: "omit",
    headers: { "Content-Type": "application/json", "X-Sayproof-Site-Key": "YOUR-SITE-KEY" },
    body: JSON.stringify({ reason, note }),
  });
  if (answer.status === 201) return answer.json();
  const problem = await answer.json().catch(() => ({}));
  throw new Error(problem.detail || problem.error || `sayproof_report_${answer.status}`);
}
```
