REST API v1

Integration via REST API

Fetch La Grana products, stock levels and your individual prices directly into your system. Full automation with JWT authentication.

🔐
JWT Auth
Secure authentication with JWT tokens and automatic refresh.
REST + JSON
Standard REST endpoints with JSON responses. Simple and predictable.
💰
Individual prices
Each account receives individual prices and discount — reflected directly in API responses.
Authentication
La Grana API uses JWT (JSON Web Tokens). First obtain a token, then attach it to every request in the Authorization header.
POST /api/token/ Get access token
FieldTypeDescription
usernamerequiredYour La Grana system login
passwordrequiredYour password
import requests

resp = requests.post("https://b2b.lagrana.pl/api/token/",
    json={"username": "twoj_login", "password": "haslo"})
token = resp.json()["access"]
headers = {"Authorization": f"Bearer {token}"}
$res = file_get_contents('https://b2b.lagrana.pl/api/token/', false,
    stream_context_create(['http' => [
        'method'  => 'POST',
        'header'  => 'Content-Type: application/json',
        'content' => json_encode(['username'=>'twoj_login', 'password'=>'haslo'])
    ]])
);
$token = json_decode($res)->access;
$authHeader = "Authorization: Bearer $token";
curl -X POST https://b2b.lagrana.pl/api/token/ \
  -H "Content-Type: application/json" \
  -d '{"username": "twoj_login", "password": "haslo"}'
