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

> Send your catalog to Depict as it changes: one JSONL endpoint for products, prices, stock and catalog configuration, with a per-line receipt for everything you send.

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

Most integrations start with a product feed that Depict polls and parses. The
Push Product API turns that around. You send products to us the moment they
change, in the model our ingestion already speaks, and every line you send
comes back with its own result.

* **No polling lag.** Your changes reach Depict when you send them, not on the
  next feed poll.
* **Faster integrations, without waiting on us.** Because you send the model we
  ingest natively, there is no per-merchant transform for Depict to build and
  maintain, and nothing for you to queue behind. The client is small enough
  that your team, or a coding agent working from this page, can write it.
* **Errors are loud, not silent.** The endpoint validates every line on
  receipt, and every rejection names the field, the reason and the line. Feeds
  fail silently; this API refuses to.
* **One contract for everything.** The same request shape covers a single price
  change, your catalog configuration, and a full catalog sync.

Everything goes through one endpoint:

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

* The request body is JSONL. Each line is one JSON object carrying one
  operation, `upsert` or `delete`.
* The response is also JSONL, one line per input line, in the same order. Line
  *N* of the response is the receipt for line *N* of the request. There are no
  job ids and no status endpoint to poll.
* The endpoint processes lines in order and applies valid lines even when
  other lines fail. A request is never all-or-nothing.

### Authentication

Every request carries a per-merchant API key:

```
Authorization: Bearer <your api 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>

## Quick example

A pricelist, a warehouse, a market, a category, one product, and one removed
size, sent as JSON Lines:

```bash theme={null}
curl "https://foundation-ingestion.depict.ai/push/v1/acme/ingest" \
  -X POST \
  -H "Authorization: Bearer $DEPICT_PUSH_API_KEY" \
  -H "Content-Type: application/x-ndjson" \
  --data-binary @catalog.jsonl
```

`catalog.jsonl`:

```jsonl theme={null}
{"operation":"upsert","type":"pricelist","object":{"external_id":"sek","currency":"SEK","tax_included":true}}
{"operation":"upsert","type":"warehouse","object":{"external_id":"stockholm","name":"Stockholm DC"}}
{"operation":"upsert","type":"market","object":{"external_id":"se","name":"Sweden","pricelist_external_id":"sek","warehouse_external_ids":["stockholm"]}}
{"operation":"upsert","type":"category","object":{"external_id":"men-tops-tshirts","locale_content":{"en":{"name":"T-shirts"},"sv":{"name":"T-shirtar"}}}}
{"operation":"upsert","type":"product","object":{"external_id":"stylish-shirt","status":"active","product_groups":[{"external_id":"stylish-shirt-blue","category_ids":["men-tops-tshirts"],"variants":[{"external_id":"stylish-shirt-blue-M","sku":"ACME-SHIRT-BLUE-M","size_name":"M","prices":[{"pricelist_external_id":"sek","price":349}],"inventory":[{"warehouse_external_id":"stockholm","sellable_quantity":12}],"locale_content":{"sv":{"title":"Ekologisk T-shirt"},"en":{"title":"Organic Cotton Tee"}}}]}]}}
{"operation":"delete","type":"variant","object":{"external_id":"stylish-shirt-blue-XS","product_group_external_id":"stylish-shirt-blue","product_external_id":"stylish-shirt"}}
```

The response is one line per input line, in the same order:

```jsonl theme={null}
{"success":true}
{"success":true}
{"success":true}
{"success":true}
{"success":true}
{"success":true,"skipped":"already_absent"}
```

A rejected line names the field and the reason, and the other lines still
apply:

```jsonl theme={null}
{"success":false,"code":"validation_error","error":"product_groups[0].variants[0].prices[0]: unknown pricelist_external_id \"nok\""}
```

The [API reference](/api-reference/headless/push-product-api) has the full
per-line response contract, including every `code` and `skipped` value.

## Let your agent build the integration

This page and the endpoint reference are a complete spec, written for an agent
to read directly. Point yours at them and let it write the client against your
own data source:

<div className="agent-prompt">
  <div className="agent-prompt-inner">
    ```text theme={null}
    Integrate the Depict Push Product API into our stack, following the spec at
    https://docs.depict.ai/data-ingestion-guide/push-product-api and the endpoint
    reference at https://docs.depict.ai/api-reference/headless/push-product-api
    The JSONL line contract as JSON Schema (necessary, not sufficient — the
    per-line receipts are the authority):
    GET https://foundation-ingestion.depict.ai/push/v1/schema

    Ask me for our merchant id, our markets, and where the product data lives.
    ```
  </div>
</div>

<Note>
  This API is still in beta. Review what your agent produces against these
  pages before going to production, and talk to us so we can flag anything
  that has moved.
</Note>

## Test your integration

Add `?dry_run=true` to any request and the endpoint validates every line and
returns the full per-line receipts without changing anything. It writes
nothing, starts no processing, and marks the response with an
`X-Push-Dry-Run: true` header. Dry-run your whole catalog before the first
real push, and again whenever your client changes.

```bash theme={null}
curl "https://foundation-ingestion.depict.ai/push/v1/acme/ingest?mode=full_sync&dry_run=true" \
  -X POST \
  -H "Authorization: Bearer $DEPICT_PUSH_API_KEY" \
  -H "Content-Type: application/x-ndjson" \
  --data-binary @catalog.jsonl
