Skip to content

Signed-in customers

Markdown

Opening the review form for a customer signed in to your site without asking for an order number, asking questions without typing a name or email address, and reviewing the items on the order detail page.

If the customer is signed in to your site, they can be allowed to write a review without being asked for an order number and email address. To do this, your server asks Sayproof, using the signed-in email address, whether the customer can review this product, and passes the answer to the script.

While questions and answers are on, a signed-in customer does not type a name or email address when asking a question either: Questions and answers. On the order detail page, the items of the order can also be reviewed inside the page: Order detail page.

The core rule does not change: the right to write a review is determined by the order record in Sayproof, not by the browser. The session only establishes who the customer is.

  1. The data-review-viewer="pending" attribute is added to the container. Until the answer arrives, the script does not show the review writing area.
  2. The browser asks an endpoint on your site (for example /api/sayproof/viewer) for the customer’s status for the product.
  3. Your server reads the signed-in email address and asks Sayproof using the server key: GET /v1/viewer.
  4. The answer returns to the browser, and the page passes it to the script with Sayproof.update(container, { viewer }).

The customer’s status is not added to the page’s HTML; it is personal and is queried each time without caching. This allows the product page itself to remain cached.

state Meaning What the customer sees
can_write They have a delivered purchase that has not yet been reviewed A “Write a review” button; the form opens without an order check
written They have already reviewed this product The status of their own review: published, under review, rejected or removed
none They have no purchase they can review The same verification area as a visitor who is not signed in
anonymous No session, or no answer could be obtained The same verification area as a visitor who is not signed in

Only orders in the channel’s market are taken into account. The email address is converted to lower case and matched exactly.

The call to Sayproof:

GET https://api.sayproof.example/v1/viewer?product=PRODUCT-CODE&scope=variant&lang=en&channel=web
Authorization: Bearer YOUR-SERVER-KEY
X-Sayproof-Customer: jane@example.com
  • The email address is only sent in the X-Sayproof-Customer header; it is never added to the URL.
  • This endpoint only works with a server key. A call made from the browser with a site key receives a 403 refused / server_only response.
  • A server key can make 6,000 calls in 5 minutes. Because all customer requests come through your server, customers do not affect each other’s limits.
  • The response carries the Cache-Control: no-store header; do not cache it on your side either.

A Next.js Route Handler example. Take the session from your own authentication library; auth() here is only an example:

import "server-only";
import { auth } from "@/auth";
export const dynamic = "force-dynamic";
const base = process.env.SAYPROOF_BASE || process.env.NEXT_PUBLIC_SAYPROOF_BASE;
const noStore = { "Cache-Control": "no-store" };
const anonymous = () => Response.json({ state: "anonymous" }, { headers: noStore });
export async function GET(request: Request) {
const asked = new URL(request.url).searchParams;
const product = asked.get("product");
const email = (await auth())?.user?.email;
if (!product || !email) return anonymous();
const query = new URLSearchParams({
product,
scope: asked.get("scope") === "group" ? "group" : "variant",
lang: asked.get("locale") ?? "en",
channel: process.env.SAYPROOF_CHANNEL ?? "web",
});
try {
const answer = await fetch(`${base}/v1/viewer?${query}`, {
headers: {
Authorization: `Bearer ${process.env.SAYPROOF_SSR_KEY}`,
"X-Sayproof-Customer": email,
},
cache: "no-store",
signal: AbortSignal.timeout(3000),
});
return answer.ok ? Response.json(await answer.json(), { headers: noStore }) : anonymous();
} catch {
return anonymous();
}
}

The same three rules apply in any other server language: read the email address from the session, not from the browser; return anonymous on failure; do not cache the answer.

<div id="reviews"
data-review-mount
data-review-product="PRODUCT-CODE"
data-review-viewer="pending"></div>
function whenSayproofReady(callback) {
if (window.Sayproof) callback(window.Sayproof);
else window.addEventListener("sayproof:ready", () => callback(window.Sayproof), { once: true });
}
async function askViewer(container) {
const query = new URLSearchParams({
product: container.dataset.reviewProduct,
scope: container.dataset.reviewScope ?? "variant",
locale: document.documentElement.lang || "en",
});
try {
const answer = await fetch(`/api/sayproof/viewer?${query}`, {
credentials: "same-origin",
cache: "no-store",
signal: AbortSignal.timeout(8000),
});
return answer.ok ? await answer.json() : { state: "anonymous" };
} catch {
return { state: "anonymous" };
}
}
const container = document.querySelector("#reviews");
askViewer(container).then((viewer) => whenSayproofReady((sayproof) => sayproof.update(container, { viewer })));

Even if no answer ever arrives, pass a value to the script: { state: "anonymous" } or viewer: null. Otherwise the review writing area remains in its waiting state.

When a submission succeeds, the script updates the customer’s status to written by itself; the form does not open again on the same page. If you want to refresh your own list, listen for the sayproof:submitted event emitted by the container: JavaScript API.

