---
title: "Recognise a retailer's report and say what would happen, writes nothing"
method: POST
path: "/api/v1/sell-out/preview"
tags: ["sell_out"]
---

# Recognise a retailer's report and say what would happen, writes nothing

`POST /api/v1/sell-out/preview`

## Request

- Base URL: `https://api.app.tightly.io`
- URL: `https://api.app.tightly.io/api/v1/sell-out/preview`
- Auth: HTTP bearer

Preview the recognized profile, period, matched rows and refusal reasons without importing sell-out. account_id is optional; when supplied, the account's confirmed declaration and mapping are used. Unrecognized account reports return a mapping proposal for review. Preview outcome counts use the same resolver as import; rows_written and rows_changed remain zero. Send original CSV/XLSX/XLS bytes inline (up to 8 MiB), or use /files/presigned-url and send application/json with exactly {"s3_key":"organization/user/uploaded-file.csv"} (up to 100 MiB). Uploaded keys must belong to the authenticated organization; foreign keys and URLs are refused. Both sources use the same native retailer parser and preserve its period and provenance. Use import_sell_out to record the report; preview never imports it. Use get_sell_out_declaration to check the account declaration without supplying a file. Requires Tightly Connect; unavailable plans return 403 plan_excludes.

Scope: `sell_out:write`, which includes `sell_out:read`.

## Query parameters

- `account_id` string
- `file_name` string

## Headers

- `Tightly-Version` string
- `Idempotency-Key` string

## Request body

- object
  - `s3_key` string, required: Key from the authenticated organization's existing presigned upload flow.

## Response `200`

What the import would do. `rows_written` and `rows_changed` are zero here by construction, this call writes nothing.

- object
  - `data` object
    - `confirmed` object, nullable: What this account has already confirmed, so a drift reads as a difference rather than a fresh interrogation. It travels beside `mapping` as well: an account that answered the four and has no profile yet sees its own answers on the proposal rather than a blank slate.
    - `mapping` object, nullable: Present only on a named account no profile recognises. `decisions` holds one entry per field sell-out is stored in, keyed by field, each with the source column proposed, a state, a confidence and the reason for it; `sample_values` holds up to three real values per proposed column; `summary` counts what is matched and what still needs a decision; `declared_terms` carries the account's four answers as rows in the same grammar; `sample` reports how much of the file was read and archived. Absent, or null, whenever a profile claimed the file.
    - `needs_you` object[]: The confirmations still outstanding, each a question with the answer we would propose.
    - `not_matched` object: What the import will find, counted by the resolver the import runs. `total` is the rows no product could be resolved for. `by_reason` counts EVERY row under its outcome, keyed by the resolver's own tokens (`matched`, `matched_on_sku_only`, `invalid_barcode`, `unknown_product`, `no_identifier`), so the counts sum to `rows_read`. `by_reason_words` carries one sentence per counted key, and `examples` names the values that could not be placed.
    - `period` object, nullable: The week the file resolved to, as start and end. Where the account's own column mapping reads the file, the week is taken from its period-end column when one is mapped and from its period-start column otherwise, and either way it is widened to the week the account confirmed its weeks end on.
    - `recognised_as` string, nullable: The format profile that claimed the file.
    - `refused` string, nullable
    - `reorder_rules` object, nullable: ALWAYS NULL HERE. import_sell_out fills this with what the retailer's own reorder policy sheet landed, and reading a policy sheet is a write, so the preview does not attempt one. A null here says nothing about whether the file carries a policy sheet, which is the one thing on the response the preview does not foretell.
    - `rows_changed` integer: What actually moved. Re-sending a week you already sent is a no-op, and this says so.
    - `rows_read` integer
    - `rows_written` integer
    - `we_read_it_as` object, nullable: The declaration the file itself proposes, to accept or correct. `week_ends_on` and `week_anchor_dow` name the day the retailer's reporting week ends, whichever day the file's own dates carry: a retailer who dates each week by its first day is proposed the last day of that week, which is the question the account was asked.
  - `message` object: severity, service and desc.

## Other responses

- `400`: No file was received, the file is over 8 MB, or the format is not recognised yet. The refusal says what to do next. An unrecognised file is a request for a profile, not a dead end. An account whose column mapping is confirmed but cannot read this particular file is refused here too, and that refusal carries `data.reason`: `not_declared` (the four have not been answered), `no_door` (the account reports by door and no column holds the door code), `mixed_currency` (the file prices in two currencies), `unreadable_file` or `no_columns`. Those five are the whole list this door can answer with, and each names the next step rather than calling the retailer unknown. ONE CASE ESCAPES THAT. A stored mapping too broken to compile at all is read as no mapping, so the file falls through to the format library and, failing that, to the Map step, exactly as a retailer nobody has mapped would. The tell is that recognised_as is null for an account whose mapping you published, and the mapping is what to look at.
- `401`: No usable key: malformed, unknown, revoked, expired or stopped. One refusal covers all five, telling a caller which is which maps the surface for them. A request with no Authorization header at all is refused 400 before any key is looked for.
- `403`: The key may not reach this operation. `plan_excludes` when Sell-out is sold with Essentials+ and this organisation's plan does not include it; `scope_missing` when the key does not hold Sell-out; `organization_mismatch` when the path names an organisation that is not the key's; `ip_not_allowed` when the caller's address is outside the key's allowlist; `account_mismatch` when a key issued to one account names another in `account_id`, and the sentence names both accounts.
- `429`: The key has spent its allowance for this minute. 600 reads and 120 writes a minute, counted per key, refused calls included. `Retry-After` says how long to wait.
- `503`: The database is briefly out of reach, which is what a failover or a full connection pool looks like from outside. Wait the seconds `Retry-After` names and send the request again. A read is safe to repeat. A write is not, because the connection was lost in the middle of it, so read the object back before sending it a second time. Branch on the status rather than on `code`, which is `POSTGRES_UNAVAILABLE` from one of the two seams that raise this and `SERVICE_UNAVAILABLE` from the other.

---

[API](https://skmtc.dev/tightly/apis/tightly-api.md) · [All operations](https://skmtc.dev/tightly/apis/tightly-api/llms.txt) · [OpenAPI document](https://skmtc.dev/tightly/apis/tightly-api/revisions/199f7eb35afb?raw)