```

Combined with `mode=full_sync`, a dry run is a pre-flight for the sweep. The
`X-Push-Full-Sync-Would-Sweep` response header tells you how many products a
real full sync would delete, without deleting anything.

One caveat: the endpoint evaluates receipts against the currently stored
state. `success: true` means the line passes validation now, and outcomes
like `unchanged` compare against what is stored at dry-run time. The
[API reference](/api-reference/headless/push-product-api#dry-run) has the
details.

## Line types

Every line is an envelope holding three fields: `operation`, `type`, and the
`object` itself.

| `operation` | `type`          | The object                                                                                        |
| ----------- | --------------- | ------------------------------------------------------------------------------------------------- |
| `upsert`    | `product`       | A whole product tree: the product, its product groups, their variants                             |
| `upsert`    | `variant`       | Exactly one variant, addressed by its parent product and group. The rest of the tree is untouched |
| `upsert`    | `pricelist`     | A pricelist, carrying the currency and whether prices include tax                                 |
| `upsert`    | `warehouse`     | A stock location                                                                                  |
| `upsert`    | `market`        | A market, defining which pricelist and warehouses serve it                                        |
| `upsert`    | `category`      | A category, with per-locale names                                                                 |
| `delete`    | `product`       | Delete a product and everything under it                                                          |
| `delete`    | `product_group` | Delete one product group and its variants                                                         |
| `delete`    | `variant`       | Delete one variant                                                                                |

<Warning>
  **Validation is strict. The endpoint rejects unknown fields instead of
  ignoring them.** A typo'd optional field fails the line loudly instead of
  silently dropping data. That is the point of this API.
</Warning>

The API also serves the complete line contract as a
[machine-readable JSON Schema](/api-reference/headless/push-product-api#machine-readable-schema).
Use it to generate types or to validate lines before sending. Passing the
schema is necessary, not sufficient. The API alone enforces a few rules, such
as id character rules, duplicates, and references between lines, so the
per-line receipts remain the authority.

## Catalog configuration

Prices reference pricelists and stock references warehouses. Those entities
must exist before the first product that references them, or earlier in the
same request. Configuration lines at the top of the file and products after
them works in a single request.

* **Pricelist.** Carries the currency (ISO 4217, uppercase, like `"SEK"`) and
  `tax_included`. Prices are per pricelist, not per market.
* **Warehouse.** A stock location with an optional display name.
* **Market.** Points at exactly one pricelist and one or more warehouses.
  Warehouse order is priority order. Depict shows a product group on a market
  when at least one of its variants has a price on that market's pricelist.
* **Category.** Carries a per-locale `name` (required), an optional `slug` and
  `description`, and an optional `parent_external_id` for the tree.

<Note>
  **Pricelist and market ids must not contain `:`.** They become part of field
  names in Depict's search index, where a colon has meaning. Use ids like
  `sek` or `b2b-eur`, not `country:SE:SEK`.
</Note>

## Operations

### Upsert

Upserts come in two grains: a whole product tree, or a single variant.

**A product upsert replaces the product's whole tree.** It sends one product
and everything under it, its product groups and their variants, in a single
line. Send the complete tree whenever the product's structure changes. A
product group or variant missing from the tree you send is removed, exactly as
if you had deleted it. Products you don't send are untouched, outside
[full sync](#full-sync).

**A variant upsert patches exactly one variant.** The line carries one whole
variant object plus `product_external_id` and `product_group_external_id` to
address its place in the tree:

```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}],"locale_content":{"sv":{"title":"Ekologisk T-shirt"},"en":{"title":"Organic Cotton Tee"}}}}
```

It replaces the variant with that `external_id` in the addressed group, or
appends it as a new variant. The unit is the whole variant object. There is no
field-level patching, mirroring the replace-tree rule one level down. The
endpoint never creates parents implicitly. Addressing a product or group that
does not exist fails the line instead of conjuring an empty parent. Resending
an identical variant returns `"skipped": "unchanged"`.

Use product upserts when structure changes or when your integration has the
whole tree at hand. Use variant upserts for high-frequency per-SKU changes,
such as a price drop or a stock tick, without resending untouched siblings.
`source_updated_at` (below) lives at product grain, so last-write-wins
ordering applies to product upserts only.

Every level is identified by `external_id`, your own identifier, and each one
only has to be unique inside its parent. A variant's `external_id` needs to be
unique within its product group, not across your catalog. Depict derives its
internal ids from yours, so sending the same tree again is harmless. The
second push updates in place rather than creating anything new, and an
identical tree returns `"skipped": "unchanged"` and costs nothing downstream.

That also means ids are contractual. Changing an `external_id` is a delete
plus a create, not a rename, so keep ids stable. Send `source_updated_at` with
each product and Depict applies last-write-wins. A pushed product older than
the stored one returns `"skipped": "stale_source_updated_at"` and is not
applied, which makes replays and out-of-order delivery safe.

### Delete

Deletes are explicit, and you can address any of the three levels. Send a
`delete` line naming the product, product group or variant, identified by its
`external_id` and those of its parents. Deletes are idempotent. Deleting
something already gone succeeds with `"skipped": "already_absent"`.

### Full sync

For a full catalog replacement, opt in per request with the query parameter
`?mode=full_sync`. The endpoint then treats the request as the complete
catalog and removes any product not present in it. It never sweeps pricelists,
warehouses, markets or categories. A catalog export naturally omits them, and
removing them would break everything that references them.

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

| Mode                    | You send          | Deletes                                        |
| ----------------------- | ----------------- | ---------------------------------------------- |
| Incremental *(default)* | Only what changed | Explicit `delete` lines only                   |
| Full sync *(opt-in)*    | The whole catalog | Absent products are removed; guardrail applies |

<Note>
  **Full sync is the recommended mode for feed-style integrations.** If you
  already produce a periodic full catalog export, send it as one `full_sync`
  request on the same schedule. Absent-means-deleted is exactly the semantics
  a feed has, with per-line receipts instead of silent parsing. Use
  incremental mode when you push changes as they happen.
</Note>

Guardrails stop a truncated export from wiping the catalog. The endpoint
withholds the sweep if any line in the request failed, or if the request
contains no successful product upserts at all. A cut-off file almost always
ends in a broken half-line, so truncation trips the first check. It also
withholds a sweep that would remove more than half of your live catalog,
unless you confirm the intent with `allow_mass_delete=true`. Your upserts
still apply. Only the absent-means-deleted step is skipped, and the response
says so in the `X-Push-Full-Sync-Sweep-Skipped` header. When the sweep runs,
`X-Push-Full-Sync-Swept` carries the number of products removed. To preview a
sweep before running it, see [Test your integration](#test-your-integration).

## Product format

The format is the model Depict ingests natively, with the same three levels
and the same field names our own integrations write. Every catalog has all
three levels, and what each one means depends on your vertical. For apparel a
product is the style, a product group is the colour and a variant is the size.
For furniture the group might be the size the shopper picks first and the
variant the colour.

| Level             | What it is                                   | Carries                                                                                  |
| ----------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------- |
| **Product**       | The style a shopper lands on                 | `external_id`, `status`, `product_type`, `brand`, `tags`, `media`, `source_updated_at`   |
| **Product group** | What the shopper picks first, usually colour | `external_id`, `status`, `category_ids`                                                  |
| **Variant**       | The sellable SKU                             | `external_id`, `sku`, `size_name`, `gtin`, prices, inventory, locale content, attributes |

One product, in full:

```json theme={null}
{
  "external_id": "stylish-shirt",
  "status": "active",
  "product_type": "T-shirts",
  "brand": "Acme",
  "tags": ["organic-cotton", "ss26"],
  "source_updated_at": "2026-08-26T09:12:00Z",
  "media": [
    {
      "external_id": "shirt-blue-front",
      "kind": "image",
      "alt_text": "Blue organic cotton tee, front",
      "sources": [
        { "url": "https://cdn.acme.example/shirt-blue-front.jpg", "width": 1600, "height": 2000 }
      ]
    },
    {
      "external_id": "shirt-blue-back",
      "kind": "image",
      "sources": [{ "url": "https://cdn.acme.example/shirt-blue-back.jpg" }]
    }
  ],
  "product_groups": [
    {
      "external_id": "stylish-shirt-blue",
      "status": "active",
      "category_ids": ["men-tops-tshirts"],
      "variants": [
        {
          "external_id": "stylish-shirt-blue-M",
          "sku": "ACME-SHIRT-BLUE-M",
          "size_name": "M",
          "gtin": "3275637890676",
          "is_active": true,
          "prices": [
            { "pricelist_external_id": "sek", "price": 349, "compare_at_price": 449 },
            { "pricelist_external_id": "eur", "price": 32 }
          ],
          "inventory": [
            { "warehouse_external_id": "stockholm", "sellable_quantity": 12 }
          ],
          "locale_content": {
            "sv": { "title": "Ekologisk T-shirt", "slug": "ekologisk-t-shirt-bla" },
            "en": { "title": "Organic Cotton Tee", "slug": "organic-cotton-tee-blue" }
          },
          "attributes": { "color": ["blue"], "material": ["organic-cotton"] },
          "media_external_ids": ["shirt-blue-front", "shirt-blue-back"]
        }
      ]
    }
  ]
}
```

* **Prices are per pricelist, not per market.** A pricelist carries the
  currency and whether prices include tax; your markets point at pricelists.
  Send `price` and, when there is one, `compare_at_price`, the strikethrough
  price. Depict derives on-sale flags and discounts from the two.
* **Stock is per warehouse.** Send `sellable_quantity` per warehouse and Depict
  works out per-market availability from the warehouses that serve each market.
  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`.
