Review Groups API

Last updated: September 30, 2026

Base path: /api/v1/review-groups

Combine reviews from multiple products (variants, packs, bundles) into a shared display pool on storefront product pages. Useful for fashion brands with size/color variants, bundles that share underlying products, or accessory lines that share reviews across colorways.

Entitlement gating — all endpoints require the :review_groups entitlement. Every plan includes it — Free, both current paid plans, and every legacy plan (no longer sold), Starter and Reviews included. A never-subscribed or cancelled store can use these endpoints. These endpoints are not LLM-backed, so they do not carry the separate 402 subscription_required gate.

Changed 2026-09-30: creating or renaming a group to a name another active group in the store already uses now returns 409 name_taken with existing_group_id (previously 422 validation_error with {"errors":{"name":["already exists"]}}). Adding a product to the group it is already in now returns 200 with already_member: true (previously 409 membership_conflict). Write endpoints now require a Read & Write key. POST /review-groups/move with a non-numeric external_product_id now returns 422 invalid_product_id (previously 422 no_active_membership). /move now also accepts an integer or a gid://shopify/Product/N id (previously 400 missing_required_params and 422 no_active_membership).

Rules

  • One group per product. A product can be an active member of at most one group. Adding it to a second group returns 409 membership_conflict; use the /move endpoint to switch it.
  • Pooled rating, no primary product. Every member shows the same pooled rating and review count; no member is the "main" product.
  • Limits. 500 distinct product IDs per group (422 too_many_members above). Soft cap of 1,000 active groups per store (422 too_many_groups). Shopify imposes no limit on these metafields; the cap is our guardrail.
  • Deleted products stay members. When a product is deleted in Shopify it stays in its group, and its reviews keep pooling into the group rating. Get Group reports it with product_status: "deleted". Remove it yourself if you don't want that.
  • Rate limits (per store). POST /review-groups (create): 20/minute. The two batch routes (/members/batch, /members/batch-remove) share a 30/minute bucket. Everything else: the general 600/minute. Over the limit returns 429 with {"detail": "Rate limit exceeded"}.
  • Propagation. Storefront changes usually appear within a minute (metafield resync runs in the background).
  • Auth. Header only: X-API-Key: YOUR_API_KEY. Never put the key in the query string. Every write needs a Read & Write key; a Read-only key gets 403 with {"detail": "Insufficient scope. This endpoint requires 'write' scope."} (note: detail, not error). The three reads work with a Read-only key.
  • Product IDs. In request bodies and in the /memberships query, send the numeric Shopify product ID ("7125386199075" or 7125386199075) or its GID ("gid://shopify/Product/7125386199075"); a shopify- prefix is also stripped. Responses always use the bare numeric string. In URL paths use the numeric ID only. Anything else returns 422 with {"error": "invalid_product_id", "invalid": [...]}; invalid echoes at most the first 20 offending values, each truncated to 128 characters.
  • Request-shape errors (422). too_many_product_ids — more than 200 raw IDs to a batch route (duplicates count), more than 250 to /memberships, or more than 1,000 raw IDs to create. invalid_product_ids_param — product_ids is missing where required, is not a list, or the /memberships query has another shape.
  • Other errors. A non-integer :id returns 400 with {"error": "invalid_id"} on PATCH /review-groups/:id, DELETE /review-groups/:id, /members, /members/batch and /members/batch-remove (GET /review-groups/:id returns 404 not_found instead). An unexpected server failure returns 500 with {"error": "internal_error"}; retrying is safe.
  • Kill switch. When review groups are disabled platform-wide, every write returns 503 with {"error": "feature_disabled", "feature": "review_groups"}. Reads keep working.

For the storefront-rendering side (per-product summary, shop-level group_summary_*, and the display_group_id pointer), see the metafields contract.

Endpoints

MethodPathKey
GET/review-groupsRead-only or Read & Write
GET/review-groups/memberships?product_ids=…Read-only or Read & Write
GET/review-groups/:idRead-only or Read & Write
POST/review-groupsRead & Write
PATCH/review-groups/:idRead & Write
DELETE/review-groups/:idRead & Write
POST/review-groups/:id/membersRead & Write
POST/review-groups/:id/members/batchRead & Write
DELETE/review-groups/:id/members/:external_product_idRead & Write
POST/review-groups/:id/members/batch-removeRead & Write
POST/review-groups/moveRead & Write

A group that belongs to another store returns 404 not_found on every :id route (cross-tenant IDOR safeguard).


List Groups

GET /api/v1/review-groups

Returns every active group in the store. No pagination; the list is bounded by the per-store cap. source is always "manual". shopify_collection_gid is reserved and always null.

Response (200)

{
  "data": [
    {
      "id": 42,
      "name": "Summer Tee variants",
      "display_name": "Summer Tee",
      "source": "manual",
      "shopify_collection_gid": null,
      "member_count": 8,
      "inserted_at": "2026-05-08T10:00:00Z",
      "updated_at": "2026-05-08T10:00:00Z"
    }
  ]
}

