Search by

haniusif / smsaexpress

SMSA Express eCommerce REST API client for PHP and Laravel — shipments, labels, tracking and lookups.

Maintainers

Package info

github.com/haniusif/smsaexpress

pkg:composer/haniusif/smsaexpress

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.0 2026-09-04 08:16 UTC

This package is auto-updated.

Last update: 2026-09-04 09:06:04 UTC


README

tests

A client for the SMSA Express eCommerce REST API — shipments, labels, tracking and lookups.

This package speaks SMSA's REST eCom API. It does not support the legacy SMSAwebExpress.asmx SOAP service, and deliberately so: every documented path under that host answers 404.

Extracted from a production Saudi logistics platform where every operation below had already been executed against SMSA's live API. Much of what follows is not a reading of their documentation but a correction to it — those places are marked , and each one cost somebody a debugging session.

Requirements

  • PHP 8.2, 8.3 or 8.4
  • Laravel 11 or 12 (for the service provider and facade; the core is framework-light)

Installation

composer require haniusif/smsaexpress

The service provider is auto-discovered. To publish the config:

php artisan vendor:publish --tag=smsaexpress-config

Configuration

SMSA_BASE_URL=https://ecomapis-sandbox.azurewebsites.net
SMSA_API_KEY=your-key

Sandbox vs production

Environment Base URL
Sandbox (default) https://ecomapis-sandbox.azurewebsites.net
Production https://ecomapis.smsaexpress.com

Sandbox is the default deliberately: a create against production books a real, billable parcel.

⚠ Keys are environment-specific. A production key returns 401 on the sandbox host. This package never falls back from one to the other — a silent switch would turn a failing test into a real shipment.

Multi-merchant credentials

If you hold a key per merchant, do not rely on the global config. Every scoping method returns a new instance:

use Haniusif\SmsaExpress\Smsa;

$smsa = app(Smsa::class)
    ->withApiKey($merchant->smsa_api_key)
    ->withCachePrefix("tenant:{$merchant->id}");

A shared client whose credentials are swapped in place will, in a queue worker serving many tenants, eventually book one merchant's parcel on another's account. The immutability is the safeguard.

Without a container:

$smsa = Smsa::make(apiKey: $key, baseUrl: 'https://ecomapis.smsaexpress.com');

Facade

use Haniusif\SmsaExpress\Facades\Smsa;

$cities = Smsa::lookups()->cities('SA');

Convenient for a single-merchant install. Multi-tenant applications should resolve and scope explicitly instead — the facade uses the key from config.

Shipments

use Haniusif\SmsaExpress\Data\{AddressData, CreateShipmentData};

$response = $smsa->shipments()->create(new CreateShipmentData(
    consignee: new AddressData(
        contactName: 'Ahmed Al Otaibi',
        contactPhoneNumber: '0512345678',
        addressLine1: 'Prince Sultan St, Ar Rawdah',
        city: 'Jeddah',
        district: 'Ar Rawdah',
        shortCode: 'RRRD2929',
    ),
    shipper: new AddressData(
        contactName: 'Acme Trading',
        contactPhoneNumber: '966000000000',
        addressLine1: 'King Fahd Road, Al Olaya',
        city: 'Riyadh',
    ),
    orderNumber: 'ORD-9001',
    contentDescription: 'Books',
    declaredValue: 120.00,
    codAmount: 0.0,
    parcels: 1,
    weight: 2.5,
));

$response->shipmentAwb;              // "231200021000"  — the shipment
$response->firstWaybill()->awb;      // "231200021879"  — the parcel
$response->waybillNumbers();         // every parcel

sawb and awb are not the same thing

sawb identifies the shipment; each waybills[*].awb identifies a parcel. For a one-box shipment SMSA returns the same number for both, which makes them look interchangeable. They are not — cancellation keys on the shipment, labels and tracking key on a parcel — so this package keeps them apart rather than collapsing them into one id.

⚠ Multi-parcel shipments

Never assume waybills[0] represents the consignment. Every waybill is preserved, and $response->waybill($awb) returns the right box's label rather than the first.

⚠ Money must be a JSON number

