# Reviews: buyer reviews, moderation, seller replies

**Status:** current · **Verified:** 2026-10-31, integration tests on a temporary database and an own stand with prod and dev keys (§7) · **Owner:** reviews line
**Serves:** `GET /docs/reviews`

## 1. What you can do here

Run the shop's reviews the way large marketplaces do: buyers post a rating, pros, cons, a comment, photos and videos;
nothing is public until the shop approves it; the shop replies, deletes, approves in batches and decides who may post.
With a key you manage the seller side: read the feed (including pending reviews), add a review on behalf of an offline
customer, moderate, reply, delete and change the review settings. Posting as a buyer, voting "helpful" and the buyer's
own state are buyer-session actions and are not available to a key. These are not record sets (`/docs/record-sets`):
record sets are rows the seller types in; reviews come from buyers and go through moderation.

## 2. Decide first (forks)

| If you want… | Take this path | Cost / catch |
|---|---|---|
| show reviews but never let buyers post (reviews "for our own use") | `PUT /reviews/settings {"post_mode":"nobody"}`, then add reviews with `POST /reviews/seller` | the storefront hides the form; `POST /reviews` answers `REVIEWS_POSTING_CLOSED` |
| let only real buyers post | `PUT /reviews/settings {"post_mode":"buyers"}` | a buyer needs a **done** (status 3) order containing **this product**; for a shop review — any done order |
| let any signed-in visitor post | `post_mode: "anyone"` (the default) | the "Verified purchase" badge still appears only when the server finds such an order |
| add an offline customer's review | `POST /reviews/seller` with a prod key | `source: "seller"`, immediately `approved`, never a verified purchase; it is a live operation |
| approve or reject many at once | `PUT /reviews/moderate` with up to 100 ids | atomic: one id of another shop → `NOT_FOUND`, nothing changes |
| show reviews of one product in your own markup | public `GET /reviews?product_id=` | the feed follows the product's review scope (own / category / shop / off) |
| list exactly one product's reviews in a back office | `GET /reviews?product_id=&exact_product=true` with `catalog:read` | ignores the scope (members only; a visitor still gets the scoped feed); owner view shows every status |
| fetch the review a webhook told you about | `GET /reviews/{id}` with `catalog:read` | owner view: any status; a visitor gets only an approved review of a visible shop |

## 3. Objects and where they live

**Review.** `id, company_id, product_id` (`0` — a review of the shop), `source` (`buyer` | `seller`), `author`
(buyer) or `author_name` (seller-added), `rating` (1–5, `0` — none), `body`, `pros`, `cons`, `files[]` (`id`, `url`),
`seller_reply`, `replied_at`, `helpful_count`, `status` (`pending` | `approved` | `rejected`),
`verified_purchase`, `created_at`. `order_id` is legacy: the badge reads `verified_purchase` only.

**Feed.** `GET /reviews` takes exactly one source — `product_id`, `category_id` or `company_id` — and returns
`result.items[], total, current_page, summary, media_total`. `summary` = `rating_avg`, `rating_count`,
`histogram` (`[0]` = one star … `[4]` = five) over **approved** reviews of the whole source; filters do not change
it. `total` counts with the filters. `media_total` counts **files** in approved reviews of the source (the
"buyers' photos and videos" gallery is the pages of `has_media=true`).

| Parameter | Values | Effect |
|---|---|---|
| `sort` | `new` (default), `helpful`, `with_media`, `rating_desc`, `rating_asc` | order |
| `rating` | repeat 1–5, e.g. `rating=4&rating=5` | only these stars |
| `has_media` | `true` | only reviews with photos or videos |
| `status` | `pending`, `approved`, `rejected` | owner view only (`catalog:read` of this shop); everyone else always gets `approved` |
| `exact_product` | `true` | this product only, regardless of scope — for shop members (owner view) |
| `page.number`, `page.limit` | default 1 / 20, limit up to 100 | offset pages; new reviews may shift a page — drop repeats by `id` |

**Settings.** `GET /reviews/settings` → `{post_mode, scope_default}`; `PUT` changes the fields you send.
`scope_default` is the same value as the shop's storefront setting (`own`, `category`, `shop`, `off`, `""` = own).

