Reviews API

Last updated: April 18, 2026

Manage reviews submitted through BetterReviews AI chat or imported from other platforms (Okendo, Judge.me, Yotpo).

List Reviews

GET /api/v1/reviews

Query Parameters

ParamTypeDescription
statusstringpending, approved, rejected
stagestringFilter by workflow stage (more precise than status). Allowed values: analyzing (transient, AI analysis pending), needs_review (pending review the merchant should look at; includes spam-flagged), awaiting_team (pending review already emailed to CS team), needs_support_email, awaiting_support, on_hold, indefinite_hold (V2 support-pipeline sub-stages), routed_to_support (legacy single-stage support value; as a filter, UNION-expands to the 4 V2 sub-stages above for backward compatibility), published (approved + visible on storefront), archived (intentionally hidden), rejected (explicitly rejected). Accepts a comma-joined list (e.g. stage=needs_support_email,awaiting_support,on_hold,indefinite_hold) to UNION across multiple stages. See the stage column on response objects below; status remains available unchanged for backwards compatibility.
min_ratingintegerMinimum rating (1-5)
max_ratingintegerMaximum rating (1-5)
product_idstringFilter by product external ID (Shopify product ID; bare numeric or gid://shopify/Product/...). When combined with has_media=true it also sets the result_shape discriminator (see below).
platformstringbetterreviews, okendo, judgeme, yotpo
is_verifiedbooleanVerified buyers only
keywordstringFilter by review-level keyword extraction (substring against analysis.keywords, case-insensitive, max 80 chars). Limited to reviews the analysis pipeline has tagged — brand-new or untagged reviews may be missing.
review_sentimentstringReview-level sentiment: positive, negative, neutral, mixed. Requires keyword; passing it alone returns 422.
product_querystringFuzzy product-name match (token-overlap against platform.products.title, store-scoped, max 200 chars). Server resolves to a product and filters reviews by it. Sets the result_shape discriminator (see below).
has_mediabooleanWhen true, scopes the result set to reviews that have at least one visible photo/video. (As of 2026-05-02 the media[] array and has_visible_media boolean are returned on every review regardless of this filter.)
marketing_readybooleanWhen true, scopes to reviews where at least one non-hidden photo passes the strict marketing-grade predicate (vision_analysis.marketing_usable=true AND vision_analysis.quality='good'). Imported reviews (Loox/Judge.me/Okendo) have no analysis and are excluded.
sortstringnewest, oldest, highest_rated, lowest_rated, most_helpful
limitintegerPage size (default 20, max 100)
offsetintegerPagination offset

When either product_query (fuzzy resolved) or product_id (direct lookup) is supplied — together with has_media=true for ad-asset shapes — the response gains top-level fields:

  • result_shape: "reviews" (default), "ad_assets" (product anchor present + has_media=true + at least one match), "ad_assets_no_media" (anchor matched but no reviews with media), or "ad_assets_unresolved" (resolver could not auto-pick — fires for product_query only).
  • resolved_product: an object with platform_id, title, and image_url of the matched product when present. Populates from either the resolver result (product_query) or a direct lookup (product_id).
  • candidates_count, alternate_candidates, and (on unresolved) candidates. Always 0/[] when the anchor is product_id (the caller has already picked).

When product_id is supplied but doesn't resolve in this store (deleted product, cross-tenant button replay, or unknown ID), the server returns result_shape: "reviews" with an empty reviews list and no resolved_product key. This avoids signaling that the product exists in some other store.

Review groups: when product_id resolves to a product that's an active member of a Review Group, the response includes reviews for all group members. Each row carries display_product_id (bare numeric Shopify ID for the product the review was originally submitted on) and display_product_title (resolved at read time) — use these to render cross-product attribution like "Reviewed: Red T-Shirt" when display_product_id doesn't match the PDP's current product. See the review groups reference.

Every review object always carries product_title, media[], has_visible_media, and admin_url. media[] is empty when the review has no photos/videos. product_title is null when the review's external_product_id doesn't resolve to a product in platform.products. admin_url is null for bot-scoped API keys (signed Phoenix deep-link tokens are kept out of Slack/Loki retention).

Inside each media[] entry, vision_analysis is null for: (a) imported platform reviews (Loox/Judge.me/Okendo) — those photos never go through the AI pipeline, and (b) photos uploaded through the AI chat where the async analysis worker hasn't completed yet (typically a 30–90s window after the customer's submit). Always handle the null case gracefully — never assume the blob is present.

Response (200)