* **Content is per locale, on the variant.** `locale_content` carries `title`,
  `description`, `short_description`, `slug` and the SEO fields. There is no
  product-level title. The product's name lives on every variant, which is
  also where translations differ.
* **Attributes are keys and values on the variant.** Both are locale-invariant
  strings. Their display labels come from your attribute configuration, so
  colour can be `blue` here and "Blå" on a Swedish storefront.
* **Media lives on the product.** `media_external_ids` on a group or variant
  selects which of the product's media it shows. Omit it and the group or
  variant shows all of the product's media.
* **Order is meaningful.** Depict stores product groups, variants and media
  sources in the order you send them.
* **Depict computes the rest.** Internal ids, category paths, availability
  rollups such as low stock and all-sizes-out-of-stock, discounts and on-sale
  flags are all derived. You never send them.

### Locale codes

Locale codes are lowercase, with an underscore before an optional region:
`sv`, `en`, `sv_se`. The endpoint rejects `sv-SE` and `sv_SE` rather than
normalizing them behind your back.

Depict also stores codes verbatim. `sv` and `sv_se` are two different locales,
each with its own search index. They do not merge, and content pushed under
one is invisible under the other. Pick one form per language and use it
consistently:

```json theme={null}
{
  "locale_content": {
    "sv": { "title": "Ekologisk T-shirt" },
    "sv_se": { "title": "Ekologisk T-shirt" }
  }
}
```

This product is now in two Swedish locales, which is rarely what you want
unless you deliberately run `sv_se` and `sv_fi` as separate storefronts.

## Limits

| Limit                        | Value                                               |
| ---------------------------- | --------------------------------------------------- |
| Request body                 | 100 MB. Split larger catalogs into several requests |
| Single JSONL line            | 8 MB                                                |
| Product groups per product   | 1–500                                               |
| Variants per product group   | 1–1000                                              |
| Media per product            | 250, with 1–20 sources each                         |
| Prices per variant           | 100 (one per pricelist)                             |
| Inventory levels per variant | 100 (one per warehouse)                             |
| Tags per product             | 250                                                 |
| Values per attribute key     | 100                                                 |
| `external_id` length         | 255 characters, no leading or trailing whitespace   |

Both integration shapes run on the same contract: many small requests as
products change through the day, or a few large ones for a full sync.

<Card title="Endpoint reference" icon="code" href="/api-reference/headless/push-product-api">
  Every field, every limit, and the per-line response contract for
  POST /push/v1/\{merchant}/ingest.
</Card>