**Buyer state** (`GET /reviews/me?product_id=` or `?company_id=`, buyer session): `my_review` in any status,
`can_post`, `post_block_reason` (`login`, `closed`, `disabled`, `purchase_required`, `already_posted`, `no_target` — no or two targets) and
`voted_ids` for the `review_ids` you pass. A key gets the anonymous answer `can_post: false, login`.

**Webhooks.** `review.submitted` — a buyer review arrived for moderation (ids and rating only).
`review.created` — a review became public by moderation (single or batch; approving an approved review sends
nothing). See `/docs/webhooks` §6.

## 4. Recipes

Every write below needs a **prod** key with `catalog:write`; first read `GET /v1/account/token` and check
`scopes` and `contour`.

### 4.1 Reviews only from the shop

1. `PUT /reviews/settings` `{"post_mode":"nobody"}`.
2. `POST /reviews/seller` `{"product_id":<PRODUCT_ID>,"author_name":"Anna","rating":5,"pros":"Soft","cons":"","body":"Great chair","file_ids":[]}`.
3. **Verify:** `GET /reviews?product_id=<PRODUCT_ID>` lists the review with `status: "approved"`,
   `verified_purchase: false`; `GET /reviews/settings` shows `post_mode: "nobody"`.

### 4.2 Moderate the queue

1. `GET /reviews?company_id=<SHOP_ID>&status=pending&page.limit=50` (owner view).
2. Approve: `PUT /reviews/moderate` `{"ids":[<ID>,<ID>],"approve":true}` → `{"updated": 2}`.
   Reject one: `PUT /reviews/<ID>/moderate` `{"approve":false}`.
3. **Verify:** reread with `status=approved`; `summary.rating_count` grew by the approved ratings.

Always send `approve`. Without it the answer is `APPROVE_REQUIRED` (it used to reject silently).

### 4.3 Reply, then remove the reply

`PUT /reviews/<ID>/reply` `{"reply":"Thank you!"}`; `{"reply":""}` removes it. **Verify:** reread the feed —
`seller_reply` and `replied_at` are set, then empty.

### 4.4 Delete

`DELETE /reviews/<ID>` → `{"deleted_after": "<+30 days>"}`. The review is removed from the feed immediately. Its
rating is removed from the product rating immediately.

### 4.5 Your own reviews component

The public feed is enough for a level-3 component or your own markup: read `GET /reviews` on the API origin with the
source, sort and filters; show `summary.histogram`; load the next page with `page.number + 1` ("Show more" or on
scroll); render `files[].url` (images and videos). Never cache `/reviews/me` with the page: it is per buyer.

### 4.6 What a buyer does (storefront, not a key)

A buyer uploads each photo or video with `POST /v1/storages/files` carrying
`metadata.review_target = {"product_id": N}` (or `{"company_id": N}`), puts the bytes to the returned URL, calls
`/done`, then `POST /reviews` with `file_ids`. The file belongs to the shop (its quota). Only the buyer who started
the upload can finish it, in a buyer session: a key gets `REVIEW_UPLOAD_SESSION_ONLY`, a shop member gets
`NOT_FOUND`. Calling `/done` again on a finished upload succeeds and changes nothing. A seller review takes only the
shop's own media library files. A rejected review can be fixed: posting again to the same target replaces it and
returns it to `pending`; its old files may be kept.

**Buyer media rules.**

| Rule | Value |
|---|---|
| formats | `jpg`, `jpeg`, `png`, `webp`; video `mp4`, `mov`, `webm` (checked by extension and by the first bytes of the file) |
| size | image up to 10 MB, video up to 200 MB (checked after the upload; a larger file is deleted, `REVIEW_MEDIA_TOO_LARGE`) |
| per review | up to 10 files, up to 2 of them videos |
| uploads per day | 20 per buyer, 200 per shop (`REVIEW_UPLOAD_LIMIT`) |
| attach | within 24 hours of the upload; an unattached upload is deleted after 24 hours |
| quota | the shop's storage plan (`STORAGE_LIMIT_REACHED`) |
| video | played as uploaded: no transcoding, no server check of the length |

Before moderation the review is hidden. Its files have a long random URL, and anyone with this URL can open them.
The files are not shown in the media library. The files are not shown in the public feed. The upload URL is valid for
15 minutes. During these 15 minutes the buyer can upload the file again. The shop sees the files inside the review.