{
  "reviews": [
    {
      "id": 789,
      "external_review_id": "abc-123",
      "platform": "betterreviews",
      "external_product_id": "shopify-7125386199075",
      "product_title": "Mighty Mug 16oz",
      "rating": 5,
      "title": "Amazing quality",
      "content": "I love this product, especially the fabric...",
      "reviewer_name": "Jane D.",
      "is_verified_buyer": true,
      "status": "pending",
      "stage": "needs_review",
      "sentiment": "positive",
      "sentiment_score": 0.82,
      "helpful_votes": 3,
      "unhelpful_votes": 0,
      "review_created_at": "2026-03-27T10:00:00Z",
      "tags": ["quality", "fabric"],
      "media": [
        {
          "full_url": "https://media.betterreviews.app/r/abc.jpg",
          "large_url": "https://media.betterreviews.app/r/abc_large.jpg",
          "thumbnail_url": "https://media.betterreviews.app/r/abc_thumb.jpg",
          "media_type": "image",
          "display_order": 0,
          "vision_analysis": {
            "description": "A close-up of a navy blue knitted sweater on a wooden table.",
            "content_type": "product-shot",
            "quality": "good",
            "quality_issues": [],
            "marketing_usable": true,
            "is_product_visible": true,
            "detail_level": "high",
            "analyzed_at": "2026-03-27T10:01:14.235Z"
          }
        }
      ],
      "has_visible_media": true,
      "admin_url": "https://admin.shopify.com/store//apps//reviews/789?t=",
      "analysis": {
        "quality_score": 8,
        "sentiment": "positive",
        "sentiment_detail": "Enthusiastic about the fabric and fit",
        "marketing_potential": "high",
        "keywords": ["quality", "fabric"],
        "support_ticket": false,
        "has_photo": false,
        "moderation_decision": "approved",
        "moderation_reason": null
      }
    }
  ],
  "total": 42,
  "limit": 10,
  "offset": 0
}

The stage field

Every review carries a stage string — a materialized projection of status, analysis.moderation_reason, email_notified_at, and archived_at. It's more precise than status for queue-style UIs:

StageWhat it means
analyzingPending; AI analysis hasn't returned yet. Typically <60s. Excluded from queue counts.
needs_reviewPending; analysis returned and the review needs a human. Includes spam-flagged reviews — clients can sub-count by checking analysis.spam=true on this stage.
awaiting_teamPending; the CS team has been emailed about it. Click-through actions live in their inbox.
routed_to_supportLegacy single-stage support label, retained for ≥2 release cycles. As a filter value it UNION-expands server-side to the 4 V2 sub-stages below (so old clients keep working); on response objects it appears only on rows that haven't been migrated to a V2 sub-stage yet.
needs_support_emailV2 cascade entry: AI tagged the review as a support ticket; awaiting the bundled-email send to the merchant's CS team. Typically transient (<1min).
awaiting_supportV2 cascade: bundled email sent to CS; awaiting a Reply Privately / Hold / Reject click.
on_holdV2 cascade: CS rep clicked Hold 5 days. Waiting for the 5d follow-up email.
indefinite_holdV2 cascade: 3 nudges + 9 days elapsed without a CS click, OR Hold extension cap of 3 reached. Visible as the "Stale" chip in the merchant dashboard.
publishedApproved AND not archived — visible on the storefront widget.
archivedarchived_at is set. Hidden from the storefront; restorable.
rejectedExplicitly rejected. Never publishes; doesn't count toward storefront aggregates.

status stays available with its existing three values (pending / approved / rejected) — additive, never removed. Use stage for queue/workflow surfaces, status for raw publish-eligibility checks.


Export Marketing Photos (Bulk ZIP)

POST /api/admin-ext/marketing/export

Synchronous bulk ZIP of selected reviews' photos + a CSV manifest. Auth: App Bridge JWT (Authorization: Bearer <session_token>). 200-photo cap (422 above). 5/hour and 30/day per-store rate limits (429 above). All review_ids must belong to the requesting store (403 otherwise).

Response is application/zip. The ZIP contains manifest.csv (one row per photo: review_id, rating, product, customer name, content, content_type, quality_issues, description, photo_filename) plus each photo as {review_id}_{idx}.{ext} with the extension derived from MIME type, never the URL suffix.

POST /api/admin-ext/marketing/export
Authorization: Bearer <jwt>
Content-Type: application/json

{"review_ids": [12345, 67890]}

List Products with Review Stats

GET /api/v1/reviews/products

Browse-by-product surface. Returns one row per product with aggregate review stats (counts by status, average rating, last review timestamp, count of reviews carrying media). Powers the merchant /reviews/products admin page.

Query Parameters

ParamTypeDescription
sortstringneeds_attention (default), most_reviews, highest_rated, lowest_rated, most_recent, title_asc
searchstringILIKE on product title (escaped, max 200 chars)
has_pendingbooleanOnly products with at least one pending review
hide_zero_review_productsbooleanDefault true; excludes products with no reviews
min_rating / max_ratingfloatClamp avg_rating
platform_idstringSingle-product fetch (max 256 chars; 422 on overflow). Returns 0..1 rows.
limitintegerPage size (default 25, max 100)
offsetintegerPagination offset

Response (200)