Response
{
  "access":  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

The access token is valid for 60 minutes. Refresh via POST /api/token/refresh/ with the refresh token.

Attach token to every request:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Base URL and format
Direct all requests to the address below. The API returns and accepts data in JSON format (UTF-8). All list endpoints are paginated — use the next field to iterate through pages.
https://b2b.lagrana.pl/api/
Paginated response
{
  "count": 1248,        // total number of results
  "next":  "https://b2b.lagrana.pl/api/products/?page=2",
  "previous": null,
  "results": [ /* ... */ ]
}
Products
Full product catalog with descriptions, images, attributes and your individual prices. client_price is the final price after discount; client_price_without_discount is the base price before discount.
GET /api/products/ Product list (paginated)
ParameterTypeDescription
pageoptionalPage number (default 1)
brandoptionalFilter by brand name
categoriesoptionalFilter by category ID
statusoptionalFilter by status ID
activeoptionaltrue / false
searchoptionalSearch by SKU or status

Response fields

id sku name brand ean active vat_rate for_who advantages stock_warehouse stock_supplier added_at updated_at client_price client_price_without_discount categories [ ] status { } prod_image [ ] description { } additional [ ]

price fields   nested objects/arrays

url = "https://b2b.lagrana.pl/api/products/"
while url:
    data = requests.get(url, headers=headers).json()
    for p in data["results"]:
        print(p["sku"], p["client_price"], p["stock_warehouse"])
    url = data["next"]
$url = 'https://b2b.lagrana.pl/api/products/';
while ($url) {
    $data = json_decode(file_get_contents($url, false,
        stream_context_create(['http' => ['header' => $authHeader]])
    ));
    foreach ($data->results as $p) {
        echo $p->sku . ' ' . $p->client_price . "\n";
    }
    $url = $data->next;
}
curl "https://b2b.lagrana.pl/api/products/" \
  -H "Authorization: Bearer $TOKEN"

# kolejna strona
curl "https://b2b.lagrana.pl/api/products/?page=2" \
  -H "Authorization: Bearer $TOKEN"
Response
{
  "count": 1248,
  "next": "https://b2b.lagrana.pl/api/products/?page=2",
  "previous": null,
  "results": [
    {
      "id": 42,
      "sku": "LG-12345",
      "name": "Nazwa produktu",
      "brand": "BrandName",
      "ean": "1234567890123",
      "active": true,
      "vat_rate": 23,
      "for_who": "Dla niej",
      "advantages": ["Zaletą jest...", "..."],
      "stock_warehouse": 24,
      "stock_supplier": 10,
      "added_at": "2025-01-15T08:00:00Z",
      "updated_at": "2026-03-01T12:00:00Z",
      "client_price": "84.15",               // price after discount
      "client_price_without_discount": "99.00", // base price
      "categories": [
        { "id": 3, "name_pl": "Wibratory", "name_en": "Vibrators", "parent": null }
      ],
      "status": { "id": 1, "name": "Nowość" },
      "prod_image": [
        { "image": "https://cdn.lagrana.pl/img/lg-12345-1.jpg", "main_image": true, "hoover_image": false },
        { "image": "https://cdn.lagrana.pl/img/lg-12345-2.jpg", "main_image": false, "hoover_image": true }
      ],
      "description": {
        "id": 5, "product": 42,
        "lang_pl": "Opis po polsku...",
        "lang_en": "Description in English...",
        "lang_de": "Beschreibung auf Deutsch...",
        // also: lang_nl, lang_fr, lang_es, lang_it, lang_gr, lang_cz, lang_sk, lang_ua, lang_ru
      },
      "additional": [
        { "id": 101, "product": 42, "item": "Kolor", "value": "Czarny" },
        { "id": 102, "product": 42, "item": "Długość", "value": "18 cm" }
      ]
    }
  ]
}
GET /api/products/{id}/ Single product by ID

Returns a single product object with the same fields as the list. Use the numeric internal ID from the id field, or use the products-sku endpoint to look up by SKU.

Product by SKU
Fetch a single product directly by its SKU code — useful for synchronizing with external systems where the internal ID is unknown.
GET /api/products-sku/{sku}/ Product by SKU
p = requests.get(
    "https://b2b.lagrana.pl/api/products-sku/LG-12345/",
    headers=headers
).json()
print(p["name"], p["client_price"], p["client_price_without_discount"])
$p = json_decode(file_get_contents(
    'https://b2b.lagrana.pl/api/products-sku/LG-12345/', false,
    stream_context_create(['http' => ['header' => $authHeader]])
));
echo $p->name . ' ' . $p->client_price;
curl "https://b2b.lagrana.pl/api/products-sku/LG-12345/" \
  -H "Authorization: Bearer $TOKEN"

Returns the same fields as GET /api/products/{id}/. Returns 404 if the SKU does not exist.

GET /api/products-sku/ Filterable list

Same filtering parameters as /api/products/ (brand, categories, status, active, search). The detail endpoint uses SKU as the key instead of numeric ID.

Categories
Full category list with multilingual names. Use the parent field to build a category tree.
GET /api/categories/ Category list
cats = requests.get("https://b2b.lagrana.pl/api/categories/",
    headers=headers).json()
for c in cats:
    print(c["id"], c["name_pl"])
$cats = json_decode(file_get_contents(
    'https://b2b.lagrana.pl/api/categories/', false,
    stream_context_create(['http' => ['header' => $authHeader]])
));
foreach ($cats as $c) echo $c->id . ' ' . $c->name_pl . "\n";
curl "https://b2b.lagrana.pl/api/categories/" \
  -H "Authorization: Bearer $TOKEN"
Response
[
  {
    "id": 1,
    "name_pl": "Wibratory",
    "name_en": "Vibrators",
    "name_de": "Vibratoren",
    "name_nl": null,
    "name_fr": null,
    "name_es": null,
    "name_it": null,
    "name_gr": null,
    "name_cz": null,
    "name_sk": null,
    "name_ua": null,
    "name_ru": null,
    "parent": null   // ID of parent category, null = top level
  }
]
Stock levels
Lightweight endpoint for checking availability without fetching full product data. Supports the same filters as /api/products/. Default page size is 1 000 — for full catalog sync use ?all=1.
GET /api/stock/ Stock for all active products
ParameterTypeDescription
alloptionalSet to 1 to disable pagination and return all products in a single response. Recommended for full catalog sync.
page_sizeoptionalNumber of results per page (default: 1000, max: 10000). Ignored when all=1.
brandoptionalFilter by brand
categoriesoptionalFilter by category ID
searchoptionalSearch by SKU
# wszystkie stany w jednym zapytaniu
stock = requests.get("https://b2b.lagrana.pl/api/stock/?all=1",
    headers=headers).json()
for item in stock:
    print(item["sku"], item["stock_warehouse"], item["stock_supplier"])
$stock = json_decode(file_get_contents(
    'https://b2b.lagrana.pl/api/stock/?all=1', false,
    stream_context_create(['http' => ['header' => $authHeader]])
));
foreach ($stock as $item) {
    echo $item->sku . ' ' . $item->stock_warehouse . "\n";
}
# wszystkie stany naraz
curl "https://b2b.lagrana.pl/api/stock/?all=1" \
  -H "Authorization: Bearer $TOKEN"
With pagination (default)
GET /api/stock/
GET /api/stock/?page=2
GET /api/stock/?page_size=500

{
  "count":    4432,   // total products
  "next":     "https://.../api/stock/?page=2",
  "previous": null,
  "results": [
    { "sku": "LG-12345", "stock_warehouse": 24, "stock_supplier": 10 },
    ...
  ]
}
All products at once (?all=1)
GET /api/stock/?all=1

[
  { "sku": "LG-12345", "stock_warehouse": 24, "stock_supplier": 10 },
  { "sku": "LG-12346", "stock_warehouse": 0,  "stock_supplier": 50 },
  ...
]  // flat array, no count/next/previous wrapper

stock_warehouse — quantity in La Grana warehouse
stock_supplier — quantity available at supplier

Orders
Create and list your orders. Each account only sees its own orders. You can reference products by internal ID or SKU. Order creation requires delivery address and a shipping label file — use multipart/form-data (not JSON).
GET /api/orders/ List your orders
orders = requests.get("https://b2b.lagrana.pl/api/orders/",
    headers=headers).json()
for o in orders["results"]:
    print(o["id"], o["status"], o["total_gross"])
$orders = json_decode(file_get_contents(
    'https://b2b.lagrana.pl/api/orders/', false,
    stream_context_create(['http' => ['header' => $authHeader]])
));
foreach ($orders->results as $o) {
    echo $o->id . ' ' . $o->status . ' ' . $o->total_gross . "\n";
}
curl "https://b2b.lagrana.pl/api/orders/" \
  -H "Authorization: Bearer $TOKEN"
Response
{
  "count": 3,
  "results": [
    {
      "id": 7,
      "external_id": "WC-1234",
      "status": "new",        // new | confirmed | rejected
      "total_gross": "267.00",
      "notes": "Uwagi do zamówienia",
      "first_name": "Jan",
      "last_name": "Kowalski",
      "phone": "500100200",
      "delivery_email": "jan@sklep.pl",
      "address": "ul. Przykładowa 1",
      "postal": "00-001",
      "city": "Warszawa",
      "country_code": "PL",
      "shipping_label": "https://b2b.lagrana.pl/media/shipping_labels/etykieta.pdf",
      "dropshipping": false,
      "created_at": "2026-03-01T12:00:00Z",
      "updated_at": "2026-03-01T12:05:00Z",
      "items_read": [
        {
          "product": 42, "sku": "LG-12345", "name": "Nazwa produktu",
          "qty": 3, "unit_price_gross": "89.00",
          "vat_rate": 23, "line_total_gross": "267.00"
        }
      ]
    }
  ]
}
POST /api/orders/ Create an order
Content-Type: multipart/form-data

Delivery address (required)

FieldTypeDescription
first_namerequiredRecipient's first name
last_namerequiredRecipient's last name
addressrequiredStreet and number
postalrequiredPostal code
cityrequiredCity
shipping_labeloptionalShipping label file (PDF/ZPL)
country_codeoptional2-letter country code (default: PL)
phoneoptionalRecipient's phone
delivery_emailoptionalRecipient's e-mail

Order fields

FieldTypeDescription
itemsrequiredJSON string — array of items (see below)
dropshippingoptionalDropshipping order — true / false (default: false). If false, a TRANSPORT-PL line is added in Vendo; if true, a DROPSHIPPING line is added.
external_idoptionalYour own reference number
notesoptionalOrder notes

Each item in the items JSON array:

FieldTypeDescription
productproduct or skuInternal product ID
skuproduct or skuProduct SKU (alternative to product)
qtyrequiredQuantity (min. 1)

Prices, VAT rates and line totals are calculated server-side from the client's assigned price list — do not send price data.

Validation — what returns 400

Product not found
If any SKU or product ID is not found in the catalog, the entire order is rejected.
Insufficient stock
If the requested quantity exceeds warehouse stock for any item, the order is rejected. All stock errors are returned at once.
{
  "non_field_errors": [
    "Product 'LG-99999' does not exist.",
    "Insufficient stock for 'LG-12345': ordered 10, available 3."
  ]
}
All-or-nothing
The order is only created in the database after all items pass validation — no partial orders are saved.
import requests, json

with open("etykieta.pdf", "rb") as label_file:
    order = requests.post(
        "https://b2b.lagrana.pl/api/orders/",
        headers=headers,
        data={
            "first_name":   "Jan",
            "last_name":    "Kowalski",
            "address":      "ul. Przykładowa 1",
            "postal":       "00-001",
            "city":         "Warszawa",
            "external_id":  "WC-5678",
            "notes":        "Pilne",
            "items": json.dumps([
                {"sku": "LG-12345", "qty": 3},
                {"sku": "LG-12346", "qty": 1},
            ]),
        },
        files={"shipping_label": ("etykieta.pdf", label_file, "application/pdf")},
    )
print(order.json())
$ch = curl_init('https://b2b.lagrana.pl/api/orders/');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => ["Authorization: Bearer $token"],
    CURLOPT_POSTFIELDS     => [
        'first_name'     => 'Jan',
        'last_name'      => 'Kowalski',
        'address'        => 'ul. Przykładowa 1',
        'postal'         => '00-001',
        'city'           => 'Warszawa',
        'external_id'    => 'WC-5678',
        'notes'          => 'Pilne',
        'items'          => json_encode([
            ['sku' => 'LG-12345', 'qty' => 3],
            ['sku' => 'LG-12346', 'qty' => 1],
        ]),
        'shipping_label' => new CURLFile('/path/to/etykieta.pdf', 'application/pdf', 'etykieta.pdf'),
    ],
]);
var_dump(json_decode(curl_exec($ch)));
curl_close($ch);
curl -X POST "https://b2b.lagrana.pl/api/orders/" \
  -H "Authorization: Bearer $TOKEN" \
  -F "first_name=Jan" \
  -F "last_name=Kowalski" \
  -F "address=ul. Przykładowa 1" \
  -F "postal=00-001" \
  -F "city=Warszawa" \
  -F "external_id=WC-5678" \
  -F "notes=Pilne" \
  -F 'items=[{"sku":"LG-12345","qty":3},{"sku":"LG-12346","qty":1}]' \
  -F "shipping_label=@/path/to/etykieta.pdf"