If the brand has turned on questions and answers, the GET /v1/viewer answer also carries an asker field, whatever the state value is. When the script sees this field, it does not show the name and email fields in the question form and shows the line “You are asking as Ayşe K. We will email you when it is answered.” instead. The question is saved with the signed-in email address, and that address is told when an answer goes live.

{
"state": "none",
"reviews": [],
"limits": { "…": "…" },
"asker": { "token": "…", "name": "Ayşe K." }
}
  • asker.token: a signed token bound to your brand and valid for 1 hour. The script sends the question with this token; Sayproof reads the email address and the name from the token, and the browser does not send the email address. Do not read or change the token’s content in your own code.
  • asker.name: the name shown next to the question. It is sent with the optional X-Sayproof-Customer-Name header; percent-encode the value as UTF-8 for non-ASCII characters (encodeURIComponent in JavaScript). Spaces are tidied and at most 120 characters are kept. Without the header, name is empty, the form says “You are asking with your account. We will email you when it is answered.” and the question shows as “Anonymous”. The name is visible to everyone on the page; send a short form such as “Ayşe K.” rather than the full name.
  • If the token has expired or is not valid, the question receives a 403 token_refused / asker_expired or asker_invalid response. The script then turns the form into the form of a visitor who is not signed in, keeping the question already written.
GET https://api.sayproof.example/v1/viewer?product=PRODUCT-CODE&scope=variant&lang=en&channel=web
Authorization: Bearer YOUR-SERVER-KEY
X-Sayproof-Customer: jane@example.com
X-Sayproof-Customer-Name: Jane%20D.

In the Next.js example above, adding "X-Sayproof-Customer-Name": encodeURIComponent(name) to the headers is enough; read the name from the session like the email address.

On the order detail page in your account area, a rating and review form can be shown inside the page for each item of the order. No dialog opens and no order number is asked for; each item has its own Send button. Your server asks Sayproof with the signed-in email address and the order number and adds the answer to the page’s HTML; the script builds the section from this answer.

GET https://api.sayproof.example/v1/viewer/order?order=100245&channel=web&lang=en
Authorization: Bearer YOUR-SERVER-KEY
X-Sayproof-Customer: jane@example.com

Each line in the answer is an item of the order and carries a state value:

state Meaning What the customer sees
can_write The item has been delivered and not yet reviewed Stars, rating topics if defined for the product, review text, the photo and video area and the row’s Send button
pending A review has been sent and is under review “Your review is in; it goes live after moderation.”
published The review is live “Your review of this item is live. Thank you!”
rejected The review was not published “Your review was not published. You can correct it and send it again with the link in the email we sent you.”
removed The review is not live at the moment “Your review of this item is not live at the moment.”
waiting The delivery has not been confirmed yet “You can rate this item once it has reached you.”
closed A review can no longer be written for this item (for example the time to write has run out, or the item was cancelled or returned) “A review cannot be written for this item.”

If the order is not found or belongs to another customer, the response is 200 and lines is empty; the script then draws nothing and the container stays empty. The rules of the product page call apply here too: read the email address from the session, call only with the server key (a call from the browser receives 403 refused / server_only), and do not cache the answer. The rate limit is shared with GET /v1/viewer.

A server-side function that writes the answer safely into a <script type="application/json"> tag:

import "server-only";
const base = process.env.SAYPROOF_BASE || process.env.NEXT_PUBLIC_SAYPROOF_BASE;
const unsafe: Record<string, string> = { "<": "\\u003c", ">": "\\u003e", "&": "\\u0026", "
": "\\u2028", "
": "\\u2029" };
export async function sayproofOrderState(order: string, email: string | null | undefined, locale: string) {
if (!order || !email) return null;
const query = new URLSearchParams({ order, channel: process.env.SAYPROOF_CHANNEL ?? "web", lang: locale });
try {
const answer = await fetch(`${base}/v1/viewer/order?${query}`, {
headers: { Authorization: `Bearer ${process.env.SAYPROOF_SSR_KEY}`, "X-Sayproof-Customer": email },
cache: "no-store",
signal: AbortSignal.timeout(3000),
});
if (!answer.ok) return null;
const state = await answer.json();
if (!state.lines.length) return null;
const json = JSON.stringify(state).replace(/[<>&

]/g, (character) => unsafe[character]);
return `<script type="application/json" data-review-order-state>${json}</script>`;
} catch {
return null;
}
}

On the page, the tag is written inside a data-review-order element whose value is the order number; the Sayproof script must also be on the page. The script finds the element by itself:

<div data-review-order="100245">
<script type="application/json" data-review-order-state>{"order":"100245","token":"…","lines":[…]}</script>
</div>

If the page is built in the browser, the same answer is passed with window.Sayproof.mountOrder(element, answer): JavaScript API. Each item is sent separately; when a submission succeeds, the row changes to the pending state and the element dispatches the sayproof:submitted event with detail: { order, line }. If the customer tries to send without giving stars, the message “Please add a star rating before sending.” is shown. For the elements and states, see DOM attributes › Reviews on the order page.