Look Up Memberships

GET /api/v1/review-groups/memberships?product_ids=123,456

Which group (if any) each product is in. Up to 250 IDs per call. Pass them comma-separated (?product_ids=123,456) or repeated (?product_ids[]=123&product_ids[]=456). Keys in the response are the normalized numeric IDs; a product in no group (or not in this store) maps to null.

Response (200)

{
  "data": {
    "123": {"group_id": 42, "group_name": "Summer Tee variants"},
    "456": null
  }
}

Errors

  • 403 entitlement_required
  • 422 invalid_product_ids_param — product_ids missing or in another shape.
  • 422 invalid_product_id — {"error": "invalid_product_id", "invalid": [...]}.
  • 422 too_many_product_ids — more than 250 IDs.

Get Single Group

GET /api/v1/review-groups/:id

Returns the group plus its full member product ID list and a parallel member_products array.

  • title is null when BetterReviews has no synced title for the product.
  • product_status is "exists" (the product is in our catalog — including draft or archived products), "deleted" (deleted in Shopify; it stays a member and its reviews keep pooling), or "unknown" (we have never synced this product).

Response (200)

{
  "data": {
    "id": 42,
    "name": "Summer Tee variants",
    "display_name": "Summer Tee",
    "source": "manual",
    "shopify_collection_gid": null,
    "member_count": 3,
    "member_product_ids": ["1001", "1002", "1003"],
    "member_products": [
      {"external_product_id": "1001", "title": "Summer Tee — Navy", "product_status": "exists"},
      {"external_product_id": "1002", "title": "Summer Tee — Sand", "product_status": "deleted"},
      {"external_product_id": "1003", "title": null, "product_status": "unknown"}
    ],
    "inserted_at": "2026-05-08T10:00:00Z",
    "updated_at": "2026-05-08T10:00:00Z"
  }
}

Create Group

POST /api/v1/review-groups

Request