Complaints & Returns
Submit and track complaints (product defects) or returns for items from your orders. This feature is opt-in — contact us if 403 Forbidden is returned for your account. Each account only sees its own tickets; status and resolution are set by La Grana staff after review.
GET /api/complaints/ List your complaints/returns
ParameterTypeDescription
statusoptionalFilter by status — new, in_review, accepted, rejected, resolved
kindoptionalcomplaint or return
orderoptionalFilter by order ID
tickets = requests.get("https://b2b.lagrana.pl/api/complaints/?status=new",
    headers=headers).json()
for t in tickets["results"]:
    print(t["id"], t["kind"], t["status"])
$tickets = json_decode(file_get_contents(
    'https://b2b.lagrana.pl/api/complaints/?status=new', false,
    stream_context_create(['http' => ['header' => $authHeader]])
));
foreach ($tickets->results as $t) {
    echo $t->id . ' ' . $t->kind . ' ' . $t->status . "\n";
}
curl "https://b2b.lagrana.pl/api/complaints/?status=new" \
  -H "Authorization: Bearer $TOKEN"
Response
{
  "count": 1,
  "results": [
    {
      "id": 12,
      "order": 7,
      "item": 18,
      "kind": "complaint",             // complaint | return
      "qty": 1,
      "reason": "Urządzenie nie ładuje się.",
      "return_tracking_number": "",
      "status": "in_review",           // new | in_review | accepted | rejected | resolved
      "resolution_type": "",            // replacement | refund | correction | repair
      "resolution_note": "",
      "photos": [
        { "id": 4, "photo": "https://b2b.lagrana.pl/media/complaint_photos/img1.jpg", "uploaded_at": "2026-07-20T09:12:00Z" }
      ],
      "created_at": "2026-07-20T09:12:00Z",
      "updated_at": "2026-07-21T11:00:00Z",
      "resolved_at": null
    }
  ]
}
POST /api/complaints/ Submit a complaint or return
Content-Type: multipart/form-data
FieldTypeDescription
orderrequiredID of one of your orders
reasonrequiredDescription of the issue
kindoptionalcomplaint or return (default: complaint)
itemoptionalID of the specific order item this ticket concerns
qtyoptionalQuantity affected (default: 1)
return_tracking_numberoptionalTracking number of the parcel sent back
photosoptionalUp to 6 image files, repeat the field for each file

