Skip to content

Transporter Integrations (Courier APIs) ​

Flow ID: IN-09 Module(s): src/Transporters Complexity: High Last Updated: 2026-09-29

Business Context ​

17 shipping courier APIs integrated for voucher creation, cancellation, tracking, label printing, and pickup scheduling. Each transporter has a dedicated class in src/Transporters/ with config, helper, and main API class.

Integrated Transporters ​

#ProviderClassProtocolAuth
1ACS CourierAcsREST JSONMulti-credential (Company+User+API Key)
2ACS (SOAP fallback)AcsSoapSOAP 1.1Same as ACS
3Geniki Taxydromiki v1GenikiSOAP 1.1 (curl)Username + Password
4Geniki Taxydromiki v2GenikiV2SOAP 1.2 (SoapClient)Username + Password + App Key
5DHL ExpressDhlREST JSON (Guzzle)HTTP Basic Auth
6ELTA CouriersEltaSOAP 1.1 (custom XML)Embedded in SOAP
7SpeedexSpeedexSOAP 1.2 (SoapClient)Session-based (login → SessionID)
8Courier CenterCenterREST JSON (QualcoProvider)Context object (UserAlias+Credential+ApiKey)
9EasyMailEasyMailSOAP 1.1 (SoapClient)Credentials object
10FISFisSOAP 1.1 (SoapClient)Web Service Code
11Box NowBoxNowREST JSONOAuth 2.0 (Client Credentials)
12Taxydema v1TaxydemaSOAP 1.1 (custom)Embedded in SOAP
13Taxydema v2TaxydemaV2REST JSON (QualcoProvider)Context object
14Cyprus PostCyprusPostREST JSON (Guzzle)Bearer Token
15ASAP (Last Mily)AsapREST JSONHMAC-SHA256 signed
16Daily CourierDailyCourierREST JSON (Guzzle)Bearer Token
17Skroutz Last MileSkroutzREST JSON (Guzzle)Bearer Token

Common Operations ​

OperationMethodDescription
Create vouchercreateVoucher()Generate shipping label with tracking code
Cancel vouchercancelVoucher() / deleteVoucher()Cancel/delete shipment
Track & tracetrackAndTrace()Get shipment status history
Print vouchergetVoucherPdf()Download shipping label PDF
Close pendingclosePendingJobs()Create pickup list/dispatch
Get picking pointsgetPickingPoints()Locker/collection point locations
Live pickup-point catalogSmartPointCatalogService::fetch(int $transporterId, ?CatalogFilter $filter = null)Aggregates getPickingPoints()/fetch() across one transporter via external API; exposed via GET /rest/transporter/{id}/smart-point (guest). (src/Domains/Transporter/SmartPointCatalog/Service.php:37)
Batch label retrieval (BoxNow only)getLabels(array $parcelIds): ?stringCalls the carrier's POST /labels:search and returns a multi-label PDF directly — no client-side merging (src/Transporters/BoxNow/BoxNow.php:159-184, request built at :171)

