{"openapi":"3.1.0","info":{"title":"Eckstein Partner API","version":"1.0.0","description":"\nArticle data, availability and delivery dates for B2B partners of Eckstein\nGmbH, and one way to send us a purchase request.\n\n## Integrating with an AI assistant\n\nWritten to be handed to an AI assistant, not summarised for one: it carries\nthe semantics, not just field names.\n\nPaste this into your assistant:\n\n> Integrate the Eckstein Partner API. Fetch the full specification from https://app.eckstein.pro/partnerapi/v1/openapi.json — one self-contained document with every endpoint, field, semantic rule and worked example you need. Read info.description for what holds across the API, then the description of each operation you call: the rules for reading its rows live there and are not repeated.\n\nSix rules whose violation is silent — a client that breaks them looks like a working one, so verify each against the spec rather than assuming:\n1. Set a User-Agent naming your integration on every request. Several HTTP libraries send a default agent the edge refuses with a 403, which is not an authentication error and says nothing about the key.\n2. Two speeds. Crawl /products once, then poll /products?changed_since= for catalogue edits; poll /availability for stock. Never re-crawl the catalogue to find stock — stock is not in it.\n3. supply_interrupted is a judgement, not a quantity. Roughly one marked article in seven still has units we can ship today. Order against available_now; do not gate on the flag.\n4. POST /orders returns a receipt, not an order. Poll /orders/requests/{order_reference} for the decision, and handle accepted_with_changes as its own case — it means the order we accepted differs from the one you sent, and its lines are the ones that count.\n5. Send exactly the documented request shape. Unknown or misspelled fields are refused, deliberately: a dropped delivery_address would ship goods to the wrong party with nothing looking wrong.\n6. Ignore response fields you do not recognise, and treat every date field as nullable. We add fields without notice and null carries meaning.\n\nOne thing to raise with your team rather than decide yourself: if you will send delivery_address for shipments to your own customers, Eckstein runs no deliverability check and ships exactly what you send. Ask which system on your side validates an address before it reaches us, and what happens when it fails — report the answer, including if there is none. Do not implement address validation in the client: that is a decision about your shipping process, not about this API.\n\n\nTwo things worth stating explicitly, easy to skim past:\n\n- Model every date field as **nullable**, not optional. Each is always\n  present, and `null` carries meaning; what it means is stated per endpoint.\n- Do not add retry logic around `401`. It is terminal.\n\nAnything this document cannot answer: kun.xiang@eckstein-gmbh.com.\n\n## How this fits your purchasing process\n\nFour operations, in the order the questions arrive:\n\n| | | how often |\n|---|---|---|\n| `GET /products` | What an article **is** — description, photos, weight, customs code | crawl once, then deltas |\n| `GET /prices` | What an article costs **your account** — net, EUR | daily is ample |\n| `GET /availability` | What you could order **now**, and when more arrives | as often as you quote |\n| `POST /orders` | Send us a purchase request. **This does not place an order** — a person here reviews it first | — |\n| `GET /orders/requests/{ref}` | What we decided about that request | until it stops saying `in_review` |\n| `GET /orders` | Where your orders stand and when the rest is expected | daily is plenty |\n\n**Two speeds, and mixing them up is the expensive mistake.** What an article\n*is* changes about once a day across the whole catalogue; whether we *have*\none changes constantly. Crawl `/products` once, then take deltas — re-crawling\n7,000 articles to find one edit spends your quota and still learns nothing\nabout stock, which is not in that feed. Ask `/availability` for anything a\ncustomer is waiting on.\n\n**Read the description of each operation you call.** What aggregates and\nwhat must not, what a `null` means there, what a row does and does not\nclaim — those rules live with the endpoint they govern and are stated once,\nthere rather than here.\n\n## What this API deliberately does not cover\n\n- **Where a parcel is right now.** `/orders` gives you the tracking number\n  and the carrier's link; the carrier answers the rest, fresher than any\n  copy of ours could be.\n\n## Authentication\n\nYour client sends two headers on every request:\n\n- **`X-Partner-API-Key`** — your key. No login, token exchange or refresh.\n  Keys are issued per partner, scoped to that partner's customer accounts.\n- **`User-Agent`** — a name identifying your integration, `AcmeERP/1.0`.\n  **Set this explicitly in the client you build**; several HTTP libraries\n  send a default agent that the edge network in front of us refuses as a\n  scraper (`Python-urllib` among them, `python-requests` and curl not).\n  That refusal is a `403` with a plain-text or HTML body — `error code:\n  1010`, say — never our JSON `401`, and it says nothing about your key.\n\nMissing, unknown, revoked and deactivated keys return an identical `401`,\ndeliberately, and not something to retry against.\n\n## How often you may call\n\n300 requests per hour per key. Over it, `429` with\n`Retry-After` in seconds — wait that long rather than retrying immediately.\nThe limit is per key, so parallel workers sharing one key share one budget.\nIt is far above any ordinary polling schedule; if yours needs more, ask\nrather than working around it.\n\n## Compatibility\n\n`v1` is in the path and stays. Within it we add fields without notice, so\n**ignore fields you do not recognise** — a client that rejects unknown\nproperties (the default in some JSON libraries) will break on a release that\nbreaks nobody else. We will not remove a field or change what an existing one\nmeans. Anything that cannot be done that way ships as a new path, with `v1`\nkept running.\n\n## Dates\n\nCalendar dates, Europe/Berlin. `/availability` adds two working days for\nhandling and transport; on `/orders` the allowance is already inside the\ndate we quote for that order. Weekends are excluded; German public holidays\nare **not**, so a date next to one may be a day optimistic.\n\nNo date is ever in the past — supply due earlier but not yet arrived is\nre-quoted from today.\n\n## Paging\n\nCompare `total` with `limit + offset` to decide whether to fetch another page.\n`total` counts rows before paging. `as_of` is the instant we read the data\nbehind that response.\n","contact":{"name":"Eckstein GmbH","url":"https://www.eckstein-shop.de","email":"kun.xiang@eckstein-gmbh.com"}},"servers":[{"url":"https://app.eckstein.pro","description":"Production"}],"paths":{"/partnerapi/v1/orders":{"get":{"tags":["Partner API"],"summary":"Delivery state and dates for your purchase orders","description":"Where your orders stand and when the rest is expected.\n\nCounterpart to `/availability`: that forecasts what you could order, this\nreports what you did order. `delivery_date` covers only this row's\noutstanding quantity.\n\n## How solid is that date\n\nA date on its own cannot tell you whether the goods exist yet, so every open\nrow also carries `supply_basis`, weakest to strongest:\n\n| `supply_basis` | The goods | Plan on it? |\n|---|---|---|\n| `ceased` | Not coming. We are not buying this article again | **No — contact us** |\n| `estimated` | Do not exist yet, and nothing is on order | No. It is a forecast |\n| `on_order` | Ordered from our supplier, not yet shipped | With care |\n| `in_transit` | Shipped to us, tracked | Yes |\n| `on_hand` | In our warehouse | Yes |\n\n`estimated` means the date is our supplier's usual lead time applied to today\n— an informed guess, not a commitment. It is not a rare edge case; a client\nthat treats every date alike will plan on guesses without knowing it.\n\n`ceased` keeps its date rather than nulling it, because a null here means\n*not known yet* and would invite you to keep waiting for something that is not\ncoming. Treat these rows as needing a conversation, not a longer wait.\n\nA row completes only when its last unit lands, so `supply_basis` reports the\n**weakest** basis behind the row — the same reason `delivery_date` reports the\nlatest date. Both are null together once nothing is outstanding.\n\n## Rows, not summaries\n\nEvery row is one article on **one of our orders**: `order_number` (ours,\nalways present, opaque — never parse it) and `purchase_order_number`\n(yours, null when you gave none), keyed by (`order_number`, `eckstein_sku`). One\npurchase order often becomes several of ours; we do not merge the rows —\na merge would invent rules about dates it cannot know, and only your own\ncopy of the purchase order can judge completeness. Getting this wrong is\nhow a partly-delivered order gets reported as untouched. Aggregate\nyourself:\n\n- Purchase-order numbers may recur weeks apart. Group by comparing our\n  `order_date` against your own issue dates — only you can tell. Within one\n  group, `quantity_open` and `quantity_delivered` for the same article\n  **add up**.\n- Rows of one order — more so of one purchase order — may span pages.\n  Never sum or judge from a partial fetch: collect every page (`total`\n  vs `limit + offset`) first. Pages are snapshots; key your set by\n  (`order_number`, `eckstein_sku`) against a repeat or miss, and refetch if\n  `total` changes between pages.\n- `delivery_date` never aggregates: the latest date behind that row, null\n  if any unit has no supportable date.\n- Our rows state what we accepted, not always everything you sent.\n  The denominator for \"is my order complete\" is your own copy, not\n  ours. `quantity_open + quantity_delivered` can shrink later: cancelled\n  and returned units leave both counters, and neither re-opens a row.\n\nParcels are listed in `shipments` per `order_number`, never per article —\nwe do not know which row travelled in which box. `shipments` covers only\nthe orders on this page; de-duplicate by `order_number` together with `tracking_number`\nfor rows spanning two pages. An order with no publishable article rows — pure freight, or not yet\nsynced from our systems — does not appear in this endpoint at all.\n\n## When one of your orders becomes several of ours\n\nWhatever is ready ships rather than waiting for the rest, so one of your\npurchase orders can become several of ours. Those carry the same\n`order_group`, and each follow-on its own `split_index` — **group by\n`order_group` rather than splitting `order_number` yourself.** The number\nstays exactly as printed on your invoice and delivery note, which is what\nmakes it your reconciliation key and also what stops us reshaping it.\n\n`?ref=` is repeatable (up to 50) and matches either number as a union:\nyour number returns every order labelled with it; one of ours returns\nthat order alone — query again with its `purchase_order_number` for the\nrest of the split. Percent-encode `#` in our numbers (`A6436816%231`), or\nit truncates into a different, real order. An empty result usually means\nno order of ours carries that purchase-order number; rarely, we hold the\norder but nothing has synced yet — ask us either way, we can tell them\napart. Without `ref`,\nnever judge a purchase order from the unfiltered list: finished orders\nand their parcels have left it; fetch by its own number instead.","operationId":"get_orders_partnerapi_v1_orders_get","security":[{"PartnerApiKey":[]}],"parameters":[{"name":"ref","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"},"maxItems":50},{"type":"null"}],"description":"Repeatable order reference, up to 50 per request: `?ref=A&ref=B`. Matches your own order number or ours, as a union — see this operation's description § *Rows, not summaries* for exactly what each returns and why `#` in one of our numbers must be percent-encoded. Omit to list everything still outstanding.","examples":["4700075201"],"title":"Ref"},"description":"Repeatable order reference, up to 50 per request: `?ref=A&ref=B`. Matches your own order number or ours, as a union — see this operation's description § *Rows, not summaries* for exactly what each returns and why `#` in one of our numbers must be percent-encoded. Omit to list everything still outstanding."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":400,"minimum":1,"description":"Rows per page.","default":400,"title":"Limit"},"description":"Rows per page."},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"description":"Rows to skip.","default":0,"title":"Offset"},"description":"Rows to skip."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerOrdersResponse"},"examples":{"split_purchase_order":{"summary":"One purchase order, two of our orders","description":"`purchase_order_number` is the same on both rows, but they belong to different `order_number`s — merging by purchase-order number alone loses that.","value":{"as_of":"2026-08-12T09:00:00Z","total":2,"limit":400,"offset":0,"lines":[{"purchase_order_number":"4700072950","order_number":"A6436816","order_date":"2026-07-30","sku":"WS83004","quantity_delivered":4,"quantity_open":0,"delivery_date":null},{"purchase_order_number":"4700072950","order_number":"A6437105","order_date":"2026-08-02","sku":"WS83004","quantity_delivered":0,"quantity_open":2,"delivery_date":"2026-08-17"}],"shipments":[]}}}}}},"401":{"description":"Missing, unknown, revoked or deactivated key. All four cases return this identical body on purpose, so a caller cannot probe for valid keys. Retrying will not help — request a new key.","content":{"application/json":{"example":{"detail":"Invalid API key"}}}},"429":{"description":"The key's hourly quota is spent. Unlike 401 this is temporary: `Retry-After` gives the seconds until the window resets, and waiting that long is the correct response. The quota is per key, not per caller, so parallel workers sharing a key share it.","headers":{"Retry-After":{"description":"Seconds until the quota resets.","schema":{"type":"integer"}}},"content":{"application/json":{"example":{"detail":"Rate limit exceeded. See Retry-After."}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["Partner API"],"summary":"Submit a purchase request for review","description":"Send us a purchase order. **This does not place an order.**\n\n## Where a rejection notice goes\n\nSet `contact_email` to the purchasing contact for this request. If you leave\nit out, we use the email stored on the `customer_number` account. The email\ninside `delivery_address` is never used for this: in a dropship order it\nbelongs to your customer and exists only for carrier delivery notifications.\n\nOnly a rejection produces an email notification, sent in German and English\nfrom our B2B inbox. It is best-effort convenience; the status returned by\n`GET /orders/requests/{order_reference}` remains authoritative.\n\n## Shipping straight to your customer\n\nInclude `delivery_address` and we send the goods there instead of to you.\nLeave it out and nothing changes: we deliver to your address on file.\n\n**The format is ours**: street and house number in separate fields, an ISO\n3166-1 alpha-2 country code, no free-form lines. Sending *\"Auf dem\nSteinbüchel 22\"* as the street is the most common source of a lost house\nnumber — German street names ending in a digit are common enough that no\nparser resolves them reliably.\n\n`country_code` has **no default**. An unstated country is a silent error.\n\n### Address validation\n\n**We do not validate delivery addresses.** What you send is what the carrier\nreceives, unchanged.\n\nPlease validate before sending. Our review covers prices, quantities and your\naccount — not deliverability, which we cannot assess for a customer of yours.\n\nCommercial terms, including risk, are set out in our AGB and your agreement\nwith us.\n\n## Delivery address only — there is no billing address to send\n\n`delivery_address` says where the **goods** go. Where the **invoice** goes is\nnot a field on this request and cannot be set through this API at all: it is\nfixed by `customer_number`, agreed when your account was set up, and changed\nby talking to us.\n\nThis is deliberate, and it is not a limitation we intend to lift. The two\naddresses answer different questions and change on completely different\ntimescales — you may ship to a different customer every single order, while\nwho we invoice is part of the commercial relationship. An API that let a\npurchase order redirect the invoice would make the party we bill a per-request\ndetail, which is exactly what it must never be.\n\nSo: one `customer_number` is one billing party. Trading through us on behalf\nof two of your own legal entities means two customer numbers, not one number\nwith an address you override per order.\n\nYour request lands in our review queue as a draft. Someone checks the prices,\nthe quantities and your customer account, and only then does it become an\norder. Until that happens it does not exist in our order system, and nothing\nhere is a commitment on either side.\n\nOur answer is the second round: `GET /orders/requests/{order_reference}`. It\ntells you whether we accepted the request, accepted it **with changes**, or\nrefused it and why. Poll it after sending; a `202` here is a receipt, not a\nconfirmation, and the review between the two rounds exists precisely because\nthe answer can differ from the request.\n\nOnce the order exists it also appears on `GET /orders` under the same\nreference, with a `supply_basis` per line. That endpoint is for tracking an\norder you already have; the one above is for finding out whether you have one.\n\n## Retrying is safe\n\n`order_reference` is your purchase order number **and** the idempotency key.\nSend the same one twice and you get the first request back, unchanged, with\n`200` instead of `202`. This matters more than it sounds: if a request times\nout you cannot tell whether we received it, and without this rule the safe\nmove would be to do nothing.\n\nConsequently one `order_reference` is one request, forever. To order more,\nsend a new reference.\n\n## Prices\n\nOptional. If you send them we record them against the lines for our team to\ncheck — sending a price does not agree it, and the price on the resulting\norder is the one from your account with us.\n\nIf **any** line carries `unit_price` you must also send `price_basis`\n(`net` or `gross`). There is no default: a wrong assumption here is wrong by\nthe VAT rate, and nothing in the data would reveal it.\n\n## Articles we cannot supply\n\nIf any line names an article we do not carry, cannot sell, or have stopped\nsupplying, the whole request is refused with `422` and a reason per article —\n**no partial order is created**. A half-placed order would leave you unsure\nwhat we hold and us unsure what you wanted.\n\n`no_longer_supplied` is the same fact as `supply_basis: \"ceased\"` on\n`GET /orders`: we are not buying that article again. Ask us for a\nreplacement rather than resending.","operationId":"submit_order_request_partnerapi_v1_orders_post","security":[{"PartnerApiKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerOrderRequest"}}}},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerOrderRequestResponse"}}}},"401":{"description":"Missing, unknown, revoked or deactivated key. All four cases return this identical body on purpose, so a caller cannot probe for valid keys. Retrying will not help — request a new key.","content":{"application/json":{"example":{"detail":"Invalid API key"}}}},"429":{"description":"The key's hourly quota is spent. Unlike 401 this is temporary: `Retry-After` gives the seconds until the window resets, and waiting that long is the correct response. The quota is per key, not per caller, so parallel workers sharing a key share it.","headers":{"Retry-After":{"description":"Seconds until the quota resets.","schema":{"type":"integer"}}},"content":{"application/json":{"example":{"detail":"Rate limit exceeded. See Retry-After."}}}},"200":{"description":"This `order_reference` already reached us. The original request is returned unchanged and nothing new was created — a timed-out submission is always safe to retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerOrderRequestResponse"}}}},"403":{"description":"Your key does not cover the `customer_number` you named."},"422":{"description":"**Two different things share this status code, and `detail` tells them apart by its own type. Branch on that, not on the status.**\n\n**`detail` is an object** — we understood the request and refused it: one or more articles cannot be ordered. **No order was created**; the whole request is refused with a reason per article, so you never have to reconstruct a half-placed order. Fix the articles, not your code.\n\n**`detail` is an array** — the request never got that far. Something was malformed: a missing field, a wrong type, or a field name we do not recognise (we refuse unknown fields rather than ignoring them, so a misspelled `delivery_address` stops here instead of silently shipping to the wrong party). Each entry carries `loc`, `msg` and `type`. Fix your code, not the articles.\n\nIn both cases nothing was created and retrying the same body will produce the same answer.","content":{"application/json":{"examples":{"articles_refused":{"summary":"Understood and refused (detail is an object)","value":{"detail":{"message":"No order was created. Some articles cannot be ordered.","lines":[{"eckstein_sku":"WS16549","reason":"no_longer_supplied"},{"eckstein_sku":"XX00000","reason":"unknown_sku"}]}}},"malformed_request":{"summary":"Malformed request (detail is an array)","value":{"detail":[{"loc":["body","delivery_adress"],"msg":"Extra inputs are not permitted","type":"extra_forbidden"}]}}}}}}}}},"/partnerapi/v1/orders/requests/{order_reference}":{"get":{"tags":["Partner API"],"summary":"What we decided about a purchase request you sent","description":"What we decided about a request you sent. The second of two rounds: `POST\n/orders` acknowledges receipt, this says yes, yes-but, or no.\n\n## The four answers\n\n| `status` | What it means for you |\n|---|---|\n| `in_review` | With our team. No order exists yet; nothing is committed. |\n| `accepted` | Accepted exactly as you sent it. |\n| `accepted_with_changes` | Accepted, but it does not match your request — or we could not confirm that it does. **Read `lines`.** |\n| `rejected` | We will not raise this order. `reason` says why. |\n\n`accepted_with_changes` is the one worth wiring up. A review that could only\nconfirm would not be a review: quantities get trimmed to what we can actually\nship, and prices get corrected to the ones on your account. Treating it as a\nplain acceptance is how you end up promising your own customer something we\nnever agreed to.\n\n## `lines` is what we would ship, not what you asked\n\nIt is present from receipt onwards, so it doubles as \"what is currently on the\ndesk\". Compare it against your own request — we do not send you a field-level\ndiff, because you hold the original and diffing it is one loop.\n\nA line you sent without `unit_price` comes back with the price we put on it.\nThat is our answer to an open line, not a change to your order, and it does\nnot by itself make the status `accepted_with_changes`.\n\n## Polling, and why email does not replace it\n\nPull is the guarantee. For a rejection we also send a best-effort bilingual\nemail to the request's `contact_email`, or to the account email when no contact\nwas supplied. We do not email acceptances or `accepted_with_changes`. Delivery\nfailure never changes the decision recorded here, and the email is never the\nauthority or something your integration should depend on alone.\n\nPoll it on the read quota after sending a request, then stop once you have an\nanswer that is not `in_review`. The answer does not change afterwards.\n\n## A refusal sticks\n\nSending the same `order_reference` again returns the same refusal rather than\nopening a second request. If you want us to look at it again — with different\nquantities, a different article, whatever the reason was — send a **new**\nreference. That is deliberate: a rejection you could erase by retrying would\nnot be an answer.\n\n## Not found is not the same as pending\n\n`404` means no request of ours carries that reference under a customer number\nyour key covers. If you sent one and get this, it did not reach us — resend\nit. `in_review` means it did, and we have not decided yet. The two call for\nopposite actions, so they are never conflated.","operationId":"get_order_request_status_partnerapi_v1_orders_requests__order_reference__get","security":[{"PartnerApiKey":[]}],"parameters":[{"name":"order_reference","in":"path","required":true,"schema":{"type":"string","maxLength":120,"description":"The `order_reference` you sent with the request. **Percent-encode it** — a reference containing `/`, `#` or a space arrives as a different string otherwise.","examples":["PO-2026-0042"],"title":"Order Reference"},"description":"The `order_reference` you sent with the request. **Percent-encode it** — a reference containing `/`, `#` or a space arrives as a different string otherwise."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerOrderRequestStatusResponse"}}}},"401":{"description":"Missing, unknown, revoked or deactivated key. All four cases return this identical body on purpose, so a caller cannot probe for valid keys. Retrying will not help — request a new key.","content":{"application/json":{"example":{"detail":"Invalid API key"}}}},"429":{"description":"The key's hourly quota is spent. Unlike 401 this is temporary: `Retry-After` gives the seconds until the window resets, and waiting that long is the correct response. The quota is per key, not per caller, so parallel workers sharing a key share it.","headers":{"Retry-After":{"description":"Seconds until the quota resets.","schema":{"type":"integer"}}},"content":{"application/json":{"example":{"detail":"Rate limit exceeded. See Retry-After."}}}},"404":{"description":"No request of ours carries that `order_reference` under a customer number your key covers. Distinct from `in_review` on purpose: never reaching us and sitting with our team call for opposite actions on your side."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/partnerapi/v1/prices":{"get":{"tags":["Partner API"],"summary":"What our articles cost your account — net, in EUR","description":"What our articles cost **your account** — the number you build your own\npricing on.\n\n## Net, in euro, on every row\n\n`price_basis` is `net` and `currency` is `EUR` on every response, stated\nrather than implied. Both are constant today; read them anyway, because a\nprice whose tax basis you had to guess is wrong by the VAT rate and nothing\nin the figure would show it.\n\n## Prices belong to an account, not to your key\n\n`customer_number` is **required**, even if your key covers only one. One key\ncan cover several of your accounts, and they can sit on different terms —\namong our current partners one key spans two, priced differently. There is no\nsensible default, so we do not invent one.\n\n## What `null` means\n\n`null` is not free and not zero: it means we hold no list price for that\narticle and therefore cannot quote you one. About one sellable article in\ntwenty is in that state. You cannot price those, and neither can we until\nsomeone here fills the gap — tell us if one you want is missing.\n\n## What this is and is not\n\nIt is your price at the moment you asked. It is **not** a quotation, not a\nreservation, and not a promise that stock exists — `/availability` answers\nthat, separately and on its own clock.\n\nWhat binds is the order we confirm: send the request, then read\n`GET /orders/requests/{order_reference}`. If we accepted at a different price\nthan you expected, that response says `accepted_with_changes` and carries the\nlines we actually accepted.\n\n## How often\n\nPrices move on their own clock — slower than stock, faster than a product's\ndescription. Refetching daily is ample; refetching before every quote is\nwasted quota, and this endpoint pages up to 1,000 articles at a time so the\nwhole catalogue is a handful of requests.","operationId":"get_prices_partnerapi_v1_prices_get","security":[{"PartnerApiKey":[]}],"parameters":[{"name":"customer_number","in":"query","required":true,"schema":{"type":"string","maxLength":50,"description":"Which of your accounts to price for. **Required even when your key covers only one** — one key can cover several accounts on different terms, so there is no default we could pick that would be right for all of them.","examples":["K270994"],"title":"Customer Number"},"description":"Which of your accounts to price for. **Required even when your key covers only one** — one key can cover several accounts on different terms, so there is no default we could pick that would be right for all of them."},{"name":"eckstein_sku","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"},"maxItems":1000},{"type":"null"}],"description":"Repeatable. Omit to price our whole sellable catalogue. Percent-encode each one.","examples":["WS83004"],"title":"Eckstein Sku"},"description":"Repeatable. Omit to price our whole sellable catalogue. Percent-encode each one."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":1000,"minimum":1,"description":"Articles per page. These rows are small, so pages are large.","default":500,"title":"Limit"},"description":"Articles per page. These rows are small, so pages are large."},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"description":"Articles to skip.","default":0,"title":"Offset"},"description":"Articles to skip."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerPricesResponse"}}}},"401":{"description":"Missing, unknown, revoked or deactivated key. All four cases return this identical body on purpose, so a caller cannot probe for valid keys. Retrying will not help — request a new key.","content":{"application/json":{"example":{"detail":"Invalid API key"}}}},"429":{"description":"The key's hourly quota is spent. Unlike 401 this is temporary: `Retry-After` gives the seconds until the window resets, and waiting that long is the correct response. The quota is per key, not per caller, so parallel workers sharing a key share it.","headers":{"Retry-After":{"description":"Seconds until the quota resets.","schema":{"type":"integer"}}},"content":{"application/json":{"example":{"detail":"Rate limit exceeded. See Retry-After."}}}},"403":{"description":"Your key does not cover the `customer_number` you named."},"409":{"description":"We hold no pricing group for that account yet, so we cannot quote it. Refused rather than answered with list price: 0% is a rate some of our groups genuinely have, so a fallback would look exactly like a real answer."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/partnerapi/v1/products":{"get":{"tags":["Partner API"],"summary":"Article master data — description, photos, weight, customs code","description":"What an article is: name, description, photos, weight, dimensions, customs\ncode. Master data only — what it costs is a contract matter and never appears\nhere, and whether we have any is `/availability`.\n\nPer-article attributes — voltage, interface, pin count as separate typed\nfields — are **not** published. Ours are still being reorganised, and a\ncontract you could build against has to be one we are not about to change.\nWhat is known about an article beyond the figures below is in\n`description_html`.\n\n**No category either**, in any scheme — not ours, not eCl@ss, not ETIM.\nPlacing our articles in *your* catalogue is a decision about your shop, and\none we would make worse from here: we would be guessing at a tree we cannot\nsee. Build your mapping from `manufacturer`, `name` and `description_html`,\nwhich are stable and are what we would have based ours on anyway.\n\nSay so if this blocks you. It is a deliberate omission rather than a\npermanent one, and knowing it costs you real work is the thing most likely to\nchange it.\n\nWithout an `eckstein_sku` filter this lists **our sellable catalogue**, not only what\nyou have bought. That is the difference from `/availability`, which defaults\nto your own order history: use this one to find articles, that one to check\nthe ones you care about.\n\n## Two state fields, and why they are two\n\nThey answer different questions, and an article can be in any combination.\nCollapsing them into one label loses the case the flags exist for.\n\n| | |\n|---|---|\n| `status` | What we decided: `active`, `discontinued`, or `not_sold` |\n| `supply_ended` | Whether a discontinued article is finished, or still being cleared |\n\n- **`discontinued` + `supply_ended: false`** — we will not buy it again, but\n  the last of it is still on our shelf or on its way to us, and it ships\n  normally. Order it if you want it.\n- **`discontinued` + `supply_ended: true`** — finished. None here, none\n  coming. Plan a replacement; waiting will not help.\n- **`not_sold`** — only ever returned for an article you named. The catalogue\n  listing never contains one.\n\nNeither is a stock level, and this endpoint carries no field that is. Both are\ndecisions about the article itself, which is why they are the same for every\npartner and every one of our companies. Anything that depends on a warehouse —\nhow many there are, when more arrive, whether we can currently get any — is\n`/availability`, which knows which of our companies you are asking.\n\n## Two rules that will bite an importer\n\n1. **`description_html` is HTML.** It contains markup and inline styles, from\n   roughly 2 KB up to 65 KB. Render it as HTML or strip the tags yourself —\n   printing it raw puts `<p>` in front of your buyers. It is `null` when we\n   have no description in the language you asked for; that is not an error,\n   and roughly one article in ten is missing one.\n\n2. **`name` does not follow `locale`.** It is the article name exactly as it\n   appears on our invoices and delivery notes, so your documents reconcile\n   against it. Only `description_html` changes with `locale`.\n\n## Taking changes instead of re-crawling\n\n`?changed_since=<instant>` returns only the articles whose published content\nmoved after that instant — the same rows in the same shape, just fewer of\nthem. A full catalogue is ~7,600 articles across 39 requests; a day's changes\nare usually **one**.\n\n```\nfirst run     GET /products                      -> crawl, keep `as_of`\nevery run     GET /products?changed_since=<as_of> -> apply, keep the new `as_of`\n```\n\nPass the `as_of` of your **previous delta response**, not the time your job\nstarted. `as_of` is the instant we read the data; anything that happened after\nit is still owed to you, and using a clock of your own drops whatever moved in\nbetween.\n\n**Articles that leave the catalogue appear here too**, with `status`\n`not_sold` — that is how you learn to stop offering something. A delta that\nonly carried live articles would let a delisted one sit on your shelf\nforever, because it would simply stop being mentioned.\n\nCannot be combined with `eckstein_sku`. One asks what moved, the other asks about\narticles you named.\n\n### What this feed deliberately does not carry\n\n**Nothing about a warehouse.** No stock, no arrival dates, and not the\nout-of-stock declaration. Those live on `/availability` and move on a\ncompletely different clock: the out-of-stock mark alone turns over about\nsixteen articles a day, against roughly one article a day of genuine\nmaster-data change. Folding them in would make this feed about 94% stock\nchurn and bury the catalogue edits inside it.\n\nSo a quiet `changed_since` **does not mean nothing happened** — it means no\narticle changed what it *is*. What we have is a separate question with a\nseparate endpoint.\n\nWe hold no change history before 2026-08-18. Asking for anything earlier\nreturns the whole catalogue, which is the honest answer rather than a short\none: crawl instead, and start your deltas from that response's `as_of`.\n\n## Articles we do not carry\n\nNamed articles that are not ours come back in `unknown_eckstein_skus`, listed the way\nyou spelled them. They are **not** returned as rows of nulls — a row of nulls\ncannot be told apart from an article we carry and know nothing about.\n\nArticle numbers are matched without regard to case, so upper-casing yours is\nsafe. Some of ours genuinely contain lowercase letters (`CP34001-001-Combo`),\nwhich is why this is worth saying.\n\n**Percent-encode the article number in the query string.** A handful of ours\ncontain a space, a `#` or a `+`, and each of those means something else in a\nURL — sent raw they arrive as a different number, and you get told we do not\ncarry an article we do carry.\n\n## What is in the catalogue listing\n\nArticles we currently sell that stand on their own. That includes ones being\ncleared out and not restocked — real stock that really ships. It excludes\n**product variations**: a variation parent cannot be ordered at all, and a\nchild only means anything beside its siblings, so neither survives an import\ninto a system that does not model them. Sets and kits are included; each is\none article with its own number, weight and dimensions.\n\nWhether an article can still be ordered is answered by `/availability`\n(`replenishment_ceased`) and by `POST /orders`, which refuses what it must.\nThis endpoint does not repeat that judgement — two places answering the same\nquestion is how they start disagreeing.\n\nNaming an article explicitly returns it whether or not it is in that listing:\nif you bought it from us, you can still get its weight and customs code, which\nyou may well owe someone.\n\n## Empty is said with `null`, never with a zero or a blank\n\nAn unmeasured weight or dimension comes back `null`, not `0`; an article\nnumber we do not hold comes back `null`, not `\"\"`. Roughly one article in\nfifteen has no recorded weight and one in five no EAN, so this is the\nordinary case rather than an edge one — and a `0` kg article would otherwise\nsail through your freight calculation.\n\n## Keeping a copy in step\n\nThere is no `changed_since`. Our own row timestamps are rewritten nightly by\nan internal sync, so we cannot honestly tell you what actually changed — a\nfilter built on them would return nearly the whole catalogue every day while\nlooking like it worked. Re-read the catalogue instead: at `limit=200` that is\nunder 40 requests, well inside the hourly budget. If you need a real delta,\nsay so and we will build one rather than dress this up as one.\n\n`images` is never truncated: a short list means we hold few photos, not that\nwe stopped counting.","operationId":"get_products_partnerapi_v1_products_get","security":[{"PartnerApiKey":[]}],"parameters":[{"name":"eckstein_sku","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"},"maxItems":200},{"type":"null"}],"description":"Repeatable, up to 200 per request: `?eckstein_sku=A&eckstein_sku=B`. Matched without regard to case. Omit to list our sellable catalogue. Article numbers we do not carry come back in `unknown_eckstein_skus` rather than as empty rows.","examples":["WS83004"],"title":"Eckstein Sku"},"description":"Repeatable, up to 200 per request: `?eckstein_sku=A&eckstein_sku=B`. Matched without regard to case. Omit to list our sellable catalogue. Article numbers we do not carry come back in `unknown_eckstein_skus` rather than as empty rows."},{"name":"locale","in":"query","required":false,"schema":{"type":"string","pattern":"^(de|en)$","description":"Language for `description_html`. German and English are the two languages we publish; there are no others. Does not affect `name`.","default":"de","title":"Locale"},"description":"Language for `description_html`. German and English are the two languages we publish; there are no others. Does not affect `name`."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":200,"minimum":1,"description":"Articles per page. Bounded by payload rather than row count — descriptions run to tens of kilobytes.","default":100,"title":"Limit"},"description":"Articles per page. Bounded by payload rather than row count — descriptions run to tens of kilobytes."},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"description":"Articles to skip.","default":0,"title":"Offset"},"description":"Articles to skip."},{"name":"changed_since","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Return only articles whose published content changed after this instant, instead of the whole catalogue. ISO 8601 with an offset (`2026-08-18T02:00:00Z`).\n\nPass the `as_of` of your **previous** delta response, not the time your job started: `as_of` is the instant we read the data, and anything after it is still owed to you.\n\nCannot be combined with `eckstein_sku` — one asks what changed, the asks about articles you named.","examples":["2026-08-18T02:00:00Z"],"title":"Changed Since"},"description":"Return only articles whose published content changed after this instant, instead of the whole catalogue. ISO 8601 with an offset (`2026-08-18T02:00:00Z`).\n\nPass the `as_of` of your **previous** delta response, not the time your job started: `as_of` is the instant we read the data, and anything after it is still owed to you.\n\nCannot be combined with `eckstein_sku` — one asks what changed, the asks about articles you named."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerProductsResponse"}}}},"401":{"description":"Missing, unknown, revoked or deactivated key. All four cases return this identical body on purpose, so a caller cannot probe for valid keys. Retrying will not help — request a new key.","content":{"application/json":{"example":{"detail":"Invalid API key"}}}},"429":{"description":"The key's hourly quota is spent. Unlike 401 this is temporary: `Retry-After` gives the seconds until the window resets, and waiting that long is the correct response. The quota is per key, not per caller, so parallel workers sharing a key share it.","headers":{"Retry-After":{"description":"Seconds until the quota resets.","schema":{"type":"integer"}}},"content":{"application/json":{"example":{"detail":"Rate limit exceeded. See Retry-After."}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/partnerapi/v1/availability":{"get":{"tags":["Partner API"],"summary":"Current availability and delivery dates per article","description":"How much of each article can be ordered now, and when more arrives.\n\n**Everything here is a forecast, not a commitment.** A commitment only\nexists once you have placed an order.\n\nRead `available_now` first, then `confirmed_arrivals` to weigh \"wait a week\"\nagainst \"source elsewhere\". Ordering part of a line now and the rest later\nis common and legitimate.\n\nWithout an `eckstein_sku` filter, the feed covers **our whole catalogue** —\nthe same articles, in the same order, that `GET /products` and `GET /prices`\npage through, so the three can be walked against the same offsets. Name\narticles explicitly to ask about a subset; the parameter is repeatable and\ntakes a full page's worth per request.\n\nNaming an article answers it whether or not the catalogue still lists it, so\nyou can keep asking about one you bought from us and we have since withdrawn.\n\nArticles that are not ours come back under `unknown_eckstein_skus` and get no\nrow. A row of zeroes means something different and narrower: **ours, and we\nhave none right now.**\n\nTwo kinds of future supply, not equally trustworthy, which is why they are\nseparate fields rather than one number:\n\n- **`available_now`** — physical stock on our shelves, unclaimed. Ships the\n  day you order.\n- **`confirmed_arrivals`** — goods in transit or on a confirmed purchase\n  order. Quantity and date both known, so both are written down.\n- **`estimated_replenishment_date`** — no specific delivery yet. Derived\n  from the manufacturer's usual lead time. Treat it as a habit, not a plan.\n\nSix rules to encode in your client:\n\n1. **Nothing is reserved.** `available_now` is what could ship at the moment\n   you asked; every partner asking then sees the same number, none of it\n   held for anyone, so two partners can both be told 50 are available.\n   Stock is one shared pool — a figure read in the morning can legitimately\n   be gone by the afternoon. Read close to the moment you order.\n\n2. **Quantities are net.** Stock already committed to other orders is\n   subtracted before you see it, and no field tells you how much is\n   committed elsewhere; that would not change what you can order, the only\n   question this endpoint answers. Adding `available_now` to the\n   `confirmed_arrivals` quantities is meaningful — that is your total\n   obtainable — but neither number contains anything spoken for elsewhere.\n\n3. **A quantity means we can back it; a bare date means we cannot.** An\n   arrival is backed by a specific shipment or purchase order, so it\n   carries a quantity. `estimated_replenishment_date` is derived from usual\n   lead time and carries no quantity — such a rule would seem to \"cover\"\n   any amount asked about. Plan firm commitments off `confirmed_arrivals`;\n   treat the estimate as guidance.\n\n4. **`available_now: 0` does not mean nothing is coming.** Read\n   `confirmed_arrivals` before concluding an article is unavailable. And\n   `null` means we do not know, never a placeholder for a default: a null\n   `estimated_replenishment_date` means we genuinely cannot date a restock.\n\n5. **`replenishment_ceased: true` means no restock is coming at all** — we\n   are not buying this article again, and `estimated_replenishment_date` is\n   null because there is nothing left to estimate. This is the one case where\n   a null date means *never* rather than *not known*, and it is why the flag\n   exists. `available_now` and `confirmed_arrivals` still apply and are still\n   orderable: selling the last of an article, including units already on\n   their way to us, is normal. What has ended is the restock after that. Plan\n   a replacement rather than a longer wait.\n\n6. **`supply_interrupted: true` is our people saying we cannot currently get\n   this in** — temporary, where `replenishment_ceased` is permanent. It is a\n   judgement, **not a quantity**: about one marked article in seven still has\n   units we can ship today, so order against `available_now` and do not gate\n   on this flag. Where it disagrees with `estimated_replenishment_date`,\n   believe this one — that date is lead-time arithmetic and knows nothing\n   about the declaration.","operationId":"get_availability_partnerapi_v1_availability_get","security":[{"PartnerApiKey":[]}],"parameters":[{"name":"eckstein_sku","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"},"maxItems":1000},{"type":"null"}],"description":"Repeatable, up to 1000 per request: `?eckstein_sku=A&eckstein_sku=B`. Omit to page through our whole catalogue — the same articles, in the same order, that `GET /products` and `GET /prices` list.\n\nAn article that is ours but has none in stock and none on order comes back as a row of zeroes — never omitted, never an error. One that is **not** ours comes back in `unknown_eckstein_skus` because a row of zeroes cannot tell those two apart.","examples":["WS83004"],"title":"Eckstein Sku"},"description":"Repeatable, up to 1000 per request: `?eckstein_sku=A&eckstein_sku=B`. Omit to page through our whole catalogue — the same articles, in the same order, that `GET /products` and `GET /prices` list.\n\nAn article that is ours but has none in stock and none on order comes back as a row of zeroes — never omitted, never an error. One that is **not** ours comes back in `unknown_eckstein_skus` because a row of zeroes cannot tell those two apart."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":1000,"minimum":1,"description":"Rows per page. Compare `total` against `limit + offset` to know whether another page exists.","default":1000,"title":"Limit"},"description":"Rows per page. Compare `total` against `limit + offset` to know whether another page exists."},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"description":"Rows to skip.","default":0,"title":"Offset"},"description":"Rows to skip."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerAvailabilityResponse"},"examples":{"typical":{"summary":"Stock on hand, plus more already on the way","value":{"as_of":"2026-08-11T03:30:00.762853Z","total":1,"limit":1000,"offset":0,"items":[{"sku":"WS83004","available_now":56,"delivery_date":"2026-08-13","confirmed_arrivals":[{"quantity":100,"delivery_date":"2026-08-17"}],"estimated_replenishment_date":"2026-08-28"}]}},"zero_but_arriving":{"summary":"available_now is 0, yet 90 units are already inbound","description":"The case most likely to be mis-implemented. Treating `available_now: 0` as 'unavailable' and stopping there loses a delivery you could already have ordered against.","value":{"as_of":"2026-08-11T03:30:00.762853Z","total":1,"limit":1000,"offset":0,"items":[{"sku":"BW13208","available_now":0,"delivery_date":null,"confirmed_arrivals":[{"quantity":90,"delivery_date":"2026-08-18"}],"estimated_replenishment_date":"2026-08-28"}]}},"estimate_only":{"summary":"A restock is expected, but no quantity can be promised","description":"Nothing is confirmed inbound, so there is a date and no quantity. Use it for planning, not for a firm commitment.","value":{"as_of":"2026-08-11T03:30:00.762853Z","total":1,"limit":1000,"offset":0,"items":[{"sku":"WS29833","available_now":0,"delivery_date":null,"confirmed_arrivals":[],"estimated_replenishment_date":"2026-09-01"}]}},"not_carried":{"summary":"An article we do not carry","description":"Every date is null rather than invented, and the article is returned as a row rather than omitted. Asking about an unknown article is a fair question with a real answer: we have none.","value":{"as_of":"2026-08-11T03:30:00.762853Z","total":1,"limit":1000,"offset":0,"items":[{"sku":"UNKNOWN-ARTICLE","available_now":0,"delivery_date":null,"confirmed_arrivals":[],"estimated_replenishment_date":null}]}}}}}},"401":{"description":"Missing, unknown, revoked or deactivated key. All four cases return this identical body on purpose, so a caller cannot probe for valid keys. Retrying will not help — request a new key.","content":{"application/json":{"example":{"detail":"Invalid API key"}}}},"429":{"description":"The key's hourly quota is spent. Unlike 401 this is temporary: `Retry-After` gives the seconds until the window resets, and waiting that long is the correct response. The quota is per key, not per caller, so parallel workers sharing a key share it.","headers":{"Retry-After":{"description":"Seconds until the quota resets.","schema":{"type":"integer"}}},"content":{"application/json":{"example":{"detail":"Rate limit exceeded. See Retry-After."}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"PartnerArrival":{"properties":{"quantity":{"type":"integer","title":"Quantity","description":"Units of this arrival still free to order. Net: quantities already committed to other orders are gone before this number is produced."},"delivery_date":{"type":"string","format":"date","title":"Delivery Date","description":"Earliest date these units can reach you if ordered now."}},"type":"object","required":["quantity","delivery_date"],"title":"PartnerArrival","description":"Stock arriving later that no existing order has claimed."},"PartnerAvailabilityItem":{"properties":{"eckstein_sku":{"type":"string","title":"Eckstein Sku"},"available_now":{"type":"integer","title":"Available Now","description":"Units shippable today. Not a reservation — every partner sees the same number and nothing is held until an order is placed."},"delivery_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Delivery Date","description":"Delivery date for `available_now` if ordered today. Null when nothing is available now."},"confirmed_arrivals":{"items":{"$ref":"#/components/schemas/PartnerArrival"},"type":"array","title":"Confirmed Arrivals","description":"Future stock we can put a quantity on, because it is backed by a shipment or a purchase order. Empty is normal."},"estimated_replenishment_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Estimated Replenishment Date","description":"When this article is normally replenished, derived from supplier lead time. An estimate, deliberately carrying no quantity: unlike `confirmed_arrivals` it is not backed by a specific delivery. Null means we cannot date a restock at all — which is the honest answer, not a gap to fill in with a default."},"replenishment_ceased":{"type":"boolean","title":"Replenishment Ceased","description":"True when we are not buying this article again, so no further restock is coming and `estimated_replenishment_date` is null.\n\n`available_now` and `confirmed_arrivals` still apply and are still orderable: stock on our shelf and a delivery already on its way to us are real, and clearing the last of an article is normal. What ends is the restock after that.","default":false},"supply_interrupted":{"type":"boolean","title":"Supply Interrupted","description":"We have declared this article's supply interrupted — a statement by our people about getting it in, **not** a stock level. Temporary, where `replenishment_ceased` is permanent; the two are independent and either can stand alone.\n\n**This does not mean the shelf is empty.** About one marked article in seven still has units we can ship today, so order against `available_now` and do not gate on this flag.\n\nWhat it is worth reading for: `estimated_replenishment_date` is lead-time arithmetic that knows nothing about this declaration, so where the two disagree, this one is the human judgement and the date is the formula.","default":false}},"type":"object","required":["eckstein_sku","available_now"],"title":"PartnerAvailabilityItem","examples":[{"available_now":56,"confirmed_arrivals":[{"delivery_date":"2026-08-17","quantity":100}],"delivery_date":"2026-08-13","eckstein_sku":"WS83004","estimated_replenishment_date":"2026-08-28"}]},"PartnerAvailabilityResponse":{"properties":{"as_of":{"type":"string","format":"date-time","title":"As Of","description":"When the underlying stock and delivery data was computed."},"total":{"type":"integer","title":"Total","description":"Article numbers in scope before paging — including any this page reports under `unknown_eckstein_skus`, so `items` can be shorter than the page you asked for."},"limit":{"type":"integer","title":"Limit"},"offset":{"type":"integer","title":"Offset"},"items":{"items":{"$ref":"#/components/schemas/PartnerAvailabilityItem"},"type":"array","title":"Items"},"unknown_eckstein_skus":{"items":{"type":"string"},"type":"array","title":"Unknown Eckstein Skus","description":"Article numbers from this page that are not ours, listed the way they reached us. They get no row: a row of zeroes would say *we have none of this*, which is not the same as *this is not one of ours* — and a client cannot tell those apart afterwards.\n\nAn article that **is** ours but has no stock and nothing on order still gets a normal row of zeroes. That row means what it says.\n\nOnly ever holds article numbers you named. The unfiltered feed is drawn from our own catalogue, so it cannot contain one we do not recognise."}},"type":"object","required":["as_of","total","limit","offset","items"],"title":"PartnerAvailabilityResponse"},"PartnerDeliveryAddress":{"properties":{"company":{"anyOf":[{"type":"string","maxLength":200},{"type":"null"}],"title":"Company","description":"Give **either** this or a name. Both is fine."},"first_name":{"anyOf":[{"type":"string","maxLength":100},{"type":"null"}],"title":"First Name"},"last_name":{"anyOf":[{"type":"string","maxLength":100},{"type":"null"}],"title":"Last Name"},"street":{"type":"string","maxLength":200,"title":"Street","description":"Street **without** the house number — that goes in its own field. Splitting them is not fussiness: putting them back together is trivial and pulling them apart is guesswork, and German street names that end in a number are common enough to get wrong.","examples":["Auf dem Steinbüchel"]},"house_number":{"type":"string","maxLength":32,"title":"House Number","description":"Kept separate. See `street`.","examples":["22"]},"address_extra":{"anyOf":[{"type":"string","maxLength":200},{"type":"null"}],"title":"Address Extra","description":"A second line if one is needed — `c/o`, floor, gate."},"postal_code":{"type":"string","maxLength":20,"title":"Postal Code","examples":["53340"]},"city":{"type":"string","maxLength":100,"title":"City","examples":["Meckenheim"]},"country_code":{"type":"string","pattern":"^[A-Za-z]{2}$","title":"Country Code","description":"ISO 3166-1 alpha-2. **Required, with no default** — a delivery address is the last place to guess a country, and defaulting one would be wrong silently rather than loudly.","examples":["DE"]},"email":{"anyOf":[{"type":"string","maxLength":200},{"type":"null"}],"title":"Email","description":"Recipient's, for the carrier's delivery notifications."},"phone":{"anyOf":[{"type":"string","maxLength":50},{"type":"null"}],"title":"Phone","description":"Recipient's, for the carrier. Some services need it."}},"additionalProperties":false,"type":"object","required":["street","house_number","postal_code","city","country_code"],"title":"PartnerDeliveryAddress","description":"Where the goods go, when that is not your own address.\n\nOne fixed shape, not yours — because we are the ones who have to hand it\nto a carrier, and a format we cannot parse becomes a parcel we cannot\nsend. Everything is a named field: there are no free-form address lines,\nsince those are exactly what makes an address impossible to check before\nit fails."},"PartnerOrderLine":{"properties":{"purchase_order_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Purchase Order Number","description":"Your own order number, exactly as it reached us. Null when you gave none."},"order_number":{"type":"string","title":"Order Number","description":"Our order number, always present, exactly as it appears on the invoice and delivery note for this order. **Do not parse it** — the parts you would be reaching for are `order_group` and `split_index` below."},"order_group":{"type":"string","title":"Order Group","description":"Orders we split share this. When we cannot ship an order in one go we raise further orders from it, and all of them — including the first — carry the same `order_group`.\n\nGroup by it to see one of your requests as a whole. Equal to `order_number` when nothing was split, which is the usual case: about one order in sixty overall, though far more for partners who order across many articles at once."},"split_index":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Split Index","description":"Which follow-on this order is, counting from 1. `null` on the first order of a group, and on the rare number we cannot read one from — never `0`, so a present value always means a genuine follow-on.\n\nNot a sequence you can rely on being complete or gapless: it is read from the number we issued, not maintained as a count."},"order_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Order Date","description":"The date we placed this order. Null when we have none."},"eckstein_sku":{"type":"string","title":"Eckstein Sku"},"quantity_delivered":{"type":"integer","title":"Quantity Delivered","description":"Units of this article already shipped to you on this order."},"quantity_open":{"type":"integer","title":"Quantity Open","description":"Units still outstanding on this order."},"delivery_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Delivery Date","description":"Date this row's outstanding quantity is expected by. Null when any outstanding unit has no date yet."},"supply_basis":{"anyOf":[{"type":"string","enum":["on_hand","in_transit","on_order","estimated","ceased"]},{"type":"null"}],"title":"Supply Basis","description":"What stands behind `delivery_date`, weakest part first — a date alone cannot tell you whether the goods exist yet.\n\n* `on_hand` — in our warehouse, waiting to go out.\n* `in_transit` — shipped to us, tracked. Risk is the transport leg.\n* `on_order` — ordered from the supplier, not yet shipped.\n* `estimated` — **nothing backs this date.** It is our supplier's usual lead time applied to today. A forecast, not a commitment.\n* `ceased` — we are not buying this article again, so the purchase this date assumes will not happen. The date is left in place rather than nulled (null would read as *not known yet* and invite you to keep waiting). **Contact us for this row.**\n\nA row completes only when every unit lands, so this reports the *weakest* basis behind it, matching `delivery_date` reporting the latest date. Null exactly when `delivery_date` is null: nothing outstanding means no date and no basis."}},"type":"object","required":["purchase_order_number","order_number","order_group","eckstein_sku","quantity_delivered","quantity_open"],"title":"PartnerOrderLine","description":"One article on one of our orders — never a cross-order summary.\n\nSee `info.description` § *Rows, not summaries* for the aggregation rules."},"PartnerOrderRequest":{"properties":{"customer_number":{"type":"string","maxLength":50,"title":"Customer Number","description":"Which of your customer numbers this order belongs to. Required even when your key covers only one — we do not guess which legal entity to bill.","examples":["K270994"]},"order_reference":{"type":"string","maxLength":120,"title":"Order Reference","description":"Your own purchase order number. It is also the idempotency key: sending the same reference twice returns the first request unchanged rather than ordering twice, so a timed-out request is always safe to retry. It is the same number you will later filter by on `GET /orders`.","examples":["4700075999"]},"contact_email":{"anyOf":[{"type":"string","maxLength":320,"format":"email"},{"type":"null"}],"title":"Contact Email","description":"Who should receive a rejection notice for this request. This is the partner's purchasing contact, not the email inside `delivery_address` (which belongs to the parcel recipient). If omitted, we use the email on the named customer account.","examples":["purchasing@example.com"]},"price_basis":{"anyOf":[{"type":"string","enum":["net","gross"]},{"type":"null"}],"title":"Price Basis","description":"Whether your `unit_price` values are net or gross. **Required if any line carries a price** — there is no default, because a guessed one is wrong by the VAT rate and the guess is invisible."},"requested_delivery_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Requested Delivery Date","description":"The date you would like. A wish for our team to see, not a date we have accepted."},"delivery_address":{"anyOf":[{"$ref":"#/components/schemas/PartnerDeliveryAddress"},{"type":"null"}],"description":"Where to send the goods, when that is **not** your own address — shipping straight to your customer.\n\nLeave it out and we deliver to your address on file, as before. Sending it is the whole signal: it says this consignment goes somewhere else, this once.\n\nThe format is ours rather than yours, and deliberately strict — we are the ones handing it to a carrier."},"comment":{"anyOf":[{"type":"string","maxLength":5000},{"type":"null"}],"title":"Comment"},"lines":{"items":{"$ref":"#/components/schemas/PartnerOrderRequestLine"},"type":"array","minItems":1,"title":"Lines"}},"additionalProperties":false,"type":"object","required":["customer_number","order_reference","lines"],"title":"PartnerOrderRequest","description":"A purchase request. Not an order until our team has reviewed it."},"PartnerOrderRequestLine":{"properties":{"eckstein_sku":{"type":"string","maxLength":100,"title":"Eckstein Sku","examples":["WS83004"]},"quantity":{"anyOf":[{"type":"number","exclusiveMinimum":0.0},{"type":"string","pattern":"^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}],"title":"Quantity","examples":[100]},"unit_price":{"anyOf":[{"type":"number","minimum":0.0},{"type":"string","pattern":"^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"},{"type":"null"}],"title":"Unit Price","description":"The price you expect for this article. Optional. We record it against the line for our team to check — sending it does not agree it."},"name":{"anyOf":[{"type":"string","maxLength":500},{"type":"null"}],"title":"Name","description":"Your description of the article, if you want it on file."}},"additionalProperties":false,"type":"object","required":["eckstein_sku","quantity"],"title":"PartnerOrderRequestLine"},"PartnerOrderRequestResponse":{"properties":{"status":{"type":"string","const":"received_pending_review","title":"Status","default":"received_pending_review"},"document_number":{"type":"string","title":"Document Number","description":"Our internal reference for this request. It is **not** an order number: once our team turns the request into an order, that order appears on `GET /orders` under your `order_reference`, with our order number attached."},"order_reference":{"type":"string","title":"Order Reference","description":"Echoed back for matching."},"message":{"type":"string","title":"Message"}},"type":"object","required":["document_number","order_reference","message"],"title":"PartnerOrderRequestResponse","description":"Acknowledgement of receipt. Deliberately not an order confirmation."},"PartnerOrderRequestStatusResponse":{"properties":{"order_reference":{"type":"string","title":"Order Reference","description":"Echoed back for matching."},"status":{"type":"string","enum":["in_review","accepted","accepted_with_changes","rejected"],"title":"Status","description":"* `in_review` — with our team, not yet decided. No order exists yet.\n* `accepted` — accepted **as you sent it**. Nothing on the order differs from what you stated.\n* `accepted_with_changes` — accepted, but it does not match your request, or we could not confirm that it does. Read `lines` before you commit to your own customer; the difference is usually a quantity or a price.\n* `rejected` — we will not be raising this order. `reason` says why. Sending the same `order_reference` again returns this same answer rather than opening a second request; use a new reference if you want us to look again."},"document_number":{"type":"string","title":"Document Number","description":"Our internal reference for the request itself. Stable from receipt onwards, and **not** an order number."},"order_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Order Number","description":"Our order number, once the order exists in our ERP — the same number this order carries on `GET /orders`, on the invoice and on the delivery note. Null while it does not exist yet, which includes the window between acceptance and the order being raised."},"received_at":{"type":"string","format":"date-time","title":"Received At","description":"When your request reached us."},"decided_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Decided At","description":"When we accepted or rejected it. Null while `in_review`."},"reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reason","description":"Why we rejected it. Null unless `status` is `rejected`."},"lines":{"items":{"$ref":"#/components/schemas/PartnerOrderResponseLine"},"type":"array","title":"Lines","description":"The order as it stands with us. Present from receipt onwards, so it is also what is currently under review — compare it against your own request rather than waiting for us to describe the difference."}},"type":"object","required":["order_reference","status","document_number","received_at"],"title":"PartnerOrderRequestStatusResponse","description":"Our answer to a purchase request: the second round.\n\nSending a request gets you a receipt (`POST /orders` → 202). This says\nwhat we then decided, which is the part that can differ from what you\nasked for."},"PartnerOrderResponseLine":{"properties":{"eckstein_sku":{"type":"string","title":"Eckstein Sku"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name","description":"Our name for the article, as it will read on documents."},"quantity":{"type":"string","pattern":"^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$","title":"Quantity","description":"Quantity we accepted."},"unit_price":{"type":"string","pattern":"^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$","title":"Unit Price","description":"Unit price on the accepted order, in the `price_basis` you sent. Where you sent none, this is the price we put on it — our answer to a line you left open, not a change to your order."}},"type":"object","required":["eckstein_sku","quantity","unit_price"],"title":"PartnerOrderResponseLine","description":"One line as we accepted it — what we would ship, not what you asked."},"PartnerOrdersResponse":{"properties":{"as_of":{"type":"string","format":"date-time","title":"As Of"},"total":{"type":"integer","title":"Total","description":"Rows in `lines` matching the request, before paging — not the number of orders or purchase orders. One order contributes several rows."},"limit":{"type":"integer","title":"Limit"},"offset":{"type":"integer","title":"Offset"},"lines":{"items":{"$ref":"#/components/schemas/PartnerOrderLine"},"type":"array","title":"Lines"},"shipments":{"items":{"$ref":"#/components/schemas/PartnerShipment"},"type":"array","title":"Shipments","description":"Parcels for the orders whose rows are on this page, per order not per article. Withheld until a tracking number exists."}},"type":"object","required":["as_of","total","limit","offset","lines"],"title":"PartnerOrdersResponse"},"PartnerPriceItem":{"properties":{"eckstein_sku":{"type":"string","title":"Eckstein Sku"},"net_price":{"anyOf":[{"type":"string","pattern":"^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"},{"type":"null"}],"title":"Net Price","description":"Your price for one unit — **net (excluding VAT), in EUR**, as stated once on the response rather than repeated here.\n\n`null` means we hold no list price for this article, so we cannot quote you one. About one sellable article in twenty is in that state. It is never sent as `0`: a zero would pass straight through your margin calculation as a free article.\n\nThis is your agreed price at the moment you asked, not a quotation and not a reservation. It can change, and the order we confirm is what binds — see `GET /orders/requests/{order_reference}`."}},"type":"object","required":["eckstein_sku"],"title":"PartnerPriceItem","description":"What one article costs this account, net of VAT, in euro."},"PartnerPricesResponse":{"properties":{"as_of":{"type":"string","format":"date-time","title":"As Of"},"customer_number":{"type":"string","title":"Customer Number","description":"The account these prices are for, echoed back. Prices are per account, not per key: one key can cover several of your accounts on different terms."},"currency":{"type":"string","const":"EUR","title":"Currency","description":"Currency of every `net_price` in this response. Always EUR."},"price_basis":{"type":"string","const":"net","title":"Price Basis","description":"Every `net_price` here is **net — VAT excluded**. Stated rather than implied: a price whose tax basis has to be guessed is wrong by the VAT rate, and nothing in the number would reveal it."},"total":{"type":"integer","title":"Total"},"limit":{"type":"integer","title":"Limit"},"offset":{"type":"integer","title":"Offset"},"items":{"items":{"$ref":"#/components/schemas/PartnerPriceItem"},"type":"array","title":"Items"},"unknown_eckstein_skus":{"items":{"type":"string"},"type":"array","title":"Unknown Eckstein Skus","description":"Article numbers you named that we do not carry."}},"type":"object","required":["as_of","customer_number","currency","price_basis","total","limit","offset","items"],"title":"PartnerPricesResponse"},"PartnerProduct":{"properties":{"eckstein_sku":{"type":"string","title":"Eckstein Sku","description":"**Our** article number — the one to quote back to us on an order and the one printed on our invoices and delivery notes.\n\nNamed for whose number it is because you hold two for the same article: this one and your own. A field called `sku` in your database is ambiguous six months later, and the same reasoning already separates `order_number` from `purchase_order_number` here.\n\nMatched without regard to case when you send it back. Some of ours genuinely contain lowercase letters.","examples":["WS83004"]},"name":{"type":"string","title":"Name","description":"The article name exactly as it appears on our invoices and delivery notes, which is what you reconcile documents against. One language — it does not follow `locale`."},"manufacturer":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Manufacturer"},"mpn":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Mpn","description":"Manufacturer part number, for matching their catalogue."},"ean":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ean"},"status":{"type":"string","enum":["active","discontinued","not_sold"],"title":"Status","description":"What we have decided about selling this article. A decision, not a stock level — how many we have is `/availability`.\n\n* `active` — we sell it and intend to keep sourcing it.\n* `discontinued` — we will not buy it again. Read `supply_ended` next: while it is `false` we are clearing the last of it and it still ships normally.\n* `not_sold` — we do not sell it at the moment. Only ever returned for an article you named; the catalogue listing does not contain these."},"supply_ended":{"type":"boolean","title":"Supply Ended","description":"True once a discontinued article is finished: none in our warehouse, none on the way, and none coming. This is the end of the line, not a wait — plan a replacement rather than a later delivery.\n\nAlways `false` while `status` is `active`."},"description_html":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description Html","description":"Product description in the requested `locale`. **HTML, not plain text** — it contains markup and inline styles, so render it as HTML or strip the tags yourself; do not print it raw. Typically ~2 KB, occasionally up to 65 KB. Null when we have no description in that language."},"weight_kg":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Weight Kg","description":"Net weight of the article itself, without packaging. **Null means nobody has weighed it** — it is never sent as `0`, so you will not mistake an unrecorded weight for a weightless article. About one article in fifteen has none.\n\nWe publish no shipping weight: ours is a flat rule applied to this number rather than anything measured, and you would be costing freight against a figure we invented."},"length_cm":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Length Cm","description":"Null when unmeasured; never `0`. See `weight_kg`."},"width_cm":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Width Cm"},"height_cm":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Height Cm"},"hs_code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Hs Code","description":"Customs tariff number, 10-digit Taric — the same one on our customs paperwork for this article."},"country_of_origin":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Country Of Origin"},"images":{"items":{"$ref":"#/components/schemas/PartnerProductImage"},"type":"array","title":"Images","description":"Every public photo we hold for the article, main image first. Not truncated: a short list means we have few, not that we stopped counting. Empty is normal."}},"type":"object","required":["eckstein_sku","name","status","supply_ended"],"title":"PartnerProduct","description":"Master data for one article. What it *is* — not what it costs, and not\nwhether we have it: price is a contract matter and stock is\n`/availability`."},"PartnerProductImage":{"properties":{"url":{"type":"string","title":"Url","description":"Publicly reachable image, no key required. Stable enough to store, but re-read it on each catalogue sync rather than treating it as permanent."},"is_primary":{"type":"boolean","title":"Is Primary","description":"The article's main image. At most one per article, and an article can have none — the list is ordered with it first either way."},"width":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Width","description":"Pixels, when we know it."},"height":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Height","description":"Pixels, when we know it."}},"type":"object","required":["url","is_primary"],"title":"PartnerProductImage","description":"One product photo, hosted by us."},"PartnerProductsResponse":{"properties":{"as_of":{"type":"string","format":"date-time","title":"As Of"},"locale":{"type":"string","enum":["de","en"],"title":"Locale","description":"The language the descriptions in this response are in."},"total":{"type":"integer","title":"Total","description":"Articles matching the request, before paging. Compare against `limit + offset` to know whether another page exists."},"limit":{"type":"integer","title":"Limit"},"offset":{"type":"integer","title":"Offset"},"items":{"items":{"$ref":"#/components/schemas/PartnerProduct"},"type":"array","title":"Items"},"unknown_eckstein_skus":{"items":{"type":"string"},"type":"array","title":"Unknown Eckstein Skus","description":"Article numbers you named that we do not carry. Reported here rather than as rows of nulls, because a row of nulls cannot be told apart from an article we carry and know nothing about. Only ever populated when you passed `eckstein_sku`."}},"type":"object","required":["as_of","locale","total","limit","offset","items"],"title":"PartnerProductsResponse"},"PartnerShipment":{"properties":{"purchase_order_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Purchase Order Number","description":"Your own order number, for grouping. Null when you gave none."},"order_number":{"type":"string","title":"Order Number","description":"Our order this parcel was dispatched for."},"order_group":{"type":"string","title":"Order Group","description":"As on the order rows. Group by it to collect every parcel for one of your requests, including the ones we split off."},"tracking_number":{"type":"string","title":"Tracking Number","description":"The carrier's parcel number. Look it up with them directly."},"tracking_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tracking Url","description":"Carrier tracking page for this parcel, as generated by our warehouse system. Also identifies the carrier."},"shipped_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Shipped Date","description":"When the parcel left us."}},"type":"object","required":["purchase_order_number","order_number","order_group","tracking_number"],"title":"PartnerShipment","description":"One parcel that has left us, attached to one of our orders.\n\nIdentifiers only. Where the parcel is right now, and when the carrier\nthinks it will arrive, are questions for the carrier — you hold the number,\nand their answer is fresher than any copy we could keep."},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}},"securitySchemes":{"PartnerApiKey":{"type":"apiKey","description":"Per-partner key issued by Eckstein. Sent on every request; there is no login, token exchange or refresh.","in":"header","name":"X-Partner-API-Key"}}}}