CODAmount and DeclaredValue are serialised as numbers, never strings. SMSA types both as Float, and its API is ASP.NET Core, whose System.Text.Json refuses a JSON string for a float property. Sending "250.50" fails every create.

⚠ Address lengths

SMSA publishes minimums as well as maximums: AddressLine1 10–100, City 3–50, ContactName 5–150. Over the maximum is truncated; under the minimum throws ValidationException, because padding a three-letter name to reach five would print something false on a label a courier reads out at the door.

Errors are structured rather than prose — the words your merchants read are yours, not this package's:

catch (ValidationException $e) {
    $e->fieldErrors;
    // ['consignee.ContactName' => ['rule' => 'min', 'min' => 5, 'actual' => 3, 'value' => 'Ali']]
}

maxParcels is 25

Present on every live service and in none of SMSA's field tables. A larger shipment is refused, never silently capped: 25 labels for 30 boxes leaves five found on a loading dock.

ShipDate has no timezone

The field carries no offset, so the zone is a decision. This package stamps Asia/Riyadh — in UTC a 21:00 booking is dated to that morning and can be routed a day late.

Labels

$pdf = $smsa->labels()->pdf($awb);        // raw bytes
$b64 = $smsa->labels()->base64($awb);     // as SMSA sent it

There is no label URL anywhere in this API — labels arrive as base64 inside the query response. This returns bytes and stops there; turning them into a response is your decision:

return response($pdf)->header('Content-Type', 'application/pdf');

⚠ Cancelling destroys the label. After a successful cancel the query 404s and the PDF is unrecoverable. Save the bytes first.

Cancellation

$result = $smsa->shipments()->cancel($response->shipmentAwb);

$result->confirmed;                 // SMSA said it cancelled
$result->temporarilyUnavailable;    // not yet — ask again later

Pass the shipment identifier. Passing an OrderNumber returns 400 "AWB is not valid!".

⚠ A new shipment cannot be cancelled immediately

For several minutes after creation, cancel answers 404 "Shipment not found" while query is simultaneously returning that waybill's label. The parcel exists; SMSA has not propagated it.

That is reported as temporarilyUnavailable, never as a refusal — reported as a refusal, a merchant believes the carrier said no and a parcel they cancelled goes out anyway.

This package will not sleep for minutes to hide it. Scheduling the retry is yours:

if ($result->temporarilyUnavailable) {
    $this->release(60);   // then 3 minutes, then 7
}

Returns and pickups (C2B)

A parcel travelling from a customer back to the merchant. Not a delivery with the addresses swapped — SMSA gives it its own family, and the differences are load-bearing.

use Haniusif\SmsaExpress\Data\CreateReturnData;

$return = $smsa->returns()->create(new CreateReturnData(
    pickup:   $customerAddress,   // where it is COLLECTED
    returnTo: $merchantAddress,   // where it goes back to
    orderNumber: 'RET-0001',
    contentDescription: 'Returned goods',
    declaredValue: 120.00,
    serviceCode: 'EDCR',          // a C2B code
));

$pdf    = $smsa->returnLabels()->pdf($return->firstWaybill()->awb);
$result = $smsa->returns()->cancel($return->shipmentAwb);

What differs from B2C, all enforced:

B2C C2B
CODAmount required absent — nothing is collected on a return
VatPaid / DutyPaid optional absent
DeclaredValue any minimum 0.1
OrderNumber any length 50 characters max
ServiceCode B2C, e.g. EDDL C2B, e.g. EDCR
Address fields ConsigneeID, ShortCode allowed omitted
Cancel PATCH /api/Shipment/b2c/{awb}/cancel POST /api/c2b/cancel/{awb}
Cancel response {awb, message} a bare string

⚠ Get the direction right. pickup is the customer; returnTo is the merchant. Reversed, this is a perfectly valid request that sends a courier to the merchant's own door.

⚠ The families are separate. A B2C query cannot find a C2B waybill, and vice versa. If you hold a waybill without knowing which family it belongs to, try one and fall back on NotFoundException — and note that SMSA reports "Shipment not found" identically for a wrong-family waybill and for a shipment that has not propagated yet.

