Product reviews
Shoppers signed in with their Throttle customer account can review the products they bought. The storefront lists published reviews and their summary, checks whether the shopper may write one, and submits it.
The settings
The merchant turns reviews on in the Product Catalog dashboard, on the Reviews screen:
| Toggle | Effect |
|---|---|
| Reviews | Reviews are listed and accepted. Off (the default), listing and submitting answer 404, eligibility answers reviews_disabled, and answers carry no ratings. |
| Verified buyers only | Only shoppers with a paid order for the product may review it. |
| Hold new reviews for approval | New reviews wait in the moderation queue (pending) until the merchant approves them. |
| Ratings on product lists | List answers carry rating: { average, count }, and the best_rated sort and rating filters become available. |
| Show imported reviews | Reviews imported from a file are shown and counted. They are never marked verified. |
The first time reviews are turned on, “Verified buyers only” and “Hold new reviews for approval” come on with it,
and the catalog looks through the last 365 days of orders for buyers. A full product carries rating while reviews are on; config() answers reviews: { enabled, onLists }.
List reviews: reviews.list
reviews.list(handle, options) answers the product's published reviews, a summary and nextCursor:
-
sort:newest(the default),highestorlowest. -
rating: 1 to 5, an exact match (4 means four-star reviews, not “4 and up”). -
limit1 to 50 (default 10), andcursorfor the next page. -
summaryis{ average, count, histogram }: the average rating (nullwith no reviews), the number of reviews, and the number per star from 1 to 5. -
Each review has
rating,title,body,displayName,verified(a verified buyer),imported,createdAtand the merchant'sreply(ornull). -
A renamed product answers
{ redirectTo }. An unknown product, or reviews turned off, is404 NOT_FOUND.
import { createCatalogClient } from '@usethrottle/catalog';
const catalog = createCatalogClient({
publishableKey: 'pc_pk_your_browser_key',
});
// Newest first by default. `rating` keeps only reviews with exactly that
// many stars. Up to 50 a page (10 by default), then follow nextCursor.
const answer = await catalog.reviews.list('ceramic-brake-pads', {
sort: 'highest',
limit: 20,
});
if ('redirectTo' in answer) {
// A renamed product: ask again with the new handle.
} else {
const { average, count, histogram } = answer.summary;
console.log(`${average ?? '–'} out of 5 from ${count} reviews`, histogram['5']);
for (const review of answer.reviews) {
console.log(review.rating, review.displayName, review.verified, review.reply?.body);
}
if (answer.nextCursor) {
await catalog.reviews.list('ceramic-brake-pads', {
sort: 'highest',
limit: 20,
cursor: answer.nextCursor,
});
}
}
Review lists are public and cached like the rest of the catalog. In React, useReviews(handle, { sort, rating, limit, cursor }) reads them.
May this shopper review? reviews.eligibility
reviews.eligibility(handle) needs the shopper's customer token (the
client's customerToken, see Customer prices) and answers { canReview, reason? }. The reasons, checked in this order:
| reason | What to show |
|---|---|
reviews_disabled | Nothing: the store does not take reviews. |
not_signed_in | A sign-in link. |
email_not_verified | Ask the shopper to verify their email address, with a way to resend the email. |
already_reviewed | Their review is in (one review per customer and product, ever). |
not_verified_buyer | “Only customers who bought this product can review it.” Only while “Verified buyers only” is on. |
Eligibility is a quick check. It does not read the order again, so a purchase refunded since can still answer canReview: true; the submit then refuses it.
Submit a review: reviews.submit
reviews.submit(handle, { rating, title?, body, displayName }) needs the
customer token too. rating is 1 to 5; title is
at most 120 characters, body 1 to 5,000 and displayName 1 to 60. It answers 201 { review: { id, status } }:
- A verified email is always required, even with “Verified buyers only” off.
- With Verified buyers only on, the shopper needs a paid order for the product, and the catalog reads that order from Throttle again before it accepts the review.
- One review per customer and product. A rejected review keeps the slot; only the merchant deleting it frees it.
-
With Hold new reviews for approval on, the review is
pendingand appears only once approved; otherwise it isapprovedand shows at once. -
Send an
idempotencyKeyand reuse it when you retry the same submission: a replay answers the first review instead of making a second.useSubmitReviewdoes this for you (see React). -
A refused token throws
CustomerTokenInvalidError: there is no signed-out fallback for a review.
import { createCatalogClient, isCatalogError } from '@usethrottle/catalog';
declare const auth: { getAccessToken(): Promise<string | null> };
// Both calls need the signed-in customer's token.
const catalog = createCatalogClient({
publishableKey: 'pc_pk_your_browser_key',
customerToken: () => auth.getAccessToken(),
});
const { canReview, reason } = await catalog.reviews.eligibility('ceramic-brake-pads');
if (!canReview) {
// reviews_disabled, not_signed_in, email_not_verified, already_reviewed
// or not_verified_buyer: show the matching message instead of the form.
console.log(reason);
} else {
try {
const { review } = await catalog.reviews.submit(
'ceramic-brake-pads',
{
rating: 5,
title: 'Quiet and clean',
body: 'No squeal after 2,000 miles.',
displayName: 'Sam',
},
// The same key for a retry of this submission: never a second review.
{ idempotencyKey: crypto.randomUUID() },
);
// 'pending' while the store holds new reviews for approval.
console.log(review.id, review.status);
} catch (error) {
if (!isCatalogError(error)) throw error;
switch (error.code) {
case 'EMAIL_NOT_VERIFIED':
case 'NOT_VERIFIED_BUYER':
case 'PURCHASE_REFUNDED':
case 'ALREADY_REVIEWED':
console.log('This review cannot be posted:', error.code);
break;
case 'RATE_LIMITED':
case 'REVIEW_QUEUE_FULL':
case 'REVIEW_VERIFICATION_UNAVAILABLE':
console.log(`Try again in ${error.retryAfterSeconds ?? 10} s`);
break;
default:
throw error;
}
}
}
Review errors
| Code | HTTP | Meaning |
|---|---|---|
EMAIL_NOT_VERIFIED | 403 | The shopper has not verified their email address. |
NOT_VERIFIED_BUYER | 403 | Verified buyers only is on, and the shopper has no paid order for the product. |
PURCHASE_REFUNDED | 403 | Verified buyers only is on, and the order behind the purchase was refunded or cancelled since. |
ALREADY_REVIEWED | 409 | The shopper already reviewed this product. |
REVIEW_QUEUE_FULL | 429 | The moderation queue holds 500 pending reviews. Retry-After: 900; the SDK throws it as RateLimitedError; it never pauses the React provider's reads. |
RATE_LIMITED | 429 | More than 5 submissions a minute from one customer, or 10 accepted reviews an hour from one IP address. Retry-After says when. |
REVIEW_VERIFICATION_UNAVAILABLE | 503 | The catalog could not read the order from Throttle to confirm the purchase. Nothing was saved; retry (Retry-After: 10). |
EXTENSION_UPGRADE_REQUIRED | 409 | The store has not accepted the Product Catalog extension version that reads customer accounts. |
CUSTOMER_TOKEN_INVALID | 401 | No customer token, or one the catalog refuses. |
NOT_FOUND | 404 | No such product, or reviews are off. |
Show review text safely
dangerouslySetInnerHTML or innerHTML.
import type { StorefrontReviewsAnswer } from '@usethrottle/catalog';
type Review = Extract<StorefrontReviewsAnswer, { reviews: unknown }>['reviews'][number];
// Review text is the shopper's own plain text. Put it in the page as text,
// never as HTML: textContent here, or {review.body} in JSX, which React
// escapes. Never innerHTML or dangerouslySetInnerHTML.
export function renderReview(review: Review): HTMLElement {
const item = document.createElement('article');
const title = document.createElement('h3');
title.textContent = review.title ?? `${review.rating} stars`;
const body = document.createElement('p');
body.textContent = review.body;
const byline = document.createElement('p');
byline.textContent = `${review.displayName}${review.verified ? ' · Verified buyer' : ''}`;
item.append(title, body, byline);
return item;
}