Search by

setono / shipmondo-php-sdk

loevgaard

Consume the Shipmondo API with this PHP SDK

Package info

github.com/Setono/shipmondo-php-sdk

pkg:composer/setono/shipmondo-php-sdk

Statistics

Installs: 5 430

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 2

v2.0.0-beta.3 2026-11-06 14:18 UTC

README

Latest Version Software License Build Status Code Coverage Mutation testing

Consume the Shipmondo API in PHP.

Installation

composer require setono/shipmondo-php-sdk

Upgrading from 1.x? See UPGRADE.md — 2.x is a rewrite with a number of breaking changes.

Usage

<?php

use Setono\Shipmondo\Client\Client;

require_once __DIR__ . '/../vendor/autoload.php';

$client = new Client('api_username', 'api_key');

$paymentGateways = $client
    ->paymentGateways()
    ->getPage()
;

foreach ($paymentGateways as $paymentGateway) {
    print_r($paymentGateway);
}

To target the sandbox API instead of production, pass sandbox: true to the constructor:

$client = new Client('api_username', 'api_key', sandbox: true);

will output something:

Setono\Shipmondo\Response\PaymentGateway\PaymentGateway Object
(
    [id] => 1234
    [name] => quickpay
    [provider] => quick_pay
    [merchantNumber] => 67894321
)

Errors

Every exception the SDK throws implements Setono\Shipmondo\Exception\ShipmondoException, so one catch nets them all, a failure of the HTTP client included:

ShipmondoException
├── ConnectionException                 no response: the HTTP client failed (its exception is the previous one)
├── ResponseAwareException              Shipmondo answered: getResponse(), getError()
│   ├── ClientErrorException            4xx
│   │   ├── UnauthorizedException       401
│   │   ├── ForbiddenException          403
│   │   ├── NotFoundException           404
│   │   ├── ValidationException         422
│   │   ├── TooManyRequestsException    429
│   │   └── UnexpectedClientErrorException  any other 4xx
│   ├── ServerErrorException            5xx
│   │   └── InternalServerErrorException
│   ├── MalformedResponseException      a 2xx body that isn't a JSON object or array
│   │   └── MappingException            a 2xx body that doesn't fit the response object
│   └── UnexpectedStatusCodeException   neither a success nor an error, e.g. a redirect the HTTP client didn't follow
├── InvalidUrlException                 a URL the client won't send the credentials to
└── WebhookException                    an incoming webhook that can't be verified or parsed (see below)
use Setono\Shipmondo\Exception\ConnectionException;
use Setono\Shipmondo\Exception\NotFoundException;
use Setono\Shipmondo\Exception\ResponseAwareException;

try {
    $salesOrder = $client->salesOrders()->getById(123);
} catch (NotFoundException) {
    // there is no such sales order
} catch (ResponseAwareException $e) {
    echo $e->getResponse()->getStatusCode(), ': ', $e->getError(); // Shipmondo's {"error": "..."} message
} catch (ConnectionException $e) {
    // no response at all; $e->getPrevious() is the HTTP client's exception
}

ConnectionException is a PSR-18 ClientExceptionInterface too, so code that catches the HTTP client's exceptions still catches it.

Receiving webhooks

Shipmondo delivers webhooks as a POST whose body is {"data": "<JWT>"}, where the JWT is HS256-signed with the key you chose when you created the webhook. Verifying that signature is the only way to prove a request really came from Shipmondo, so the SDK ships a WebhookParser that does it for you and hands back a typed WebhookEvent:

Webhook keys must be at least 32 bytes. HS256 requires a key of at least 256 bits / 32 bytes (RFC 7518 §3.2). The SDK enforces this both when you create a webhook (WebhookRequest) and when you verify one (WebhookParser), so make sure the key you set on the webhook is long enough — a shorter key cannot be verified.

<?php

use Setono\Shipmondo\Exception\MalformedWebhookException;
use Setono\Shipmondo\Exception\WebhookVerificationException;
use Setono\Shipmondo\Webhook\WebhookParser;

// $request is a PSR-7 ServerRequestInterface (from your framework / PSR-7 bridge).
// $key is the key you set on the webhook when creating it via $client->webhooks()->create(...).

