Putiikkipalvelu Docs
SDK

Reviews

List and submit product reviews using the Storefront SDK

Overview

The reviews resource fetches a product's approved reviews (with the average rating and star distribution) and submits new reviews. Submitting works for both anonymous visitors and logged-in customers — passing a session token unlocks the verified-purchase check and the optional review reward code.

Reviews must be enabled for the store (reviewsEnabled feature flag). When they are off, the list returns nothing and submitting fails with REVIEWS_DISABLED.

Methods

reviews.list(slug, options?)

Fetch the approved reviews for a product, plus the aggregate rating and the 1–5 star distribution. Only APPROVED reviews are returned, newest first (up to 100).

const { averageRating, reviewCount, distribution, reviews } =
  await storefront.reviews.list('gold-ring');

Parameters:

ParamTypeRequiredDescription
slugstringYesProduct URL slug

Returns: Promise<ReviewListResponse>

Throws: NotFoundError if the product does not exist or is not visible in the store


reviews.submit(params, sessionId?, options?)

Submit a review for a product.

// Anonymous review
await storefront.reviews.submit({
  slug: 'gold-ring',
  rating: 5,
  body: 'Loistava tuote, nopea toimitus!',
  authorName: 'Matti',
});

// Logged-in review — pass the session token to enable the
// verified-purchase check and the reward code
const { review, reward } = await storefront.reviews.submit(
  { slug: 'gold-ring', rating: 5, body: 'Loistava tuote!' },
  sessionId
);

Parameters:

ParamTypeRequiredDescription
paramsSubmitReviewParamsYesThe review payload (see below)
sessionIdstringNoCustomer session token. When valid, the review is submitted as the logged-in customer (enables verified-purchase + reward). Omit for anonymous reviews.

SubmitReviewParams:

FieldTypeRequiredDescription
slugstringYesProduct slug (max 200 chars)
ratingnumberYesStar rating, 1–5
bodystringYesReview text (max 5000 chars)
titlestringNoOptional short title (max 200 chars)
authorNamestringNoDisplay name for anonymous reviews (max 100 chars). Ignored when sessionId is passed.

Returns: Promise<SubmitReviewResponse>

Throws: ValidationError (invalid rating / empty body / missing slug), RateLimitError (anonymous per-IP daily limit), or StorefrontError for REVIEWS_DISABLED (403) and ALREADY_REVIEWED (409) — check error.code.

Most reviews publish immediately

Reviews are not all held for moderation. Logged-in reviews and clean anonymous reviews are published (APPROVED) right away. The only ones held as PENDING are anonymous reviews that contain a link. Read review.status in the response to know which happened, and show your "published" vs. "awaiting review" confirmation accordingly.


Fetch Options

Both methods accept an optional trailing options object:

OptionTypeDescription
signalAbortSignalCancel the request
cacheRequestCacheFetch cache mode
next.revalidatenumber | falseNext.js ISR revalidation time in seconds
next.tagsstring[]Next.js cache tags for on-demand revalidation

Don't over-cache the review list

reviews.list() reflects newly published reviews. If you cache it with ISR, use a short revalidate (or a cache tag you revalidate after a submit) so a customer who just posted a review sees it without a long delay.


Response Types

ReviewStatus

type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED';

Review

A single approved review returned by reviews.list().

interface Review {
  id: string;
  rating: number;              // 1–5
  title: string | null;        // null when omitted or hidden by a moderator
  body: string;                // "" when hidden by a moderator
  contentHidden: boolean;      // true = text redacted, rating still counts
  verifiedPurchase: boolean;   // false if the store hides the verified badge
  merchantReply: string | null;
  createdAt: string;           // ISO 8601 timestamp
  reviewerName: string;        // first name, anonymous name, or "Asiakas"
}

ReviewListResponse

Response from reviews.list().

interface ReviewListResponse {
  averageRating: number | null;          // null when there are no reviews
  reviewCount: number;                   // total approved reviews
  distribution: Record<string, number>;  // counts per star: { "1": 0, ..., "5": 12 }
  reviews: Review[];                      // up to 100, newest first
}

SubmitReviewParams

