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_takenwithexisting_group_id(previously422 validation_errorwith{"errors":{"name":["already exists"]}}). Adding a product to the group it is already in now returns200withalready_member: true(previously409 membership_conflict). Write endpoints now require a Read & Write key.POST /review-groups/movewith a non-numericexternal_product_idnow returns422 invalid_product_id(previously422 no_active_membership)./movenow also accepts an integer or agid://shopify/Product/Nid (previously400 missing_required_paramsand422 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/moveendpoint 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_membersabove). 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 returns429with{"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 gets403with{"detail": "Insufficient scope. This endpoint requires 'write' scope."}(note:detail, noterror). The three reads work with a Read-only key. - Product IDs. In request bodies and in the
/membershipsquery, send the numeric Shopify product ID ("7125386199075"or7125386199075) or its GID ("gid://shopify/Product/7125386199075"); ashopify-prefix is also stripped. Responses always use the bare numeric string. In URL paths use the numeric ID only. Anything else returns422with{"error": "invalid_product_id", "invalid": [...]};invalidechoes 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_idsis missing where required, is not a list, or the/membershipsquery has another shape. - Other errors. A non-integer
:idreturns400with{"error": "invalid_id"}onPATCH /review-groups/:id,DELETE /review-groups/:id,/members,/members/batchand/members/batch-remove(GET /review-groups/:idreturns404 not_foundinstead). An unexpected server failure returns500with{"error": "internal_error"}; retrying is safe. - Kill switch. When review groups are disabled platform-wide, every write returns
503with{"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
| Method | Path | Key |
|---|---|---|
GET | /review-groups | Read-only or Read & Write |
GET | /review-groups/memberships?product_ids=… | Read-only or Read & Write |
GET | /review-groups/:id | Read-only or Read & Write |
POST | /review-groups | Read & Write |
PATCH | /review-groups/:id | Read & Write |
DELETE | /review-groups/:id | Read & Write |
POST | /review-groups/:id/members | Read & Write |
POST | /review-groups/:id/members/batch | Read & Write |
DELETE | /review-groups/:id/members/:external_product_id | Read & Write |
POST | /review-groups/:id/members/batch-remove | Read & Write |
POST | /review-groups/move | Read & 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_required422 invalid_product_ids_param—product_idsmissing 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.
titleisnullwhen BetterReviews has no synced title for the product.product_statusis"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]
} | Field | Type | Description |
|---|---|---|
name | string (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_name | string (optional) | Customer-facing label rendered in the storefront disclosure subtitle. Same character rules as name (XSS / template-injection defense). Capped at 100 characters. |
product_ids | array (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}. Useexisting_group_idfor create-if-missing. (existing_group_idis omitted in the rare case the other group was deleted between the conflict and the lookup.)409 membership_conflict— one or moreproduct_idsare 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/moveendpoint.422 invalid_product_id—{"error": "invalid_product_id", "invalid": ["abc"]}.422 invalid_product_ids_param—product_idsis not a list.422 too_many_product_ids— more than 1,000 raw entries inproduct_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_nameover its cap or containing rejected characters. Body includes anerrorsmap.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— invalidnameordisplay_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 (conflictingas above). Use the/moveendpoint.422 invalid_product_id422 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— fullconflictinglist; 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_idsmissing 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_idisn'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_id422 too_many_product_ids— more than 200 IDs (duplicates count).422 invalid_product_ids_param—product_idsmissing 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_idis 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_iddoesn'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_idisn'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.summarystays per-product (unchanged for headless integrations).product.metafields.betterreviews.display_group_idis 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.
Book a demo