Headless use
MarkdownDisplaying the review summary and list with your own design, while leaving the review writing flow to Sayproof.
If you want to decide the entire look of the review section yourself, you fetch the data from Sayproof and display the summary, the list, sorting and filters with your own components. This way of working is called headless. The approach is framework-independent: it can be implemented with plain JavaScript, React, Vue, Svelte or any server-side template engine.
Division of responsibilities:
| Brand side | Sayproof side |
|---|---|
| Summary, list, review card, sorting, filters, paging, vote and report buttons | Order verification, review form, photo and video upload, invitation and correction links |
The review writing flow stays in the script. This ensures that verification rules, consent texts and abuse protection work in the same way for every brand, and that the form never opens before the order has been verified.
Container and script
Section titled “Container and script”Add the data-review-mode="headless" attribute to the container. The script then creates no summary, list or sorting in this container; it only runs the review writing flow and handles invitation links.
<div id="reviews" data-review-mount data-review-mode="headless" data-review-product="PRODUCT-CODE" data-review-scope="variant" data-review-locale="en"></div>
<script type="module" src="https://cdn.sayproof.example/sayproof.js" data-review-site-key="YOUR-SITE-KEY" data-review-base="https://api.sayproof.example" data-review-locale="en"></script>The script adds no content inside this container; the container only needs to be present on the page. The review form and the order verification are shown in a dialog that opens on top of the page.
Getting the data
Section titled “Getting the data”The data can be obtained from two sources, with identical fields:
- On the server: the
statefield of the SSR response. The list is built on the server, and search engines see the text in the first response. Add thestate_scripttag from the same response as a direct child of the container; the script takes the interface texts and required elements from it without a network request. - In the browser: the
GET /v1/reviews/listendpoint. The site key is sent in the URL and no custom header is used, so the browser does not make a preflight request.
const base = "https://api.sayproof.example";const siteKey = "YOUR-SITE-KEY";
export async function listReviews({ product, scope = "variant", lang = "en", sort, page = 1, filters = {} }) { const query = new URLSearchParams({ key: siteKey, product, scope, lang, page: String(page) }); if (sort) query.set("sort", sort); if (filters.rating) query.set("rating", String(filters.rating)); if (filters.verified) query.set("verified", "1"); if (filters.media) query.set("media", "1"); if (filters.q) query.set("q", filters.q); if (filters.topic) query.set("topic", filters.topic); const answer = await fetch(`${base}/v1/reviews/list?${query}`, { credentials: "omit" }); if (!answer.ok) throw new Error(`sayproof_list_${answer.status}`); return answer.json();}The main fields you will use in the response:
| Field | Content |
|---|---|
aggregate |
count, average, stars_filled (number of filled stars; halves are rounded up), distribution, verified_count, imported_count, imported_label, structured_count, structured_average (the last two are the number and the average without imported reviews; the structured data is calculated from them) |
reviews[] |
id, rating, title, body, author, published_at, verified, helpful_count, unhelpful_count, aspects, media[] (url, srcset, width, height, alt_text, kind, poster_url), seller_response, resolution, origin, experience (if process, show the review with the words.experience_process label) |
sorts |
The permitted sort options and their labels; your sort menu must only offer these options |
aspects, topics |
The averages of the rating topics and the words used most often in reviews |
gallery, media_reviews |
The newest photos and videos from the product’s reviews (each with the review’s rating and author) and the number of reviews with photos or video |
core |
The required elements: method_statement.url and label, sort.allowed and sort.default |
words |
Interface texts in the requested language: verified, seller_response, resolution, count, statement, search, filter_verified, filter_media, gallery, helpful_question, helpful_yes, helpful_no… |
report_reasons |
Report reasons and their labels |
page, pages, page_size, total |
Paging information |
When requesting subsequent pages, send back the page_size value from the response. For all fields and filter rules, see Storefront API.
Votes and reports
Section titled “Votes and reports”The “helpful” vote:
export async function vote(reviewId, direction, visitorKey) { const answer = await fetch(`${base}/v1/reviews/${encodeURIComponent(reviewId)}/helpful`, { method: "POST", credentials: "omit", headers: { "Content-Type": "application/json", "X-Sayproof-Site-Key": siteKey }, body: JSON.stringify({ vote: direction, visitor_key: visitorKey }), }); if (!answer.ok) throw new Error(`sayproof_vote_${answer.status}`); return answer.json();}- The
votevalue can beup,downornone. When the selected direction is pressed again, sendnone; the vote is withdrawn. - On screen and in your own records, use the
vote,helpful_countandunhelpful_countvalues from the response; do not guess the result in the browser. visitor_keyis used to de-duplicate the same visitor’s votes; a random value stored in the browser is sufficient.- The default component stores the visitor’s votes in
localStorageunder thesayproof-voteskey (review ID →up/down). If you use the same key, both interfaces share the same vote record.
Reporting: POST /v1/reviews/<id>/report, body {"reason": "…", "note": "…"}. Take the list of reasons from the report_reasons field; do not hard-code it on the client side.
The “Write a review” button
Section titled “The “Write a review” button”Your button calls the window.Sayproof.write(container) method. The result:
| Customer status | What opens |
|---|---|
A signed-in customer can write (can_write) |
The review form, without an order number being requested |
They have already reviewed this product (written) |
Nothing; the method returns false |
| Unknown, or no session | The order number and email address are requested; if the conditions are met, the form opens in the same dialog |
If the container is not ready (no data-review-ready attribute) or another dialog is open, the method returns false. For signed-in customers, see Signed-in customers.
When a submission succeeds, the container emits a bubbling sayproof:submitted event. Because the review is published after approval, you do not need to refresh the list immediately.
const container = document.querySelector("#reviews");document.querySelector("#write-review").addEventListener("click", () => window.Sayproof?.write(container));container.addEventListener("sayproof:submitted", (event) => showThanks(event.detail.product));Example: plain JavaScript
Section titled “Example: plain JavaScript”Summary, sorting, list and the required link. The texts are taken from the words field of the response.
function node(tag, text, attributes = {}) { const element = document.createElement(tag); if (text) element.textContent = text; for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value); return element;}
function fill(template, values) { return template.replace(/\{(\w+)\}/g, (_, name) => String(values[name] ?? ""));}
function renderReviews(root, answer, onSort) { const { aggregate, core, words, sorts } = answer;
const summary = node("p", `${aggregate.average} · ${fill(words.count, { count: aggregate.count })}`); const statement = node("a", core.method_statement.label, { href: core.method_statement.url, target: "_blank", rel: "noopener" });
const select = node("select", "", { "aria-label": words.sort }); for (const sort of sorts) { const option = node("option", sort.label, { value: sort.code }); if (sort.code === answer.sort) option.selected = true; select.append(option); } select.addEventListener("change", () => onSort(select.value));
const list = node("ol"); for (const review of answer.reviews) { const item = node("li", "", { "data-review-id": review.id }); item.append(node("strong", `${review.rating}/5`)); if (review.verified) item.append(node("span", words.verified)); if (review.title) item.append(node("h3", review.title)); if (review.body) item.append(node("p", review.body)); if (review.seller_response) item.append(node("blockquote", `${words.seller_response}: ${review.seller_response.body}`)); if (review.resolution) item.append(node("aside", `${words.resolution}: ${review.resolution.body}`)); list.append(item); }
root.replaceChildren(summary, select, list, statement);}Example: React
Section titled “Example: React”import { useEffect, useState } from "react";import { listReviews } from "./sayproof";
export function Reviews({ product, locale }: { product: string; locale: string }) { const [answer, setAnswer] = useState(null); const [sort, setSort] = useState<string | undefined>();
useEffect(() => { let alive = true; listReviews({ product, lang: locale, sort }).then((next) => alive && setAnswer(next), () => alive && setAnswer(null)); return () => { alive = false; }; }, [product, locale, sort]);
if (!answer) return null; const { aggregate, core, words, sorts, reviews } = answer;
return ( <section> <p>{aggregate.average} · {words.count.replace("{count}", String(aggregate.count))}</p> <select aria-label={words.sort} value={sort ?? answer.sort} onChange={(event) => setSort(event.target.value)}> {sorts.map((one) => ( <option key={one.code} value={one.code}>{one.label}</option> ))} </select> <ol> {reviews.map((review) => ( <li key={review.id}> <strong>{review.rating}/5</strong> {review.verified ? <span>{words.verified}</span> : null} {review.title ? <h3>{review.title}</h3> : null} {review.body ? <p>{review.body}</p> : null} {review.seller_response ? <blockquote>{words.seller_response}: {review.seller_response.body}</blockquote> : null} {review.resolution ? <aside>{words.resolution}: {review.resolution.body}</aside> : null} </li> ))} </ol> <a href={core.method_statement.url} target="_blank" rel="noopener">{core.method_statement.label}</a> </section> );}If you use SSR for the initial display, set the initial value of answer to the state from the server; the page then arrives filled in the first response.
View counting
Section titled “View counting”The script also counts product views for containers used in headless mode; no further action is required.