LEGIT B2B · Developers

Partner REST API

Connect your web shop, ERP or marketplace listing to the LEGIT B2B catalogue: every product with your own net prices, live stock per warehouse, logistics and customs data, GPSR safety data, documents and images — as JSON, always current. Your API key is created by an approved business account under Account → Export → API— by the account owner or a login with full access (sub-accounts). Orders placed through the API belong to the whole company: every login of it sees them, and the API lists and cancels any of the company's orders.

Only able to import a file? See the XML feed documentation — and the step-by-step move from XML to the API below when you are ready.

Start here

  1. Use the API if you can. It is faster, always current, lets you refresh only prices and stock every few minutes, and you can place orders through it — also drop-shipping straight to your customer.
  2. Create your API key under Account → Export → API and copy it somewhere safe (it is shown once).
  3. Try one call: curl -H "Authorization: Bearer YOUR_KEY" https://b2b.legit.si/api/v1/stock — or import the whole API into Postman / Insomnia from https://b2b.legit.si/api/v1/openapi.json (File → Import → paste the link) and set your key as the Bearer token.
  4. Build your ordering with test orders first (POST /orders?test=true): they are checked and answered exactly like real ones, but nothing is ever placed.
  5. Not a developer, or new to APIs? Download the instructions below and give the file to an AI assistant (ChatGPT, Claude, Copilot…) together with the name of your shop system — it contains everything needed to build the connection.

Base URL and format

Base URL https://b2b.legit.si/api/v1. JSON in UTF-8: read the catalogue, place orders and follow them. Every response is { "data": …, "meta": … }.

Authentication

Create a key under Account → Export → API. It starts with lb2b_ and is shown once — we keep only a fingerprint. Send it on every call:

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

Creating a new key disables the old one immediately; revoking leaves you without a key. Keys work only while your business account is approved. Keep the key on your server — never in a browser or an app.

When you create a key you choose what it may do: Full access (catalogue, stock, placing and cancelling orders) or Read only (catalogue, stock and reading orders — placing or cancelling answers 403 READ_ONLY_KEY). A stock / price sync needs only a read-only key.

Endpoints

GET /me

The account the key belongs to: customer e-mail, store, currency, languages.

GET /products

The catalogue, paged. Query parameters:

  • lang — comma-separated languages (sl, en, hr, de, it, sr, bs, es, fr, pl, ro); default sl. Text fields come as objects keyed by language.
  • omit — sections to leave out, comma-separated: description, specifications, images, documents, safety, logistics, warehouses. Everything is included by default.
  • page (from 1), limit (1–500, default 100), ean (comma-separated EANs, up to 500).

meta carries page, limit, totalItems, totalPages.

curl -H "Authorization: Bearer lb2b_…" "https://b2b.legit.si/api/v1/products?lang=en&page=1&limit=100"

GET /products/{ean}

One product (variant). 404 when we do not sell it.

GET /stock

Every product in one light response — ean, price, specialPrice, specialValidFrom, specialValidTo, specialQtyLimit, specialQtyLeft, currency, inStock, stock, warehouses, deliveryDays, incoming. Use it for frequent price/stock updates; fetch the full products less often. Stock numbers (total and per warehouse) never go above 51 — 51 means more than 50.

{ "data": [ { "ean": "0650311612944", "price": 18, "currency": "EUR",
             "inStock": true, "stock": 41, "warehouses": [ { "name": "Ljubljana (B13)", "stock": 41 } ],
             "deliveryDays": 1, "incoming": { "date": "2026-12-15", "quantity": 50, "more": true } } ],
  "meta": { "totalItems": 482, "generatedAt": "2026-10-15T12:00:00.000Z" } }

The product object