{
  "products": [
    {
      "platform_id": "shopify-7125386199075",
      "title": "Mighty Mug 16oz",
      "image_url": "https://cdn.shopify.com/.../mug.jpg",
      "total_reviews": 23,
      "pending_count": 4,
      "approved_count": 18,
      "rejected_count": 1,
      "avg_rating": 4.6,
      "last_review_at": "2026-05-01T14:22:00Z",
      "media_count": 3
    }
  ],
  "total": 87,
  "limit": 25,
  "offset": 0,
  "has_more": true
}

media_count is "reviews with at least one visible media item", not total media items across reviews. Status counts include hidden reviews (moderation-worthy from the merchant's POV). Bot-scope keys are allowed.


Get Single Review

GET /api/v1/reviews/:id

Returns the same shape as a single review from the list.


Get Conversation Transcript

GET /api/v1/reviews/:id/transcript

For reviews collected via an AI chat, returns the turn-by-turn conversation plus two draft snapshots: the AI-generated draft the shopper saw before editing (initial_draft_text) and the final submitted body (final_draft_text). 404 when the review belongs to another store or has no linked conversation. Text fields are scrubbed of email addresses before being returned (redacted to [REDACTED_EMAIL]). Paginated from day one.

Query Parameters

ParamTypeDescription
limitintegerPage size (default 100, max 200)
offsetintegerPagination offset

Response (200)

{
  "conversation_id": 12345,
  "messages": [
    {"role": "assistant", "content": "Thanks for buying — how was it?", "inserted_at": "2026-04-10T14:02:00Z"},
    {"role": "user", "content": "I love the fabric. Contact me at [REDACTED_EMAIL] if you want more feedback.", "inserted_at": "2026-04-10T14:02:45Z"}
  ],
  "total": 8,
  "limit": 100,
  "offset": 0,
  "initial_draft_text": "Loved this hoodie — the fabric is soft and the fit is right for my frame.",
  "final_draft_text": "Loved this hoodie. Fabric is soft, fit is great for my frame, and the color is exactly as shown.",
  "initial_draft_source": "llm"
}

Draft Fields

FieldTypeDescription
initial_draft_textstring | nullThe AI-generated draft shown to the shopper before they edited and submitted. Captured when the shopper first sees the review screen; not overwritten by later edits in the current flow. Null for conversations collected before this feature shipped, or for reviews submitted without reaching the review screen.
final_draft_textstring | nullThe shopper's submitted body, as stored on the review. Captured at submission; not overwritten by later edits in the current flow. Null if the shopper never submitted.
initial_draft_source"llm" | "synthesized" | nullHow the initial draft was produced. "llm" = the language model returned the draft. "synthesized" = the chat transcript was stitched together by BetterReviews as a fallback when the model didn't return a usable draft.

Diffing initial_draft_text against final_draft_text reveals which words the shopper added, removed, or kept from the AI's pre-edit draft — useful for authenticity signals and prompt-quality analysis.


Approve Review

PATCH /api/v1/reviews/:id/approve

Approves a pending review. It becomes visible on the storefront and triggers a Shopify metafield sync.

{"ok": true, "review_id": 789, "status": "approved"}

Reject Review

PATCH /api/v1/reviews/:id/reject

{"ok": true, "review_id": 789, "status": "rejected"}

Delete Review

DELETE /api/v1/reviews/:id

{"ok": true}

Upsert Merchant Reply

PUT /api/v1/reviews/:id/reply

Create or update the merchant reply on a review. One reply per review — subsequent upserts overwrite. HTML tags and unicode control characters are stripped server-side. Max 10,000 characters. Triggers a metafield resync so the storefront widget reflects the change. Rate-limited to 30 mutations per store per minute.

Request

{"body_raw": "Thanks for the feedback — glad it shipped fast."}

Response (200)

{
  "ok": true,
  "reply": {
    "id": 42,
    "review_id": 789,
    "body_raw": "Thanks for the feedback — glad it shipped fast.",
    "body_html": "Thanks for the feedback — glad it shipped fast.",
    "author_name": null,
    "is_private": false,
    "created_at": "2026-04-18T12:00:00Z",
    "updated_at": "2026-04-18T12:00:00Z"
  }
}

Delete Merchant Reply

DELETE /api/v1/reviews/:id/reply

Removes the merchant reply attached to a review. Idempotent — 200 even if no reply existed. Triggers a metafield resync so the widget drops the reply.

{"ok": true}

Review Statistics

GET /api/v1/reviews/stats

{
  "total_reviews": 307713,
  "pending_count": 12,
  "approved_count": 306000,
  "rejected_count": 1701,
  "average_rating": 4.3,
  "reviews_by_rating": {"1": 5000, "2": 10000, "3": 30000, "4": 80000, "5": 182713},
  "verified_reviews": 250000,
  "verified_percentage": 81.2,
  "total_helpful_votes": 15000,
  "reviews_last_30_days": 4500,
  "reviews_last_7_days": 1100,
  "latest_review_date": "2026-03-27T10:00:00Z"
}

Search Reviews

POST /api/v1/reviews/search

{"query": "fabric quality", "limit": 10}