Review Groups API

Last updated: May 8, 2026

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.

Plan gating — all endpoints require the :review_groups entitlement. Calls from the Reviews tier return 403 entitlement_required. Available on Pro and Enterprise plans.

Limits — 500 members per group (cap enforced server-side; 422 too_many_members above). A product can be an active member of at most one group at a time (single-display-membership; 409 membership_conflict on overlap, resolve via the /move endpoint).

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

List Groups

GET /api/v1/review-groups

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"
    }
  ]
}

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 with resolved product titles. Returns 404 not_found if the group does not belong to the authenticated store (cross-tenant IDOR safeguard).

member_products[].title is null when the product hasn't been synced to BetterReviews yet (e.g. just-deleted Shopify product or sync lag).

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"},
      {"external_product_id": "1002", "title": "Summer Tee — Sand"},
      {"external_product_id": "1003", "title": null}
    ],
    "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", "1002", "1003"]
}
FieldTypeDescription
namestring (required)Internal-only label (merchant sees, shoppers don't). Capped at 100 characters.
display_namestring (optional)Customer-facing label rendered in the storefront disclosure subtitle. Validation rejects <, >, {{, }}, ${, and backticks (XSS / template-injection defense). Capped at 100 characters.
product_idsstring[] (optional)Bare numeric Shopify product IDs to add as initial members. The shopify- prefix is stripped server-side. Cap 500.

Responses

  • 201{"data": {...group...}}
  • 409 membership_conflict — one or more product_ids are already displaying from another group. Body includes a conflicting array with external_product_id and the existing group_id. Resolve via the /move endpoint.
  • 422 too_many_members — more than 500 product IDs supplied.
  • 422 validation_error — invalid display_name (XSS chars / over cap) or other field-level validation failure. Body includes errors map.
  • 503 feature_disabled — global kill-switch flipped (REVIEW_GROUPS_ENABLED=false). Reads remain available.

Update Group

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

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

Responses

  • 200{"data": {...group...}}
  • 404 not_found — group doesn't belong to authenticated store.
  • 422 validation_error — invalid 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 revert to per-product reviews on the storefront within ~60 seconds (metafield resync). Soft-deleted rows are hard-deleted by the daily tombstone-sweep worker after 90 days.

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" }

Responses

  • 200{"data": {"added": true}}
  • 404 not_found — group doesn't belong to authenticated store.
  • 409 membership_conflict — product is already an active member of another group. Use the /move endpoint.
  • 422 too_many_members — group is already at the 500-member cap.
  • 503 feature_disabled — kill-switch flipped.

Remove Member

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

Soft-removes the membership. Tombstone-swept after 90 days.

Responses

  • 200{"data": {"removed": true}}
  • 404 not_found — group or membership not found in this store.
  • 503 feature_disabled — kill-switch flipped.

Move Membership (Conflict Resolution)

POST /api/v1/review-groups/move

Move a product from one group's display to another's. Requires expected_current_group_id for optimistic-concurrency safety; if the product's actual current group has changed since the merchant loaded the page, the request returns 409 stale (refresh and retry).

Request

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

Responses

  • 200{"data": {"moved": true}}
  • 409 staleexpected_current_group_id doesn't match server state; refresh and retry.
  • 404 not_found — target group doesn't belong to authenticated store.
  • 422 too_many_members — target group is at the 500-member cap.
  • 422 no_active_membership — product has no active membership to move.
  • 400 same_groupexpected_current_group_id == target_group_id.
  • 400 missing_required_params — request omitted one of external_product_id / expected_current_group_id / target_group_id.
  • 503 feature_disabled — kill-switch flipped.

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.