FieldTypeMeaning
productId, variantIdstringOur ids. One entry per sellable variant; variants of one product share productId.
eanstringEAN / GTIN — the product identifier, always present. Match products by it.
modelstring | nullManufacturer model / MPN.
name, url, descriptionobject (lang → string)Per requested language. name falls back to the store language; description is HTML and may be missing.
optionsobject (lang → string)Variant options of a several-variant product ("64GB + 2TB"); empty otherwise.
brand{ code, name } | null
categories[{ code, path }]Deepest categories only; path is per language, e.g. "Computing > Computers > Mini PCs".
pricenumber | nullYOUR regular net price (without VAT), in currency — your partner price, without a special.
specialPricenumber | nullA special price that applies to you now (net, lower than price); null = none. You pay specialPrice when it is set, else price.
specialValidFrom, specialValidTostring | nullThe special price's first and last day (YYYY-MM-DD, both included); null = no start / no end.
specialQtyLimitnumber | nullThe most units the special price covers in total (since it started); null = no limit.
specialQtyLeftnumber | nullUnits you can still buy at the special price; null = no limit. Beyond it you pay price.
currencystringISO 4217, EUR.
vatRatenumber | nullVAT % of the product in Slovenia (VAT is added on the invoice where it applies).
rrpnumber | nullRecommended retail price, VAT included.
inStockboolean
stocknumber | nullUnits available now over all warehouses, Always a number, never above 51 (51 = more than 50). For a made-to-order product it is the quantity we can supply (inStock is then always true).
warehouses[{ name, stock, deliveryDays }]Per warehouse (e.g. "Ljubljana (B13)", "Šentjernej"), stock never above 51; deliveryDays = working days to deliver from that warehouse (null = not set).
deliveryDaysnumber | nullWorking days to deliver: the fastest warehouse that has it in stock, else the product’s own delivery time.
restockDatestring | nullWhen an out-of-stock product is expected back (YYYY-MM-DD), set by our staff; null = not known.
incoming{ date, quantity, more } | nullMore stock on order from our supplier: the earliest expected date (YYYY-MM-DD), the quantity then (at most 50; more = true above 50). null = nothing on order.
logisticsobjectgrossWeightKg, netWeightKg, lengthCm, widthCm, heightCm (package), unitsPerBox, customsTariff (HS / CN code), countryOfOrigin (ISO-2), extraPackages ([{ grossWeightKg, lengthCm, widthCm, heightCm }] — a product shipped in several boxes: every box after the first; empty otherwise).
warrantyMonthsnumber | null
imagesstring[]Absolute URLs, main image first.
specificationsobject (lang → [{ group, name, value }])Technical specification rows.
attributesobject (lang → [{ group, groupCode, value, valueCode }])The product attributes our store filters by (colour, connection, size …). The codes stay the same in every language — map on them.
charging{ minPowerW, maxPowerW, powerDelivery, chargerIncluded } | nullMinimum / maximum charging power in watts (a number, may have decimals, e.g. 1.5; null = not entered); powerDelivery (USB Power Delivery) and chargerIncluded are always true or false. null = no charging data.
energyLabel{ eprelNumber, labelUrl, classLabelUrl, productSheetUrl } | nullEU energy label: the EPREL registration number and links to the energy label, the energy class label and the product information sheet; any field may be null. null = none.
manufacturer, euResponsiblePerson{ name, address, country, contact } | nullGPSR data.
safetyImagesstring[]Product safety images (GPSR).
documents[{ title, language, url }]Manuals, safety instructions, declarations (PDF).
updatedAtstring | nullISO 8601, last change of the product data.

Product changes

What changed in the catalogue, field by field — names, descriptions, every specification attribute, images, documents, logistics and customs data, GPSR data, categories, brand, RRP, delivery days; new and removed products. (Prices and stock change all the time and have their own feed: /stock and the products.changed webhook.) We compare the catalogue every 12 hours and keep the log for 90 days. Several edits of a product between two checks arrive as one entry — from how it was at the previous check to how it is now.

GET /products/changes?after={id}&limit=100

