Skip to content

Headless use

Markdown

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

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.

The data can be obtained from two sources, with identical fields:

  • On the server: the state field of the SSR response. The list is built on the server, and search engines see the text in the first response. Add the state_script tag 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/list endpoint. 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.

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 vote value can be up, down or none. When the selected direction is pressed again, send none; the vote is withdrawn.
  • On screen and in your own records, use the vote, helpful_count and unhelpful_count values from the response; do not guess the result in the browser.
  • visitor_key is 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 localStorage under the sayproof-votes key (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.

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

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

The script also counts product views for containers used in headless mode; no further action is required.