status, resolution_type and resolution_note are read-only — set by La Grana staff after review. Subscribe to the complaint.status_changed webhook to get notified when a ticket is resolved.

import requests

with open("usterka.jpg", "rb") as photo:
    resp = requests.post(
        "https://b2b.lagrana.pl/api/complaints/",
        headers=headers,
        data={
            "order": 7,
            "kind": "complaint",
            "item": 18,
            "qty": 1,
            "reason": "Urządzenie nie ładuje się.",
        },
        files={"photos": ("usterka.jpg", photo, "image/jpeg")},
    )
print(resp.json())
$ch = curl_init('https://b2b.lagrana.pl/api/complaints/');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => ["Authorization: Bearer $token"],
    CURLOPT_POSTFIELDS     => [
        'order'  => 7,
        'kind'   => 'complaint',
        'item'   => 18,
        'qty'    => 1,
        'reason' => 'Urządzenie nie ładuje się.',
        'photos' => new CURLFile('/path/to/usterka.jpg', 'image/jpeg', 'usterka.jpg'),
    ],
]);
var_dump(json_decode(curl_exec($ch)));
curl_close($ch);
curl -X POST "https://b2b.lagrana.pl/api/complaints/" \
  -H "Authorization: Bearer $TOKEN" \
  -F "order=7" \
  -F "kind=complaint" \
  -F "item=18" \
  -F "qty=1" \
  -F "reason=Urządzenie nie ładuje się." \
  -F "photos=@/path/to/usterka.jpg"
