PHPackages                             starmile/partner-sdk - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [API Development](/categories/api)
4. /
5. starmile/partner-sdk

ActiveLibrary[API Development](/categories/api)

starmile/partner-sdk
====================

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

v6.16.0(2w ago)0137—3.1%MITPHPPHP &gt;=7.1

Since Jul 5Pushed 1w agoCompare

[ Source](https://github.com/starmile-io/partner-sdk-php)[ Packagist](https://packagist.org/packages/starmile/partner-sdk)[ Docs](https://api.starmile.io)[ RSS](/packages/starmile-partner-sdk/feed)WikiDiscussions prod Synced 1w ago

READMEChangelogDependencies (3)Versions (16)Used By (0)

Starmile Partner SDK (PHP)
==========================

[](#starmile-partner-sdk-php)

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](https://api.starmile.io).

Installation
------------

[](#installation)

```
composer require starmile/partner-sdk
```

Requires PHP **7.1+** with the `curl` and `json` extensions.

Authentication
--------------

[](#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

[](#configuration-options)

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

OptionDefaultDescription`base_url``https://api.starmile.io`API host. Point at your sandbox/staging outside production.`scopes`all grantedSubset of the credential's scopes to request on the token.`http_client`bundled cURL clientAny `HttpClientInterface` (Guzzle/Symfony/PSR-18 adapter, a mock).`token_storage`in-memoryA `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
------------

[](#capabilities)

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

ResourceScope(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`, `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`### Catalogue

[](#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

[](#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):

```
// 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',
]);

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

[](#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'));
```

### Status pool (replaces webhooks)

[](#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

[](#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

[](#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']
```

Error handling
--------------

[](#error-handling)

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

ExceptionHTTPMeaning`AuthenticationException`401Missing/expired/invalid token or bad credentials.`AuthorizationException`403Credential lacks the scope, is revoked, or API off.`NotFoundException`404Unknown resource (or another partner's).`ConflictException`409No longer changeable (shipment received / in custody).`ValidationException`422Validation failed; field errors + event `hint`.`RateLimitException`429Too many requests; carries `getRetryAfter()`.`ApiException`otherAny 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 &amp; resilience
------------------------

[](#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 "...502 Bad Gateway..." string
}
```

Constants
---------

[](#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
---------------------

[](#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
------------------------------

[](#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
-------

[](#testing)

```
composer install
composer test
```

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

Versioning &amp; support
------------------------

[](#versioning--support)

Semantic Versioning. New Partner API capabilities are added here in lockstep with the server; see the [CHANGELOG](CHANGELOG.md).

License
-------

[](#license)

MIT — see [LICENSE](LICENSE).

###  Health Score

40

—

FairBetter than 86% of packages

Maintenance97

Actively maintained with recent releases

Popularity14

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity36

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~2 days

Total

14

Last Release

15d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/54883542?v=4)[Samir Mammadhasanov](/maintainers/samirmhsnv)[@samirmhsnv](https://github.com/samirmhsnv)

---

Top Contributors

[![samirmhsnv](https://avatars.githubusercontent.com/u/54883542?v=4)](https://github.com/samirmhsnv "samirmhsnv (58 commits)")

---

Tags

sdkshippinglogisticscross-borderPartner-APIstarmile

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/starmile-partner-sdk/health.svg)

```
[![Health](https://phpackages.com/badges/starmile-partner-sdk/health.svg)](https://phpackages.com/packages/starmile-partner-sdk)
```

###  Alternatives

[octw/aramex

A Library to integrate with Aramex APIs

3028.9k](/packages/octw-aramex)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
