# Send your price list to ADAM

One HTTP call replaces the spreadsheet. Your system posts the feed, we tell you row by row what we read and what we could not.

Validate a payload without creating anything in Settings → Integrations → Sandbox.

- OpenAPI 3.1: https://adam.ua/en/docs/api/openapi.json
- HTML: https://adam.ua/en/docs/api

## Getting a key

Settings → Integrations → Connect. The secret is shown once at creation and stored only as a hash, so keep it where your ERP can read it. Send it as `Authorization: Bearer …`.

```bash
curl -X POST https://ingest.adam.ua/v1/products \
  -H "Authorization: Bearer adam_live_…" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"items":[{"sku":"KRB-46","name":"Карбамід 46% гранульований","brand":"Sumykhimprom","category":"Добрива","unit":"кг","price":12.5,"stock":1000,"warehouse":"Київський склад","moq":100,"updated_at":"2026-08-24T14:02:31Z"}]}'
```

## The item, field by field — `POST /v1/products`

| Field | Type | | Rule |
| --- | --- | --- | --- |
| `name` | string · ≤300 | **required** | What the product is called in your system. Trimmed, non-empty. |
| `brand` | string · ≤120 | **required** | Resolved within the category by the matcher. |
| `category` | string · ≤120 | **required** | Human form. Resolved against the slugs and aliases listed below. |
| `unit` | string · ≤40 | **required** | Human form: «шт», «шт.», «кг», «т», «каністра» all resolve. |
| `price` | number | **required** | Greater than 0. A number, not a string; `.` as the decimal separator. |
| `stock` | number | **required** | 0 or more. Zero means out of stock, not unknown. |
| `sku` | string · ≤100 | optional | Your own article number. Optional by schema, but without it a later PATCH has nothing to resolve against. |
| `warehouse` | string · ≤200 | optional | Matched against your own warehouses — first the exact label or city, then the one warehouse that contains the value. No match or no value at all falls back to your default: your only warehouse, or the one you marked primary. Two candidates is the one case we refuse to guess: the row keeps no warehouse and reports `warehouse_ambiguous`. |
| `moq` | number | optional | Greater than 0. Defaults to 1. |
| `description` | string · ≤500 | optional | Free text. Used when a row becomes a request for a new catalogue product. |
| `updated_at` | rfc3339 | optional | When the item last changed in your system. Enables the ordering rule on PATCH. |

A field we do not read fails the whole request instead of being dropped. Dropping it quietly is how you come to believe you set a currency you did not set.

## The response

202, because rows are accepted for processing with matching and review still ahead. Rejections name your own item index, so a feed you fix one pass at a time converges.

```json
{
  "request_id": "8f14e45f-ceea-467a-9c4c-1b0f0e5c9e2a",
  "job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "accepted": 1198,
  "rejected": [
    {
      "index": 42,
      "sku": "XYZ-1",
      "reason": "category_unresolved",
      "detail": "«Хімія» matches no category"
    },
    {
      "index": 87,
      "sku": "KRB-99",
      "reason": "required_field_missing",
      "field": "brand"
    }
  ],
  "rejected_total": 2
}
```

## Reason codes — `POST /v1/products`

| Code | Scope | Meaning |
| --- | --- | --- |
| `invalid_envelope` | whole request | The body is not an object with an items array. |
| `unknown_field` | whole request | A field we do not read. Sent back rather than dropped, so you never believe you set something you did not. |
| `idempotency_key_reused` | whole request | This Idempotency-Key was already used for a request with a different body. Use a new key — the stored result belongs to the earlier request, not this one. |
| `no_items` | whole request | items is empty. |
| `too_many_items` | whole request | More items than the per-request limit. Send fewer. |
| `required_field_missing` | one row | Carries `field` — the required field that was absent or blank. On PATCH /v1/offers that is always sku. |
| `field_too_long` | one row | Carries `field`. The bound for each field is in the table above. |
| `price_invalid` | one row | Not a number, or not greater than 0. |
| `stock_invalid` | one row | Not a number, or negative. Zero is valid. |
| `moq_invalid` | one row | Not a number, or not greater than 0. |
| `sku_duplicate_in_payload` | one row | The same SKU appears earlier in this request. The first occurrence wins. |
| `updated_at_invalid` | one row | Not RFC 3339, for example 2026-08-24T14:02:31Z. |
| `review_backlog` | whole request | Your unreviewed rows are at the limit. Finish reviewing before sending more. |
| `category_unresolved` | one row | The value matches no slug and no alias. |
| `unit_unresolved` | one row | The value matches no unit. |
| `wrong_endpoint` | one row | You sent `discontinued` here. Withdrawal and relisting belong to PATCH /v1/offers. |