Discontinued products
List of products withdrawn from sale. Use it to remove these products from your own store. Each entry contains the product SKU and the date it was marked as discontinued.
GET /api/discontinued-products/ List discontinued products
ParameterTypeDescription
skuoptionalFilter by exact SKU
searchoptionalSearch by SKU
discontinued = requests.get("https://b2b.lagrana.pl/api/discontinued-products/",
    headers=headers).json()
for item in discontinued["results"]:
    print(item["sku"], item["discontinued_at"])
$discontinued = json_decode(file_get_contents(
    'https://b2b.lagrana.pl/api/discontinued-products/', false,
    stream_context_create(['http' => ['header' => $authHeader]])
));
foreach ($discontinued->results as $item) {
    echo $item->sku . ' ' . $item->discontinued_at . "\n";
}
curl "https://b2b.lagrana.pl/api/discontinued-products/" \
  -H "Authorization: Bearer $TOKEN"
Response
{
  "count":    2,
  "next":     null,
  "previous": null,
  "results": [
    { "id": 1, "sku": "LG-12345", "discontinued_at": "2026-06-11T10:32:00Z" },
    { "id": 2, "sku": "LG-12346", "discontinued_at": "2026-06-10T08:15:00Z" }
  ]
}
POST /api/discontinued-products/ Mark a product as discontinued

