Customer reviews
A brand's product reviews in one searchable place. Add them from any review app, CSV or agent, then find the right customer words for an ad.
A customer review is one product review a customer wrote, kept exactly as written, with its rating, photos, order and the product it is about. Reviews belong to a brand and come from anywhere: a LOOX connection, a CSV export from Judge.me or Okendo, your own code, or an AI agent using the MCP server. ID prefix: crev_. Scopes: customer_reviews:read / customer_reviews:write.
Reviews are keyed by (source, external_id) within the brand, so sending the same review again updates it instead of duplicating it.
Fields
| Field | Type | Notes |
|---|---|---|
id | string | crev_… |
brand_id | string | |
product_id | string or null | The catalog product. Null while unmatched |
product_link | string | linked (matched automatically), unmatched (the product isn't in your catalog yet; it links on its own once imported), manual (set by a person; syncs never change it) |
product_ref | object | The product as the source named it: { shopify_product_id, handle, name, url } |
source | string | Where it was collected: loox, judgeme, okendo, yotpo, stamped, junip, manual, other, … |
external_id | string | The source's ID, or a derived fp_… fingerprint when you sent none |
ingested_via | string | How it reached us: api, mcp, csv, dashboard, loox_sync |
import_id | string or null | The import that last wrote it |
rating | number | 1 to 5 |
title, body | string | Exactly as the customer wrote them |
author | object | { name, location, verified_buyer }. The email is never returned |
incentivized | boolean or null | True when the customer got something for the review. Disclose it in ads |
order | object | { id, number }: the Shopify order |
attributes | array | [{ question, answer }], for example Size: True to size |
photos | array | { image_id, url, thumbnail_url, width, height, usage_rights, status, source_url, error }. Photos are copied into the image library in the background (status: pending, ready, failed) |
videos | array | { url, thumbnail_url, status: "external" }. Kept as links, not copied |
merchant_reply | object or null | { body, replied_at } |
signals | object | AI labels added in the background: { status, ad_potential (0-100), sentiment, themes, objection, best_quote } |
reviewed_at, created_at, updated_at, archived_at | string |
best_quote is always an exact excerpt of body. themes come from a fixed apparel vocabulary: fit, sizing, fabric, comfort, quality, durability, style, shipping, price, service, gift.
Endpoints
| Method | Path | What it does |
|---|---|---|
GET | /v1/customer-reviews | List and search. Filters below |
POST | /v1/customer-reviews/upsert | Create or update up to 100 reviews, with per-item results |
POST | /v1/customer-reviews | Create one review |
GET | /v1/customer-reviews/{id} | Get |
PATCH | /v1/customer-reviews/{id} | Fix the product link, archive, set photo usage_rights. Content fields only on reviews no sync owns |
POST | /v1/customer-reviews/{id}/archive / unarchive | Hide or restore. Archiving survives syncs |
DELETE | /v1/customer-reviews/{id} | Permanently delete a review you added (not one a sync owns; archive those) |
GET | /v1/customer-reviews/stats?brand_id=… | Counts: total, unmatched, AI analysis and photo copy progress |
For more than a few hundred reviews, import a CSV with POST /v1/imports instead.
Add or update reviews
/upsert takes up to 100 items. Each item is validated on its own: one bad review fails only itself, and the response is 200 with data: { results, counts, dry_run }: one result per item, in the order you sent them, plus totals. Run it with dry_run: true first to see what would happen without writing anything.
const reviews = [
{
source: "judgeme",
external_id: "jdg-40211",
product_ref: { shopify_product_id: "8123456789012", handle: "madison-long-jacket" },
rating: 5,
title: "Finally a coat that fits",
body: "Finally a jacket that fits my shoulders and still looks polished. Warm without the bulk.",
author: { name: "Sarah M.", location: "Austin, TX", verified_buyer: true },
order: { id: "5550123456789", number: "#1042" },
attributes: [{ question: "Size", answer: "True to size" }],
photos: [{ url: "https://example.com/photos/1.jpg", usage_rights: "unknown" }],
reviewed_at: "2026-10-14",
},
];
const { data: check } = await sal("/v1/customer-reviews/upsert", {
method: "POST",
body: { brand_id: "brand_a1b2c3d4e5f67890", items: reviews, dry_run: true },
});
for (const result of check.results.filter((r) => r.action === "failed")) {
console.error(result.index, result.error.code, result.error.message);
}
const { data: written } = await sal("/v1/customer-reviews/upsert", {
method: "POST",
idempotencyKey: crypto.randomUUID(),
body: { brand_id: "brand_a1b2c3d4e5f67890", items: reviews },
});
console.log(written.counts); // { created: 1, updated: 0, unchanged: 0, failed: 0 }| Result field | Notes |
|---|---|
index | Position of the item in your request |
external_id | The ID the item was matched on |
action | created, updated, unchanged or failed |
id | The crev_… ID. Null when the item failed, or on a dry run that would create it |
error | { code, message, field } when the item failed |
Rules for every review:
- Always send
sourceandexternal_id. Without anexternal_id, a fingerprint of the body, rating, author, date and product is used, and an edit at the source creates a second review. - Never change the text. Send
bodyexactly as the customer wrote it. Synced reviews can't be edited at all. - Identify the product with
product_id, orproduct_refwith the Shopify product ID or handle (the exact name is a last resort). A review for a product that isn't in the catalog is kept asunmatchedand links itself when you import that product. - Emails are private.
author.emailis used for matching only and never returned. - Dates are ISO 8601 (
2026-10-14or2026-10-14T09:30:00Z). Other formats like10/14/2026are refused, not guessed. - URLs must be
httporhttps(photos[].url,videos[].url,source_url). - Shopify product IDs are digits (
8123456789012) orgid://shopify/Product/8123456789012. A spreadsheet's scientific notation (8.12346E+12) is refused because the digits are already lost: format the column as text before exporting.
Re-sending is always safe, so a nightly job can send the last few days of reviews every time.
Search reviews
Filters: brand_id, product_id, product_link, source, rating, min_rating, has_photos, themes, sentiment, min_ad_potential, order_id, import_id, reviewed_after, reviewed_before, archived, ids. List filters take comma-separated values.
search is keyword search over the title and body, with quoted phrases, or and -word. Add search_mode=semantic to search by meaning instead ("bought it as a gift"); semantic results are ranked by relevance and aren't paginated. order_by is one signal: reviewed_at (default), rating, ad_potential, created_at or relevance.
const params = new URLSearchParams({
brand_id: "brand_a1b2c3d4e5f67890",
product_id: "prod_9c1e2a7b5d3f4860",
search: '"true to size"',
min_rating: "4",
order_by: "ad_potential",
limit: "20",
});
const { data: reviews, has_more } = await sal(`/v1/customer-reviews?${params}`);
for (const review of reviews) {
console.log(review.id, review.rating, review.signals.best_quote ?? review.body);
}Page with starting_after set to the last review's ID while has_more is true. See Pagination.
Photos and usage rights
Every photo has usage_rights: granted, not_granted or unknown (the default for customer photos). It is informational and never blocks an ad, so check it before you use a photo, and ask the brand about unknown ones. Record the answer on the review or on the image:
await sal("/v1/customer-reviews/crev_a1b2c3d4e5f67890", {
method: "PATCH",
body: { photos: [{ image_id: "img_a1b2c3d4e5f67890", usage_rights: "granted" }] },
});PATCH /v1/images/{id} with { "usage_rights": "granted" } does the same from the image library.
Using reviews in ads
- Quote the customer verbatim. Trimming is fine; rewording or combining quotes is not.
best_quoteis a safe starting point. - Incentivized reviews (
incentivized: true) need a disclosure in the ad. - Archived and deleted reviews disappear from search and agents. Deleting a review keeps its photo files, so ads that already use them keep rendering.
- Customer photos are never used as a product's own photos. They are only used when you or an agent pick them from a review.
Reviews from LOOX
A LOOX connection syncs the brand's published product reviews every night (or on Sync now). A review removed at LOOX is deleted here only after two consecutive complete syncs no longer see it, so one sync that ran while reviews were changing never deletes a review that is still published. A sync that doesn't finish removes nothing.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | The envelope is malformed (no brand_id, more than 100 items). Item errors come back in results instead |
| 400 | SEMANTIC_SEARCH_QUERY_REQUIRED | search_mode=semantic without a search |
| 404 | BRAND_NOT_FOUND / CUSTOMER_REVIEW_NOT_FOUND | Not in your organization |
| 409 | CUSTOMER_REVIEW_EXISTS | POST /v1/customer-reviews with a source and external_id the brand already has. Use /upsert to update |
| 409 | CUSTOMER_REVIEW_SYNC_OWNED | Changing the content of, or deleting, a review a sync owns. Archive it instead |
| 422 | CUSTOMER_REVIEW_INVALID | The review in POST /v1/customer-reviews failed validation |
| 422 | CUSTOMER_REVIEW_PHOTO_NOT_FOUND | The image_id in a PATCH isn't one of this review's photos |
Related
- Post-purchase survey responses
- Imports: CSV imports and the template
- Images
- Search
Imports
Import a Shopify product from its URL, or a CSV of customer reviews or post-purchase survey responses, in the background.
Post-purchase survey responses
What customers said right after buying, with every question kept as asked and the order it belongs to. Search them for objections, motivations and attribution.