Oldest first. id is the number of the CHANGE ENTRY (not of the product) — every entry gets a new, higher one, so a product changed three times appears three times with three ids and nothing is skipped. Use since (ISO time) only for your very first call; after that always pass the last id you processed as after — a time can repeat or miss entries that share the same second, an id cannot. meta.nextAfter is the value for your next call, meta.hasMore says whether to call again right away.

{ "data": [ {
    "id": 128, "at": "2026-10-17T17:41:33.282Z",
    "ean": "0650311612944", "productId": "216", "name": "3D naslonjala za roke",
    "change": "updated",                         // added | updated | removed
    "changes": [
      { "field": "name.sl", "before": "3D naslonjala", "after": "3D naslonjala za roke" },
      { "field": "description.sl", "added": ["Barva: modra"], "removed": ["Barva: črna"] },
      { "field": "specifications.sl["Tehnične informacije / Teža"]", "before": "2 kg", "after": "2,4 kg" },
      { "field": "images", "added": ["https://…/new.jpg"], "removed": [] },
      { "field": "logistics.customsTariff", "before": null, "after": "94039100" }
    ] } ],
  "meta": { "limit": 100, "hasMore": false, "nextAfter": 128 } }

Orders

An order through the API is the same as an order on the web shop: your prices, your payment terms, the same confirmation and shipping e-mails to your account's e-mail. Products are identified by ean.

POST /orders

Place an order. The body (JSON):

{
  "reference": "PO-2026-0042",          // required: YOUR order number (max 64) — see "Safe retries"
  "lines": [ { "ean": "0650311612944", "quantity": 2 } ],     // 1–200 lines, quantity 1–9999
  "shippingAddress": {                  // optional: leave it out to deliver to your own company address
    "fullName": "Ana Novak", "company": "Novak d.o.o.",
    "streetLine1": "Slovenska cesta 1", "streetLine2": null,
    "postalCode": "1000", "city": "Ljubljana", "countryCode": "SI",
    "phoneNumber": "+386 31 000 000"     // required — the courier calls or texts it
  },
  "delivery": { "method": "carrier", "carrier": "gls" },  // optional — see "Delivery" below
  "note": "Deliver after 2 pm",         // optional, max 1000
  "payment": "contract",                // optional: "contract" or "bank-transfer"
  "language": "sl"                      // optional: the e-mails' language (sl, en, hr, de, it, sr, bs, es, fr, pl, ro)
}
  • 201 — the order was placed; data is the order object.
  • Payment: left out, your contract terms are used when your account has them, otherwise bank transfer (the order object then carries the IBAN and the reference to pay with).
  • Delivery: { "method": "carrier" } (default) — we ship with our carrier; add "carrier": "gls" (or dpd, posta, dhl) to prefer one. A preference is honoured when that carrier serves the destination; on an order that ships free we choose. { "method": "pickup" } — you collect it at our warehouse (where the store offers it). { "method": "dropship", … } — your own label, see Drop-shipping.
  • Shipping with our carrier is charged exactly as on the web shop (see Delivery at checkout); the order object shows the amount in totals.shipping and the carrier in delivery.carrier. Drop-shipping with your own label and pickup are free.
  • Products that are out of stock can be ordered: they ship when the goods arrive (their quantity stays open until then).

Test orders

POST /orders?test=true with the same body checks everything a real order checks — products, prices, stock, address, delivery, your payment terms, the label — and answers with the order as it would be ("code": null, "status": "test", "meta": { "test": true }). Nothing is placed, no stock is held and no e-mail is sent. Use it while you build and test your connection.

Safe retries

reference is unique per account. Sending the same reference again — after a timeout, a crash, a double click — never creates a second order: you get 200 with the order that already exists and "meta": { "created": false }. So when you are not sure whether an order went through, simply send it again.

GET /orders

Your orders, newest first. Query: page, limit (1–100, default 20), status (open, received, partially_shipped, shipped, delivered, cancelled), updatedSince (ISO 8601 — poll with the time of your last run to get only what changed).

GET /orders/{code or reference}

One order, by our order number or by your reference.

