haniusif / smsaexpress
SMSA Express eCommerce REST API client for PHP and Laravel — shipments, labels, tracking and lookups.
Requires
- php: ^8.2
- illuminate/http: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
- nesbot/carbon: ^2.72|^3.0
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
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.asmxSOAP 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.
pickupis the customer;returnTois 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.
- ⚠
ScanDateTimeis local time, with its offset in a separateScanTimeZonefield. They are combined; parsing the stamp alone shifts every event three hours. - ⚠
ScanTypecan be empty. A shipment accepted but not yet collected carries one scan with a blank code. It is normalised tonull, 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
queryreports 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.
DEX09means Delivered while every otherDEX…code is a delivery exception. A prefix rule marks delivered parcels as failed. Watch also forPOD(delivered),DEX14/RTO(return in progress) againstRTS(return completed), andSMS/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.
- ⚠
cityCodeis not unique. Dammam and Dhahran are bothDMM. Never use it as a key; the create endpoint routes on the city name. - ⚠
serviceTypesplits 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.