Search by

starmile / partner-sdk

samirmhsnv

Official PHP SDK for the Starmile Partner API — cross-border logistics integration (catalogue, orders, status pool, inbound events).

v7.1.0 2026-09-21 09:28 UTC

README

Official PHP SDK for the Starmile Partner API — the partner-facing integration surface of the Starmile cross-border logistics platform. It wraps every Partner API capability behind a small, typed, dependency-free client: catalogue lookup, order intake and management, the pull-based status pool, and inbound lifecycle events.

  • No runtime dependencies beyond ext-curl and ext-json — installs cleanly on legacy stacks.
  • Wide PHP support: 7.1 → 8.4.
  • Automatic OAuth2 — tokens are fetched, cached, and refreshed for you.
  • Typed exceptions mapped from HTTP status codes.
  • Fluent builders for orders, parcels, products, and events, with local validation of event types and fields before anything hits the network.

This package is a thin, faithful client over the HTTP API. The HTTP API remains the canonical contract; the public reference lives at api.starmile.io.

Installation

composer require starmile/partner-sdk

Requires PHP 7.1+ with the curl and json extensions.

Authentication

The Partner API uses OAuth2 client credentials. Your organization's back office issues you a client_id and client_secret (a Partner API credential). The SDK exchanges them for a short-lived bearer token at POST /oauth/token and attaches it to every call — you never touch the token endpoint directly.

use Starmile\PartnerSdk\Client;

$starmile = Client::create(
    getenv('STARMILE_CLIENT_ID'),
    getenv('STARMILE_CLIENT_SECRET')
);

Keep the client_secret server-side. Read it (and the base URL) from the environment — never hardcode credentials or per-environment hosts.

Configuration options

Client::create($clientId, $clientSecret, $options) accepts:

Option Default Description
base_url https://api.starmile.io API host. Point at your sandbox/staging outside production.
scopes all granted Subset of the credential's scopes to request on the token.
http_client bundled cURL client Any HttpClientInterface (Guzzle/Symfony/PSR-18 adapter, a mock).
token_storage in-memory A TokenStorageInterface to share one token across processes.
verify_tls true TLS verification (keep on in production).
connect_timeout 10 Connection timeout (seconds).
timeout 30 Total request timeout (seconds).
user_agent starmile-partner-sdk-php/x Override the User-Agent.
max_attempts 3 Total attempts for safe (GET) calls on transient failure. 1 disables auto-retry.
retry_base_delay_ms 200 Base backoff between retries (exponential + jitter).
retry_max_delay_ms 5000 Cap on the computed backoff.

Capabilities

The Partner API surface is reached through four resource groups, each gated by the scopes on your credential.

Resource Scope(s) Endpoints
$starmile->catalogue() catalogue:read GET /api/v1/services, GET /api/v1/rates
$starmile->orders() orders:create, orders:update, orders:cancel POST /api/v1/orders, POST /api/v1/orders/{order}/parcels, PATCH /api/v1/orders/{order}/parcels/{parcel}, POST /api/v1/orders/{order}/parcels/{parcel}/cancel, POST /api/v1/orders/{order}/cancel
$starmile->statusPool() status:read GET /api/v1/partner/changes
$starmile->events() events:transport, events:pudo, events:customs, leg:handoff POST /api/v1/partner/events

The same four groups exist on API v2 under $starmile->v2() — see API v2 — items / items. v2 adds one more: $starmile->v2()->orders()->deliveryCode() (delivery_code:read, GET /api/v2/orders/delivery-code) and $starmile->v2()->orders()->proofOfDelivery() (pod:read, GET /api/v2/orders/pod).

Catalogue

$services = $starmile->catalogue()->services(); // valid service_id values to order against
$rates    = $starmile->catalogue()->rates();    // the rates bound to your partner

Each rate's service_id is a Service's own id (the same value you send on order creation), so a rate maps to exactly one Service — correlate a rate to a service by matching $rate['service_id'] to $service['id'].

Orders

Build an order with the fluent builders (or pass a raw array matching the API body). The corridor and delivery type come from the Service; you do not send a rate — Starmile resolves the billing rate automatically.

use Starmile\PartnerSdk\Builder\OrderBuilder;
use Starmile\PartnerSdk\Builder\ParcelBuilder;
use Starmile\PartnerSdk\Builder\ProductBuilder;