A closed set, so your integration can branch on them. One code per rejected row: a row failing several checks reports the first in the order above.

## Updating price and stock — `PATCH /v1/offers`

Once a position is linked, `PATCH /v1/offers` moves its price and stock with no review and no human step. You send the article number you already sent to the product feed; we change the offer behind it. This is the call your ERP runs on a schedule.

```bash
curl -X PATCH https://ingest.adam.ua/v1/offers \
  -H "Authorization: Bearer adam_live_…" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"items":[{"sku":"KRB-46","price":12.9,"stock":840,"updated_at":"2026-08-25T09:00:00Z"},{"sku":"KAS-32","stock":0},{"sku":"OLD-01","discontinued":true}]}'
```

| Field | Type | | Rule |
| --- | --- | --- | --- |
| `sku` | string · ≤100 | **required** | Your own article number, as sent to POST /v1/products. The only way to name the position. |
| `price` | number | optional | Greater than 0. Omit it and the price stays as it is. |
| `stock` | number | optional | 0 or more. Zero sets the offer to out of stock and leaves it listed. |
| `moq` | number | optional | Greater than 0. The minimum order quantity; the unit is not changed here. |
| `discontinued` | boolean | optional | true withdraws the offer, false puts it back. Withdrawal always succeeds; relisting can be refused. |
| `updated_at` | rfc3339 | optional | When the position last changed in your system. Send it and an out-of-order retry cannot overwrite a newer price; omit it and the last request to arrive wins. Equal to the stored one with the same values is a no-op; equal with different values is equal_timestamp_conflict. |

An item needs its `sku` plus at least one of `price`, `stock`, `moq`, `discontinued`. Everything you leave out stays as it is, so a stock-only sweep never touches a price. `sku` on its own is rejected rather than counted as applied.

Send `updated_at` and retries stop being dangerous: an item whose timestamp is older than the last one we applied changes nothing and comes back as `stale_update`. Omit it and the last request to arrive wins, which is fine for a single scheduled job and wrong for two.

`unchanged` counts items whose offer already held the values you sent. Nothing was written and nothing is wrong — a nightly feed that resends everything gets `applied` for what moved and `unchanged` for the rest, instead of thousands of errors. Sending the same `updated_at` with the same values lands here too; sending it with different values is `equal_timestamp_conflict`, because equal timestamps cannot say which version is current.

Withdrawal and relisting are not symmetric, and it is better you know why than discover it. `discontinued: true` always succeeds — taking your own offer down needs nobody's permission. `discontinued: false` puts it back only if the offer is complete and your account may publish; otherwise the row comes back as `relist_blocked` with the reason in `detail`.

`not_published` is the number of applied rows sitting on an offer a farmer cannot see — a draft, a paused offer. The price did change; nobody is looking at it. A patch never publishes on your behalf, so this count is how you find out rather than wondering why nothing moved.

```json
{
  "request_id": "1c9d4b7e-3f60-4a21-8de5-7a2b6c0f9e11",
  "applied": 1198,
  "unchanged": 613,
  "not_published": 42,
  "rejected": [
    {
      "index": 17,
      "sku": "ZIR-20",
      "reason": "sku_ambiguous",
      "detail": "7 of your offers carry this code: Зірочка Z-20, Зірочка Z-22, …"
    },
    {
      "index": 63,
      "sku": "KRB-46",
      "reason": "stale_update",
      "detail": "last applied 2026-08-25T09:00:00+00"
    }
  ],
  "rejected_total": 2
}
```

### Reason codes — `PATCH /v1/offers`

