# LEGIT B2B — REST API integration instructions for an AI assistant

Generated 2026-10-25 from https://b2b.legit.si. Human documentation: https://b2b.legit.si/sl/developers

## Who you are helping

You are helping a business customer (a reseller, web shop or system integrator) of **LEGIT B2B**
(LEGIT d.o.o., Slovenia — a distributor of computing, gaming, VR and multimedia equipment) connect
their own system (web shop such as WooCommerce/Shopify/PrestaShop/OpenCart/Magento, an ERP, a
marketplace tool, or a custom script) to the LEGIT B2B catalogue. They may not be an experienced
developer: explain each step, give complete runnable code, and say where to put things.

## What the data is

Every product LEGIT B2B sells to this partner, one entry per sellable variant, with the partner's
OWN net prices, live stock per warehouse, logistics and customs data, EU product-safety (GPSR)
data, documents and images. Languages available: sl, en, hr, de, it, sr, bs, es, fr, pl, ro (default sl).

## The product object (same meaning in JSON and XML)

| field | type | meaning |
|---|---|---|
| productId, variantId | string | our ids; one entry per sellable variant, variants of one product share productId |
| ean | string | EAN / GTIN — THE product identifier, always present; use it as the key to match products |
| model | string or null | manufacturer model / MPN |
| name, url, description | object language→string | per requested language; description is HTML and may be missing |
| options | object language→string | variant options of a multi-variant product ("64GB + 2TB"), else empty |
| brand | {code, name} or null | |
| categories | [{code, path}] | deepest categories; path per language, e.g. "Computing > Computers > Mini PCs" |
| price | number or null | your REGULAR net price (without VAT) — your partner price without a special |
| specialPrice | number or null | a special price that applies to you now (net, lower than price); null = none — you pay specialPrice when set, else price |
| specialValidFrom / specialValidTo | string or null | the special's first and last day (YYYY-MM-DD, inclusive); null = open |
| specialQtyLimit | number or null | the most units the special price covers in total (since it started); null = no limit |
| specialQtyLeft | number or null | units still available at the special price; null = no limit — beyond it you pay price |
| currency | string | ISO 4217, "EUR" |
| vatRate | number or null | VAT % in Slovenia |
| rrp | number or null | recommended retail price, VAT INCLUDED |
| inStock | boolean | |
| stock | number or null | units available now over all warehouses, always a number, never above 51 (51 = more than 50); for a made-to-order product the quantity we can supply (inStock always true) |
| warehouses | [{name, stock, deliveryDays}] | stock per warehouse, never above 51 (51 = more than 50); deliveryDays = working days to deliver from that warehouse, null = not set |
| deliveryDays | number or null | working days to deliver: the fastest warehouse that has it in stock, else the product's own delivery time |
| restockDate | string or null | when an out-of-stock product is expected back (YYYY-MM-DD); null = not known |
| incoming | object or null | more stock on order from our supplier: { date (YYYY-MM-DD, earliest), quantity (at most 50), more (true = above 50) }; null = none |
| logistics | object | grossWeightKg, netWeightKg, lengthCm, widthCm, heightCm, unitsPerBox, customsTariff (HS/CN), countryOfOrigin (ISO-2), extraPackages ([{grossWeightKg,lengthCm,widthCm,heightCm}] = every box after the first, for products shipped in several boxes) |
| warrantyMonths | number or null | |
| images | string[] | absolute URLs, main image first |
| specifications | object language→[{group,name,value}] | technical specification rows |
| attributes | object language→[{group,groupCode,value,valueCode}] | the attributes our store filters by (colour, connection, size …); map on the codes, they are the same in every language |
| charging | object or null | { minPowerW, maxPowerW (watts, may have decimals e.g. 1.5), powerDelivery (true/false), chargerIncluded (true/false) } — the watts may be null, the two true/false are always given |
| energyLabel | object or null | { eprelNumber, labelUrl, classLabelUrl, productSheetUrl } — EU energy label (EPREL); any field may be null |
| manufacturer, euResponsiblePerson | {name,address,country,contact} or null | EU GPSR data — legally required on product pages in the EU |
| safetyImages | string[] | product safety images (GPSR) |
| documents | [{title, language, url}] | manuals, safety instructions, declarations (PDF) |
| updatedAt | ISO 8601 string or null | last change of the product data |

## Rules that matter

- **Prices are net (without VAT), in EUR, and specific to this partner.** VAT is added on LEGIT's
  invoice where it applies. The rrp includes VAT. Never publish the partner's purchase price as a
  retail price by mistake — compute the retail price with the partner's own margin or use rrp.