{
  "name": "Summer Tee variants",
  "display_name": "Summer Tee",
  "product_ids": ["1001", "gid://shopify/Product/1002", 1003]
}
FieldTypeDescription
namestring (required)Internal-only label (merchant sees, shoppers don't). 1–200 characters. Must be unique among the store's active groups. Rejects <, >, {{, }}, ${, and backticks.
display_namestring (optional)Customer-facing label rendered in the storefront disclosure subtitle. Same character rules as name (XSS / template-injection defense). Capped at 100 characters.
product_idsarray (optional)Initial members, up to 500 distinct IDs (at most 1,000 raw entries). Numeric IDs or GIDs (see Rules).

Responses

  • 201 — {"data": {...group...}}
  • 409 name_taken — {"error": "name_taken", "existing_group_id": 42}. Use existing_group_id for create-if-missing. (existing_group_id is omitted in the rare case the other group was deleted between the conflict and the lookup.)
  • 409 membership_conflict — one or more product_ids are already in another group; nothing is created. Body: {"error": "membership_conflict", "conflicting": [{"external_product_id": "1001", "group_id": 7, "group_name": "Old group"}]}. Resolve via the /move endpoint.
  • 422 invalid_product_id — {"error": "invalid_product_id", "invalid": ["abc"]}.
  • 422 invalid_product_ids_param — product_ids is not a list.
  • 422 too_many_product_ids — more than 1,000 raw entries in product_ids.
  • 422 too_many_members — more than 500 distinct product IDs supplied.
  • 422 too_many_groups — the store already has 1,000 active groups.
  • 422 validation_error — name / display_name over its cap or containing rejected characters. Body includes an errors map.
  • 503 feature_disabled — kill switch flipped.

Update Group

PATCH /api/v1/review-groups/:id

{
  "name": "New name",
  "display_name": "New label"
}

Responses

  • 200 — {"data": {...group...}} (same shape as List).
  • 404 not_found — group doesn't belong to authenticated store.
  • 409 name_taken — {"error": "name_taken", "existing_group_id": 42}.
  • 422 validation_error — invalid name or display_name.
  • 503 feature_disabled — kill switch flipped.

Delete Group

DELETE /api/v1/review-groups/:id

Soft-deletes the group plus all memberships. Member products go back to their own per-product reviews on the storefront, usually within a minute. There is no restore; soft-deleted groups are retained.

Responses

  • 200 — {"data": {"deleted": true}}
  • 404 not_found — group doesn't belong to authenticated store.
  • 503 feature_disabled — kill switch flipped.

Add Member

POST /api/v1/review-groups/:id/members

{ "external_product_id": "1004" }

Idempotent: adding a product that is already in this group changes nothing.

Responses

  • 200 — {"data": {"added": true}} (newly added).
  • 200 — {"data": {"added": false, "already_member": true}} (already in this group).
  • 404 not_found — group doesn't belong to authenticated store.
  • 409 membership_conflict — product is in another group (conflicting as above). Use the /move endpoint.
  • 422 invalid_product_id
  • 422 too_many_members — group is already at the 500-member cap.
  • 503 feature_disabled — kill switch flipped.

Batch Add Members

POST /api/v1/review-groups/:id/members/batch

{ "product_ids": ["1004", "1005", "gid://shopify/Product/1006"] }

Up to 200 IDs per call. All-or-nothing: if any ID is in another group, nothing is written and the response lists every conflict. IDs already in this group are reported, not errors, so a retried batch is safe.

Responses

  • 200 — {"data": {"added": ["1005", "1006"], "already_member": ["1004"]}}
  • 404 not_found — group doesn't belong to authenticated store.
  • 409 membership_conflict — full conflicting list; nothing written.
  • 422 invalid_product_id — {"error": "invalid_product_id", "invalid": [...]}; nothing written.
  • 422 too_many_product_ids — more than 200 IDs (duplicates count).
  • 422 invalid_product_ids_param — product_ids missing or not a list.
  • 422 too_many_members — the batch would take the group past 500.
  • 429 — batch bucket (30/minute per store, shared with batch remove).
  • 503 feature_disabled — kill switch flipped.

Remove Member

DELETE /api/v1/review-groups/:id/members/:external_product_id

:external_product_id is the numeric ID. Soft-removes the membership.

Responses

  • 200 — {"data": {"removed": true}}
  • 404 not_found — group not found in this store, or the product isn't an active member.
  • 422 invalid_product_id — :external_product_id isn't numeric.
  • 503 feature_disabled — kill switch flipped.

Batch Remove Members

POST /api/v1/review-groups/:id/members/batch-remove

{ "product_ids": ["1004", "1005"] }

Up to 200 IDs per call. Idempotent: IDs not in this group are reported in not_member.

Responses

  • 200 — {"data": {"removed": ["1004"], "not_member": ["1005"]}}
  • 404 not_found — group doesn't belong to authenticated store.
  • 422 invalid_product_id
  • 422 too_many_product_ids — more than 200 IDs (duplicates count).
  • 422 invalid_product_ids_param — product_ids missing or not a list.
  • 429 — batch bucket (30/minute per store, shared with batch add).
  • 503 feature_disabled — kill switch flipped.

Move Membership (Conflict Resolution)

POST /api/v1/review-groups/move

Move a product from one group to another. Requires expected_current_group_id for optimistic-concurrency safety; if the product's current group has changed since you read it, the request returns 409 stale (re-read and retry).

external_product_id may be a string or a number, and a GID is accepted.

Request

{
  "external_product_id": "1001",
  "expected_current_group_id": 42,
  "target_group_id": 99
}

Responses

  • 200 — {"data": {"moved": true}}
  • 400 same_group — expected_current_group_id == target_group_id.
  • 400 missing_required_params — a field is missing, external_product_id is not a string or integer, or a group ID is not an integer.
  • 404 not_found — target group doesn't belong to authenticated store.
  • 409 stale — expected_current_group_id doesn't match server state; re-read and retry.
  • 409 membership_conflict — a concurrent write put the product in another group.
  • 422 invalid_product_id — external_product_id isn't a Shopify product ID.
  • 422 no_active_membership — product has no active membership to move.
  • 422 too_many_members — target group is at the 500-member cap.
  • 503 feature_disabled — kill switch flipped.

Example: sync groups from a script

# Create (or find) the group
curl -s -X POST https://api.betterreviews.app/api/v1/review-groups \
  -H "X-API-Key: YOUR_WRITE_KEY" -H "Content-Type: application/json" \
  -d '{"name": "Summer Tee variants", "display_name": "Summer Tee"}'
# → 201 {"data":{"id":42,...}}  or  409 {"error":"name_taken","existing_group_id":42}

# Add up to 200 products per call (safe to retry)
curl -s -X POST https://api.betterreviews.app/api/v1/review-groups/42/members/batch \
  -H "X-API-Key: YOUR_WRITE_KEY" -H "Content-Type: application/json" \
  -d '{"product_ids": ["gid://shopify/Product/1001", "1002"]}'

# Check where products currently are
curl -s "https://api.betterreviews.app/api/v1/review-groups/memberships?product_ids=1001,1002,1003" \
  -H "X-API-Key: YOUR_WRITE_KEY"

Storefront metafields written by groups

When a product joins a group:

  • product.metafields.betterreviews.summary stays per-product (unchanged for headless integrations).
  • product.metafields.betterreviews.display_group_id is written as a small pointer payload.
  • shop.metafields.betterreviews.group_summary_<store_id>_<group_id> is written with the aggregate.

JSON-LD AggregateRating schema stays product-only on every PDP — Google's product reviews policy prohibits cross-product aggregation in product schema. See aggregation disclosure for FTC framing and the metafields contract for the full schema.