| Code | Scope | Meaning |
| --- | --- | --- |
| `no_change_requested` | one row | sku and nothing changeable. A timestamp alone is not a change — send price, stock, moq or discontinued. |
| `discontinued_invalid` | one row | Present but not a boolean. `"yes"` is not true. |
| `sku_unknown` | one row | No live mapping for this code. Send the position through POST /v1/products once; after it is confirmed, PATCH works forever. |
| `sku_pending_catalog` | one row | You already sent this code and the position is waiting for our catalogue team. Do not resend it — PATCH starts working on this code the moment the product is created. |
| `sku_ambiguous` | one row | More than one of your offers carries this code, so we cannot tell which price to move. `detail` names the products. Give them distinct codes in your own system. |
| `offer_missing` | one row | The mapping exists but its offer is gone. Send the position through POST /v1/products again. |
| `stale_update` | one row | Your updated_at is older than the last one we applied, so nothing changed. `detail` carries the timestamp we hold. |
| `equal_timestamp_conflict` | one row | Your updated_at equals the one we already applied, but the values differ — so we cannot tell which is current and refuse rather than guess. Advance updated_at. If the values match, the item is counted in `unchanged` instead and no error is returned. |
| `relist_blocked` | one row | `discontinued: false` was refused by the publish gate. Withdrawal always succeeds; relisting needs the offer to be complete and your account able to publish. `detail` carries the reason. |
| `price_tiered` | one row | This offer has a quantity price ladder, and a single flat price cannot replace it. Remove the ladder in the dashboard, then the API manages this position. |

Only the codes this endpoint alone returns. Every request-level code and every value check in the table above applies here too, with `field` naming `sku` where the product feed would name a dozen fields.

## Pull — we fetch the feed ourselves

Exactly the same contract as `PATCH /v1/offers` — the same request structure, the same item fields, the same reason codes, the same effect. The only difference is direction: instead of your system calling us, we call a URL you host, once a day. Nothing else in the pipeline changes. You set it up under Settings → Integrations.

It cannot create products. An article number we do not know is rejected `sku_unknown`. New positions still go through `POST /v1/products` and its review queue; pull moves price and stock on positions that are already linked, and nothing more.

The body is the one you already send to `PATCH /v1/offers`: an `items` array whose objects carry `sku` plus `price`, `stock`, `moq`, `discontinued` and `updated_at`. At least one of `price`, `stock`, `moq`, `discontinued` per item, or the item is rejected `no_change_requested`. An unrecognised field rejects the whole request as `unknown_field` — deliberately, not as a silent drop. At most 5000 items (`too_many_items`), and one batch over 16 MiB of JSON is `payload_too_large`.

Once a day. You pick the hour, 00 to 23, and the time zone it is counted in — so 03:00 means 03:00 where your system runs, and the switch to and from summer time is handled for you. The next run is shown on the card. Pick the hour by which your export is already written, not the hour it starts.

Authentication is optional: either nothing, or one `Authorization: Bearer <token>` header that you set under Settings → Integrations. The token is stored encrypted, sent to your server on every fetch, and never written into a log or a process argument. A 401 or 403 from you is `feed_http_error` with that status, not an auth code of ours.

### What we require of your URL

`https://` only — plain HTTP is refused before any connection is made. Port 443 or no port at all: we pin the address your hostname resolved to with `curl --resolve host:443:<address>`, and that pin covers the pair (host, port), so a URL on another port would fall through to ordinary DNS at fetch time and defeat the pin entirely. Give a hostname, not an address — no IP literals, and no hexadecimal, octal or integer form of one (`https://0x7f000001/` is refused), because a client treats those as an address and never resolves them.

A valid TLS certificate matching that hostname. We verify the certificate's subject, not only its chain and expiry — a certificate valid for a different name is refused. And no redirects: we send `--max-redirs 0`, so a 3xx comes back to you as `feed_http_error` with its status. Serve the file at the URL you gave us.

A plain `GET` — we send no body and no query of our own. The response must be valid UTF-8; anything else is `bad_encoding`, and Windows-1251 is the common mistake for exports from Ukrainian and Russian accounting systems. 16 MiB is the ceiling: above it the fetch stops and you get `too_large`. You have 20 seconds to respond; resolving your hostname has its own six-second budget before that. Do not cache the feed for long behind a CDN — we fetch once a day, so a `Cache-Control` max-age of hours or days means we read yesterday's file and your prices look stuck. Give it a short max-age or none.

When you save the feed we resolve your hostname and record the address. On every later fetch we resolve it again and connect only to an address your own DNS publishes for that hostname right now, preferring the recorded one while it is still among them. Two consequences worth stating: moving hosting needs no action from you as long as DNS is updated, and we will never connect to an address your DNS does not currently name. If every address it publishes is private, loopback, carrier-NAT, link-local or reserved space, the run stops with `host_not_routable`; if it publishes none at all, `pin_unresolvable`.

### When a run fails