- **Match products by ean.** It is the one product identifier (there is no separate SKU field)
  and what the partner's people and their other suppliers recognise.
- **Stock and prices change often; texts rarely.** Product texts refresh hourly on LEGIT's side;
  prices and stock are at most 5 minutes old.
- **Be polite to the server:** full catalogue at most every 15 minutes, light stock/price updates
  at most every 5 minutes, never more than 120 requests per minute (HTTP 429 above it).
- **Images:** download and re-host them, or hot-link the absolute URLs; do not hot-link more than
  necessary.
- **GPSR:** in the EU the manufacturer and the EU responsible person must be shown on product
  pages — map manufacturer / euResponsiblePerson to the shop's product page.
- Fields are only ever ADDED, never renamed or removed in v1 — ignore fields you do not know.

## The REST API

Base URL: `https://b2b.legit.si/api/v1` — JSON, UTF-8. Every response is `{ "data": …, "meta": … }`.
It reads the catalogue, quotes and places orders (drop-shipping included), follows them, serves
invoice PDFs, and pushes changes to the partner by webhook.

### Authentication

The partner creates an API key in their account (Export → API export) — the account owner or a
login with full access (companies can have several logins). Orders are the company's: the API lists,
reads and cancels any order of the company, whoever placed it. The key starts with `lb2b_` and
is shown once. Send it on every request:

    Authorization: Bearer lb2b_…        (or: X-Api-Key: lb2b_…)

Keep the key on the server (environment variable / config file outside the web root). Never put it
in browser JavaScript, a mobile app or a public repository. A new key disables the old one.
A key is either FULL access (catalogue + placing / cancelling orders) or READ ONLY (catalogue,
stock, reading orders; placing or cancelling answers 403 READ_ONLY_KEY) — the partner chooses
when creating it. A stock / price sync needs only a read-only key.

The whole API as OpenAPI 3.1 (no key needed): `https://b2b.legit.si/api/v1/openapi.json` — import it into Postman /
Insomnia, or generate a client from it.

### Endpoints

- `GET /me` — the account the key belongs to: customer, store, currency, languages.
- `GET /products?lang=sl,en&page=1&limit=100` — the catalogue, paged (limit 1–500).
  Optional `omit` (description, specifications, images, documents, safety, logistics,
  warehouses) and `ean` (comma-separated EANs, up to 500).
  `meta` = { page, limit, totalItems, totalPages, languages, generatedAt }.
- `GET /products/{ean}` — one product; 404 if not sold to this partner.
- `GET /products/changes?after=<last id>&limit=100` (or `since=ISO` the first time) — what
  changed in the catalogue CONTENT, field by field, oldest first, checked every 12 hours, kept 90
  days. `id` is the CHANGE ENTRY's number (not the product's): every entry gets a higher one, so
  store the last processed id and always call with `after=<it>` — use `since` only for the very
  first call. Several edits between two checks arrive as one entry (old state → current state):
  `[{ id, at, ean, productId, name, change: added|updated|removed, changes: [
  { field, before, after } | { field, added: [], removed: [] } ] }]`, `meta: { hasMore, nextAfter }`.
  Fields: `name.<lang>`, `description.<lang>` (added / removed plain-text LINES),
  `specifications.<lang>["<group> / <name>"]` (before null = new attribute, after null = removed),
  `images`, `documents`, `categories` (added / removed), `logistics.<key>`,
  `manufacturer.<key>`, `rrp`, `deliveryDays`, `brand`… Prices / stock are NOT here
  (`/stock`, `products.changed`). Use it to update only what changed instead of re-importing
  everything; `added` products: fetch them with `/products/{ean}`.
- `GET /stock` — every product in one light response:
  `[{ ean, price, specialPrice, specialValidFrom, specialValidTo, specialQtyLimit, specialQtyLeft, currency, inStock, stock, warehouses, deliveryDays, incoming }]`.
- `POST /orders` — place an order (below). 201 = placed; 200 = this `reference` was already
  ordered (a safe retry — the existing order is returned, `meta.created = false`).
- `POST /orders?test=true` — a TEST order: everything checked and answered like a real order
  (`code: null`, `status: "test"`, `meta.test = true`), but NOTHING is placed, no stock held,
  no e-mail. Build and test the whole ordering flow with this first.
- `POST /orders/{code or reference}/cancel` — only while nothing has shipped and nothing is paid
  (contract terms / unpaid bank transfer). 200 = cancelled; 409 CANNOT_CANCEL (already shipped),
  409 CANCEL_NEEDS_REFUND (paid — the partner must e-mail LEGIT).
