Reviews API

Last updated: October 2, 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
qstringUnified free-text search (max 200 chars; trimmed; 400 q exceeds 200-character limit over cap). Case-insensitive substring (ILIKE) across review title, content, reviewer name, tagged keywords, and the product title of the review's product. It does not search customer email. When q is supplied the server skips the separate product_query fuzzy resolver (product titles are matched directly in SQL), so a product_query sent alongside q is ignored.
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_querystringSingle 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). Mutually exclusive with product_queries[] (422 mutually_exclusive_product_filter if both are supplied).
product_queries[]arrayMulti-product OR (v3.1 T9, 2026-05). Up to 5 entries, each ≤200 chars. The server fuzzy-matches each; matched products' reviews are unioned, unresolved entries surface in meta.unresolved_queries (partial-resolve). Mutually exclusive with product_query.
product_typestringShopify product type, exact match (v3.1 T9). Up to 256 chars. Resolved to platform_ids server-side (LIMIT 200 before :product_ids union).
tags[]arrayShopify product tags, OR semantics (v3.1 T9 — matches reviews whose product carries ANY of the listed tags). Up to 10 entries, each ≤64 chars. Combines with product_type as OR.
date_fromstringISO8601 date or datetime. Inclusive lower bound on review_created_at. 422 date_out_of_range if more than 1 day in the future; 422 invalid_date_range if date_from > date_to.
date_tostringISO8601 date or datetime. Inclusive upper bound on review_created_at. Same boundary checks as date_from.
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.
sortstringOne of newest, most_helpful, highest, lowest, most_detailed, most_relevant, featured, most_informative, critical_first, best_media, marketing_potential, marketing, marketing_free. Unknown or omitted values fall back to newest. marketing ranks by AI quality score + marketing potential + verified buyer + photos + length and sinks reviews with no AI analysis below every analyzed review; marketing_free is the same idea without analysis fields (length, verified buyer, photos, helpful-vote ratio, recency). Every sort carries review id as its final tiebreaker, so pagination is stable across same-timestamp bulk imports.
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, product_image_url, shopify_customer_id, 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; product_image_url is null under the same condition (no resolved product). shopify_customer_id is the bare numeric Shopify customer ID for the reviewer; it is null for bot-scoped API keys (same treatment as admin_url). admin_url is null for bot-scoped API keys (signed Phoenix deep-link tokens are kept out of Slack/Loki retention). These fields are present on both the list and single-review (GET /api/v1/reviews/:id) shapes.

Merchant-scoped keys only: On GET /api/v1/reviews and GET /api/v1/reviews/:id, each review also carries reviewer_email (the reviewer's email, decrypted; null when none was collected), custom_fields (extra fields carried over from the source platform, {} when none), and platform_metadata (source-platform extras on imported reviews, e.g. product_handle). Before platform_metadata is returned, known personal-data keys are removed: ip_address, reviewer_ip, raw_data, email, customer_email, phone, location, and any key starting with address (top-level keys, case-insensitive). Treat reviewer_email as customer personal data: don't log it or forward it to third parties. Note: POST /api/v1/reviews/search with a merchant key is proxied to the LLM service and currently returns the stored platform_metadata unfiltered (follow-up tracked).

Bot-scoped keys (the Slack bot): On GET /api/v1/reviews and GET /api/v1/reviews/:id, each review is an explicit allowlist and nothing else: id, external_product_id, product_title, product_image_url, rating, title, content, reviewer_name (null when reviewer_display_name is set), reviewer_display_name, is_verified_buyer, shopify_customer_id (always null), status, review_created_at, helpful_votes, unhelpful_votes, analysis (only quality_score, sentiment, marketing_potential, use_cases, keywords, has_photo), media[], has_visible_media, and admin_url (always null). They never receive reviewer_email, platform_metadata, custom_fields, external_order_id, user_id, or any reviewer location field. Bot keys are also restricted to the routes the Slack bot calls (review list/stats/show/search/approve/reject, sending a review request, conversation and email-request stats, and the Slack channel-mapping, user-auth, role-counts and audit-log routes); every other route returns 403 insufficient_scope.

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": "7125386199075",
      "product_title": "Mighty Mug 16oz",
      "product_image_url": "https://cdn.shopify.com/.../mug.jpg",
      "rating": 5,
      "title": "Amazing quality",
      "content": "I love this product, especially the fabric...",
      "reviewer_name": "Jane D.",
      "is_verified_buyer": true,
      "shopify_customer_id": "1234567890",
      "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"],
      "rights_status": "none",
      "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.

