# Admin API

> Calling the management endpoints used by the panel with a server key; permissions and the shared list contract.

Source: /en/reference/management-api/

The endpoints used by the panel (`/admin/v1/…`) can also be called server to server; for example to transfer reviews into your own data warehouse or to generate the audit report automatically.

## Authentication

```http
GET https://api.sayproof.example/admin/v1/reviews?status=published
Authorization: Api-Key sp_<prefix>_<secret>
```

Which endpoints a key can call depends on its **permissions**:

| Key permission | Operations covered |
|---|---|
| Read reviews (`read`) | Viewing reviews, invitations, send and connection logs, and statistics |
| Decide on reviews (`moderate`) | Read operations plus approval, rejection and bulk actions |
| Download files (`export`) | Read operations plus the audit report, the activity history and file exports (CSV, Excel, JSON) |
| Change settings (`write`) | Read operations and settings. Only the Sayproof team can grant this permission. |
| Prepare pages on the server (`ssr`) | Only the SSR and signed-in customer endpoints |
| Send orders (`orders`) | Only the order and delivery transfer endpoints (`/v1/ingest/orders`, `/v1/ingest/deliveries`) |

- Server keys created in the panel carry the "Send orders" (`orders`) and "Prepare pages on the server" (`ssr`) permissions and cannot call the Admin API. When `{"kind": "api_key", "label": "…"}` is sent to `POST /admin/v1/channels` in a panel session without `scopes`, the key is also created with these two permissions. The panel does not let you pick permissions; a key with permission to read, decide on reviews or download files is created in a panel session by adding `scopes` to the same request (for example `"scopes": ["read", "export"]`). An empty list returns `400 invalid / scopes_required` and an unknown permission `400 invalid / unknown_scope: <permission>`. Only the Sayproof team can grant the change settings (`write`) permission; when it is requested from a brand's panel, `400 invalid / scope_reserved: write` is returned.
- **The user, channel, key, personal data request and order connection endpoints cannot be called with any key**; these operations can only be performed in a panel session and according to the user's role. As a result, a compromised key cannot be used to create new keys, add users or process personal data requests. When these endpoints are called with a key, `403` is returned: `forbidden / scope_missing` on the user, channel, key and personal data request endpoints; on the order connection endpoints `forbidden / panel_session_required` if the key has `write`, otherwise `forbidden / scope_missing`.
- The invitation delivery settings (`invite_delivery`, `reminder_delivery`, `webhook_url`, `support_url`, `sender_address`) can also only be changed in a panel session. If a `POST /admin/v1/settings` request tries to change one of these fields with a key, it receives `403 refused / panel_session_required`; the other settings can be changed.
- Calls outside the key's permissions receive a `403` response; calls without credentials or with an invalid key receive a `401` response. Every change made with a key is recorded on the **Activity history** screen under the key's name. Reads that show personal data (review details, review history, invitations and the message log, questions, searching by email address, the opt-out check) are also written under the key's name to the **Activity history** › **Personal data access** tab.

## Main endpoints