try {
    $event = (new WebhookParser())->parse($request, $key);
} catch (WebhookVerificationException) {
    // Forged request or wrong key — do NOT process it.
    http_response_code(403);
    return;
} catch (MalformedWebhookException) {
    http_response_code(400);
    return;
}

$event->action;       // WebhookAction::Create     (typed enum, from the SMD-Action header)
$event->resourceType; // WebhookResourceName::Shipments  (from the SMD-Resource-Type header)
$event->resourceId;   // int|null (the SMD-Resource-Id header)
$event->data;         // array<array-key, mixed> — the resource, snake_case, as in the API docs
$event->data['id'];

// Reply within 3 seconds with a 200, then do the heavy lifting out of band.
http_response_code(200);

action and resourceType are typed enums — if Shipmondo ever sends an action or resource the SDK doesn't model, parse() throws MalformedWebhookException rather than passing an unknown value through. (WebhookResourceName::actions() lists the actions valid for a resource, and WebhooksEndpoint::create() rejects an invalid resource/action pair before sending.)

If you are not on PSR-7, pass the raw body and headers instead:

$event = (new WebhookParser())->parsePayload($rawBody, $headers, $key);

Both methods pin the HS256 algorithm, so a token presenting any other alg (including none) is rejected. The webhook key is passed per call, so a server that hosts several webhooks can read the SMD-Webhook-Id header to pick the right key before verifying.

WebhookParser implements WebhookParserInterface, so you can type-hint the interface in your controllers/services and inject the parser (or a mock) via your DI container.

Production usage

Internally this library uses the CuyZ/Valinor library which is particularly well suited for turning API responses into DTOs (and request DTOs into JSON). However, this library has some overhead and works best with a cache enabled.

The Client is immutable: configure a cached mapper/normalizer and inject them through the constructor. Use the static helpers so the SDK's required configuration (date formats, superfluous-key handling, and the request null-stripping / snake_case transformers) is applied to your cached builders:

<?php

use CuyZ\Valinor\Cache\FileSystemCache;
use CuyZ\Valinor\MapperBuilder;
use CuyZ\Valinor\NormalizerBuilder;
use Setono\Shipmondo\Client\Client;

require_once '../vendor/autoload.php';

$cache = new FileSystemCache('path/to/cache-directory');

$mapperBuilder = Client::configureMapperBuilder((new MapperBuilder())->withCache($cache));
$normalizerBuilder = Client::registerNormalizerTransformers((new NormalizerBuilder())->withCache($cache));

$client = new Client(
    'API_USERNAME',
    'API_KEY',
    mapperBuilder: $mapperBuilder,
    normalizerBuilder: $normalizerBuilder,
);

You can read more about it here: Valinor: Performance and caching.

Notes

Accessing fields the SDK doesn't model yet

Every response object exposes a ->raw property containing the full decoded response with its original snake_case keys (the same names as the Shipmondo API docs). Use it to reach fields the SDK doesn't type yet:

$order = $client->salesOrders()->getById(123);
$order->id;                  // typed property
$order->raw['order_status']; // any field, straight from the API payload

Sales orders are eventually consistent

A sales order you just created via salesOrders()->create() may not appear in salesOrders()->getPage() / paginate() immediately — Shipmondo indexes the list asynchronously, so there can be a short delay before a new order is listed. The order is available straight away by id, so for read-after-write use the id returned by create():

$created = $client->salesOrders()->create($request);
$order = $client->salesOrders()->getById($created->id); // available immediately

Keeping a chosen pickup point

A pickup point found with pickupPoints()->search() can be kept, e.g. the one a customer chose in a checkout, and sent later as the service point of a sales order. Its JSON holds the typed fields (not raw), and PickupPoint::fromArray() rebuilds it from that JSON, throwing an \InvalidArgumentException if a key is missing or has the wrong type:

$json = json_encode($pickupPoint);

$pickupPoint = PickupPoint::fromArray(json_decode($json, true));
$request->servicePoint = ServicePoint::fromPickupPoint($pickupPoint);