Tracking

$tracking = $smsa->tracking()->single($awb);
$tracking = $smsa->tracking()->reference('ORD-9001');
$all      = $smsa->tracking()->bulk([$awb1, $awb2]);

$tracking->isDelivered;
$tracking->destinationCity;              // SMSA spells this "DesinationCity"
$tracking->latestScan()->receivedBy;
  • ⚠ Scans arrive newest first. They are exposed oldest first, sorted by timestamp only when every scan carries one — an incomplete sort silently interleaves events whose position was never in doubt.
  • ScanDateTime is local time, with its offset in a separate ScanTimeZone field. They are combined; parsing the stamp alone shifts every event three hours.
  • ScanType can be empty. A shipment accepted but not yet collected carries one scan with a blank code. It is normalised to null, and $scan->status() falls back to the description — map that to created, not unknown.
  • ⚠ Tracking outlives the shipment. It answers 200 for a waybill that query reports as 404 after cancellation.

Status mapping

$catalogue = $smsa->lookups()->statuses();
$missing   = $smsa->statuses()->unmapped(['DL', 'OD', 'HOP']);

Mapping onto your own status enum is deliberately not done here — the target vocabulary is yours.

⚠ Map by exact code, never by prefix. DEX09 means Delivered while every other DEX… code is a delivery exception. A prefix rule marks delivered parcels as failed. Watch also for POD (delivered), DEX14/RTO (return in progress) against RTS (return completed), and SMS/ADV, which are notifications rather than states and must not overwrite a real one.

unmapped() exists because a carrier adding a code is silent: nothing breaks, parcels just start reading as unknown.

Lookups

$smsa->lookups()->cities('SA');
$smsa->lookups()->searchCities('jed');
$smsa->lookups()->serviceTypes();
$smsa->lookups()->offices();
$smsa->lookups()->currencies();

Every lookup returns a whole catalogue with no search parameter, and the cities call took 9.3 seconds for 214 rows against production — so they are cached (default 24h) and filtered in memory.

  • cityCode is not unique. Dammam and Dhahran are both DMM. Never use it as a key; the create endpoint routes on the city name.
  • serviceType splits B2C from C2B. Booking a return on an outbound code sends a courier to deliver instead of collect.

Error handling

use Haniusif\SmsaExpress\Exceptions\{
    SmsaException, AuthenticationException, ValidationException,
    NotFoundException, ProviderUnavailableException, UnsupportedOperationException
};
Exception When
AuthenticationException 401/403 — often the right key for the wrong environment
ValidationException 400/422, or a rule broken before sending. Carries fieldErrors
NotFoundException 404. Ask isPropagationDelay() before concluding anything
ProviderUnavailableException Transport failure, 5xx, 429. Carries retryable
UnsupportedOperationException Documented by SMSA, not implemented here

SMSA has no single error schema — ASP.NET ModelState objects, RFC 7235 problem documents and bare strings all appear. All three are parsed.

Security

The API key travels as a header and is never written to an exception message, an observer record or a debug dump. apikey, api_key and passkey are always redacted.

Observability

Keep your own audit trail without this package knowing your logger exists:

$smsa = $smsa->observe(function (array $record): void {
    ApiLog::create([
        'method' => $record['method'],
        'url' => $record['url'],
        'payload' => $record['payload'],     // already redacted
        'status' => $record['status'],
        'duration_ms' => $record['duration_ms'],
        'error' => $record['error_message'],
    ]);
});

Testing

Every test in this repository fakes HTTP. Never point a test suite at production — a create books a real, billable parcel.

Http::fake(['*' => Http::response($capturedBody)]);
composer install
vendor/bin/pest

Not implemented

Documented by SMSA, deliberately absent rather than stubbed — a stub that quietly returns an empty result is how a caller ends up believing a return was booked:

Operation Endpoint
Customs invoice POST /api/invoice
Identity details POST /api/shipment/identity-details
Full address by short code GET /api/Lookup/FullAddressByShortCode/{code}

Calling $smsa->invoices() or ->identityDetails() throws UnsupportedOperationException.

Licence

MIT.