Skip to content

Admin API

Markdown

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

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.

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.
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.
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.

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:

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).

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.
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”).

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, Shopify orders, WooCommerce orders.

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):

{
"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.

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.

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.

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).

{
"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.

Fetching published reviews page by page (Python)

Section titled “Fetching published reviews page by page (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)

Section titled “Generating and verifying an audit report (Node.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)

Section titled “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.

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