The rights_status field

Every review carries a rights_status string indicating whether you have the reviewer's permission to feature their words or photos in marketing (ads, social, email): none (not asked, or the reviewer declined), requested (a permission email is pending), or cleared (the reviewer granted permission). Defaults to none. Displaying a review on your product page does not require clearance; using it in marketing does.


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, variant SKU, or variant barcode (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": "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 or rejected review. It becomes visible on the storefront and triggers a Shopify metafield sync.

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

Returns 409 {"error": "already_actioned"} when the review is already approved, or changed concurrently.


Reject Review

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

Rejects a pending or published review. It is removed from the storefront. Any store API key that can call this endpoint (including the Slack bot's) can unpublish a live review.

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

Returns 409 {"error": "already_actioned"} when the review is already rejected, or changed concurrently. A synced (Okendo / Judge.me) review you reject stays rejected even if the source platform sends it again.


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}

Top Reviews

GET /api/v1/reviews/top

The best review(s) for one product, ranked in SQL. No model call, no AI credit spend. This is the single source of truth for which review to put in an ad, on a landing page, or in an email.

Requires a store-scoped API key — a storeless (global) key gets 403 {"detail": "Store-scoped API key required"}.

Query Parameters

ParamTypeDefaultDescription
product_idstring—Required. Shopify product ID: bare numeric (7891234567890), shopify-<id> or gid://shopify/Product/<id>. Max 64 bytes; must be numeric after normalization.
objectivestringmarketingmarketing (AI quality score, marketing potential, verified buyer, photos, length) or helpful (helpful votes, then recency).
limitinteger51–20.
min_ratinginteger—1–5. Only reviews rated at or above this.
require_mediaboolean—true restricts to reviews with at least one visible photo/video. Any non-boolean value is ignored (no error).
exclude_idsarray or string[]Review IDs to leave out. Accepts exclude_ids[]=1&exclude_ids[]=2 or exclude_ids=1,2. Non-numeric entries are dropped silently; more than 50 parsed IDs is an error.

Only reviews that are approved, not hidden and have non-empty content are ever returned. The query is product-scoped: even if the product belongs to a Review Group, group siblings are never pooled in — a top review is always a review of the product you asked for.

The marketing ranking has no star-rating term — it ranks on quality score, marketing potential, verified buyer, photos and length. Pass min_rating=4 if you only want 4–5★ reviews.

Rate limited to 60 requests per minute per store, a bucket shared with the top_reviews MCP tool. Over the limit: 429 {"detail": "rate_limited"}.

Errors

Every error answers with the envelope {"detail": "<code>"}.

StatusCodeCause
403Store-scoped API key requiredThe key is not scoped to a single store.
422invalid_product_idMissing, longer than 64 bytes, or not numeric after normalization.
422invalid_objectiveNot marketing or helpful.
422limit_out_of_rangeNot an integer in 1–20.
422invalid_min_ratingNot an integer in 1–5.
422exclude_ids_too_manyMore than 50 parseable IDs.
429rate_limitedMore than 60 requests in a minute for this store (shared with the top_reviews MCP tool).

Request

curl "https://api.betterreviews.app/api/v1/reviews/top?product_id=7891234567890&objective=marketing&limit=2" \
  -H "X-API-Key: YOUR_API_KEY"

Response (200)

{
  "data": [
    {
      "id": 48213,
      "product_id": "7891234567890",
      "rating": 5,
      "title": "Held up through two winters",
      "content": "I wear this every day walking the dog in Minnesota...",
      "reviewer_display_name": "Dana R.",
      "is_verified_buyer": true,
      "word_count": 112,
      "quality_score": 9,
      "rights_status": "cleared",
      "created_at": "2026-02-11T09:14:00Z",
      "media": [
        {"id": 9912, "url": "https://media.betterreviews.app/...", "type": "image"}
      ]
    }
  ],
  "tier": "A",
  "coverage": {"total": 418, "analyzed": 266},
  "objective": "marketing",
  "product_id": "7891234567890"
}

Review objects carry no reviewer email or customer ID — only the public display name. rights_status (none / requested / cleared) tells you whether you already hold permission to use the review in marketing.

Tier and coverage. coverage counts every review that matched the filters for this product: total and analyzed (how many have an AI analysis blob).

  • tier: "A" — at least one matching review has been AI-analyzed. The marketing ranking uses the analysis signals, and analyzed reviews always sort above unanalyzed ones.
  • tier: "C" — nothing on this product has been analyzed yet (typical for a freshly imported catalogue). For objective=marketing the server transparently falls back to the analysis-free marketing_free composite, so you still get a sensible ordering. Results are usable, just weaker signal — not an error.

Not the same as the Klaviyo feed. The Klaviyo "best reviews" newsletter feed has its own store-wide ranking (quality score, then rating, then recency) and may surface a different review for the same product.


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

Hybrid (semantic + keyword) search over review content. Behavior diverges by API key scope:

  • Bot-scope keys (v3.1 T9, 2026-05): in-Elixir hybrid search via the hybrid_search_reviews PostgreSQL function. The server embeds query through a Python /internal/embed endpoint (1536-dim, 5s timeout) and ranks results. Fails closed with 503 search_unavailable on embed errors — no silent degrade to lexical-only. Per-store rate-limit 60/minute; IP rate-limit 120/minute.
  • Non-bot keys: proxied to Python's natural-language search endpoint (unchanged from pre-T9 behavior).

Requires an active paid subscription: on the Free plan, or when the subscription is pending, cancelled or frozen, this returns 402 subscription_required. The same applies to nl-search, ask and summary.

Request

{
  "query": "fabric quality",
  "limit": 10,
  "date_from": "2026-01-01",
  "date_to": "2026-05-23",
  "min_rating": 4,
  "product_queries": ["print pattern", "digital pattern"],
  "product_type": "Apparel",
  "tags": ["sale", "eco"]
}

The store is always taken from the API key: a store_id in the request body is ignored on every /reviews/* endpoint that accepts one (search, nl-search, ask, summary, webhooks/subscribe, connect, disconnect, metrics, bulk-ingest, bulk-embeddings, extract-keywords, product/prepare).

Filter parameters

  • query (required, 1–256 chars)
  • limit (1–20, default 20)
  • min_rating (1–5)
  • product_query (single fuzzy match, ≤200 chars) — mutually exclusive with product_queries
  • product_queries (array, max 5 entries) — multi-product OR; unresolved entries surface in meta.unresolved_queries
  • product_type (string, ≤256 chars) — Shopify product type, exact match
  • tags (array, max 10 entries) — OR semantics across the product's tag list
  • date_from / date_to (ISO8601) — bounds on review_created_at

Error responses

  • 422: invalid_query, query_too_long, invalid_limit, invalid_min_rating, invalid_date, invalid_date_range, date_out_of_range, mutually_exclusive_product_filter, product_queries_unresolved, too_broad
  • 429: per-store bucket (60/min) or IP bucket (120/min) exhausted
  • 503 search_unavailable: Python embed failed; clients should fall back to GET /api/v1/reviews with filters