Static Ads Lab
Concepts

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

FieldTypeNotes
idstringcrev_…
brand_idstring
product_idstring or nullThe catalog product. Null while unmatched
product_linkstringlinked (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_refobjectThe product as the source named it: { shopify_product_id, handle, name, url }
sourcestringWhere it was collected: loox, judgeme, okendo, yotpo, stamped, junip, manual, other, …
external_idstringThe source's ID, or a derived fp_… fingerprint when you sent none
ingested_viastringHow it reached us: api, mcp, csv, dashboard, loox_sync
import_idstring or nullThe import that last wrote it
ratingnumber1 to 5
title, bodystringExactly as the customer wrote them
authorobject{ name, location, verified_buyer }. The email is never returned
incentivizedboolean or nullTrue when the customer got something for the review. Disclose it in ads
orderobject{ id, number }: the Shopify order
attributesarray[{ question, answer }], for example Size: True to size
photosarray{ 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)
videosarray{ url, thumbnail_url, status: "external" }. Kept as links, not copied
merchant_replyobject or null{ body, replied_at }
signalsobjectAI labels added in the background: { status, ad_potential (0-100), sentiment, themes, objection, best_quote }
reviewed_at, created_at, updated_at, archived_atstring

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

MethodPathWhat it does
GET/v1/customer-reviewsList and search. Filters below
POST/v1/customer-reviews/upsertCreate or update up to 100 reviews, with per-item results
POST/v1/customer-reviewsCreate 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 / unarchiveHide 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 fieldNotes
indexPosition of the item in your request
external_idThe ID the item was matched on
actioncreated, updated, unchanged or failed
idThe 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 source and external_id. Without an external_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 body exactly as the customer wrote it. Synced reviews can't be edited at all.
  • Identify the product with product_id, or product_ref with 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 as unmatched and links itself when you import that product.
  • Emails are private. author.email is used for matching only and never returned.
  • Dates are ISO 8601 (2026-10-14 or 2026-10-14T09:30:00Z). Other formats like 10/14/2026 are refused, not guessed.
  • URLs must be http or https (photos[].url, videos[].url, source_url).
  • Shopify product IDs are digits (8123456789012) or gid://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_quote is 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

StatusCodeMeaning
400VALIDATION_ERRORThe envelope is malformed (no brand_id, more than 100 items). Item errors come back in results instead
400SEMANTIC_SEARCH_QUERY_REQUIREDsearch_mode=semantic without a search
404BRAND_NOT_FOUND / CUSTOMER_REVIEW_NOT_FOUNDNot in your organization
409CUSTOMER_REVIEW_EXISTSPOST /v1/customer-reviews with a source and external_id the brand already has. Use /upsert to update
409CUSTOMER_REVIEW_SYNC_OWNEDChanging the content of, or deleting, a review a sync owns. Archive it instead
422CUSTOMER_REVIEW_INVALIDThe review in POST /v1/customer-reviews failed validation
422CUSTOMER_REVIEW_PHOTO_NOT_FOUNDThe image_id in a PATCH isn't one of this review's photos