beziworld / laravel-idosell
Laravel client for the IdoSell Shop Admin API: multiple shops, typed orders, products, stock and prices, customers, packages and labels, returns, RMA, sales documents, webhooks with events and rate limit handling.
Requires
- php: ^8.3
- ext-json: *
- guzzlehttp/guzzle: ^7.8
- illuminate/contracts: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.7|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- phpstan/phpstan-strict-rules: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-04 17:52:22 UTC
README
IdoSell Shop Admin API client for Laravel. Orders, products, stock and prices, customers, parcels and labels, returns, complaints and sales documents of one or many IdoSell shops:
- Admin API v8 (REST,
X-API-KEYor an IdoSell Apps bearer token), with 45 operations behind dedicated methods and a genericrequest()for the other 300 - multiple shops: named connections and runtime credentials stored per tenant (agencies, SaaS)
- typed, immutable objects for orders (lines, addresses, payments), products (sizes, stock per warehouse),
customers, returns, complaints, parcels, labels and documents; amounts are decimal strings with the
currency next to them, dates are
CarbonImmutablein the shop time zone - lazy iterators over every paginated search (
resultsPagefrom 0, up to 100 per page) - stock and price writers that chunk to the 100-element limit and collect per-item errors of
207 Multi-Status - webhooks: route with bearer token (and optional
x-iai-signature) verification and a Laravel event - error envelope (
errors.faultCode/faultString,X-Error) turned into exceptions; 429 handled without ever retrying writes
What this package adds. Packagist has no Laravel package for IdoSell and no maintained PHP client for the v8 REST Admin API (the official IdoSell SDK repository is empty). This package covers the integration work an agency repeats for every shop: configuration per shop, pagination, typed orders and products, stock and price writes within the API limits, labels and invoices as binary files, and verified webhooks.
Installation
composer require beziworld/laravel-idosell
php artisan vendor:publish --tag=idosell-config # optional
IDOSELL_DOMAIN=client12345.idosell.com # panel address or shop domain IDOSELL_API_KEY=YXBwbGljYXRpb24... # Settings > API > Access (Administration > API > Admin API access keys)
Requires PHP 8.3+ and Laravel 12 or 13. Requests go to https://{domain}/api/admin/v8/...; change the major
version with IDOSELL_API_VERSION. Dates are exchanged without a time zone, so they are read and written in
IDOSELL_TIMEZONE (Europe/Warsaw by default). IdoSell has no sandbox: every call works on a real panel, and
API calls (including webhook deliveries) count towards the API usage of the shop.
Multiple shops
'connections' => [ 'default' => ['domain' => env('IDOSELL_DOMAIN'), 'api_key' => env('IDOSELL_API_KEY')], 'outlet' => ['domain' => 'outlet.example.pl', 'api_key' => env('IDOSELL_OUTLET_KEY')], ],
use Beziworld\IdoSell\Facades\IdoSell; IdoSell::orders()->statuses(); // default connection IdoSell::connection('outlet')->products()->find(31); // Credentials stored per tenant; the name identifies the shop in webhook events $client = IdoSell::using(['domain' => $shop->domain, 'api_key' => $shop->api_key], name: 'shop-'.$shop->id); // IdoSell Apps authorised with OAuth use a bearer token instead of the key IdoSell::using(['domain' => $shop->domain, 'access_token' => $token]);
Orders
use Beziworld\IdoSell\Enums\OrderStatus; foreach (IdoSell::orders()->iterate(['ordersStatuses' => ['new', 'payment_waiting']]) as $order) { $order->serialNumber; // 1234 (the number shown in the panel) $order->status; // OrderStatus::New, null for statuses defined in the panel $order->statusName; // raw status code $order->addedAt; // CarbonImmutable, Europe/Warsaw $order->currency; // "PLN" $order->productsCost; // "259.98" $order->deliveryCost; // "15.99" $order->total(); // "276.07" (exact decimal addition) $order->billingAddress->taxId; // "5260001246" $order->deliveryAddress->city; $order->pickupPoint?->id; // "KRA010" foreach ($order->lines as $line) { $line->code; $line->sizeId; $line->quantity; $line->priceGross; $line->totalGross(); } $order->payments[0]->amount; // "276.07" $order->raw; // the original record, including fields not mapped } IdoSell::orders()->find(1234); // ?Order IdoSell::orders()->findByExternalId('AL-555'); // marketplace order id IdoSell::orders()->setStatus(1234, OrderStatus::Packed); IdoSell::orders()->setStatus(1234, 17); // status defined in the panel, by id IdoSell::orders()->setNote(1234, 'Gift wrap'); IdoSell::orders()->update([['orderSerialNumber' => 1234, 'clientNoteToCourier' => 'Call first']]);
Synchronising orders with the scheduler
// routes/console.php use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Schedule; Schedule::call(function () { $since = Cache::get('idosell:orders:since', now()->subDay()); $started = now(); IdoSell::orders()->modifiedSince($since)->each(function (Order $order) { LocalOrder::updateOrCreate(['idosell_sn' => $order->serialNumber], [ 'status' => $order->statusName, 'total' => $order->total(), // store as DECIMAL, no float rounding 'currency' => $order->currency, ]); }); Cache::forever('idosell:orders:since', $started->subMinutes(2)); // small overlap, upserts are idempotent })->everyFiveMinutes()->withoutOverlapping();
For several shops loop over IdoSell::connectionNames() (or your tenant table with IdoSell::using()) and keep
one cursor per shop.
Products, stock and prices
foreach (IdoSell::products()->iterate(['productIsVisible' => 'y']) as $product) { $product->id; $product->code; $product->name; // name in "pol" (all languages in $product->names) $product->retailPrice; // "49.90" foreach ($product->sizes as $size) { $size->id; // "uniw" for products without sizes $size->codeExternal; // EAN / external code $size->stock; // [1 => "3.00", 2 => "2.00"] keyed by warehouse id } } IdoSell::products()->changedSince(now()->subHour(), 'quantity_changed'); IdoSell::products()->stocks([31, 32]); // list<StockLevel> per warehouse and size // Stock: set, add or subtract; grouped per product and sent in chunks of 100 IdoSell::products()->setStock(31, warehouseId: 1, quantity: 12, sizeId: 'M'); $result = IdoSell::products()->updateStockLevels([ ['product' => 'MUG-1', 'warehouse' => 1, 'quantity' => 5, 'size' => 'uniw'], ['product' => 'MUG-2', 'warehouse' => 1, 'quantity' => 2, 'operation' => Products::OPERATION_ADD], ], identType: 'index'); $result->successful(); // false when any product failed $result->errors; // list<ItemError>: path, code, message // Prices: gross retail price by product id, or any price fields IdoSell::products()->updatePrices([31 => 49.90, 32 => ['productRetailPrice' => 10.0, 'productWholesalePrice' => 8.0]]);
Shipments, tracking and labels
IdoSell::shipments()->couriers('PL'); // list<Courier> IdoSell::shipments()->addTrackingNumber(1234, courierId: 100, trackingNumber: '6200123456789'); IdoSell::orders()->packages([1234]); // list<Package>: trackingNumber, trackingUrl, courierName // Courier shipment through the panel integration; labels come back as binary contents $labels = IdoSell::shipments()->createLabels(1234, parcelParameters: ['weight' => '1.2']); Storage::put('labels/1234.'.$labels[0]->extension(), $labels[0]->contents); // pdf, zpl, epl, ... IdoSell::shipments()->labels(55, Shipments::EVENT_RETURN); // labels of a return
Returns, complaints and documents
IdoSell::returns()->createdSince(now()->subDay())->each(fn (OrderReturn $return) => ...); IdoSell::returns()->create(1234, warehouseId: 1, products: [['id' => 31, 'size' => 'uniw', 'quantity' => 1, 'price' => 49.9]]); IdoSell::returns()->setStatus(77, 2); IdoSell::rma()->setStatus(4, 2, 'We will send a replacement.'); $invoices = IdoSell::documents()->get([1234], 'vat_invoice'); // list<Document>, PDF decoded Storage::put('invoices/'.$invoices[0]->filename(), $invoices[0]->contents); IdoSell::documents()->create([1234], 'vat_invoice'); IdoSell::documents()->upload(1234, 'FV 1/11/2026', file_get_contents('erp-invoice.pdf'));
Any other endpoint
IdoSell::get('system/shopsData'); IdoSell::post('vouchers/vouchers/search', ['resultsLimit' => 10]); // wrapped in {"params": ...} IdoSell::put('menu/menu', ['menu_list' => [...]], wrap: false); // endpoints without the wrapper IdoSell::request('DELETE', 'packages/packages', ['packageId' => 9]);
List values in queries are joined with commas and nested arrays become key[sub]=..., as the API expects.
Webhooks
Webhooks are configured per API key in the panel (Settings > API > Access > Webhook): URL, events and an
optional authorization token, which IdoSell sends as Authorization: Bearer {token}.
IDOSELL_WEBHOOKS_ENABLED=true IDOSELL_WEBHOOK_TOKEN=a-long-random-string # the same value as in the panel
Register https://your-app/idosell/webhook (default connection) or https://your-app/idosell/webhook/outlet.
Every verified delivery dispatches WebhookReceived; the payload is the response of the matching API gateway
(GET orders/orders for order events), so it maps to the same objects:
use Beziworld\IdoSell\Enums\EventType; use Beziworld\IdoSell\Events\WebhookReceived; Event::listen(function (WebhookReceived $event) { if ($event->eventType === EventType::OrderStatusUpdated) { foreach ($event->orders() as $order) { /* $event->connection, $order->status */ } } // $event->eventUid stays the same on redelivery: make listeners idempotent });
Failed deliveries (no 2xx answer) are retried every 10 minutes for an hour, then hourly for 23 hours; a webhook
failing for 7 days is disabled. The platform also sends x-iai-signature ("HMAC hash of the payload"); the
algorithm and key are not documented, so its check is opt-in (IDOSELL_WEBHOOK_SECRET,
IDOSELL_WEBHOOK_SIGNATURE_ALGORITHM=sha256). For tenant shops call
app(WebhookHandler::class)->handle($request, $client->config(), 'shop-7') from your own controller.
Rate limits
The Admin API answers 429 Too Many Requests when the shop limit is exceeded. Read requests are retried once
when Retry-After is at most IDOSELL_RETRY_MAX_WAIT seconds (10 by default); otherwise, and always for writes,
RateLimitExceeded is thrown with retryAfter(). Writes are never retried, because they are not idempotent.
Errors
| Exception | When | Details |
|---|---|---|
AuthenticationFailed |
HTTP 401 or 403 (invalid key, missing permission) | httpStatus(), faultString() holds the X-Error header, for example "Błędne hasło oraz login" |
ApiException |
errors.faultCode other than 0 and 2, HTTP 4xx/5xx, non-JSON body, connection failure |
faultCode(), faultString(), httpStatus(), method(), path(), body() |
RateLimitExceeded |
HTTP 429 not retried | retryAfter() |
InvalidResponse |
a label is not valid base64 | |
InvalidSignature |
webhook token or signature mismatch | |
InvalidConfiguration |
missing domain or credentials, unknown connection |
Fault code 2 ("Empty result") is not an error: searches return an empty list. Per-item failures of writes
(HTTP 207) do not throw; they are collected in WriteResult::$errors. All exceptions extend
Beziworld\IdoSell\Exceptions\IdoSellException.
Coverage of the API
| Resource | Admin API operations |
|---|---|
orders() |
POST orders/orders/search, GET/POST/PUT orders/orders, GET orders/statuses, POST orders/unfinished/search, GET orders/history, GET orders/packages, GET orders/labels, PUT orders/courier, PUT orders/shippingCosts, PUT orders/warehouse |
products() |
POST products/products/search, GET/POST/PUT products/products, POST products/products/delete, GET/PUT products/stocks, PUT products/stockQuantity, GET/PUT products/sizes |
customers() |
GET/POST/PUT clients/clients, GET clients/deliveryAddress |
shipments() |
GET couriers/couriers, GET couriers/pickupPoints, POST packages/packages/search, POST/PUT/DELETE packages/packages, GET/POST packages/labels |
returns() |
GET/POST/PUT returns/returns, GET returns/statuses |
rma() |
GET/PUT rma/rma, GET rma/statuses |
documents() |
GET orders/documents, POST orders/documents/create, POST orders/documents, POST orders/documents/delete |
The full OpenAPI document of a shop is published at https://{domain}/api/doc/admin/v8/json; every operation in
it can be called with request(), get(), post() or put().
Development
composer install
composer ci # pint --test, phpstan (level 8, strict rules), pest
Tests use Http::fake() with stray requests prevented; nothing is sent to a real shop.
Author
Created and maintained by Kamil Ćwiertnia, beziworld.eu.
- E-mail: biuro@beziworld.eu
- Issues: GitHub issues
Need an e-commerce integration? Get in touch through beziworld.eu.
License
MIT. See LICENSE.md. This package is not affiliated with IdoSell (IAI S.A.).