$order = OrderBuilder::make($serviceId, 'ORD-1001')   // service_id + your order_id
    ->recipient('Jane Doe', '+994500000000', 'jane@example.com', '5AB12C3')  // 4th arg = gov_id (AZ FIN / passport)
    ->deliverToPudo(42)                                // or ->deliverHome('1', '2') / ->deliverToLocker($lockerId)
    ->shippingCost(9.90)
    ->addParcel(
        ParcelBuilder::make('ITEM-1')                // your per-item reference (echoed back as partner_tracking)
            ->merchantTracking('BARCODE-1')            // the physical sticker code (merchant_tracking)
            ->weightGrams(1200)
            ->addProduct(
                ProductBuilder::make('Running shoes')
                    ->hsCode('640299')
                    ->declaredValue(59.99, 'USD')
                    ->quantity(1)
            )
    );

$created = $starmile->orders()->create($order);
echo $created['order_id'];              // STM… (our order id)
echo $created['region_status'];         // mapped | pending_mapping | not_applicable
echo $created['items'][0]['parcel_id']; // STM… (our parcel id for your item_id)
var_dump($created['duplicate']);        // false — true when the order_id was already used

Creating an order is idempotent on your order_id. Re-sending one you already used (a retry after a timeout, a double submit) creates nothing: the original order is replayed — HTTP 200 instead of 201, duplicate is true, and order_id, region_status and items[] are exactly what you got the first time. Use it to recover our ids when you are unsure a create landed; to change an accepted order, use updateParcel() instead — the rest of the body is ignored on a replay.

For a Home Delivery service the destination region is resolved from your own (parent_region, region) reference, map-only per partner. If it is not mapped yet the order is still accepted and comes back with region_status pending_mapping — an operator maps your region in Starmile and the waiting order resolves automatically, so you do not resend it. mapped means the region was resolved; not_applicable means the service has no home region (PUDO / locker / clearance). Compare against Starmile\PartnerSdk\Enum\RegionStatus.

Orders and parcels are addressed by your own references afterwards — the order_id you sent, and a parcel's item_id (which the parcel carries back as partner_tracking in responses):

// Add a new parcel to an existing order (one parcel, same shape as a create parcels[] entry).
// Returns ['order_id' => ..., 'item' => ['item_id' => ..., 'parcel_id' => ...]].
$added = $starmile->orders()->addParcel('ORD-1001', [
    'item_id' => 'ITEM-3',
    'merchant_tracking' => 'BARCODE-3',
    'products' => [['name' => 'Ceramic mug', 'declared_value' => 12.50, 'currency' => 'USD']],
]);

// Update a shipment that has not been received yet (partial; `products` replaces the list).
$starmile->orders()->updateParcel('ORD-1001', 'ITEM-1', [
    'weight_grams' => 1500,
    'merchant_tracking' => 'BARCODE-1B',
]);

// Split one OR MORE parcels off onto a NEW, cloned order (a fresh order with the
// same service/flow/customer/destination). Name the parcels with $itemIds (an
// array of your own item references, one or more, distinct) — all move onto the
// one new order, and the source keeps the rest. You name the new order with your
// own reference ($newOrderId); it must be unused and different from the source.
// Returns ['order_id' => <new order Starmile tracking>, 'new_order_id' => ...,
//          'source_order_id' => ..., 'items' => [['item_id' => ..., 'parcel_id' => ...], ...]].
// Ordinary orders: pre-custody only (409 once received). A CONSOLIDATION order
// can be split any time before its boxes are packed (a received/shelved parcel
// can still be pulled out); only a packed consolidation is 409. A single-package
// or cancelled order, or naming every parcel, is 409; an item_id that names no
// active parcel is 404; a used/duplicate $newOrderId is 422.
$split = $starmile->orders()->split('ORD-1001', ['ITEM-2', 'ITEM-3'], 'ORD-1001-B', 'ships separately');

// Cancel a single parcel while it is still pre-custody (409 once received).
// When it was the order's last active parcel, the order is cancelled too.
$starmile->orders()->cancelParcel('ORD-1001', 'ITEM-1', 'item out of stock');

// Cancel an order while it is still pre-custody (409 once in custody).
$starmile->orders()->cancel('ORD-1001', 'customer changed mind');

Parcel labels (PDF)

Download a SINGLE parcel's label as a PDF, rendered from your organization's default parcel label template. Address the parcel by its merchant_tracking (sticker code) or its parcel_id (our parcel id, returned as items[].parcel_id on create). The method returns the raw PDF bytes. Scope: labels:read.

// By merchant_tracking (sticker code).
file_put_contents('label.pdf', $starmile->orders()->label('BARCODE-1'));

// By parcel_id (our parcel id, from items[].parcel_id on create).
$pdf = $starmile->orders()->labelByParcelId('STM0000000121');

// A whole ORDER's own label (order barcode/weight/contents) by the order's tracking number.
file_put_contents('order-label.pdf', $starmile->orders()->labelByOrderId('STM0000000120'));

