---
title: "Land a retailer's own sell-out report against one named account"
method: POST
path: "/api/v1/sell-out/import"
tags: ["sell_out"]
---

# Land a retailer's own sell-out report against one named account

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

## Request

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

Import a retailer report for the explicitly named account_id; the account is never inferred. The account declaration and mapping must be confirmed. Reports without a date require week_start (and week_end where appropriate). Unrecognized formats or incomplete declarations are refused. Existing idempotency and account-bound authorization apply to both input sources. 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 confirm_sell_out_declaration to confirm account answers before importing; use preview_sell_out to inspect the same file without writing. not_matched.by_reason counts every row by resolver outcome, including matches; not_matched.total counts refused rows, and not_matched.by_reason_words explains each outcome. Repeated files report both rows submitted and rows actually changed. Requires Tightly Connect; unavailable plans return 403 plan_excludes.

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

## Query parameters

- `account_id` string, required
- `week_start` string, date

## 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 landed. `recognised_as` and `period`, `rows_read` / `rows_written` / `rows_changed`, `not_matched` (`total`, `by_reason`, `by_reason_words`, `examples`), the account's `confirmed` declaration, the `we_read_it_as` the profile proposed, and `reorder_rules` where the file carried the retailer's own reorder policy.

- object
  - `data` object
    - `confirmed` object, nullable: What this account has already confirmed, so a drift reads as a difference rather than a fresh interrogation.
    - `needs_you` object[]: The confirmations still outstanding, each a question with the answer we would propose.
    - `not_matched` object: Every row under the outcome the resolver reached. `total` is the rows no product could be resolved for. `by_reason` is keyed by the resolver's own tokens (`matched`, `matched_on_sku_only`, `invalid_barcode`, `unknown_product`, `no_identifier`) and counts all of them, 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: What read the file: the format profile that claimed it, or, where none did, the column mapping confirmed for this account, named with its version.
    - `refused` string, nullable
    - `reorder_rules` object, nullable: The account's OWN reorder policy, where their file carried one: `read` is how many (account, variant) rules landed and `not_stated` how many of their rows could not state one. NULL rather than a zero for an account that sends no policy sheet at all, which is almost all of them: measured across nine real retailer exports, one does. "0 rules read" on every import would read as a failure of ours rather than as a thing that account does not do, so absence is absence, and a zero beside a refusal count means their sheet was there and none of it landed.
    - `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, no account, a format we do not recognise yet, a file too large to send inline, or a declaration this account has not confirmed, each with its own sentence, and the same payload so the four questions can be answered without re-uploading. An account whose column mapping is confirmed but cannot read this particular file is refused with `data.reason`: `not_declared`, `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. Where the mapping speaks, the reason names the step that is actually missing rather than calling a retailer you import every week a stranger. 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, is refused as a format we do not recognise yet. The tell is that recognised_as is null for an account whose mapping you published: the mapping is the thing to look at, not the file. Publishing the account's column mapping again from the Map step is the recovery.
- `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)