Admin accounts only. The discontinued_at date is set automatically to the current date and time. If the SKU is already on the list, the existing entry is returned unchanged.

FieldTypeDescription
skurequiredSKU of the discontinued product
resp = requests.post("https://b2b.lagrana.pl/api/discontinued-products/",
    headers=headers, json={"sku": "LG-12345"})
print(resp.json())
$ch = curl_init('https://b2b.lagrana.pl/api/discontinued-products/');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => ["Authorization: Bearer $token", "Content-Type: application/json"],
    CURLOPT_POSTFIELDS     => json_encode(['sku' => 'LG-12345']),
]);
var_dump(json_decode(curl_exec($ch)));
curl_close($ch);
curl -X POST "https://b2b.lagrana.pl/api/discontinued-products/" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"sku": "LG-12345"}'
Response
{ "id": 3, "sku": "LG-12345", "discontinued_at": "2026-06-11T16:00:00Z" }
Webhooks
Get notified the moment a product's price or stock changes — no more waiting for the next daily XML/CSV batch. Contact us to have a subscription set up for your endpoint.
POST /api/webhooks/ Register a subscription

Admin accounts only — subscriptions are provisioned by La Grana on your request.

FieldTypeDescription
event_typerequiredOne of: stock.changed, price.changed, product.updated, order.cancelled, complaint.status_changed
target_urlrequiredYour HTTPS endpoint that will receive the POST
Payload sent to your target_url
JSON
{
  "event_id":   "3f9c2e6a-1b2d-4e5f-8a9b-0c1d2e3f4a5b",
  "event_type": "price.changed",
  "sku":        "LG-12345",
  "changes": {
    "price_hurt_pln": { "old": "12.50", "new": "13.00" }
  },
  "timestamp": "2026-07-11T14:32:05.123456+00:00"
}

Every request carries an HMAC-SHA256 signature so you can verify it really came from us:

HeaderDescription
X-LaGrana-Signaturesha256=<hex-digest>
X-LaGrana-TimestampUnix seconds — reject if older than 300s to prevent replay
X-LaGrana-Eventevent_type
X-LaGrana-Deliveryevent_id
Python
import hmac, hashlib

expected = hmac.new(
    SECRET.encode(),
    f"{timestamp}.".encode() + raw_body,
    hashlib.sha256,
).hexdigest()

is_valid = hmac.compare_digest(f"sha256={expected}", request.headers["X-LaGrana-Signature"])
XML Feed
An alternative to JSON API — a product feed in XML format, accessible via a unique token (no JWT required). Useful for platforms that support XML product import (e.g. PrestaShop, IdoSell). Contact us to obtain your token.
GET /api/xml/{token}/ Product XML feed
XML
<?xml version="1.0" encoding="UTF-8"?>
<products>
  <product>
    <id>42</id>
    <sku>LG-12345</sku>
    <name>Nazwa produktu</name>
    <description>Opis po polsku...</description>
    <category>Wibratory</category>
    <brand>BrandName</brand>
    <price>84.15</price>   <!-- your price -->
    <stock>24</stock>
    <image>https://cdn.lagrana.pl/img/lg-12345-1.jpg</image>
    <active>true</active>
  </product>
</products>

The feed requires no authentication header — the token in the URL grants access. Prices reflect your individual rates.

Error codes
The API uses standard HTTP codes. Error responses include a detail field with a description.
CodeMeaning
200Success
201Created (e.g. new order)
400Request error — check parameters or required fields
401Missing authentication or expired token
403No permission to access resource
404Resource not found
429Request limit exceeded (rate limiting)
Ready to integrate?

Log in and start using the API

API access is granted on request — contact us or log in if you already have an account.

Log in Request access