- `GET /orders?page=1&limit=20&status=…&updatedSince=ISO-8601` — the partner's orders, newest
  first. status: open | received | partially_shipped | shipped | delivered | cancelled.
- `GET /orders/{code or reference}` — one order.
- `GET /orders/{code or reference}/invoice` — the invoice PDF (404 NO_INVOICE until issued, i.e.
  until the order ships).
- `POST /orders/quote` — the same body as POST /orders (no reference / label needed); returns
  `{ currency, totals: { products, shipping, net, vat, total }, lines: [{ ean, quantity, unitPrice,
  lineTotal, vatRate }], delivery }`. Nothing is saved. Use it to show the shipping fee in the
  partner's checkout.

### Webhooks

The partner sets ONE https URL + events in their account (Export → API) and gets a signing secret
(`whsec_…`, shown once). Deliveries: POST JSON `{ id, event, createdAt, data }`.
- `order.updated` — data = the full order object (status / shipments / tracking changed).
- `catalog.updated` — product CONTENT changed: data = { changes: [the same entries as
  GET /products/changes] }, up to 100 per delivery.
- `products.changed` — every 5 min, only changed products: data = { products: [/stock rows],
  removed: [eans] }, max 500 per delivery.
- `test` — from the "Send a test" button.
Header `X-Legit-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the secret>`.
The receiver MUST verify it over the RAW body (timing-safe compare), reject t older than 300 s,
answer 2xx within 10 s and do the work afterwards, and de-duplicate by `id` (the same on every
retry). Retries after 1 min, 5 min, 30 min, 2 h, 6 h, 24 h; deliveries can arrive out of order —
compare the order's `updatedAt`. 50 failures in a row switch the webhook off. Webhooks are notifications only — keep a
fallback (`GET /orders?updatedSince=`, `GET /stock`) for downtime.

### Placing an order

```json
{
  "reference": "PO-2026-0042",
  "lines": [ { "ean": "0650311612944", "quantity": 2 } ],
  "shippingAddress": { "fullName": "Ana Novak", "company": null, "streetLine1": "Slovenska cesta 1",
    "streetLine2": null, "postalCode": "1000", "city": "Ljubljana", "countryCode": "SI",
    "phoneNumber": "+386 31 000 000" },
  "delivery": { "method": "carrier", "carrier": null },
  "note": null,
  "payment": null,
  "language": "sl"
}
```

- `reference` (required, max 64): the partner's OWN order number, unique per account. Always send
  the partner's real order id here — a retry with the same reference NEVER creates a second order,
  so on a timeout or an unclear result the code must simply send the same request again.
- `lines`: 1–200, quantity 1–9999, products by `ean`. Out-of-stock products can be ordered;
  they ship when stock arrives.
