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
| Param | Type | Description |
|---|---|---|
status | string | pending, approved, rejected |
stage | string | Filter 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_rating | integer | Minimum rating (1-5) |
max_rating | integer | Maximum rating (1-5) |
product_id | string | Filter 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). |
platform | string | betterreviews, okendo, judgeme, yotpo |
is_verified | boolean | Verified buyers only |
keyword | string | Filter 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_sentiment | string | Review-level sentiment: positive, negative, neutral, mixed. Requires keyword; passing it alone returns 422. |
product_query | string | Fuzzy 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_media | boolean | When 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_ready | boolean | When 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. |
sort | string | newest, oldest, highest_rated, lowest_rated, most_helpful |
limit | integer | Page size (default 20, max 100) |
offset | integer | Pagination 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 forproduct_queryonly).resolved_product: an object withplatform_id,title, andimage_urlof 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. Always0/[]when the anchor isproduct_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:
| Stage | What it means |
|---|---|
analyzing | Pending; AI analysis hasn't returned yet. Typically <60s. Excluded from queue counts. |
needs_review | Pending; 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_team | Pending; the CS team has been emailed about it. Click-through actions live in their inbox. |
routed_to_support | Legacy 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_email | V2 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_support | V2 cascade: bundled email sent to CS; awaiting a Reply Privately / Hold / Reject click. |
on_hold | V2 cascade: CS rep clicked Hold 5 days. Waiting for the 5d follow-up email. |
indefinite_hold | V2 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. |
published | Approved AND not archived — visible on the storefront widget. |
archived | archived_at is set. Hidden from the storefront; restorable. |
rejected | Explicitly 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
| Param | Type | Description |
|---|---|---|
sort | string | needs_attention (default), most_reviews, highest_rated, lowest_rated, most_recent, title_asc |
search | string | ILIKE on product title (escaped, max 200 chars) |
has_pending | boolean | Only products with at least one pending review |
hide_zero_review_products | boolean | Default true; excludes products with no reviews |
min_rating / max_rating | float | Clamp avg_rating |
platform_id | string | Single-product fetch (max 256 chars; 422 on overflow). Returns 0..1 rows. |
limit | integer | Page size (default 25, max 100) |
offset | integer | Pagination 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
| Param | Type | Description |
|---|---|---|
limit | integer | Page size (default 100, max 200) |
offset | integer | Pagination 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
| Field | Type | Description |
|---|---|---|
initial_draft_text | string | null | The 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_text | string | null | The 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" | null | How 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}