Search by

beziworld / laravel-idosell

kamcw

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.

Package info

github.com/Beziworld/laravel-idosell

Homepage

Issues

pkg:composer/beziworld/laravel-idosell

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

v1.0.0 2026-11-04 17:51 UTC

This package is auto-updated.

Last update: 2026-11-04 17:52:22 UTC


README

tests Latest Version License

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-KEY or an IdoSell Apps bearer token), with 45 operations behind dedicated methods and a generic request() 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 CarbonImmutable in the shop time zone
  • lazy iterators over every paginated search (resultsPage from 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

Polska wersja dokumentacji

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.

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.).