POST /orders/{code or reference}/cancel

Cancels the order — only while nothing has shipped and nothing is paid yet (your contract terms, or a bank transfer not paid yet). 200 with the cancelled order (also when it already was). 409 CANNOT_CANCEL once anything has shipped, 409 CANCEL_NEEDS_REFUND when it is paid — e-mail us and we cancel and refund it. Needs a full-access key.

GET /orders/{code or reference}/invoice

The invoice as a PDF (application/pdf) — the same document you get by e-mail when the order ships. 404 NO_INVOICE until it is issued.

POST /orders/quote

What an order would cost — send the same body as POST /orders (reference and the label are not needed). Nothing is ordered or saved. The answer has totals(products, shipping, net, VAT, total) and every line's price, so your shop can show the shipping fee before your customer pays.

{ "data": { "currency": "EUR",
  "totals": { "products": 36, "shipping": 3.9, "net": 39.9, "vat": 8.78, "total": 48.68 },
  "lines": [ { "ean": "0650311612944", "quantity": 2, "unitPrice": 18, "lineTotal": 36, "vatRate": 22 } ],
  "delivery": { "method": "carrier", "countryCode": "SI" } } }

Drop-shipping

We send the parcel straight to your customer. Two ways:

"shippingAddress": { …your customer, as on the label… },
"delivery": {
  "method": "dropship",
  "label": {
    "url": "https://your-system.com/labels/PO-2026-0042.pdf",   // https link to the PDF …
    // "pdfBase64": "JVBERi0xLjQK…",                             // … or the PDF itself, base64
    "carrier": "GLS",                                            // optional: the carrier on the label
    "trackingNumber": "6260123456"                               // optional: the parcel number on the label
  }
}

The label must be a PDF of at most 5 MB. A link must be https, publicly reachable without a login, and must not redirect — we download it once when the order is placed, so it only needs to work for a few minutes. carrier and trackingNumberare optional — when you give the tracking number, the order object reports it as the shipment's tracking. We print nothing of ours for a drop-shipping parcel: no invoice, no delivery note.

The order object

Amounts are in EUR (net = without VAT). Each line says how much has shipped and how much is still open; each shipment says what went in which parcel — so a part delivery is always clear: what you got, and what is still coming.

{
  "code": "133750512",                 "reference": "PO-2026-0042",
  "status": "partially_shipped",       // received | partially_shipped | shipped | delivered | cancelled
  "state": "PartiallyShipped",         // our internal state, for support
  "placedAt": "2026-10-15T20:39:59.713Z", "updatedAt": "…", "currency": "EUR",
  "totals": { "products": 36, "shipping": 3.9, "net": 39.9, "vat": 8.78, "total": 48.68 },
  "lines": [ { "ean": "0650311612944", "name": "3D naslonjala za roke",
               "ordered": 2, "cancelled": 0, "shipped": 1, "open": 1,
               "unitPrice": 18, "lineTotal": 36, "vatRate": 22,
               "inStock": false, "expectedAt": "2026-11-05" } ],   // only while open: can it ship now? if not, when (if known)
  "shipments": [ { "shippedAt": "…", "delivered": false,
                   "parcels": [ { "trackingNumber": "16962038820121", "carrier": "DPD", "trackingUrl": "https://…" } ],
                   "lines": [ { "ean": "0650311612944", "quantity": 1 } ] } ],
  "delivery": { "method": "carrier", "dropship": false, "carrier": "DPD", "address": { … } },   // method: carrier | pickup | dropship
  "billingAddress": { … },
  "payment": { "method": "contract", "paid": false, "dueDays": 30, "reference": "133750512" },
  "invoiceNumber": null,               "note": null
}

Order errors (HTTP 422) say what to fix: INVALID_INPUT (with details, one entry per field), UNKNOWN_PRODUCT (details.eans), LABEL_UNUSABLE, NO_SHIPPING, NO_COMPANY_ADDRESS, PAYMENT_UNAVAILABLE, CANNOT_ADD. Nothing is saved when an order is refused. A line still open says whether it can ship now (inStock) and, when it cannot, the expected restock date (expectedAt, when we know it).