## 5. Silently ignored

| Supplied | What happens |
|---|---|
| `order_id` in `POST /reviews` | ignored; the server decides the verified purchase |
| `status=pending` from a visitor or another shop | replaced by `approved` (public projection) |
| `product_id` together with `company_id` in `GET /reviews` | `product_id` wins |
| a key on `GET /reviews/me` | answered as an anonymous visitor (`login`) |
| `helpful` vote repeated | counted once; `helpful:false` without a vote changes nothing |
| `review_upload`, `review_state`, `review_target`, `uploader` in `metadata` of a regular upload | rejected with `BAD_METADATA`: only the server writes them |
| `scope_default`/`post_mode` omitted in `PUT /reviews/settings` | that field is kept |

## 6. Limits

| Field / operation | Limit |
|---|---|
| `author_name` | 1–255 characters |
| `body`, `reply` | up to 10000 characters |
| `pros`, `cons` | up to 2000 characters each |
| `rating` | optional, 1–5 |
| `file_ids` | up to 10 per review |
| live buyer reviews per target | one per product and one per shop; a rejected one can be replaced |
| batch moderation | 1–100 ids (more — `INVALID_ARGUMENT` from validation) |
| `page.limit` | 1–100, default 20 |
| deletion window | 30 days |

Codes: `REVIEWS_POSTING_CLOSED`, `REVIEW_PURCHASE_REQUIRED`, `REVIEWS_DISABLED`, `REVIEW_ALREADY_EXISTS`,
`EMPTY_REVIEW`, `APPROVE_REQUIRED`, `REVIEWS_FORBIDDEN` (not a current member of the shop, or settings by an editor),
`REVIEW_NOT_FOUND` / `PRODUCT_NOT_FOUND` (missing, another shop's or hidden — the same answer), `PAT_SCOPE_MISSING`,
`PAT_CONTOUR_MISMATCH` (a draft key on any write: reviews are live, there is no draft of them),
`PAT_METHOD_NOT_ALLOWED` (`POST /reviews`, `POST /reviews/{id}/helpful` with a key).

| Endpoint | Scope | Draft key |
|---|---|---|
| `GET /reviews`, `GET /reviews/{id}` | `catalog:read` opens the owner view; without it — the public one | reads |
| `GET /reviews/settings` | `catalog:read` | reads |
| `POST /reviews/seller`, `PUT /reviews/{id}/moderate`, `PUT /reviews/moderate`, `PUT /reviews/{id}/reply`, `DELETE /reviews/{id}`, `PUT /reviews/settings` | `catalog:write` | `PAT_CONTOUR_MISMATCH` |
| `POST /reviews`, `POST /reviews/{id}/helpful` | buyer session only | — |
| `GET /reviews/me` | public; a key sees the anonymous answer | — |

Settings need the owner or an administrator; the other writes need any current member (owner, admin, editor).

## 7. How this was verified

2026-10-31, integration tests on a temporary PostgreSQL (`-run TestReviews`, `-run TestReviewUpload`):

- Another shop gets `NOT_FOUND` on moderate, reply, delete, seller review and batch.
- Each shop has its own settings. A user who is not a member gets `REVIEWS_FORBIDDEN`. An editor gets
  `REVIEWS_FORBIDDEN` on settings.
- Modes `nobody`, `buyers` and `anyone` work as described. The client `order_id` does not give the badge.
- A rejected review is replaced. A fix that races with approval or deletion does not overwrite them.
- Two parallel posts create only 1 shop review. Votes are counted once.
- Filters, sorts, pages, `media_total` and `exact_product` work. Buyer state works for a visitor, the author and a new
  buyer.
- 6 parallel approvals of 1 product give the exact `rating_count`.
- A batch fails as a whole if one review is deleted at the same time.
- Uploads: only the uploader finishes them; limits hold under 30 parallel requests; a file older than 24 hours is not
  attached; 3 videos are refused.
Key matrix: `tools/mcpcheck/scenarios/reviews.json` on the own stand (prod, read-only, draft and other-shop keys).
Earlier measurements of the seller endpoints with prod and dev keys (2026-10-28) are kept in the reviews line folder.
