> ## Documentation Index
> Fetch the complete documentation index at: https://docs.depict.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Push Product API reference

> Every field, limit and per-line response code for the Push Product API ingest endpoint.

<Warning>
  **Beta — in development.** This API is being finalized. The shapes on this
  page match the current implementation, but details can still change before
  general availability. Talk to your Depict contact before building against it.
  The [integration guide](/data-ingestion-guide/push-product-api) covers the
  concepts: the three-level model, operations, and full sync.
</Warning>

## POST /push/v1/\{merchant}/ingest

```
POST https://foundation-ingestion.depict.ai/push/v1/{merchant}/ingest
```

`merchant` is your Depict merchant id. The request body is JSONL. Each line is
one JSON object carrying one operation. The endpoint processes lines in order
and responds with JSONL too, one line per input line, in the same order.

### Authentication

```
Authorization: Bearer <your api key>
```

A missing or wrong key returns `401`. The response does not distinguish an
unknown merchant from a wrong key.

<Note>
  **Your Depict contact issues this key** during the beta. It is specific to
  this API, not the credentials Depict's storefront or portal APIs use. It
  authenticates a server-to-server write API, so keep it secret and send it
  only from your backend, never from a browser.
</Note>

### Query parameters

| Parameter           | Values          | Meaning                                                                                                                                                                                                                                           |
| ------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode`              | `full_sync`     | Treats this request as the complete catalog and removes products absent from it, subject to the [guardrails](#full-sync-response-headers). Recommended for feed-style integrations that send a periodic full export. Omit for incremental pushes. |
| `dry_run`           | `true`, `false` | Validates the request and returns the full per-line receipts without writing anything or starting processing. See [Dry run](#dry-run).                                                                                                            |
| `allow_mass_delete` | `true`, `false` | Confirms a `full_sync` sweep that would remove more than half of the live catalog. The endpoint withholds such a sweep without it.                                                                                                                |

Parameters parse strictly. Any value other than the ones listed returns `400`
rather than defaulting to off, so a misspelled preview flag can never run a
real ingest.

### Request-level errors

Request-level failures return a JSON error instead of receipts:

| Status | Meaning                                                                               |
| ------ | ------------------------------------------------------------------------------------- |
| `401`  | Missing `Authorization: Bearer` header, unknown merchant, or wrong key                |
| `400`  | Unrecognised query parameter value, or the merchant is not set up for push ingestion  |
| `405`  | Any method other than `POST`                                                          |
| `413`  | Body over 100 MB, or a single line over 8 MB. Split the catalog into several requests |

`401`, `400` and `405` refuse the request before any line is processed. The
body caps can also trip mid-stream, because the endpoint processes lines as
they arrive. Earlier lines are then already applied, and the `413` body names
how many lines were processed.

The endpoint reports everything else per line, with HTTP status `200`. An
invalid line gets a failure receipt and processing continues with the next
line.

### Dry run

Add `?dry_run=true` to validate a request without changing anything. The
endpoint runs the same per-line validation, returns the same receipt
contract, and marks the response with an `X-Push-Dry-Run: true` header. It
writes nothing and starts no processing. Authentication is still required.

With `mode=full_sync`, a dry run also previews the sweep. The response
carries `X-Push-Full-Sync-Would-Sweep` with the number of products a real
request would remove, and the guardrail verdicts appear in
`X-Push-Full-Sync-Sweep-Skipped` exactly as they would for real. Nothing is
deleted.

The endpoint evaluates receipts against the currently stored state, so a dry
run is a statement about now, not a reservation:

* `success: true` means the line passes validation now. A later real push can
  still fail on state that changed in between, for example a `conflict` from
  concurrent edits.
* The stateful outcomes `unchanged`, `stale_source_updated_at` and
  `already_absent` compare against what is stored at dry-run time.
* A variant line whose parent product exists only earlier in the same request
  validates against that in-request tree and never reports `unchanged`,
  because there is no stored variant to compare with.

## Machine-readable schema

The API serves the full JSONL line contract as a JSON Schema (draft 2020-12):

```
GET https://foundation-ingestion.depict.ai/push/v1/schema
```

No authentication required. Depict generates the schema from the exact
validator the ingest endpoint runs. Its `$id` names the deployed version it
belongs to, and responses carry an `ETag` for caching. Use it to generate
types for your client or to validate lines before sending them.

<Note>
  **Passing the schema is necessary, not sufficient.** The API enforces some
  rules that JSON Schema cannot express: no leading or trailing whitespace or
  control characters in ids, no `:` in pricelist ids and market names,
  duplicate-id rejection, and references between lines. The per-line receipts
  are the authority.
</Note>

## The line envelope

Every line is an object with the same three fields:

<ResponseField name="operation" type="&#x22;upsert&#x22; | &#x22;delete&#x22;" required>
  What to do with the object.
</ResponseField>

<ResponseField name="type" type="string" required>
  For `upsert`: `product`, `variant`, `pricelist`, `warehouse`, `market` or
  `category`. For `delete`: `product`, `product_group` or `variant`.
</ResponseField>

<ResponseField name="object" type="object" required>
  The payload, shaped by `type`. See below.
</ResponseField>

<Warning>
  **Validation is strict at every level.** An unknown field anywhere in the
  object fails the line with `validation_error`. The API never drops a field
  silently.
</Warning>

### External ids

Every object is identified by `external_id`, your identifier, 1–255
characters, with no leading or trailing whitespace and no control characters.
An id only has to be unique within its parent. Ids are contractual. Depict
derives its internal ids from yours, so changing an `external_id` is a delete
plus a create.

Pricelist ids, market ids and market names additionally must not contain `:`,
because they become part of field names in Depict's search index.

### References between lines

A product's `pricelist_external_id`, `warehouse_external_id` and `category_ids`
references, and a market's pricelist and warehouse references, must resolve to
an entity that already exists, pushed in an earlier request or on an earlier
line of the same request. Put configuration lines before the products that use
them. An unknown reference fails the line with `validation_error` and an error
naming the exact path.

## Upsert: product

`{"operation":"upsert","type":"product","object":{...}}` carries the whole
product tree. An upsert replaces the stored tree. The endpoint removes product
groups and variants absent from the pushed tree.

<ResponseField name="external_id" type="string" required>
  Product id, unique across your catalog.
</ResponseField>

<ResponseField name="status" type="&#x22;active&#x22; | &#x22;draft&#x22; | &#x22;archived&#x22;" default="active">
  Only `active` products are shown.
</ResponseField>

<ResponseField name="product_type" type="string">
  Product type, for example `T-shirts`. 1–255 characters.
</ResponseField>

<ResponseField name="brand" type="string">
  Brand name. 1–255 characters.
</ResponseField>

<ResponseField name="tags" type="string[]" default="[]">
  Up to 250 tags, each 1–255 characters.
</ResponseField>

<ResponseField name="source_updated_at" type="string">
  ISO 8601 timestamp with timezone (`2026-08-26T09:12:00Z` or with a numeric
  offset). When present, Depict applies last-write-wins. A pushed product older
  than the stored one is skipped with `stale_source_updated_at`, which makes
  replays and out-of-order delivery safe.
</ResponseField>

<ResponseField name="media" type="Media[]" default="[]">
  Up to 250 media objects, described under [Media](#media). `external_id`s
  must be unique within the product.
</ResponseField>

<ResponseField name="product_groups" type="ProductGroup[]" required>
  1–500 product groups, in display order. `external_id`s must be unique within
  the product.
</ResponseField>

### Product group

<ResponseField name="external_id" type="string" required>
  Group id, unique within the product.
</ResponseField>

<ResponseField name="status" type="&#x22;active&#x22; | &#x22;draft&#x22; | &#x22;archived&#x22;" default="active" />

<ResponseField name="category_ids" type="string[]" default="[]">
  Up to 100 category `external_id`s. Each must reference a pushed category.
</ResponseField>

<ResponseField name="variants" type="Variant[]" required>
  1–1000 variants, in display order. `external_id`s must be unique within the
  group.
</ResponseField>

<ResponseField name="media_external_ids" type="string[]">
  Which of the product's media this group shows, by media `external_id`, up to
  100\. Omitted means all of the product's media. Ids not present in the
  product's `media` fail the line.
</ResponseField>

### Variant

<ResponseField name="external_id" type="string" required>
  Variant id, unique within the product group.
</ResponseField>

<ResponseField name="sku" type="string">
  1–255 characters.
</ResponseField>

<ResponseField name="size_name" type="string">
  Display size, for example `M`. 1–255 characters.
</ResponseField>

<ResponseField name="gtin" type="string">
  1–64 characters.
</ResponseField>

<ResponseField name="is_active" type="boolean" default="true">
  Inactive variants are kept but not sellable.
</ResponseField>

<ResponseField name="prices" type="Price[]" default="[]">
  Up to 100 prices, at most one per pricelist:

  * `pricelist_external_id` *(string, required)*. Must reference a pushed
    pricelist.
  * `price` *(number, required)*. The current price, ≥ 0, in the pricelist's
    currency.
  * `compare_at_price` *(number)*. The strikethrough price, ≥ 0. Depict
    derives on-sale flags and discounts from the two.
</ResponseField>

<ResponseField name="inventory" type="InventoryLevel[]" default="[]">
  Up to 100 levels, at most one per warehouse:

  * `warehouse_external_id` *(string, required)*. Must reference a pushed
    warehouse.
  * `sellable_quantity` *(integer, required)*. Must be ≥ 0. Oversold stock is
    yours to clamp. The endpoint rejects negative values rather than folding
    them to zero. Prefer real quantities over booleans, since they power
    signals like low stock. If your source only knows in or out of stock,
    send `1` and `0`.
</ResponseField>

<ResponseField name="locale_content" type="{ [locale]: LocaleContent }" default="{}">
  Keyed by locale code, lowercase with an underscore before an optional
  region: `sv`, `en`, `sv_se`. Depict stores codes verbatim, so `sv` and
  `sv_se` are two different locales that never merge. Each value can carry:

  * `title` *(string, 1–4096)*
  * `description` *(string, ≤ 65536)*
  * `short_description` *(string, ≤ 65536)*
  * `slug` *(string, 1–2048)*
  * `seo_title` *(string, ≤ 4096)*
  * `seo_description` *(string, ≤ 65536)*

  There is no product-level title. The product's name lives here, on every
  variant.
</ResponseField>

<ResponseField name="attributes" type="{ [key]: string[] }" default="{}">
  Locale-invariant keys (1–255 characters) mapping to 1–100 values (each
  1–8192 characters). Display labels come from your attribute configuration.
</ResponseField>

<ResponseField name="media_external_ids" type="string[]">
  Which of the product's media this variant shows, up to 100. Omitted means
  all of the product's media.
</ResponseField>

### Media

<ResponseField name="external_id" type="string" required>
  Media id, unique within the product.
</ResponseField>

<ResponseField name="kind" type="&#x22;image&#x22; | &#x22;video&#x22; | &#x22;external_video&#x22; | &#x22;model_3d&#x22;" required />

<ResponseField name="alt_text" type="string">
  ≤ 4096 characters.
</ResponseField>

<ResponseField name="poster_url" type="string">
  Poster image URL for video media. ≤ 4096 characters.
</ResponseField>

<ResponseField name="sources" type="MediaSource[]" required>
  1–20 sources, renditions of the same media, in order:

  * `url` *(string, required)*. ≤ 4096 characters.
  * `format` *(string)*. For example `jpg`.
  * `mime_type` *(string)*. For example `image/jpeg`.
  * `width`, `height` *(positive integers)*.
</ResponseField>

## Upsert: variant

`{"operation":"upsert","type":"variant","object":{...}}` patches exactly one
variant into the stored product tree without resending the tree. The `object`
is a full [variant](#variant) plus two addressing fields:

<ResponseField name="product_external_id" type="string" required>
  The variant's product. It must already exist. An unknown product fails the
  line with `validation_error`. The endpoint never creates parents implicitly.
</ResponseField>

<ResponseField name="product_group_external_id" type="string" required>
  The variant's product group under that product. It must already exist.
</ResponseField>

Semantics:

* If the addressed group has a variant with this `external_id`, the pushed
  variant replaces it. Otherwise the endpoint appends the pushed variant to
  the group. The unit is the whole variant object. There is no field-level
  patching.
* The endpoint validates `media_external_ids` against the stored product's
  media.
* A variant identical to what is stored returns `"skipped": "unchanged"`.
* The product's `source_updated_at` is untouched. Last-write-wins ordering
  applies to whole-product upserts only.
* If concurrent pushes edit the same product while the patch applies, the line
  fails with `conflict`. It is safe to retry.

```jsonl theme={null}
{"operation":"upsert","type":"variant","object":{"external_id":"stylish-shirt-blue-L","product_external_id":"stylish-shirt","product_group_external_id":"stylish-shirt-blue","sku":"ACME-SHIRT-BLUE-L","size_name":"L","prices":[{"pricelist_external_id":"sek","price":349}],"inventory":[{"warehouse_external_id":"stockholm","sellable_quantity":3}]}}
```

## Upsert: catalog configuration

These entities must exist before the first product or market that references
them, or earlier in the same request.

### Pricelist

`{"operation":"upsert","type":"pricelist","object":{...}}`

<ResponseField name="external_id" type="string" required>
  Must not contain `:`.
</ResponseField>

<ResponseField name="currency" type="string" required>
  ISO 4217 code, uppercase, like `"SEK"`.
</ResponseField>

<ResponseField name="tax_included" type="boolean" required>
  Whether prices on this pricelist include tax.
</ResponseField>

### Warehouse

`{"operation":"upsert","type":"warehouse","object":{...}}`

<ResponseField name="external_id" type="string" required />

<ResponseField name="name" type="string">
  Display name. 1–1024 characters.
</ResponseField>

### Market

`{"operation":"upsert","type":"market","object":{...}}`

<ResponseField name="external_id" type="string" required>
  Must not contain `:`.
</ResponseField>

<ResponseField name="name" type="string" required>
  Display name, 1–1024 characters. Must not contain `:`.
</ResponseField>

<ResponseField name="pricelist_external_id" type="string" required>
  The one pricelist serving this market. Must reference a pushed pricelist.
</ResponseField>

<ResponseField name="warehouse_external_ids" type="string[]" required>
  1–100 warehouse ids, in priority order. Earlier warehouses win when
  computing availability. Each must reference a pushed warehouse.
</ResponseField>

Depict shows a product group on a market when at least one of its variants has
a price on the market's pricelist.

### Category

`{"operation":"upsert","type":"category","object":{...}}`

<ResponseField name="external_id" type="string" required />

<ResponseField name="parent_external_id" type="string">
  The parent category, for building the tree.
</ResponseField>

<ResponseField name="locale_content" type="{ [locale]: CategoryLocaleContent }" default="{}">
  Keyed by locale code, same rules as variant locale content. Each value:

  * `name` *(string, 1–1024, required)*
  * `slug` *(string, 1–2048)*
  * `description` *(string, ≤ 65536)*
</ResponseField>

## Delete

Deletes address any of the three levels by the `external_id` path from the
product down. Deletes are explicit and idempotent. Omission from an upsert of
a different product never deletes anything, and deleting something already
gone succeeds with `"skipped": "already_absent"`.

```jsonl theme={null}
{"operation":"delete","type":"product","object":{"external_id":"stylish-shirt"}}
{"operation":"delete","type":"product_group","object":{"external_id":"stylish-shirt-blue","product_external_id":"stylish-shirt"}}
{"operation":"delete","type":"variant","object":{"external_id":"stylish-shirt-blue-M","product_group_external_id":"stylish-shirt-blue","product_external_id":"stylish-shirt"}}
```

| `type`          | `object` fields                                                   |
| --------------- | ----------------------------------------------------------------- |
| `product`       | `external_id`                                                     |
| `product_group` | `external_id`, `product_external_id`                              |
| `variant`       | `external_id`, `product_group_external_id`, `product_external_id` |

## Per-line response

The response has `Content-Type: application/x-ndjson`, and line *N* answers
the *N*th non-empty line of the request. Blank lines carry no receipt. The
response is the receipt. There are no job ids and no status endpoint.

<ResponseField name="success" type="boolean" required>
  Whether the line was accepted.
</ResponseField>

<ResponseField name="code" type="string">
  Machine-readable failure class. Present only when `success` is `false`:

  * `invalid_json`. The line is not valid JSON.
  * `validation_error`. The line failed schema validation or references an
    unknown pricelist, warehouse or category.
  * `conflict`. Concurrent pushes edited the same product while the endpoint
    applied this line's variant upsert or sub-product delete. Safe to retry.
  * `internal_error`. Something failed on Depict's side. Retry, and tell us if
    it persists.
</ResponseField>

<ResponseField name="error" type="string">
  Human-readable path and reason. Present only when `success` is `false`, for
  example
  `product_groups[0].variants[0].prices[0]: unknown pricelist_external_id "nok"`.
</ResponseField>

<ResponseField name="skipped" type="string">
  Present when the line was accepted but changed nothing:

  * `unchanged`. The payload is identical to what is stored.
  * `stale_source_updated_at`. The stored product has a newer
    `source_updated_at`, so last-write-wins kept it.
  * `already_absent`. A delete of something already gone.
</ResponseField>

```jsonl theme={null}
{"success":true}
{"success":true,"skipped":"unchanged"}
{"success":false,"code":"validation_error","error":"object.currency: currency is an ISO 4217 code like \"SEK\""}
```

Depict may add response fields over time. Ignore fields you do not recognise.

<Note>
  Receipts currently arrive after the endpoint has processed the whole
  request, not streamed line by line. Your client can still stream the upload
  and read the full response at the end.
</Note>

### Full sync response headers

With `?mode=full_sync`, response headers report the outcome of the
absent-means-deleted sweep:

| Header                           | Meaning                                                                                                                                                                                                                                  |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-Push-Full-Sync-Swept`         | The sweep ran; value is the number of products removed                                                                                                                                                                                   |
| `X-Push-Full-Sync-Would-Sweep`   | Dry run only. The number of products a real `full_sync` would remove                                                                                                                                                                     |
| `X-Push-Full-Sync-Sweep-Skipped` | The endpoint withheld the sweep. The value says why: failed lines, no successful product upserts, or a sweep that would remove more than half of the live catalog without `allow_mass_delete=true`. The request's upserts still applied. |