| Endpoint | Permission | Purpose |
|---|---|---|
| `GET /admin/v1/reviews` | `read` | The review list; rows carry the product name from the catalogue as `product_name` (empty if the catalogue does not have it). `q` searches the title, review text, product name and code, order number, and the author's name and email address; a full review ID returns that review. With `?export=csv`, `?export=xlsx` or `?export=json`, a CSV, Excel or JSON file carrying the same fields is returned (requires the `export` permission). `flag_open` on a row says the review has an automatic check finding waiting for a decision; `source` is `{"platform", "name"}` for a review imported from a marketplace (for example `{"platform": "trendyol", "name": "Trendyol"}`) and `null` for the others. `entry_point=import` lists only imported reviews. |
| `GET /admin/v1/reviews/<id>` | `read` | Review details: the name the writer entered (`author`, always the full name) and whether the writer asked for the name to be hidden on the site (`name_hidden`), proof of purchase, history, automatic checks, reports and the actions that can be taken (`available_events`). The photo and video addresses (`media[].url`, `poster_url`, `srcset`) point to the file endpoint below. |
| `GET /admin/v1/reviews/<id>/media/<file id>/<file name>` | `read` | A photo or video of the review (the original file, the smaller sizes, the video cover). It opens even when the review is not published; the response is not cached (`Cache-Control: private, no-store`) and can be requested in parts with the `Range` header. An unknown file or a file of another review returns `404 not_found`. |
| `DELETE /admin/v1/reviews/<id>/media/<file id>` | `moderate` | Body `{"reason"}`: removes one photo or video of the review. `reason` is one of `personal_data`, `profanity`, `data_subject_request` and `legal_order` (the same list is in `review_media_removal` of `GET /admin/v1/reasons`, and in `media_removal_reasons` of the review details); a missing or unknown reason returns `400 invalid / reason_required` or `400 invalid / reason`. The file is deleted permanently from both the public and the closed area; the review's text, rating and status do not change. The response is `204`. The removal is written to the review's history and to the **Activity history**. An unknown file, a file already removed or a file of another review returns `404 not_found`. |
| `POST /admin/v1/reviews/<id>/transition` | `moderate` | Body `{"event", "reason"}`: actions such as approval, rejection, hiding and removal |
| `GET /admin/v1/moderation/queue` | `moderate` | Reviews awaiting approval; `product_name`, `source` and the `q` search work as in the review list. With `channel=<channel code>`, the list and `counters` cover only that channel's reviews; `counters.elsewhere` (`reviews`, `responses`) is the number of reviews and seller replies waiting for approval in the other channels (both `0` without `channel`). |
| `POST /admin/v1/moderation/bulk` | `moderate` | Body `{"ids", "action": "approve" \| "reject", "reason"}`; up to 200 reviews per request. Reviews that are not awaiting approval are skipped without changes. The response `{"count", "skipped"}` gives the number of reviews handled and skipped. |
| `GET /admin/v1/reviews/<id>/trace` | `export` | The review's file: `{"review", "timeline"}`. The `timeline` lines carry the steps of the review and its seller response, reports, the resolution note and rejection notices (`subject_type: "notice"`) in time order. `?format=csv\|xlsx\|json` downloads it as a file and writes `review_history.downloaded` to the **Activity history**. In CSV and Excel, the **Open in the panel** line of the review part carries the address that opens the panel with this review open; JSON does not have this field. |
| `POST /admin/v1/reviews/<id>/reports/resolve` | `moderate` | Body `{"note"}` (optional, up to 1000 characters): closes the review's open reports. The response `{"closed"}` is the number of reports closed. |
| `GET /admin/v1/reasons` | `read` | Reason lists, in the requested language; `review_media_removal` carries the reasons for removing a single photo or video |
| `GET /admin/v1/metrics`, `GET /admin/v1/insights` | `read` | Statistics. With `channel=<channel code>` the numbers cover only that channel; an unknown channel code gets `400 invalid / channel`. In a `GET /admin/v1/insights` request, a channel sent with a `market` it does not belong to gets `400 invalid / channel_market_mismatch`. The `ai` field of the `GET /admin/v1/insights` response is the AI package's summary for the period (arrives with v2; currently `null`). |
| `GET /admin/v1/invites`, `GET /admin/v1/send-logs` | `read` | Invitations and the message log. In the invitation list, `sent=1` returns only invitations whose email was sent; with `channel=<channel code>`, the list and `counters` are limited to that channel's orders. In the message log, `q` also searches the recipient's address; `channel_kind` (`email` or `webhook`; `whatsapp` arrives with v2, currently no message is sent by this route) filters by sending route; `channel=<channel code>` limits the list and `counters` to that channel's messages (invitations by the order's channel, rejection notices by the review's, answer notices by the question's). If the customer's personal data was erased with a data protection request, `recipient` is empty and `recipient_erased` is `true`. The invitations' `counters.conversion` is the share of sent invitations with at least one product reviewed. |
| `POST /admin/v1/invites/retry` | `write` | Queues invitations in the "Failed" state again. The body is optional: `{"ids": […]}` (up to 500 invitations) and `{"channel": "<channel code>"}` (only that channel's invitations); without either, all of them. Invitations whose link has expired are skipped. The response is `{"queued", "expired"}`. |
| `GET /admin/v1/diagnostics` | `read` | Connection log |
| `POST /admin/v1/opt-outs/check` | `read` | Body `{"addresses": […]}` (up to 1,000 addresses per request): returns the addresses in the list that do not want emails: `{"opted_out": [{"address", "channel_kind", "reason", "created_at"}]}`. A phone number can be given in international form (`+905321112233`) or written the local way for your channels' market (`0532 111 22 33`); the answer returns it as you sent it. |
| `GET /admin/v1/opt-outs/export` | `export` | Downloads the whole list of people who do not want emails; `?format=csv` (default), `?format=xlsx` or `?format=json`. The file carries not the address itself but the SHA-256 value of the lower-cased email address (`address_hash`, `channel_kind`, `reason`, `created_at`). Phone numbers in WhatsApp rows (arrives with v2; currently the list holds no phone rows) are stored as a keyed hash specific to Sayproof and appear in the file with that value; to match your phone list, use the `POST /admin/v1/opt-outs/check` endpoint. Every download is recorded in the **Activity history**. |
| `GET /admin/v1/policies` | `read` | The market rules versions the brand sees (`profiles`; Sayproof rules and the brand's own versions, `own`, `in_force`, `scheduled`) and the limits per market (`limits`). With `channel=<channel code>`, only the rules of that channel's market and of that channel are returned. A new version (`POST`) is opened only in a panel session, with the "Can manage market rules" permission: [Updating the rules](/en/features/market-rules.md#updating-the-rules). |
| `GET /admin/v1/method-statement` | `read` | The method statement rows for each active channel and language (`rows`) and the verified domains you can use for your own page (`domains`). Each row carries `channel`, `channel_label`, `market`, `language`, `state`, `external_url`, `hosted_url`, `effective_url`, `current_fingerprint`, `confirmed` and the statement text (`statement`). `?channel=<channel code>&lang=en` returns one channel's statement and the status of your own page. An unknown channel code receives `400 invalid / channel`. |
| `POST /admin/v1/method-statement`, `POST /admin/v1/method-statement/confirm` | `write` | Saves the address of your own page: body `{"channel", "language", "external_url"}`; an empty `external_url` returns the link to the Sayproof page. Confirmation: body `{"channel", "language", "fingerprint"}`; `fingerprint` is the row's `current_fingerprint`, and if the text changed in the meantime `409 statement_changed` is returned. An unknown channel code receives `400 invalid / channel`. |
| `POST /admin/v1/audit-file` | `export` | Body `{"market", "channel", "start", "end"}`: generates and stores the audit report. With neither `market` nor `channel` the report covers all channels, with only `market` all channels of that market, and with `channel` only that channel; the market is taken from the channel. An unknown channel code gets `400 invalid / channel`, and a `market` that does not match the channel's market gets `400 invalid / channel_market_mismatch`. The stored report and its file name carry the channel code; the list of earlier reports can be filtered with `channel`. |
| `GET /admin/v1/audit-file/<id>?format=json\|csv\|xlsx` | `export` | Downloads a stored report; the `X-Sayproof-Fingerprint` header is the verification code. In CSV and Excel, the rows of removed and hidden reviews have an **Open in the panel** column; in Excel the addresses can be clicked. |
| `GET /admin/v1/audit-log` | `export` | Activity history. The **Download** menu in the panel has Sayproof build the file with `POST /admin/v1/audit-log/export`: body `{"format", "title", "filters", "columns", "labels", "changes"}`. The rows are read on the server from these records with the same filters as the list (`filters`); at most 10,000 records. `columns` holds the names of the eight columns, `labels` the on-screen names of the action, role and target codes (`action`, `role`, `target`) and the `system` word, and `changes` the **What changed** text per record number; the last column is the action code. The same request records the download in the **Activity history** as the `audit_log.downloaded` action with the format, the number of rows and the file's SHA-256 hash (`fingerprint`). A malformed request returns `400 invalid / rows`; more than 10,000 records returns `400 invalid / rows_over_limit`. |
| `GET /admin/v1/access-log` | `export` | Personal data access (**Activity history** › **Personal data access**). Rows carry `action` (`review.viewed`, `reviews.searched`, `review_history.viewed`, `invites.listed`, `send_logs.listed`, `questions.listed`, `question.viewed`, `subject_requests.listed`, `subject_request.viewed`, `subject_request.previewed`, `users.listed`, `opt_out.checked`, `imported_reviews.listed`), `actor_id`, `actor_role`, `via` (`session`, `api_key`, `support`, `console`), `target_type`, `target_id`, `count` (the number of records carrying personal data that the read returned) and `at`. Filters are `action`, `actor` and `via`; `q` searches the person and the record number. Entries cannot be changed and are deleted after 365 days. |
| `GET\|POST /admin/v1/invite-templates` | `write` | Invitation email templates. Rows carry the `channel` field (the channel code; empty for a template made for all channels). If `channel` is given when saving, the template is used only for that channel's orders and `market` is taken from the channel; an unknown channel code receives `400 invalid / channel`. |
| `GET /admin/v1/questions` | `read` | The question list. Filters: `state`, `market`, `channel`, `unanswered=1`, `answers_waiting=1`; `q` searches the question text, the asker's name and email address and the product code. The response also carries `counters` (`waiting`, `unanswered`, `answers_waiting`) and whether the brand's questions and answers are on (`enabled`). |
| `GET /admin/v1/questions/<id>` | `read` | Question details: the asker's name (`asker_name`, the full name) and whether it is hidden on the site (`name_hidden`), the answers (each carries `author_name` and `name_hidden`), the result of the email each answer sent to the asker (`notice`), the possible actions (`available_events`) and the history (`ledger`) |
| `POST /admin/v1/questions/<id>/transition`, `POST /admin/v1/answers/<id>/transition` | `moderate` | Body `{"event", "reason"}`: approve, reject, review again, hide and show again |
| `POST /admin/v1/questions/<id>/answer` | `moderate` | Body `{"body"}`: writes a brand answer to a live question and publishes it at once; the response is the answer's row with `201`. An answer that fails the checks gets `409 answer_refused` (`text_steering`, `text_health_claim`, `text_personal_data`, `text_profanity`, `question_not_published`). |
| `POST /admin/v1/reviews/<id>/seller-response/draft` | `moderate` | A seller reply draft from the AI package (arrives with v2; currently `409 seller_response_refused`, with `detail` `ai_package_off` or `ai_feature_closed`): body `{"tone"}` is optional (`formal` or `warm`; without it, the brand's default tone), response `{"draft"}`. The draft is not saved. |
| `GET /admin/v1/ai` | `read` | The AI package's status (arrives with v2; currently `404`). The package request `POST /admin/v1/ai/requests` also arrives with v2; currently `404`. |
| `GET\|POST /admin/v1/settings` | `write` | Settings. `time_zone` in the response is the time zone the sending hours follow (for example `Europe/Istanbul`). Other fields include `questions_enabled`, `reply_tone` (`formal` or `warm`), `ai_translation_enabled`, `ai_summary_enabled` (these three arrive with v2; currently saved with no effect), `syndication_enabled`, `reminder_consent` (`not_required` or `required`) and the sending routes available in the installation (`deliveries`). The sending route fields (`invite_delivery`, `reminder_delivery`, `webhook_url`, `support_url`, `sender_address`) change only in a panel session; with a key, `403 refused / panel_session_required`. Choosing a route that is off in the installation returns `400 invalid / invite_delivery_unavailable` or `reminder_delivery_unavailable`. |

## Personal data requests

The personal data request endpoints cannot be called with any key; they only work in a panel session, for a user with the "Can handle personal data requests" permission. A request record does not store the customer's email address in plain form, and the data copy prepared for an access request is not stored either.

Request rows (the rows of the `GET /admin/v1/subject-requests` list and the request responses) carry the `can_copy` field. If its value is `true`, the request is a completed access request and its copy can be downloaded again.

`POST /admin/v1/subject-requests/<id>/copy` prepares the copy of a completed access request again from the current records. The body carries the customer's email address from the request:

```http
POST https://api.sayproof.example/admin/v1/subject-requests/<id>/copy
Content-Type: application/json

{"identifier": "jane@example.com", "format": "xlsx"}
```

Without `format` the response is a JSON body; with `csv`, `xlsx` or `json` the same content is returned as a file (CSV and Excel layout: [Access file](/en/features/personal-data.md#access-file)).

| Response | Meaning |
|---|---|
| `200` | The request's details (`id`, `kind`, `state`, `received_at`, `completed_at`, `affected_reviews`, `purged_media`) together with the `export` and `export_details` fields. The note written on the request and the user who handled it are not included in the copy; for the content, see [Access file](/en/features/personal-data.md#access-file). |
| `400 invalid / identifier_required` | `identifier` is missing or empty |
| `400 identifier_mismatch` | The address does not match the one in the request record; no copy is given |
| `400 invalid / format` | `format` is not `csv`, `xlsx` or `json` |
| `404 not_found` | There is no request with this ID |
| `409 not_copyable` | The request is not a completed access request |

Every successful request is recorded in the **Activity history** with the action `subject_request.copied`; on screen it shows as "Copy of a personal data access request downloaded".

The record of any request is read with `GET /admin/v1/subject-requests/<id>`; `?format=csv`, `?format=xlsx` or `?format=json` downloads the record as a file. This download is written to the **Activity history** with the action `subject_request.downloaded` ("Personal data request record downloaded").

## Order connections

The endpoints of connections that read orders from platforms such as Akinon, Shopify and WooCommerce cannot be called with any key; they only work in a panel session, for a user with the "Can change settings" permission. The panel side and the reading rules: [Akinon orders](/en/integrate/akinon.md), [Shopify orders](/en/integrate/shopify.md), [WooCommerce orders](/en/integrate/woocommerce.md).

| Endpoint | Purpose |
|---|---|
| `GET /admin/v1/order-sources` | The connections (`items`) and the platforms with a ready connection (`platforms`; each platform's connection fields are in `fields`) |
| `POST /admin/v1/order-sources` | Sets up a connection. Body: `platform`, `channel` and the platform's connection fields (Akinon: `base_url`, `remote_channel`, `username`, `password`; Shopify: `base_url`, `client_secret`, optionally `client_id`; WooCommerce: `base_url`, `consumer_key`, `consumer_secret`); optionally `backfill_days` (1–365, default 30), `interval_minutes` (5–1440, default 15) and `enabled` |
| `POST /admin/v1/order-sources/check` | Tries without saving. The body is the same as for setup; `channel` is not needed. |
| `GET\|PATCH\|DELETE /admin/v1/order-sources/<id>` | Shows, changes (`enabled`, `interval_minutes`, `backfill_days` and the platform's connection fields; sign-in details only change when they are sent, and if `base_url` changes to another address the sign-in details must be sent again) or removes the connection |
| `POST /admin/v1/order-sources/<id>/check` | Tries with the saved details |
| `POST /admin/v1/order-sources/<id>/run` | Starts a read immediately and returns `202`; `409 source_inactive` for a paused connection, `409 channel_inactive` while the channel is off |

Sign-in details (such as the password, Client secret, Consumer key and Consumer secret) never appear in answers; the fields that are not sign-in details come back in `values`, and the `has_credentials` field shows whether sign-in details are saved. `channel_active` tells whether the connection's channel is on; while the channel is off, the connection does not read. The try answer does not contain the customer's email, only whether there is one (`has_email`):

```json
{
  "orders": 3,
  "complete": true,
  "skipped": {"status_unknown": 1},
  "sample": [
    {"external_id": "100234", "placed_at": "2026-09-28T10:12:00+00:00", "payment_state": "paid", "lines": 2, "states": ["delivered"], "has_email": true}
  ]
}
```

`skipped` counts the orders that could not be read, by reason: `order_incomplete` (number or date missing), `status_unknown` (status not recognised), `line_incomplete` (product code missing on a line), `order_without_lines` (no readable product lines) and, on Shopify only, `lines_truncated` (more than 50 product lines; the first 50 were read). In the connection's last read summary (`last_report.skipped`), orders that could not be saved in Sayproof are counted under `order_refused`.

## Marketplace reviews

The endpoints of connections that take reviews from marketplaces cannot be called with any key; they only work in a panel session, for a user with the "Can change settings" permission. The two endpoints that list and remove the reviews cannot be called with any key either; they only work in a panel session, for a user with the "Can handle personal data requests" permission. The panel side and the rules: [Marketplace reviews](/en/features/marketplace-reviews.md).

| Endpoint | Purpose |
|---|---|
| `GET /admin/v1/marketplace/sources` | The connections (`items`; each carries `last_run`, `runs` and `attention`; `attention` gives the kind `held` for a connection whose removals are held back or `failed` for one that could not be read several times in a row, with the count and the last error code, and is `null` when there is no problem), the marketplaces (`platforms`) and the ways reviews arrive (`routes`) |
| `POST /admin/v1/marketplace/sources` | Sets up a connection. Body: `platform` (`trendyol`, `hepsiburada`, `amazon`, `n11`, `ciceksepeti`), `channel`, `seller_id`, `route` (`file` or `feed`); for the `feed` route `feed_url` (`https`) and an optional `secret`. The setup is written to the **Activity history** with who did it. A second connection for the same channel, marketplace and seller number gets `409 source_exists`. |
| `GET\|PATCH\|DELETE /admin/v1/marketplace/sources/<id>` | Shows, changes (`enabled`; for the `feed` route `feed_url` and `secret`; `seller_id` cannot be changed, `400 seller_id_locked`) or removes the connection (`409 source_has_reviews` while imported reviews are live or a review removed by a personal data request is recorded). `secret` is never returned; `has_secret` says whether one is saved. |
| `POST /admin/v1/marketplace/sources/<id>/runs` | Starts a read and returns `202`. For the `file` route send `file` as `multipart/form-data` (CSV, `.xlsx` or JSON; at most 20 MB and 100,000 rows); the file is a full comparison. For the `feed` route the body takes `scope` (`new` or `full`, default `full`). Both take `confirm_removals` (when `true`, removals that were held back are applied). `409 run_in_progress` while another read runs, `409 source_inactive` for a paused connection or a closed channel. If the file cannot be read the answer is `400` with `column_missing: <column>`, `file_unreadable`, `unsupported_format`, `too_large` or `too_many_rows` in `detail`. |
| `GET /admin/v1/marketplace/sources/<id>/runs` | The latest reads; the counts of each read and the matching report in `report` |
| `POST /admin/v1/marketplace/sources/<id>/check` | Tries without saving anything: send `file` for the `file` route, the address is read for the `feed` route. The answer holds `rows`, `eligible`, `would_import`, `would_update`, `would_remove`, `matched_barcodes`, `unmatched_barcodes`, `unmatched` and `skips`. |
| `GET /admin/v1/marketplace/imported-reviews` | Lists imported reviews; `q` searches the review text, author name and marketplace review number, `platform` filters. |
| `POST /admin/v1/marketplace/removals` | Removes the picked imported reviews through a personal data request. Body: `reviews` (imported review ids, at most 100) and an optional `note`. The `201` answer holds `id`, `reviews`, `media_purged` and `state`. The request is written to the **Personal data requests** list as a deletion. |

The `state` of a read row is `queued`, `running`, `succeeded`, `held` (removals held back) or `failed`; `error` is the code of a failed read. `report` carries `matched_barcodes`, `unmatched_barcodes`, `unmatched` (`barcode`, `reason`: `no_product`, `no_barcode` or `ambiguous`, `reviews`), `skips`, `photos_missing` and, for a held read, `held_products`.

## The shared list contract

List endpoints use the same parameters and the same response format.

Parameters: the name of every filter defined on the endpoint, `q` (search), `from` and `to` (a date or an exact time; if only a date is given, the whole day is included), `sort`, `page` and `page_size` (up to 100).

```json
{
  "items": [],
  "total": 0, "page": 1, "pages": 1,
  "facets": { "state": [{ "value": "sent", "count": 12 }] },
  "filters": [{ "name": "state", "group": "invite_state", "faceted": true, "advanced": false, "kind": "value" }],
  "sorts": ["oldest", "recent"]
}
```

- `filters` lists the filters the endpoint accepts. `kind` indicates the filter's type: `value` (a list of values), `flag` (`=1`) or `choice` (named options).
- `facets` counts the values of each filter while excluding that filter itself.

Values in responses are returned as codes (`published`, `profanity` and so on). To show them on screen, convert reason and status names into text using the `GET /admin/v1/reasons` response and your own translation files.

## Examples

### Fetching published reviews page by page (Python)

```python
import os

import requests

BASE = os.environ["SAYPROOF_BASE"]
HEADERS = {"Authorization": f"Api-Key {os.environ['SAYPROOF_ADMIN_KEY']}", "Accept-Language": "en"}


def published_reviews(since):
    page = 1
    while True:
        answer = requests.get(
            f"{BASE}/admin/v1/reviews",
            params={"status": "published", "from": since, "sort": "oldest", "page": page, "page_size": 100},
            headers=HEADERS,
            timeout=30,
        )
        answer.raise_for_status()
        body = answer.json()
        yield from body["items"]
        if page >= body["pages"]:
            return
        page += 1


for review in published_reviews("2026-09-01"):
    print(review["id"], review["rating"])
```

### Generating and verifying an audit report (Node.js)

```js
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";

const base = process.env.SAYPROOF_BASE;
const headers = { Authorization: `Api-Key ${process.env.SAYPROOF_EXPORT_KEY}`, "Content-Type": "application/json" };

const created = await fetch(`${base}/admin/v1/audit-file`, {
  method: "POST",
  headers,
  body: JSON.stringify({ market: "TR", start: "2026-09-01", end: "2026-09-30" }),
}).then((answer) => answer.json());

const download = await fetch(`${base}/admin/v1/audit-file/${created.copy.id}?format=json`, { headers });
const content = Buffer.from(await download.arrayBuffer());
const fingerprint = download.headers.get("X-Sayproof-Fingerprint");

if (createHash("sha256").update(content).digest("hex") !== fingerprint) {
  throw new Error("audit_file_fingerprint_mismatch");
}
await writeFile(`audit-${created.copy.id}.json`, content);
```

The SHA-256 value of the JSON file is the same as the verification code; this comparison shows that the file has not been changed since it was downloaded.

### Looking up addresses that do not want emails (Python)

To leave out the addresses on Sayproof's list before your own email sends, look the addresses up in groups of 1,000. The response returns only the addresses found on the list.

```python
import os

import requests

BASE = os.environ["SAYPROOF_BASE"]
HEADERS = {"Authorization": f"Api-Key {os.environ['SAYPROOF_ADMIN_KEY']}"}


def opted_out(addresses):
    found = set()
    for start in range(0, len(addresses), 1000):
        answer = requests.post(
            f"{BASE}/admin/v1/opt-outs/check",
            json={"addresses": addresses[start : start + 1000]},
            headers=HEADERS,
            timeout=30,
        )
        answer.raise_for_status()
        found |= {row["address"] for row in answer.json()["opted_out"]}
    return found
```