v2 addresses labels differently. On $starmile->v2(), order_id is YOUR order reference and tracking_number is ours — the same meanings the rest of v2 uses. parcel_id does not exist on v2 at all. See API v2 — items / items.

Status pool (replaces webhooks)

Poll the pool with the cursor you last processed; you receive every change after it, in order. Persist the returned cursor to resume next time.

// One page at a time:
$page = $starmile->statusPool()->changes($since = 0, $limit = 100);
foreach ($page->changes() as $change) {
    // $change['cursor'], ['tracking_number'], ['external_parent_id'], ['external_id'], ['country'], ['status'], ['previous_status'], ['reason'], ['reason_detail'], ['occurred_at'], ['timezone']
}
$next = $page->nextCursor();
$more = $page->hasMore();

// Or drain everything, auto-paging:
foreach ($starmile->statusPool()->each($since = 0) as $change) {
    $since = $change['cursor']; // persist this
}

// Just one order's (or parcel's) history — pass a tracking number to narrow the
// feed server-side, then page from 0 until it is exhausted:
foreach ($starmile->statusPool()->each($since = 0, $limit = 100, 'STM000123') as $change) {
    // only changes for tracking number STM000123
}

// Or track by YOUR OWN reference — pass the external_parent_id you sent on create,
// so you never have to hold our tracking number:
foreach ($starmile->statusPool()->each($since = 0, $limit = 100, null, 'PO-1001') as $change) {
    // only changes for your order PO-1001
}

An order's feed opens with the status it was created in (typically waiting_for_arrival), carrying previous_status: null — so the first row you read for an order is always the point we took it on, not whichever hub event came first. previous_status is non-null on every row after that.