interface SubmitReviewParams {
  slug: string;
  rating: number;       // 1–5
  body: string;
  title?: string;
  authorName?: string;  // anonymous only — ignored when a session is passed
}

SubmitReviewResponse

Response from reviews.submit().

interface SubmitReviewResponse {
  success: boolean;
  review: {
    id: string;
    status: ReviewStatus;       // "APPROVED" (published) or "PENDING" (held)
    verifiedPurchase: boolean;
  };
  reward: SubmitReviewReward | null;
}

interface SubmitReviewReward {
  code: string;              // e.g. "KIITOS10"
  discountType: string;      // "PERCENTAGE" | "FIXED_AMOUNT"
  discountValue: number;     // percentage, or fixed amount in cents
}

When is `reward` non-null?

The reward code is returned only for a logged-in, verified-purchase review, and only when the store has review rewards enabled and the linked discount code is still valid. For anonymous submissions it is always null. Reveal it in the order-history review flow — never on the public product page.


Examples

Product Detail — Rating Summary and List

import { storefront } from '@/lib/storefront';

export default async function ProductReviews({ slug }: { slug: string }) {
  const { averageRating, reviewCount, distribution, reviews } =
    await storefront.reviews.list(slug, { next: { revalidate: 300 } });

  if (reviewCount === 0) {
    return <p>Ei vielä arvosteluja.</p>;
  }

  return (
    <section>
      <h2>
        {averageRating?.toFixed(1)} · {reviewCount} arvostelua
      </h2>

      <ul>
        {[5, 4, 3, 2, 1].map((star) => (
          <li key={star}>
            {star} ★ — {distribution[String(star)] ?? 0}
          </li>
        ))}
      </ul>

      <ul>
        {reviews.map((review) => (
          <li key={review.id}>
            <strong>{review.reviewerName}</strong> — {review.rating} ★
            {review.verifiedPurchase && <span> ✓ Vahvistettu ostaja</span>}
            {review.title && <p>{review.title}</p>}
            <p>{review.contentHidden ? 'Sisältö piilotettu' : review.body}</p>
            {review.merchantReply && <blockquote>{review.merchantReply}</blockquote>}
          </li>
        ))}
      </ul>
    </section>
  );
}

Submitting a Review

'use server';

import { storefront, ValidationError, StorefrontError } from '@/lib/storefront';

export async function submitReview(formData: FormData, sessionId?: string) {
  try {
    const { review, reward } = await storefront.reviews.submit(
      {
        slug: String(formData.get('slug')),
        rating: Number(formData.get('rating')),
        body: String(formData.get('body')),
        title: String(formData.get('title') || '') || undefined,
        authorName: String(formData.get('authorName') || '') || undefined,
      },
      sessionId
    );

    return {
      published: review.status === 'APPROVED',
      // Show the code only in the order-history flow, never on the product page
      rewardCode: reward?.code ?? null,
    };
  } catch (error) {
    if (error instanceof ValidationError) {
      return { error: 'Tarkista arvosana ja arvostelun teksti.' };
    }
    if (error instanceof StorefrontError && error.code === 'ALREADY_REVIEWED') {
      return { error: 'Olet jo arvostellut tämän tuotteen.' };
    }
    throw error;
  }
}

Revealing the Reward Code (logged-in order-history flow)

const { reward } = await storefront.reviews.submit(
  { slug, rating, body },
  sessionId
);

if (reward) {
  // e.g. "Käytä koodia KIITOS10 seuraavaan tilaukseesi"
  console.log(`Käytä koodia ${reward.code} seuraavaan tilaukseesi`);
}

Error Handling

import {
  NotFoundError,
  ValidationError,
  RateLimitError,
  StorefrontError,
} from '@putiikkipalvelu/storefront-sdk';

try {
  await storefront.reviews.submit({ slug, rating, body }, sessionId);
} catch (error) {
  if (error instanceof ValidationError) {
    // 400 — invalid rating, empty body, or missing slug
  }
  if (error instanceof RateLimitError) {
    // 429 — too many anonymous reviews from this IP today
  }
  if (error instanceof StorefrontError) {
    // Check error.code for REVIEWS_DISABLED (403) or ALREADY_REVIEWED (409)
    console.error(`${error.message} (${error.code})`);
  }
  throw error;
}

On this page