Skip to content

Storefront API

Markdown

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

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

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.

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

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:

{
"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.
  • 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.
  • 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.

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

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

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.

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.

{
"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.
  • 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.

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

{ "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.

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.

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.

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.

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.

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:

{
"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.

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

Terminal window
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:

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}`);
}