Every run leaves a row in the request journal on the integrations screen, marked as having come from the feed, with the counts and the rejections. A failure you can act on also sends you an in-app notification.

Failures on our side — a lost lease, our database — deliberately send no notification and are labelled as ours, because they are not something you can fix and the next scheduled run retries by itself.

| Code | Scope | Meaning |
| --- | --- | --- |
| `bad_url` | whole request | The stored URL is no longer valid. Enter it again. |
| `bad_pin` | whole request | We could not read the address stored for this feed. Check the address again and save to replace it. |
| `pin_unresolvable` | whole request | Your hostname does not resolve at all right now. Check your DNS, then check the address again. |
| `host_not_routable` | whole request | Your hostname resolves, but every address it currently publishes is one we will not connect to — private, loopback, carrier-NAT, link-local or reserved space. Point the hostname at a public address. |
| `bad_auth` | whole request | The token stored for this feed could not be used. Enter it again and save. |
| `too_large` | whole request | The response is larger than we accept. Split it or drop the fields you do not need. |
| `timeout` | whole request | Your feed did not answer in time. |
| `fetch_failed` | whole request | We could not connect to your feed. |
| `feed_http_error` | whole request | Your feed answered with an HTTP error. A 401 or 403 means the feed refused our token. |
| `bad_encoding` | whole request | The response was not valid UTF-8. Serve the file as UTF-8. |
| `malformed_json` | whole request | The response is not valid JSON. |
| `payload_too_large` | whole request | One batch of your feed was too large for us to apply. Drop the fields you do not need, or split the file. |
| `feed_not_found` | whole request | The feed record went missing while the run was in progress. Save the feed again; if it keeps happening, contact support. |
| `lease_expired` | whole request | We started a run and had to drop it before it finished. Nothing is wrong with your feed — the next scheduled run will try again. |
| `lease_lost` | whole request | Another run picked this feed up while we were applying it, so we rolled back and nothing changed. Nothing is wrong with your feed — the next scheduled run will try again. |
| `internal_error` | whole request | Something broke on our side while applying your feed. Nothing is wrong with your feed — the next scheduled run will try again. |
| `lock_timeout` | whole request | Something broke on our side while applying your feed. Nothing is wrong with your feed — the next scheduled run will try again. |
| `statement_timeout` | whole request | Something broke on our side while applying your feed. Nothing is wrong with your feed — the next scheduled run will try again. |

Codes only a pull run produces — they describe the fetch itself, before any item is read. Every code in the `PATCH /v1/offers` tables applies to the payload unchanged, because it is the same payload. The last three are ours, not yours: the next scheduled run retries by itself and you get no notification for them.

## Categories we recognise

Read live from the catalogue, so this list cannot drift from what the resolver accepts. Case and trailing punctuation do not matter.