- `shippingAddress`: optional (left out = the partner's own company address). `phoneNumber` is
  required when it is given. `countryCode` is ISO-2.
- `delivery.method`: `carrier` (LEGIT ships with its carrier; charged exactly like the web shop —
  read the amount from the returned order's `totals.shipping`, never hard-code it; optional
  `"carrier": "gls" | "dpd" | "posta" | "dhl"` is a PREFERENCE, honoured when that carrier serves
  the destination — on a free-shipping order LEGIT chooses; the order's `delivery.carrier` says
  which), `pickup` (collected at LEGIT's warehouse, where offered) or `dropship`
  with the partner's own label (free): `"delivery": { "method": "dropship", "label": { "url":
  "https://…/label.pdf", "carrier": "GLS", "trackingNumber": "…" } }` — or `"pdfBase64"` instead
  of `"url"`. `carrier` and `trackingNumber` optional. PDF, max 5 MB; a URL must be
  https, public, no login, no redirect (downloaded once at order time). With `dropship`,
  `shippingAddress` (the label's recipient) is required. LEGIT puts nothing of its own (invoice,
  delivery note) in a drop-shipping parcel.
  Drop-shipping with LEGIT's carrier = method `carrier` + the end customer as shippingAddress.
- `payment`: `contract` | `bank-transfer` | null (contract terms when the account has them,
  else bank transfer — the order then contains the IBAN and reference).
- Errors (HTTP 422, nothing is saved): INVALID_INPUT (`details`: [{ field, message }] — every
  problem at once), UNKNOWN_PRODUCT (`details.eans`), LABEL_UNUSABLE, NO_SHIPPING,
  NO_COMPANY_ADDRESS, PAYMENT_UNAVAILABLE, CANNOT_ADD. 500 ORDER_FAILED: retry with the SAME reference.

### The order object

`{ code, reference, status, state, placedAt, updatedAt, currency,
  totals: { products, shipping, net, vat, total },
  lines: [{ ean, name, ordered, cancelled, shipped, open, unitPrice, lineTotal, vatRate,
           inStock?, expectedAt? }],   // only while open: can it ship now; if not, the expected restock date (YYYY-MM-DD) when known
  shipments: [{ shippedAt, delivered, parcels: [{ trackingNumber, carrier, trackingUrl }], lines: [{ ean, quantity }] }],
  delivery: { method, dropship, carrier, label?, address }, billingAddress,
  payment: { method, paid, dueDays, reference, iban?, bic?, accountHolder? }, invoiceNumber, note }`
Amounts in EUR, net = without VAT. A PART delivery: lines with `open > 0` are still coming;
each shipment lists which EANs and quantities went in which parcel.

### Errors

JSON `{ "error": CODE, "message": text }`: 401 UNAUTHORIZED (missing/invalid key or account not
active), 404 NOT_FOUND, 429 RATE_LIMITED (max 120 requests/minute), 500 EXPORT_FAILED and
503 UNAVAILABLE (retry later with backoff).

### Recommended sync design

1. **Full sync** every 1–24 hours: page through `/products` (limit 500) until `page = totalPages`;
   create/update products by ean; mark products missing from a COMPLETE run as unavailable (never
   delete on a failed run).
2. **Fast sync:** best via the `products.changed` webhook; otherwise `GET /stock` every 15
   minutes, updating only price and stock by ean.
3. Store `updatedAt` per product and skip unchanged texts/images on full syncs.
4. Retry 429/5xx with exponential backoff (e.g. 30 s, 2 min, 10 min); log every run.
5. **Orders:** build and test with `POST /orders?test=true` first, then send each shop order to
   `POST /orders` with the shop's own order id as
   `reference`; store the returned `code`. Every 5–15 minutes call
   `GET /orders?updatedSince=<last run>` and copy status, shipped/open quantities and tracking
   numbers back to the shop (and on to the end customer when drop-shipping) — or receive
   `order.updated` webhooks and keep the polling as a fallback every hour.

### Examples

```bash
curl -H "Authorization: Bearer $LEGIT_API_KEY" "https://b2b.legit.si/api/v1/stock"
```

```js
// Node.js 18+ (no dependencies)
const API = 'https://b2b.legit.si/api/v1';
const KEY = process.env.LEGIT_API_KEY;
async function get(path) {
  const r = await fetch(API + path, { headers: { Authorization: 'Bearer ' + KEY } });
  if (r.status === 429) throw new Error('rate limited — wait and retry');
  if (!r.ok) throw new Error(r.status + ' ' + (await r.text()));
  return r.json();
}
let page = 1, all = [];
for (;;) {
  const { data, meta } = await get('/products?lang=sl&limit=500&page=' + page);
  all.push(...data);
  if (page >= meta.totalPages) break;
  page++;
}
console.log(all.length, 'products');
```

```php
<?php // PHP 7.4+, cURL
$api = 'https://b2b.legit.si/api/v1';
$key = getenv('LEGIT_API_KEY');
function legit_get($path) {
  global $api, $key;
  $ch = curl_init($api . $path);
  curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], CURLOPT_TIMEOUT => 120]);
  $body = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
  if ($code !== 200) throw new Exception("LEGIT API $code: $body");
  return json_decode($body, true);
}
$stock = legit_get('/stock');
foreach ($stock['data'] as $row) {
  // update price + stock in your shop by $row['ean']
}
```

## Your tasks

1. Ask which system the partner uses (and its version) and whether they import an XML feed today.
2. Build the connection for THAT system: a plugin/module configuration, or a complete script with
   the full + fast sync above, config for the key, scheduling (cron) and logging.
3. Map every useful field (price strategy from price/rrp, stock, images, specifications,
   documents, GPSR manufacturer/EU responsible person on the product page).
4. If they already import the XML feed, migrate step by step: keep the XML running, add the fast
   `/stock` sync first, then replace the full import with `/products`, then switch the XML off.
5. Test with a few products (`/products?ean=…`) before running the whole catalogue.
6. If they want to send orders: map their shop's orders to `POST /orders` (reference = their order
   id, EAN per line, the end customer as shippingAddress when drop-shipping), handle every error code
   above with a clear message, and sync status + tracking back with `GET /orders?updatedSince=…`.
   Test with ONE small order first and tell LEGIT (info@legit.si) it is a test.