Webhooks

Instead of asking us every few minutes, let us tell you. Set one https URL under Account → Export → API, choose the events and press "Send a test". We send a POST with a JSON body:

{ "id": "6f1c…",                     // unique per delivery, SAME on every retry — ignore one you have already processed
  "event": "order.updated",          // or "products.changed", "catalog.updated", "test"
  "createdAt": "2026-10-15T21:21:19.015Z",
  "data": { … } }

Check the signature

Every delivery carries X-Legit-Signature: t=<unix seconds>,v1=<hex> — an HMAC-SHA256 of "<t>.<raw body>" with your signing secret (whsec_…, shown once on the Export page). Compute it over the RAW body, compare, and reject deliveries older than 5 minutes:

// Node.js (Express: app.post('/legit-webhook', express.raw({ type: 'application/json' }), handler))
import crypto from 'node:crypto';
function verify(rawBody, header, secret) {
  const { t, v1 } = Object.fromEntries(header.split(',').map(p => p.split('=')));
  const expected = crypto.createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex');
  return Math.abs(Date.now() / 1000 - Number(t)) < 300 &&
         crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
<?php // PHP
$raw = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_LEGIT_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', $sig['t'] . '.' . $raw, getenv('LEGIT_WEBHOOK_SECRET'));
if (!hash_equals($expected, $sig['v1'] ?? '') || abs(time() - (int)$sig['t']) > 300) { http_response_code(400); exit; }
http_response_code(200);
$event = json_decode($raw, true);   // then process $event['event'] / $event['data']

Freshness, caching, limits

Errors

JSON with an HTTP status:

401 { "error": "UNAUTHORIZED",  "message": "Missing or invalid API key …" }
404 { "error": "NOT_FOUND",     "message": "…" }
429 { "error": "RATE_LIMITED",  "message": "At most 120 requests per minute." }
422 { "error": "INVALID_INPUT", "message": "…", "details": [ { "field": "lines[0].ean", "message": "Required." } ] }
500 { "error": "EXPORT_FAILED", "message": "…" }        retry later
500 { "error": "ORDER_FAILED",  "message": "…" }        retry with the SAME reference
503 { "error": "UNAVAILABLE",   "message": "…" }        retry later

Moving from XML to the API

The data is the same — every XML element has a JSON field of the same name. A safe order of work:

  1. Keep your XML import running as it is.
  2. Add a small job that calls /stock every 5–15 minutes and updates only price and stock, matched by ean. This alone gives you fresher stock than any feed.
  3. Replace the full XML import with /products: page through it with limit=500 until page equals totalPages, create or update by ean. Run it every few hours.
  4. When both jobs run cleanly for a few days, switch the XML import off.
  5. Never delete products because one run failed — only after a complete, successful run.
// Node.js 18+: refresh price + stock by EAN
const r = await fetch('https://b2b.legit.si/api/v1/stock', { headers: { Authorization: 'Bearer ' + process.env.LEGIT_API_KEY } });
if (!r.ok) throw new Error('LEGIT API ' + r.status);
for (const row of (await r.json()).data) {
  // updateMyProduct(row.ean, { cost: row.price, stock: row.stock })
}
<?php // PHP: the same with cURL
$ch = curl_init('https://b2b.legit.si/api/v1/stock');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('LEGIT_API_KEY')]]);
$rows = json_decode(curl_exec($ch), true)['data'];
foreach ($rows as $row) { /* update price + stock by $row['ean'] */ }

The base URL and the key stay the same. Fields are only ever added; a breaking change would come as /api/v2.

Instructions for AI

The whole contract in one file for ChatGPT, Claude or Copilot — give it the file and the name of your shop system: ⬇ Instructions for AI — API.

Changelog

Questions or an integration we should know about: [email protected].