starmile / partner-sdk
Official PHP SDK for the Starmile Partner API — cross-border logistics integration (catalogue, orders, status pool, inbound events).
Requires
- php: >=7.1
- ext-curl: *
- ext-json: *
Requires (Dev)
- phpunit/phpunit: >=7.5 <10
Suggests
- psr/log: Pass any PSR-3 logger to Configuration to log requests and responses.
Provides
None
Conflicts
None
Replaces
None
- dev-prod
- v7.1.0
- v7.0.0
- v6.23.0
- v6.22.0
- v6.21.0
- v6.20.0
- v6.19.0
- v6.18.0
- v6.17.0
- v6.16.0
- v6.15.0
- v6.14.0
- v6.13.0
- v6.12.0
- v6.11.0
- v6.10.1
- v6.10.0
- v6.9.0
- v6.6.0
- v6.5.0
- v6.4.0
- v6.3.0
- v6.2.0
- v6.1.0
- v3.x-dev
- dev-feat/cmx-114-delivery-code
- dev-dev
- dev-feat/partner-split-v2-fold
- dev-feat/partner-split-unpacked-consolidation
- dev-feat/partner-split-order
This package is auto-updated.
Last update: 2026-09-21 09:28:21 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-curlandext-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_idis YOUR order reference andtracking_numberis ours — the same meanings the rest of v2 uses.parcel_iddoes 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 isitem_id(v1:item_id). - The create response returns our order reference as
tracking_number, echoes your ownorder_id, and each item carries no Starmile tracking number of its own — you address it by youritem_idor itsmerchant_trackingonly. - Status-pool rows name your order reference
order_id(v1:external_parent_id) and the item referenceitem_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.