external_parent_id is your own reference for the order. external_id is your reference for a single parcel (the item_id you sent on create) and is present only on parcel-scoped changes — e.g. a parcel received at the hub — so you can act on that exact parcel; order-level changes leave external_id null. country is the ISO-2 country the change occurred in (the hub's country), so you can tell an origin event (e.g. CN) apart from a destination one (e.g. AZ). occurred_at is a plain Y-m-d H:i:s timestamp (e.g. 2026-06-20 09:14:00); timezone gives the IANA zone it is expressed in (e.g. UTC) — parse occurred_at in that zone.

Why a change happened

A change that has a reason carries reason — a stable code from Enum\Reason — and sometimes reason_detail, free text a person wrote. Branch on reason; show reason_detail to a human.

use Starmile\PartnerSdk\Enum\Reason;

foreach ($starmile->statusPool()->each($since = 0) as $change) {
    switch ($change['reason']) {          // often null — most changes have no why
        case Reason::CUSTOMER_ABSENT:
            $this->offerRedelivery($change['external_parent_id']);
            break;
        case Reason::MISSING_DECLARATION:
            $this->askShopperForInvoice($change['external_parent_id']);
            break;
        case null:
            break;                        // nothing to explain — just a milestone
        default:
            // A code this SDK version predates. Codes are only ever ADDED, never
            // renamed, so treat an unknown one as "some other reason" and fall
            // back to the text rather than failing.
            $this->flagForReview($change['reason'], $change['reason_detail']);
    }
}

Codes cover customs holds (missing_declaration, inaccurate_information, prohibited_content_restricted_item, commercial_quantity_personal_allowance_exceeded), failed deliveries (customer_absent, address_not_found, customer_refused, could_not_reach_customer, wrong_or_incomplete_address) and cancellations (cancelled_by_partner, cancelled_by_customer, cancelled_by_operator). When you report an event whose data carries a reason, sending one of these codes publishes it on the merchant's feed as a code they can act on automatically; any other wording is passed through untouched as free text.

Inbound events

Report a lifecycle event for a package you handle as a carrier (shipment.*), PUDO point (parcel.*), customs broker (customs.*), or foreign organization on the next leg (leg.*). The EventBuilder validates the type and its data fields locally — an unknown type or field throws before a request is made.

use Starmile\PartnerSdk\Enum\EventType;

$outcome = $starmile->events()->reportEvent(
    EventType::SHIPMENT_OUT_FOR_DELIVERY,
    $trackingNumber,
    'evt-0001',                              // your idempotency key
    ['driver' => 'Driver A', 'eta' => '2026-06-28T09:00:00Z']
);
// $outcome['result'], $outcome['order_status']

Each accepted event advances the package. An event that is not a legal next step for the package's current status is rejected with a 422 carrying an error and a hint (see error handling below).

The recognised event types, their scope, and their data fields are exposed on the EventType enum:

EventType::all();                                     // every recognised type
EventType::scopeFor(EventType::CUSTOMS_HELD);         // 'events:customs'
EventType::dataFieldsFor(EventType::SHIPMENT_DELIVERED); // ['note','recipient_name','signed_by','proof_of_delivery']

API v2 — items / items

$starmile->v2() reaches the /api/v2 surface. The scopes and polling model are the same as v1; the vocabulary differs:

  • The box array is items[] (v1: parcels[]), and your per-box reference is item_id (v1: item_id).
  • The create response returns our order reference as tracking_number, echoes your own order_id, and each item carries no Starmile tracking number of its own — you address it by your item_id or its merchant_tracking only.
  • Status-pool rows name your order reference order_id (v1: external_parent_id) and the item reference item_id (v1: external_id). The status vocabulary is identical to v1.
$created = $starmile->v2()->orders()->create(array(
    'service_id'     => 12,
    'order_id'       => 'PO-1001',
    'customer_email' => 'buyer@example.com',
    'items'          => array(
        array(
            'item_id'      => 'BOX-1',
            'merchant_tracking' => 'MT-0001',
            'products'          => array(array('name' => 'Widget')),
        ),
    ),
));

// $created['tracking_number'] — our reference; $created['items'][0]['item_id'] — yours.

// Add an item to a MULTI-item order (a single-item order is 409 — create a new order instead).
$starmile->v2()->orders()->addItem('PO-1001', array(
    'item_id' => 'BOX-3',
    'products' => array(array('name' => 'Ceramic mug')),
));

$starmile->v2()->orders()->updateItem('PO-1001', 'BOX-1', array('weight_grams' => 900));

// Split one OR MORE items off onto a NEW cloned order (the v2 twin of
// orders()->split()). Name the items with $itemIds (an array). Result follows the
// v2 wire: ['tracking_number' => <new order ref>, 'order_id' => 'PO-1001-B'
// (your new ref), 'source_order_id' => ..., 'items' => [['item_id' => ...,
// 'merchant_tracking' => ...], ...]]. Moving a SINGLE item yields a single-item
// order stored the v2 way (item on the order, no separate item to address, so you
// cannot addItem() to it); moving TWO OR MORE yields a multi-item order that keeps
// its items. A CONSOLIDATION order is splittable while unpacked; a folded
// single-item source order, an already-packed consolidation, or naming every item
// has nothing left to split (409); an item_id that names no active item is 404.
$starmile->v2()->orders()->split('PO-1001', ['BOX-2', 'BOX-3'], 'PO-1001-B', 'ships separately');

foreach ($starmile->v2()->statusPool()->each(0) as $change) {
    // $change['order_id'] is YOUR reference on v2.
}

Delivery code (v2)

The code the recipient reads to the courier at the door. Your customers may never see our tracking page, so this lets you show it in your own app. Order-level: one order carries one code however many boxes it ships in, and it does not change after a failed attempt. It exists as soon as the order does — there is nothing to poll for. Scope: delivery_code:read.

delivery_code:read and pod:read are separate grants: the code is a live secret that still authorises a handover, a proof of delivery is a record of one that already happened. A credential may hold either without the other.

$code = $starmile->v2()->orders()->deliveryCode('PO-1001');            // by YOUR reference
$code = $starmile->v2()->orders()->deliveryCodeByTrackingNumber('CMX0000012345');

if ($code['status'] === 'active') {
    echo $code['delivery_code'];   // e.g. "4821"
}

Read status before displaying anything — the absence of a code is an ordinary answer, not an error:

status What to do
active Show the code
used Delivered; the code has done its job
not_required This organization does not use delivery codes — delivery_code is null, show nothing
not_yet_issued No code on the order (only orders created before codes existed)

An order that is not yours answers 404, never 403 — the two are deliberately indistinguishable.

Proof of delivery (v2)

The signed record of a handover, as PDF bytes. Scope: pod:read.

$pdf = $starmile->v2()->orders()->proofOfDelivery('PO-1001');
file_put_contents('pod.pdf', $pdf);

// Narrow it to one box:
$pdf = $starmile->v2()->orders()->proofOfDelivery('PO-1001', null, 'MT-0001');

One section per handover. A courier records one per box; a pickup at a PUDO point is one collection for the whole order (a single act, verified once), so a multi-box order may show either shape. Each section carries your references, the recipient, the address, the time in the delivery country's own timezone, and only the evidence actually captured — the delivery code in full, the signature, the photo. Nothing is printed empty, and a handover with no evidence says so.

The status codes are not interchangeable:

Code Means
409 Not delivered yet. The order exists and it is yours — do not go looking for a reference problem.
404 Not yours, or we do not hold it. Deliberately the same answer for both.

A partly delivered order still returns its document, with the remaining boxes listed under "Not yet delivered".

Migrating from v1: v1 and v2 are separate contracts served in parallel — pick one per integration. The v2 status-pool cursor is a new id space: a stored v1 cursor is meaningless there, so start the v2 drain from since = 0 (optionally filtered per order) and dedupe on what you have already processed.

Error handling

Non-2xx responses raise a typed exception; all extend StarmileException.

Exception HTTP Meaning
AuthenticationException 401 Missing/expired/invalid token or bad credentials.
AuthorizationException 403 Credential lacks the scope, is revoked, or API off.
NotFoundException 404 Unknown resource (or another partner's).
ConflictException 409 No longer changeable (shipment received / in custody).
ValidationException 422 Validation failed; field errors + event hint.
RateLimitException 429 Too many requests; carries getRetryAfter().
ApiException other Any other non-2xx; base for the above.
TransportException — Network failure (no response).
use Starmile\PartnerSdk\Exception\ValidationException;
use Starmile\PartnerSdk\Exception\RateLimitException;
use Starmile\PartnerSdk\Exception\StarmileException;

try {
    $starmile->orders()->create($order);
} catch (ValidationException $e) {
    $e->errors();      // ['service_id' => ['The service id field is required.'], ...]
    $e->allMessages(); // flat list
    $e->getHint();     // event rejections include a "how to fix" hint
} catch (RateLimitException $e) {
    sleep($e->getRetryAfter() ?: 1);
} catch (StarmileException $e) {
    // any other failure
    $e->getMessage();
}

Retries & resilience

By default the SDK retries safe (GET) requests on transient failures — network errors, 429, and 5xx — with exponential backoff + jitter, honoring a Retry-After header. Non-idempotent writes (POST /orders, POST /partner/events) are never retried automatically, so a flaky response can't create a duplicate order. Tune or disable this with max_attempts (see options above). Creating an order is safe to retry either way — it is idempotent on your order_id.

When you do want a write retried, opt in per call with retry() — mirroring Laravel's HTTP client. It returns a one-off client; the original is unchanged:

// Retry this create up to 3 times (writes included, because you asked):
$starmile->retry(3, 200)->orders()->create($order);

// Custom decision — also retry a specific conflict:
use Starmile\PartnerSdk\Exception\RateLimitException;

$starmile
    ->retry(4, 200, fn ($e) => $e instanceof RateLimitException || $e->getStatusCode() === 409)
    ->events()->report($event);

When a failure can't be decoded as JSON (e.g. a gateway's HTML 502), the raw body is preserved on the exception via getRawBody():

catch (\Starmile\PartnerSdk\Exception\ApiException $e) {
    $e->getResponseBody(); // [] when the body wasn't JSON
    $e->getRawBody();      // the original "<html>...502 Bad Gateway..." string
}

Constants

The SDK ships the server vocabularies verbatim so you compare against names, not magic strings:

  • Starmile\PartnerSdk\Enum\Scope — every OAuth2 scope.
  • Starmile\PartnerSdk\Enum\EventType — every inbound event type (+ scope/field maps).
  • Starmile\PartnerSdk\Enum\OrderStatus — every status seen in the pool.
  • Starmile\PartnerSdk\Enum\PackageType — fragile / breakable / liquid.
  • Starmile\PartnerSdk\Enum\DeliveryMethod — home / pudo / locker.

Custom HTTP transport

Replace the default cURL transport with anything implementing HttpClientInterface (e.g. to reuse a configured Guzzle client, add retries, or mock in tests):

use Starmile\PartnerSdk\Client;
use Starmile\PartnerSdk\Http\HttpClientInterface;
use Starmile\PartnerSdk\Http\RawResponse;

final class GuzzleTransport implements HttpClientInterface
{
    public function send($method, $url, array $headers = [], $body = null)
    {
        // ... call Guzzle, then:
        return new RawResponse($statusCode, $responseHeaders, $responseBody);
    }
}

$starmile = Client::create($id, $secret, ['http_client' => new GuzzleTransport()]);

Token sharing across processes

By default the token lives in process memory. Implement TokenStorageInterface (backed by APCu, Redis, a PSR-16 cache, or a file) and pass it as token_storage to reuse one token across requests/workers and avoid re-hitting /oauth/token.

Testing

composer install
composer test

The suite drives the client through a scripted in-memory transport — no network.

Versioning & support

Semantic Versioning. New Partner API capabilities are added here in lockstep with the server; see the CHANGELOG.

License

MIT — see LICENSE.