- `seeds` — Seeds: Hybrids · Seed · seeds · Sowing material · Гибриды · Гібриди · Кукурудза · Кукуруза · Насіннєвий матеріал · Насіння · Насіння кукурудзи · Насіння рапсу · Насіння сої · Насіння соняшника · Посевной материал · Посівний матеріал · Пшеница · Пшениця · Рапс · Семена · Семена кукурузы · Семена подсолнечника · Семена подсолнуха · Семена рапса · Семена сои · Семенной материал · Соняшник · Соя · Ячмень · Ячмінь
- `fertilizers` — Fertilizers: Fertiliser · Fertilizer · fertilizers · Growth stimulants · Micro fertilizers · Агрохимия · Агрохімія · Добрива · Микроудобрения · Микроудобрения и стимуляторы роста · Минеральные удобрения · Мікродобрива · Мікродобрива та стимулятори росту · Мінеральні добрива · Органические удобрения · Органічні добрива · Стимулятори росту · Стимуляторы роста · Удобрения
- `pesticides` — Crop Protection: Agrochemicals · CPP · Crop Protection · Fungicide · Fungicides · Herbicide · Herbicides · Insecticide · Insecticides · Pesticide · pesticides · Plant protection · Агрохимикаты · Агрохімікати · Адъюванты · Адьюванти · Акарициди · Акарициды · Гербицид · Гербициды · Гербіцид · Гербіциди · Десиканти · Десиканты · Засоби захисту · Засоби захисту рослин · ЗЗР · Инсектицид · Инсектициды · Інсектицид · Інсектициди · Пестициди · Пестициди та агрохімікати · Пестициды · Пестициды и агрохимикаты · Прилипатели · Прилипачі · Прилипачі (пав) · Протравители · Протруйник · Протруйники · Родентициди · Родентициды · СЗР · Средства защиты · Средства защиты растений · Фунгицид · Фунгициды · Фунгіцид · Фунгіциди
- `fuel-lubricants` — Fuel & Lubricants: Diesel · Fuel · fuel-lubricants · Grease · Lubricants · Oils · Антифриз · Бензин · Горюче-смазочные материалы · ГСМ · Дизельне пальне · Дизельное топливо · ДТ · Масла · Мастила · Пальне · Пально-мастильні матеріали · ПММ · Смазки · Топливо
- `machinery` — Machinery: Combines · Equipment · Implements · machinery · Tractors · Комбайни · Комбайны · Обладнання · Оборудование · Прицепы · Причепи · Сельхозтехника · Сільгосптехніка · Техника · Техніка · Трактори · Тракторы
- `spare-parts` — Spare Parts: Bearings · Belts · Filters · Parts · spare-parts · Детали · Деталі · Запчасти · Запчастини · Ножи · Ножі · Підшипники · Подшипники · Ремені · Ремни · Фильтры · Фільтри
- `tires-wheels` — Tires & Wheels: Rims · Tires · tires-wheels · Tyres · Wheels · Диски · Колеса · Колёса · Покришки · Покрышки · Шини · Шини та диски · Шины · Шины и диски
- `supplies` — Supplies: supplies · Витратні матеріали · Расходные материалы · Спецодежда · Спецодяг · Тара · Упаковка
- `services` — Services: services · Послуги · Сервис · Сервіс · Услуги
- `livestock` — Livestock: livestock · Ветпрепарати · Ветпрепараты · Животноводство · Корма · Корми · Тваринництво
- `energy-systems` — Energy Systems: energy-systems · Генератори · Генераторы · Енергосистеми · Солнечные панели · Сонячні панелі · Энергосистемы

## Units we recognise

- `piece`: шт · штука · штук · штуки · од · одиниця · pcs · pc · piece · unit
- `kg`: кг · кілограм · килограмм · кілограмів · kg
- `ton`: т · t · тонна · тонн · тонни · ton · tonne
- `liter`: л · l · літр · литр · літрів · liter · litre
- `bag`: мішок · мешок · мішків · bag
- `pack`: уп · пак · пачка · упаковка · паковання · pack
- `canister`: каністра · канистра · кан · canister
- `bottle`: пляшка · бутылка · флакон · bottle
- `set`: набір · набор · set
- `hectare`: га · гектар · гектарів · ha · hectare

## Limits and idempotency

- `POST /v1/products` — 5000 × 10 min · 30 req / 10 min
- `PATCH /v1/offers` — 5000 × 60 s · 30 req / 60 s
- 20000 unresolved rows · 10 open jobs
- `sku` + ≥1 of `price`, `stock`, `moq`, `discontinued` per `PATCH /v1/offers` item

Send `Idempotency-Key` on every real call. A repeat with the same key returns the stored result — the same job, the same rejections — and creates nothing, so a timeout on your side never becomes a second price list. A replay carries the first 20 rejections and rejected_total; page the rest from the rejections endpoint.

## Open it in your own tool

The contract above is also published as an OpenAPI 3.1 document, generated from the same source — so it cannot drift from this page. Point any client at it and you get request builders, validation and a `Try it` console without us shipping one.

```
https://adam.ua/en/docs/api/openapi.json
```

- Postman · Insomnia · Bruno — Import → Link, paste the URL
- Swagger Editor — File → Import URL
- Client generators — openapi-generator, oazapfts, openapi-typescript

Sending a real request needs a live key. Do that from Settings → Integrations → Sandbox, where the key stays in your own session — not from a page anyone can open.

## What happens next

Accepted rows are matched against our catalogue and land in your review screen. A position we recognise is confirmed there; one we do not becomes a request for a new catalogue product. Review is a human step and it is not instant — plan your first feed with that in mind. It is also a one-off: once a position is linked, its price and stock travel through `PATCH /v1/offers` and never queue for review again.

## Not supported yet

`currency`, `vat_rate` and `lead_time_days` are not read on either endpoint, so sending them is an error rather than a silent drop. Quantity price ladders are not editable over the API either — an offer that has one refuses a flat `price` as `price_tiered`.