BoxNow batch labels (#632). Unlike ACS (chunks of 10) and Center (chunks of 20), getLabels() deliberately does NO chunking: the carrier documents no maximum, 200 orders is a tested floor rather than a known ceiling, and mergePdfs() hardcodes ACS label geometry so it is unusable for A4. Sheet layout (parcelIds, paperSize, perPage) is read from config, not passed in as call arguments (BoxNow.php:172-178). The PAPER_SIZE/LABELS_PER_PAGE settings that drive this are documented in AD-06 Transporter Admin; the admin bulk action that triggers batch printing is documented in AD-03 Order Management.

Base Classes ​

  • QualcoProvider: Shared base for Center and TaxydemaV2 (REST JSON with Context auth)
  • BaseTransporterConfig: Standard config interface with getFieldConfiguration() for admin UI schema and initialize() for settings loading

Protocol Distribution ​

  • REST JSON: 9 providers (DHL, BoxNow, ASAP, DailyCourier, Skroutz, Center, TaxydemaV2, CyprusPost, ACS)
  • SOAP 1.1: 6 providers (ACS SOAP, Geniki v1, EasyMail, Elta, Taxydema v1, FIS)
  • SOAP 1.2: 2 providers (Geniki v2, Speedex)

Environment Support ​

Most providers support dual environments (testing/production) via config toggle. SSL verification uses cached cacert.pem in development, true in production.

BoxNow request-timeout seam is now live (#632). buildOptions() (src/Transporters/BoxNow/BoxNow.php:287) always read $this->config->timeout, but no config declared the property, so BaseTransporterConfig::__get() returned null and the guard was unreachable — every BoxNow request ran with Guzzle's unbounded default. BoxNowConfig::$timeout is now declared (src/Transporters/BoxNow/BoxNowConfig.php:44) defaulting to 0, so behaviour is unchanged out of the box but the guard is now reachable. Code-level only: no registry key, no getFieldConfiguration() entry, not read in initialize().

Live Platform Data ​

Aggregate statistics from production databases across all client deployments:

Transporter configuration: 14 transporters configured (8 active):

  • Active: ACS, ACS-CY, DHL, SVUUM, Courier Center, BOXNOW + 2 more
  • 6 inactive/standby configurations

Shipping volume data:

  • 14,537 total pricing data points across 5 pricing tables (weight-based, zone-based, flat-rate, volumetric, smart-point)
  • 150,000+ smart point deliveries (locker/collection point), representing ~12% of all orders
  • 3 store locations configured (all active, all GR)

Smart Point Tracking (shop_order_smart_point table):

ColumnTypeDescription
idint (PK, AI)Row identifier
order_idint (FK)Associated order
transporter_idint (FK)Transporter providing the smart point
shop_idint (FK)Shop/store location
json_datatextSmart point metadata (locker ID, address, provider details)

Smart Point Catalog API ​

Two distinct smart-point surfaces exist in the REST API — do not conflate them:

EndpointPurposeAuthStorage
GET /rest/order/smart-pointPer-order saved pickup-point rowauth=backend, roles [ADMIN, ORDERS] (application/config/rest_policies.php:776)shop_order_smart_point DB row
GET /rest/transporter/{id}/smart-pointLive pickup-point catalog for one transporterauth=guest (application/config/rest_policies.php:842-843)No DB storage — live external API call, cached 10 min

Provider activation gating ​

Service::fetch() (src/Domains/Transporter/SmartPointCatalog/Service.php:37-86) applies a 3-stage gate:

  1. Transporter row exists in DB AND active=1 — else 404 unknown_transporter (:40-42)
  2. class_name is in $config['smartPoints'] (application/config/app.php:436-459, 12 providers) — else 409 no_provider (:47-50)
  3. provider->areSmartPointsEnabledForThisProvider() returns true — else 409 not_enabled_for_provider (:56-58)

The former stage 4 (409 widget_only, from shouldFetchSmartPoints()) is gone (#810): the widget preference (USE_TRANSPORTER_WIDGET) is a rendering choice of the legacy storefront, not a provider capability, so it no longer refuses the catalog — a headless client that cannot run the vendor widget still needs the list (comment at Service.php:59-66; SmartPointsDisabledException now has only no_provider and not_enabled_for_provider).

Provider shape normalization ​

The SmartPoints provider implementations (src/SmartPoints/Transporters/) return the legacy storefront shape that Adv_order::smartPointsInitialize() consumes — a nested array keyed by class_name:

[ $class_name => [
    'smartPointStationData' => SmartPointDTO[],
    'smartPointsEnable'     => bool,
    'responseError'?        => string
] ]

SmartPointCatalogService::normalize($result, $className) (src/Domains/Transporter/SmartPointCatalog/Service.php:149-166) unwraps $result[$className]['smartPointStationData'] into a flat array_values() list before the isMalformed() guard runs. Already-flat lists (empty [] from stub providers) pass through unchanged via isFlatDtoList() (:168-176). The isMalformed() guard (:178-181) runs after normalization as a post-normalization safety net — a genuinely unknown shape still produces a TransporterApiException (502). The malformed check also runs inside the cached closure (:78-80) and on cache hits (:111), so a malformed cached entry is discarded and refetched. The provider implementations are never touched; the legacy storefront continues to receive the nested shape via its own call path.

Payload schema ​

Each pickup point in the response collection is a SmartPointDTO with 17 fields (src/Rest/Transporter/Resources/SmartPoint/Resource.php:9-30):

FieldTypeNullable
idstringno
namestringno
addressstringno
citystringyes
countrystringno
latitudefloatno
longitudefloatno
postalCodeintegerno
imagestringyes
notestringyes
titlestringyes
typestringyes
emailstringyes
workingHoursstringyes
phonestringyes
stationDestinationstringyes
stationBranchDestinationintegeryes

SmartPointAvailabilityChecker (feature-flag helper) ​

src/Domains/Transporter/SmartPointCatalog/SmartPointAvailabilityChecker.php (:isAvailable()) was added to the SmartPointCatalog namespace to serve the GET /rest/features discovery endpoint (#287); it is distinct from Service::fetch() — it performs no external API call, only a single SELECT 1 ... WHERE active=1 AND class_name IN (...) LIMIT 1 DB existence check via Repository::hasActiveSmartPointProvider(array $classNames): bool (src/Domains/Transporter/Transporter/Repository/Repository.php).

Catalog caching ​

The normalised catalog is cached for CACHE_TTL_SECONDS = 600 (10 minutes) per transporter via Service::cached(), under the key smart-point-catalog:{id} (src/Domains/Transporter/SmartPointCatalog/Service.php:21,68-85,98-125). The adapter is cache.l2, injected as $cache (src/Domains/Transporter/container.php:91-94). The cache is optional (?CacheAdapterInterface $cache = null, Service.php:27): with no adapter every request fetches live (:100-102). A cache read or write failure is treated as a miss and never fails the request (:106-110,117-121). Query filters are applied after the cache, so one upstream fetch serves every filtered read for the TTL.

Query filters (#810) ​

GET /rest/transporter/{id}/smart-point accepts four optional query keys, parsed by CatalogFilter::fromQuery() (src/Domains/Transporter/SmartPointCatalog/CatalogFilter.php:53-99):

KeyRule
countryISO 3166-1 alpha-2 (/^[A-Za-z]{2}$/), upper-cased; only points in that country
near"lat,lng", range-checked; keeps points within radius and sorts them distance-ascending
radiusPositive integer metres, default 25000 (DEFAULT_RADIUS_METRES); ignored without near
limitPositive integer; applied last

CatalogFilter::apply() filters, sorts and limits the normalised DTO list (:106-139). A key that is present but does not parse raises InvalidCatalogQueryException, which the controller maps to 400 with error.code = invalid_query_params and error.key naming the offender (src/Rest/Transporter/Controllers/SmartPoint.php:92-100).

External Rate Catalog API ​

Two REST endpoints expose live carrier pricing and availability lookups. Neither caches results. Both are guest-accessible on the same rationale as the legacy storefront equivalents they wrap.

GET /rest/transporter/{id}/dhl-rates ​

  • Route: application/config/rest_routes.php:1057-1058 (+ locale-prefixed variant)
  • Auth: guest (application/config/rest_policies.php:851-852)
  • Controller: src/Rest/Transporter/Controllers/DhlRates.php
  • Service: src/Domains/Transporter/ExternalRates/DhlRatesService.php
  • What it does: wraps legacy AdvTransporters::baseGetDhlRates(). Hits the DHL Rates API live (no cache). Returns list<{productName, productCode, price, pickupDateTime}>.
  • Query params:
    • destinationCountryCode (required)
    • weight in grams (required, >0; divided by 1000 for DHL kg at DhlRatesService.php:77)
    • destinationPostalCode (optional)
  • Status codes: 200 list; 400 missing_query_params/invalid_weight; 404 unknown_transporter (row missing or active != 1); 409 carrier_class_mismatch (transporter class_name !== 'DHL'); 502 transporter_unavailable
  • Testability: $dhlFactory is injectable (DhlRatesService.php:34-40); production default builds new Dhl(new DhlConfig($settings))
  • Added in commit 0d06e18b0 (#113f)

GET /rest/transporter/{id}/asap-services ​

  • Route: application/config/rest_routes.php:1059-1060; auth: guest (application/config/rest_policies.php:853-854)
  • Controller: src/Rest/Transporter/Controllers/AsapServices.php
  • Service: src/Domains/Transporter/ExternalRates/AsapServicesService.php
  • What it does: multi-hop — geocodes customer address (OpenStreetMaps), fetches carrier depot list, queries ASAP for available services. Thin bridge over legacy AdvTransporters::getAsapServices() (porting rejected as not worth refactor — docblock at AsapServicesService.php:10-29). Server-side cutoff-time filter and Next Day/Express whitelist apply per the legacy contract. Returns the same {productName, productCode, price, pickupDateTime} shape as the DHL endpoint.
  • Query params:
    • country, weight (grams), address, city (all required)
    • postalCode, county (optional)
  • Status codes: same set as DHL; 409 fires on class_name !== 'ASAP'
  • Added in commit 0d06e18b0 (#113f)

ExternalRates exception classes ​

New namespace Advisable\Domains\Transporter\ExternalRates\Exceptions (distinct from SmartPointCatalog's exceptions):

ExceptionTriggerHTTP status
UnknownTransporterExceptionTransporter row missing or active != 1404 unknown_transporter
CarrierClassMismatchExceptionclass_name doesn't match endpoint (carries transporterId, expectedClassName, actualClassName)409 carrier_class_mismatch
TransporterApiExceptionWraps any \Throwable from upstream carrier/geocoding API502 transporter_unavailable

Carrier Data in REST Checkout ​

/rest/checkout/shipping returns three carrier discriminators per option from ShippingCalculator: className (transporter class_name), deliveryOptionType (Registry-configured), requiresCarrierData (true iff DHL/ASAP/SmartPoint-capable AND delivery option allows pickup). After order placement, CarrierDataDispatcher routes the transporterExternalData blob to the matching persister (DhlVoucherPersister → shop_order_dhl_vouchers, AsapDataPersister → shop_order_asap_data, SmartPointPersister → shop_order_smart_point). Full detail in CF-06.

Pricing fields, and the options-leak fix (#568): alongside those three discriminators, each option now carries the threshold-aware cost (free-shipping threshold, TRANS_FREE_ALL, and the overweight per-kg surcharge already applied — previously the raw transporters_options_pricing row cost, ignoring the cart entirely), plus two new fields: overweightCost (display-only duplicate of the surcharge already inside cost — never sum it into a total) and deliveryCost (the cash-on-delivery surcharge, non-zero only when the request's payWay is delivery). Separately, the per-option options array — the genuinely selectable extras (insurance, Saturday delivery, …) — no longer includes the 7 transporters_options_pricing pricing-control reg_keys (TRANS_COST_LIMIT, WEIGHT_LIMIT, PRICE_PER_KG, TRANS_FREE_ALL, DELIVERY_COST, DELIVERY_COST_MIN_FREE, MIN_ORDER_AMOUNT); before #568 those were serialized onto the wire as tickable {id, name, extraCost} entries, so a headless client could select a pricing control as if it were a purchasable option. See CF-06 §Shipping Cost Calculation (Modern REST) for the full calculation and AD-06 §transporters_options_pricing for the key inventory.

Known Issues & Security Gaps ​

  1. [RESOLVED — commit 5128ad7d5] BoxNow/Skroutz/ACS provider shape mismatch caused 502 on GET /rest/transporter/{id}/smart-point. SmartPointCatalogService::normalize() (src/Domains/Transporter/SmartPointCatalog/Service.php:149-166) unwraps the nested-by-class_name provider shape to flat SmartPointDTO[] before the isMalformed() guard. Belt-and-suspenders guard remains at :178-181.

  2. [NOTE — by design, commit 5128ad7d5] Five transporter class names have no entry in $config['smartPoints'] and will always return 409 no_provider from GET /rest/transporter/{id}/smart-point: CYPRUSPOST, DAILYCOURIER, ASAP, DHL, and TAXYDEMA. These are intentional omissions — they are cost-calculation/home-delivery carriers with no pickup-point provider class. TAXYDEMA is superseded by TAXYDEMAV2. Behavior is consistent with legacy Adv_order::smartPointsInitialize(). Documented inline at application/config/app.php:449-458.

  3. [NOTE] getPickingPoints() terminology in legacy docs/code refers to the same underlying operation as fetch() in SmartPointsInterface.php:20. The REST endpoint surfaces the modern interface name.

  4. [OPEN — minor] The OpenAPI tag description for the smart-point controller still says "guest-accessible, uncached" (src/Rest/Transporter/Controllers/SmartPoint.php:31), contradicting the 10-minute catalog cache. See AD-06 Transporter Admin §Known Issues.

Tests ​

  • tests/Integration/Domains/Transporter/SmartPointCatalog/ServiceTest.php covers nested-shape normalization and provider-map assertions (commit 34e9d7620, #270).
  • tests/Unit/Domains/Transporter/ExternalRates/DhlRatesServiceTest.php and AsapServicesServiceTest.php provide unit coverage for the external-rate services. No live-API integration tests exist for DHL/ASAP.
  • tests/Unit/Rest/Transporter/Controllers/SmartPointTest.php, tests/Unit/Rest/Transporter/Resources/SmartPoint/ResourceTest.php — unit coverage for the smart-point REST surface.