Product Catalog

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:

ToggleEffect
ReviewsReviews are listed and accepted. Off (the default), listing and submitting answer 404, eligibility answers reviews_disabled, and answers carry no ratings.
Verified buyers onlyOnly shoppers with a paid order for the product may review it.
Hold new reviews for approvalNew reviews wait in the moderation queue (pending) until the merchant approves them.
Ratings on product listsList answers carry rating: { average, count }, and the best_rated sort and rating filters become available.
Show imported reviewsReviews 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), highest or lowest.
  • rating: 1 to 5, an exact match (4 means four-star reviews, not “4 and up”).
  • limit 1 to 50 (default 10), and cursor for the next page.
  • summary is { average, count, histogram }: the average rating (null with 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, createdAt and the merchant's reply (or null).
  • A renamed product answers { redirectTo }. An unknown product, or reviews turned off, is 404 NOT_FOUND.
Reviews and their summary
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:

reasonWhat to show
reviews_disabledNothing: the store does not take reviews.
not_signed_inA sign-in link.
email_not_verifiedAsk the shopper to verify their email address, with a way to resend the email.
already_reviewedTheir 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 pending and appears only once approved; otherwise it is approved and shows at once.
  • Send an idempotencyKey and reuse it when you retry the same submission: a replay answers the first review instead of making a second. useSubmitReview does this for you (see React).
  • A refused token throws CustomerTokenInvalidError: there is no signed-out fallback for a review.
Eligibility, then submit
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

CodeHTTPMeaning
EMAIL_NOT_VERIFIED403The shopper has not verified their email address.
NOT_VERIFIED_BUYER403Verified buyers only is on, and the shopper has no paid order for the product.
PURCHASE_REFUNDED403Verified buyers only is on, and the order behind the purchase was refunded or cancelled since.
ALREADY_REVIEWED409The shopper already reviewed this product.
REVIEW_QUEUE_FULL429The moderation queue holds 500 pending reviews. Retry-After: 900; the SDK throws it as RateLimitedError; it never pauses the React provider's reads.
RATE_LIMITED429More than 5 submissions a minute from one customer, or 10 accepted reviews an hour from one IP address. Retry-After says when.
REVIEW_VERIFICATION_UNAVAILABLE503The catalog could not read the order from Throttle to confirm the purchase. Nothing was saved; retry (Retry-After: 10).
EXTENSION_UPGRADE_REQUIRED409The store has not accepted the Product Catalog extension version that reads customer accounts.
CUSTOMER_TOKEN_INVALID401No customer token, or one the catalog refuses.
NOT_FOUND404No such product, or reviews are off.

Show review text safely

Escape review text
A review's title, body, display name and reply are plain text written by a shopper or the merchant, stored as given. The API never turns them into HTML and never cleans them: your storefront must escape them when it renders them. React escapes text in JSX for you; never pass review text to dangerouslySetInnerHTML or innerHTML.
Without a framework
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;
}