API
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 document · Download as Markdown — for AI agents
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 ….
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
| 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.
{
"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
| 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_too_long | one row | Carries |
| 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 |
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
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.
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.
{
"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
}| 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. |
| 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. |
| 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. |
| 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 |
| relist_blocked | one row |
|
| 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.
- seedsSeeds
Hybrids · Seed · seeds · Sowing material · Гибриды · Гібриди · Кукурудза · Кукуруза · Насіннєвий матеріал · Насіння · Насіння кукурудзи · Насіння рапсу · Насіння сої · Насіння соняшника · Посевной материал · Посівний матеріал · Пшеница · Пшениця · Рапс · Семена · Семена кукурузы · Семена подсолнечника · Семена подсолнуха · Семена рапса · Семена сои · Семенной материал · Соняшник · Соя · Ячмень · Ячмінь
- fertilizersFertilizers
Fertiliser · Fertilizer · fertilizers · Growth stimulants · Micro fertilizers · Агрохимия · Агрохімія · Добрива · Микроудобрения · Микроудобрения и стимуляторы роста · Минеральные удобрения · Мікродобрива · Мікродобрива та стимулятори росту · Мінеральні добрива · Органические удобрения · Органічні добрива · Стимулятори росту · Стимуляторы роста · Удобрения
- pesticidesCrop Protection
Agrochemicals · CPP · Crop Protection · Fungicide · Fungicides · Herbicide · Herbicides · Insecticide · Insecticides · Pesticide · pesticides · Plant protection · Агрохимикаты · Агрохімікати · Адъюванты · Адьюванти · Акарициди · Акарициды · Гербицид · Гербициды · Гербіцид · Гербіциди · Десиканти · Десиканты · Засоби захисту · Засоби захисту рослин · ЗЗР · Инсектицид · Инсектициды · Інсектицид · Інсектициди · Пестициди · Пестициди та агрохімікати · Пестициды · Пестициды и агрохимикаты · Прилипатели · Прилипачі · Прилипачі (пав) · Протравители · Протруйник · Протруйники · Родентициди · Родентициды · СЗР · Средства защиты · Средства защиты растений · Фунгицид · Фунгициды · Фунгіцид · Фунгіциди
- fuel-lubricantsFuel & Lubricants
Diesel · Fuel · fuel-lubricants · Grease · Lubricants · Oils · Антифриз · Бензин · Горюче-смазочные материалы · ГСМ · Дизельне пальне · Дизельное топливо · ДТ · Масла · Мастила · Пальне · Пально-мастильні матеріали · ПММ · Смазки · Топливо
- machineryMachinery
Combines · Equipment · Implements · machinery · Tractors · Комбайни · Комбайны · Обладнання · Оборудование · Прицепы · Причепи · Сельхозтехника · Сільгосптехніка · Техника · Техніка · Трактори · Тракторы
- spare-partsSpare Parts
Bearings · Belts · Filters · Parts · spare-parts · Детали · Деталі · Запчасти · Запчастини · Ножи · Ножі · Підшипники · Подшипники · Ремені · Ремни · Фильтры · Фільтри
- tires-wheelsTires & Wheels
Rims · Tires · tires-wheels · Tyres · Wheels · Диски · Колеса · Колёса · Покришки · Покрышки · Шини · Шини та диски · Шины · Шины и диски
- suppliesSupplies
supplies · Витратні матеріали · Расходные материалы · Спецодежда · Спецодяг · Тара · Упаковка
- servicesServices
services · Послуги · Сервис · Сервіс · Услуги
- livestockLivestock
livestock · Ветпрепарати · Ветпрепараты · Животноводство · Корма · Корми · Тваринництво
- energy-systemsEnergy 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
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 https://adam.ua/en/docs/api.md
- 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.