puntopost / php-sdk
Official PHP SDK for the PuntoPost parcel delivery API
Requires
- php: >=7.4
- ext-curl: *
- ext-json: *
Requires (Dev)
- league/openapi-psr7-validator: ^0.22.0
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^1.0
- phpunit/phpunit: ^9.6
- symplify/easy-coding-standard: ^9.4
README
Official PHP SDK for the PuntoPost API. Integrate parcel delivery services directly into your application.
Table of contents
- Requirements
- Installation
- Basic setup
- Authentication
- Merchant API
- Webhooks
- Error handling
- Custom HTTP client
Requirements
- PHP >= 7.4 (compatible up to PHP 8.5+)
ext-curl(only required when using the built-in HTTP client)ext-json
Installation
composer require puntopost/php-sdk
Basic setup
The SDK has no hardcoded base URL. You must specify the target environment (production, sandbox, or your own test server):
use PuntoPost\Sdk\V1\PuntoPostClient; $client = new PuntoPostClient('https://api.host.com');
The second optional parameter accepts an HttpClientInterface instance. When omitted, CurlHttpClient is used by
default.
Authentication
Login
Authenticates the user and stores the JWT automatically for all subsequent requests.
| Parameter | Type | Required | Description |
|---|---|---|---|
username |
string |
Yes | Account username |
password |
string |
Yes | Account password |
use PuntoPost\Sdk\Exception\PuntoPostException; use PuntoPost\Sdk\Exception\ValidationException; use PuntoPost\Sdk\V1\PuntoPostClient; use PuntoPost\Sdk\V1\Request\LoginRequest; $client = new PuntoPostClient('https://api.host.com'); try { $response = $client->auth()->login(new LoginRequest( 'my.user', // username 'my_password' // password )); echo $response->getToken(); // JWT token to use in subsequent requests echo $response->getExpiresIn(); // seconds until the token expires } catch (ValidationException $e) { // HTTP 400 — one or more fields failed validation print_r($e->getFieldErrors()); } catch (PuntoPostException $e) { // HTTP 401 — wrong credentials, blocked user, etc. echo $e->getStatusCode(); // e.g. 401 echo $e->getErrorType(); // e.g. UNAUTHORIZED echo $e->getErrorDetail(); // descriptive message }
Tip: The token could be stored in your system and set directly on the client for subsequent requests, without needing to log in again until it expires.
When you already have a valid token (e.g. obtained via login, or stored in your system), you must set it directly:
$client->setToken('my-jwt-token');
To clear the token:
$client->clearToken();
Merchant API
Get merchant details
| Parameter | Type | Required | Description |
|---|---|---|---|
id |
string |
Yes | Your merchant ID assigned by PuntoPost |
use PuntoPost\Sdk\V1\Request\GetMerchantRequest; $response = $client->merchant()->getMerchant(new GetMerchantRequest( 'MERCHANT_ID' // id )); $merchant = $response->getDetail(); echo $merchant->getId(); echo $merchant->getName(); echo $merchant->isEnabled() ? 'active' : 'inactive'; echo $merchant->isWebhookEnabled() ? 'webhook on' : 'webhook off'; echo $merchant->getWebhookUrl(); // Your webhook url (nullable) foreach ($merchant->getUsers() as $user) { // Your users with API access echo $user->getId(); echo $user->getUsername(); echo $user->getEmail(); echo $user->isEnabled() ? 'active' : 'inactive'; echo $user->getCreatedAt()->format('Y-m-d H:i:s'); } foreach ($merchant->getPudos() as $pudo) { // Your registered depots — same model as list/detail PUDO (`PickUpDropOff`) echo $pudo->getId(); echo $pudo->getExternalId(); echo $pudo->getType(); // 'pudo', 'logistic' or 'merchant' echo $pudo->getName(); echo $pudo->getDescription(); echo $pudo->getSchedule(); // opening hours as free text echo $pudo->getPhone(); // phone number echo $pudo->isEnabled() ? 'active' : 'inactive'; echo $pudo->getCreatedAt()->format('Y-m-d'); foreach ($pudo->getScheduleItems() as $item) { // structured schedule echo $item->getDay(); // 'mon', 'tue', ... 'sun' echo $item->getStart(); // e.g. '09:00' echo $item->getEnd(); // e.g. '18:00' } }
Check if a postal code has coverage
| Parameter | Type | Required | Description |
|---|---|---|---|
postalCode |
string |
Yes | Postal code to check (e.g. '06600') |
use PuntoPost\Sdk\V1\Request\CheckCoverageRequest; $response = $client->merchant()->checkCoverage(new CheckCoverageRequest( '06600' // postalCode )); if ($response->isCovered()) { echo 'Postal code has coverage'; } else { echo 'No coverage in that area'; }
Get all postal codes with coverage
$response = $client->merchant()->getCoverageList(); foreach ($response->getPostalCodes() as $postalCode) { echo $postalCode . PHP_EOL; } // Check membership directly if ($response->has('06600')) { echo 'Covered'; }
List PUDOs by coordinate
Search PUDOs around a geographic point using ListPudosRequest::byCoordinate().
| Parameter | Type | Required | Description |
|---|---|---|---|
coordinate |
Coordinate |
Yes | Center point for the search. See Coordinate fields below |
radiusKm |
int |
No | Search radius in kilometres around the coordinate. Uses the API default if omitted |
cursor |
Pagination |
No | Pagination cursor to fetch a specific page. See Pagination fields below |
Coordinate
| Field | Type | Required | Description |
|---|---|---|---|
latitude |
float |
Yes | Latitude of the center point |
longitude |
float |
Yes | Longitude of the center point |
Pagination
| Field | Type | Required | Description |
|---|---|---|---|
offset |
int |
Yes | Number of items to skip (0 for the first page) |
limit |
int |
Yes | Maximum number of items to return per page |
use PuntoPost\Sdk\V1\Request\DTO\Coordinate; use PuntoPost\Sdk\V1\Request\DTO\Pagination; use PuntoPost\Sdk\V1\Request\ListPudosRequest; $response = $client->merchant()->listPudos( ListPudosRequest::byCoordinate( new Coordinate(19.4326, -99.1332), // coordinate — latitude and longitude of the center point 10 // radiusKm — search within 10 km (optional) ) ); foreach ($response->getItems() as $pudo) { echo $pudo->getId(); // PUDO ID echo $pudo->getExternalId(); // Short id to display echo $pudo->getType(); // 'pudo', 'logistic' or 'merchant' echo $pudo->getName(); echo $pudo->getSchedule(); // opening hours as free text echo $pudo->getPhone(); // phone number echo $pudo->isEnabled() ? 'active' : 'inactive'; echo $pudo->getCreatedAt()->format('Y-m-d'); foreach ($pudo->getScheduleItems() as $item) { // structured schedule echo $item->getDay(); // 'mon', 'tue', ... 'sun' echo $item->getStart(); // e.g. '09:00' echo $item->getEnd(); // e.g. '18:00' } // address echo $pudo->getAddress()->getPostalCode(); echo $pudo->getAddress()->getCity(); echo $pudo->getAddress()->getAddress(); // street and number $addressCoord = $pudo->getAddress()->getCoordinate(); echo $addressCoord->getLatitude(); echo $addressCoord->getLongitude(); }
List PUDOs by postal code
Search PUDOs within a postal code area using ListPudosRequest::byPostalCode().
| Parameter | Type | Required | Description |
|---|---|---|---|
postalCode |
string |
Yes | Postal code to search in (e.g. '06600') |
radiusKm |
int |
No | Search radius in kilometres around the postal code center. Uses the API default if omitted |
cursor |
Pagination |
No | Pagination cursor to fetch a specific page. See Pagination fields below |
Pagination
| Field | Type | Required | Description |
|---|---|---|---|
offset |
int |
Yes | Number of items to skip (0 for the first page) |
limit |
int |
Yes | Maximum number of items to return per page |
use PuntoPost\Sdk\V1\Request\DTO\Pagination; use PuntoPost\Sdk\V1\Request\ListPudosRequest; $response = $client->merchant()->listPudos( ListPudosRequest::byPostalCode( '06600', // postalCode 5 // radiusKm (optional) ) ); // No filters — returns all PUDOs (API default applies) $response = $client->merchant()->listPudos();
Cursor-based pagination — getNext() returns a ready-to-use ListPudosRequest built automatically from the API's
next-page URL. Pass it directly to the next call:
$response = $client->merchant()->listPudos( ListPudosRequest::byPostalCode('06600', 5) ); while ($response->getNext() !== null) { $response = $client->merchant()->listPudos($response->getNext()); foreach ($response->getItems() as $pudo) { echo $pudo->getName() . PHP_EOL; } }
You can also start pagination manually by passing a Pagination cursor as the third argument:
$response = $client->merchant()->listPudos( ListPudosRequest::byPostalCode( '06600', // postalCode 5, // radiusKm (optional) new Pagination(0, 5) // cursor (optional) ) );
Get PUDO details
| Parameter | Type | Required | Description |
|---|---|---|---|
id |
string |
Yes | PUDO ID |
use PuntoPost\Sdk\V1\Request\GetPudoRequest; $response = $client->merchant()->getPudo(new GetPudoRequest( 'PUDO_ID' // id )); $pudo = $response->getDetail(); echo $pudo->getId(); // PUDO ID echo $pudo->getExternalId(); // Short id to display echo $pudo->getType(); // 'pudo', 'logistic' or 'merchant' echo $pudo->getName(); // display name echo $pudo->getSchedule(); // opening hours as free text echo $pudo->getPhone(); // phone number echo $pudo->isEnabled() ? 'active' : 'inactive'; echo $pudo->getCreatedAt()->format('Y-m-d'); foreach ($pudo->getScheduleItems() as $item) { // structured schedule echo $item->getDay(); // 'mon', 'tue', ... 'sun' echo $item->getStart(); // e.g. '09:00' echo $item->getEnd(); // e.g. '18:00' } echo $pudo->getAddress()->getPostalCode(); echo $pudo->getAddress()->getCity(); echo $pudo->getAddress()->getAddress(); // street and number $coordinate = $pudo->getAddress()->getCoordinate(); echo $coordinate->getLatitude(); echo $coordinate->getLongitude();
Create a C2C parcel (Consumer to Consumer)
A customer drops off the parcel at an origin PUDO and another customer picks it up at a destination PUDO.
CreateC2CParcelRequest
| Parameter | Type | Required | Description |
|---|---|---|---|
merchantId |
string |
Yes | Your Merchant ID |
content |
ParcelContentData |
Yes | Description and optional content details |
sender |
PersonData |
Yes | Customer dropping off the parcel |
receiver |
PersonData |
Yes | Customer picking up the parcel |
destinationId |
string |
Yes | PUDO ID where the receiver will collect |
ParcelContentData
| Parameter | Type | Required | Description |
|---|---|---|---|
description |
string |
Yes | Short description of the parcel contents |
declaredValue |
DeclaredValue |
No | Declared monetary value. See DeclaredValue fields below |
imageUrl |
string |
No | URL of an image representing the contents |
weightKg |
float |
No | Weight of the parcel in kilograms |
DeclaredValue — use the named constructor DeclaredValue::mxn(amount) instead of instantiating directly.
| Field | Type | Required | Description |
|---|---|---|---|
value |
float |
Yes | Monetary amount (e.g. 250.0) |
currency |
string |
Yes | Currency code. Currently only 'MXN' is supported via DeclaredValue::mxn(value) |
PersonData
| Parameter | Type | Required | Description |
|---|---|---|---|
firstName |
string |
Yes | First name |
lastName |
string |
Yes | Last name |
email |
string |
Yes | Contact email address |
phone |
string |
No | Contact phone number (e.g. +525512345678) |
postalCode |
string |
No | Postal code of the person's address |
use PuntoPost\Sdk\V1\Request\CreateC2CParcelRequest; use PuntoPost\Sdk\V1\Request\DTO\DeclaredValue; use PuntoPost\Sdk\V1\Request\DTO\ParcelContentData; use PuntoPost\Sdk\V1\Request\DTO\PersonData; $request = new CreateC2CParcelRequest( 'MERCHANT_ID', // merchantId new ParcelContentData( 'Programming book', // description DeclaredValue::mxn(250.0), // declaredValue (optional) 'https://example.com/img.jpg', // imageUrl (optional) 1.2 // weightKg (optional) ), new PersonData( // sender 'Juan', // firstName 'García', // lastName 'juan@example.com', // email '+525512345678', // phone (optional) '06600' // postalCode (optional) ), new PersonData( // receiver 'Ana', // firstName 'López', // lastName 'ana@example.com', // email '+525587654321', // phone (optional) '44100' // postalCode (optional) ), 'DESTINATION_PUDO_ID' // destinationId — Pudo ID ); $response = $client->merchant()->createC2CParcel($request); $parcel = $response->getDetail(); echo $parcel->getId(); echo $parcel->getTracking();
The returned
Parcelobject contains exactly the same fields as the response from Get parcel details.
Create a B2C parcel (Business to Consumer)
The merchant drops off the parcel at their origin depot and the customer picks it up at a destination PUDO.
CreateB2CParcelRequest
| Parameter | Type | Required | Description |
|---|---|---|---|
merchantId |
string |
Yes | Your Merchant ID |
content |
ParcelContentData |
Yes | Description and optional content details |
receiver |
PersonData |
Yes | Customer picking up the parcel |
originId |
string |
Yes | Your depot - PUDO ID |
destinationId |
string |
Yes | PUDO ID where the customer collects |
ParcelContentDataandPersonDatafields are the same as in C2C.
use PuntoPost\Sdk\V1\Request\CreateB2CParcelRequest; use PuntoPost\Sdk\V1\Request\DTO\DeclaredValue; use PuntoPost\Sdk\V1\Request\DTO\ParcelContentData; use PuntoPost\Sdk\V1\Request\DTO\PersonData; $request = new CreateB2CParcelRequest( 'MERCHANT_ID', // merchantId new ParcelContentData( 'Smartphone', // description DeclaredValue::mxn(3500.0) // declaredValue (optional) ), new PersonData( // receiver 'María', 'Pérez', 'maria@example.com', '+525511223344' // phone (optional) ), 'ORIGIN_PUDO_ID', // originId - Your PUDO ID 'DESTINATION_PUDO_ID' // destinationId - PUDO ID ); $response = $client->merchant()->createB2CParcel($request); $parcel = $response->getDetail();
The returned
Parcelobject contains exactly the same fields as the response from Get parcel details.
Create a C2B parcel (Consumer to Business)
A customer drops off the parcel at an origin PUDO and the merchant picks it at his destination depot.
CreateC2BParcelRequest
| Parameter | Type | Required | Description |
|---|---|---|---|
merchantId |
string |
Yes | Your Merchant ID |
content |
ParcelContentData |
Yes | Description and optional content details |
sender |
PersonData |
Yes | Customer sending the parcel |
destinationId |
string |
Yes | Your depot - PUDO ID |
ParcelContentDataandPersonDatafields are the same as in C2C.
use PuntoPost\Sdk\V1\Request\CreateC2BParcelRequest; use PuntoPost\Sdk\V1\Request\DTO\ParcelContentData; use PuntoPost\Sdk\V1\Request\DTO\PersonData; $request = new CreateC2BParcelRequest( 'MERCHANT_ID', // merchantId new ParcelContentData( 'Product return' // description ), new PersonData( // sender 'Carlos', 'Ruiz', 'carlos@example.com' ), 'DESTINATION_PUDO_ID' // destinationId - Your PUDO ID ); $response = $client->merchant()->createC2BParcel($request); $parcel = $response->getDetail();
The returned
Parcelobject contains exactly the same fields as the response from Get parcel details.
Get parcel details
| Parameter | Type | Required | Description |
|---|---|---|---|
identifier |
string |
Yes | Parcel ID, tracking number, or label — any of the three is accepted |
use PuntoPost\Sdk\Exception\PuntoPostException; use PuntoPost\Sdk\V1\Request\GetParcelRequest; try { $response = $client->merchant()->getParcel(new GetParcelRequest( 'MXT0000000001' // identifier )); $parcel = $response->getDetail(); // identifiers & tracking echo $parcel->getId(); // parcel ID echo $parcel->getTracking(); // tracking number echo $parcel->getQrTracking(); // URL of the PNG QR code for tracking echo $parcel->getLabel(); // label identifier (nullable) echo $parcel->getQrLabel(); // URL of the PNG QR code for the label (nullable) // dates echo $parcel->getCreatedAt()->format('Y-m-d H:i:s'); $expireAt = $parcel->getExpireAt(); echo $expireAt !== null ? $expireAt->format('Y-m-d H:i:s') : 'no expiry'; // nullable // status echo $parcel->getStatus()->getValue(); // content echo $parcel->getContent()->getDescription(); echo $parcel->getContent()->getWeightKg(); // nullable echo $parcel->getContent()->getImageUrl(); // nullable $declaredValue = $parcel->getContent()->getDeclaredValue(); // nullable if ($declaredValue !== null) { echo $declaredValue->getValue(); // e.g. 250.0 echo $declaredValue->getCurrency(); // e.g. 'MXN' } // sender echo $parcel->getSender()->getFirstName(); echo $parcel->getSender()->getLastName(); echo $parcel->getSender()->getEmail(); echo $parcel->getSender()->getPhone(); // nullable echo $parcel->getSender()->getPostalCode(); // nullable // receiver echo $parcel->getReceiver()->getFirstName(); echo $parcel->getReceiver()->getLastName(); echo $parcel->getReceiver()->getEmail(); echo $parcel->getReceiver()->getPhone(); // nullable echo $parcel->getReceiver()->getPostalCode(); // nullable // origin PUDO (nullable — absent on B2C parcels) $origin = $parcel->getOrigin(); if ($origin !== null) { echo $origin->getId(); echo $origin->getExternalId(); echo $origin->getType(); // 'pudo', 'logistic' or 'merchant' echo $origin->getName(); echo $origin->getDescription(); echo $origin->getSchedule(); echo $origin->getPhone(); echo $origin->isEnabled() ? 'active' : 'inactive'; echo $origin->getCreatedAt()->format('Y-m-d'); foreach ($origin->getScheduleItems() as $item) { echo $item->getDay() . ': ' . $item->getStart() . '-' . $item->getEnd(); } echo $origin->getAddress()->getPostalCode(); echo $origin->getAddress()->getCity(); echo $origin->getAddress()->getAddress(); $originCoord = $origin->getAddress()->getCoordinate(); if ($originCoord !== null) { echo $originCoord->getLatitude(); echo $originCoord->getLongitude(); } } // destination PUDO $destination = $parcel->getDestination(); echo $destination->getId(); echo $destination->getExternalId(); echo $destination->getType(); // 'pudo', 'logistic' or 'merchant' echo $destination->getName(); echo $destination->getDescription(); echo $destination->getSchedule(); echo $destination->getPhone(); echo $destination->isEnabled() ? 'active' : 'inactive'; echo $destination->getCreatedAt()->format('Y-m-d'); foreach ($destination->getScheduleItems() as $item) { echo $item->getDay() . ': ' . $item->getStart() . '-' . $item->getEnd(); } echo $destination->getAddress()->getPostalCode(); echo $destination->getAddress()->getCity(); echo $destination->getAddress()->getAddress(); $destCoord = $destination->getAddress()->getCoordinate(); if ($destCoord !== null) { echo $destCoord->getLatitude(); echo $destCoord->getLongitude(); } // status history (chronological list of status transitions) foreach ($parcel->getStatusHistory() as $entry) { echo $entry->getStatus()->getValue(); // status at that point in time echo $entry->getWhen()->format('Y-m-d H:i:s'); // when the transition happened } } catch (PuntoPostException $e) { echo $e->getStatusCode(); // 401, 403, 404, etc. }
The ParcelStatus object returned by getStatus() provides typed helper methods to check each status:
use PuntoPost\Sdk\V1\Response\Model\Enum\ParcelStatus; $status = $parcel->getStatus(); if ($status->isDelivered()) { echo 'Parcel delivered'; } // Or compare the raw value against a constant if ($status->getValue() === ParcelStatus::IN_ORIGIN_POINT) { echo 'At origin point'; }
Available statuses
Note: Statuses prefixed with
RETURN_andRETURN_FAIL_only apply to C2C parcels. B2C and C2B shipments will never transition into those states.
| Constant | Description |
|---|---|
CREATED |
Parcel registered; not yet at origin PUDO |
IN_ORIGIN_POINT |
Parcel dropped off at the origin PUDO |
IN_TRANSIT_DEPOT |
In transit between origin PUDO and sorting depot |
IN_DEPOT |
Arrived at sorting depot |
IN_TRANSIT_DESTINATION |
In transit from sorting depot to destination PUDO |
IN_DESTINATION_POINT |
Arrived at destination PUDO; awaiting collection |
IN_REROUTED_POINT |
Redirected to an alternative PUDO |
DELIVERED |
Collected by the recipient — final state |
RETURN_IN_DESTINATION_POINT |
Return initiated; parcel at the destination PUDO |
RETURN_IN_TRANSIT_DEPOT |
Return in transit to sorting depot |
RETURN_IN_DEPOT |
Return arrived at sorting depot |
RETURN_IN_TRANSIT_ORIGIN |
Return in transit from depot to origin PUDO |
RETURN_IN_ORIGIN_POINT |
Return arrived at origin PUDO |
RETURN_IN_REROUTED_POINT |
Return redirected to an alternative PUDO |
RETURN_DELIVERED |
Return collected by the merchant — final return state |
RETURN_FAIL_IN_ORIGIN_POINT |
Return failed; parcel held at origin PUDO |
RETURN_FAIL_IN_TRANSIT_DEPOT |
Return failed; parcel in transit to depot |
RETURN_FAIL_IN_DEPOT |
Return failed; parcel held at depot |
RETURN_FAIL_DELIVERED |
Return failed but delivered back — review required |
INCIDENCE |
An issue has been flagged on this parcel |
CANCELLED |
Parcel cancelled - final state |
LOST |
Parcel reported as lost - final incidence state |
Mark a parcel as ready for pickup
Notifies the system that the parcel is prepared and ready to be collected at the origin PUDO. Only valid when the parcel
is in created status.
Note: This action is only available with B2C shipments.
| Parameter | Type | Required | Description |
|---|---|---|---|
identifier |
string |
Yes | Parcel ID, tracking number, or label — any of the three is accepted |
use PuntoPost\Sdk\V1\Request\MarkParcelReadyRequest; $response = $client->merchant()->markParcelReady(new MarkParcelReadyRequest( 'MXT0000000001' // identifier — parcel ID, tracking number, or label )); echo $response->getStatusCode(); // 204 echo $response->isSuccess(); // true
Cancel a parcel
Cancels a parcel. Only valid while the parcel has not yet entered transit (i.e. before it leaves the origin PUDO).
| Parameter | Type | Required | Description |
|---|---|---|---|
identifier |
string |
Yes | Parcel ID, tracking number, or label — any of the three is accepted |
use PuntoPost\Sdk\Exception\PuntoPostException; use PuntoPost\Sdk\V1\Request\CancelParcelRequest; try { $response = $client->merchant()->cancelParcel(new CancelParcelRequest( 'MXT0000000001' // identifier — parcel ID, tracking number, or label )); echo $response->getStatusCode(); // 204 } catch (PuntoPostException $e) { if ($e->getStatusCode() === 409) { // Parcel is already in transit or delivered — cannot be cancelled echo $e->getErrorType(); // e.g. STATUS_CONFLICT } }
Webhooks
The SDK provides a WebhookHandler to parse incoming webhook payloads sent by the PuntoPost API to your application.
Pass the raw request body (JSON string) to parse() and get back a typed event object:
use PuntoPost\Sdk\V1\Webhook\WebhookHandler; use PuntoPost\Sdk\V1\Webhook\Event\ParcelStatusChangedEvent; use PuntoPost\Sdk\V1\Webhook\Event\ParcelOriginChangedEvent; use PuntoPost\Sdk\V1\Webhook\Event\ParcelDestinationChangedEvent; use PuntoPost\Sdk\V1\Webhook\Event\UnknownWebhookEvent; $handler = new WebhookHandler(); // In plain PHP: $event = $handler->parse(file_get_contents('php://input')); // In Symfony / Laravel: // $event = $handler->parse($request->getContent());
Handling known events
Use instanceof to determine the event type and access its typed data:
if ($event instanceof ParcelStatusChangedEvent) { echo $event->getId(); // parcel ID echo $event->getTracking(); // tracking number echo $event->getStatus()->getValue(); // e.g. 'in_destination_point' echo $event->getStatus()->isDelivered(); // false foreach ($event->getStatusHistory() as $entry) { echo $entry->getStatus()->getValue(); echo $entry->getWhen()->format('Y-m-d H:i:s'); } } if ($event instanceof ParcelOriginChangedEvent) { echo $event->getId(); echo $event->getTracking(); $origin = $event->getOrigin(); // PickUpDropOff echo $origin->getName(); echo $origin->getAddress()->getPostalCode(); } if ($event instanceof ParcelDestinationChangedEvent) { echo $event->getId(); echo $event->getTracking(); $destination = $event->getDestination(); // PickUpDropOff echo $destination->getName(); echo $destination->getAddress()->getPostalCode(); }
Available event types
| Event class | event_type value |
Typed detail |
|---|---|---|
ParcelStatusChangedEvent |
parcel_status_changed |
ParcelStatus + StatusHistoryEntry[] |
ParcelOriginChangedEvent |
parcel_origin_changed |
PickUpDropOff (new origin) |
ParcelDestinationChangedEvent |
parcel_destination_changed |
PickUpDropOff (new destination) |
Unknown or future events
The JSON body must include event_type (string) and detail (object/array); otherwise parse() throws InvalidArgumentException.
If the API introduces new event types in the future, the SDK does not throw for unknown event_type values as long as those keys are present. The behaviour depends on the strategy you choose when creating the handler:
CAPTURE_UNKNOWN (default) — unknown events are returned as UnknownWebhookEvent, giving you access to the raw event_type and detail array:
$handler = new WebhookHandler(); // or explicit: new WebhookHandler(WebhookHandler::CAPTURE_UNKNOWN) $event = $handler->parse($json); if ($event instanceof UnknownWebhookEvent) { echo $event->getEventType(); // e.g. 'parcel_weight_updated' print_r($event->getDetail()); // raw associative array }
IGNORE_UNKNOWN — unknown events are silently ignored and parse() returns null:
$handler = new WebhookHandler(WebhookHandler::IGNORE_UNKNOWN); $event = $handler->parse($json); // null for unknown event types
Note: Invalid JSON, or a missing/invalid
event_type/detail, will throwInvalidArgumentExceptionregardless of the strategy.
Error handling
All exceptions extend PuntoPostException. There are only two types:
| Class | When thrown |
|---|---|
ValidationException |
HTTP 400 with field-level validation errors |
PuntoPostException |
Any other API error (401, 403, 404, 409, 5xx, …) |
All string properties default to '' when the API response does not include them.
use PuntoPost\Sdk\Exception\PuntoPostException; use PuntoPost\Sdk\Exception\ValidationException; try { $response = $client->merchant()->createC2CParcel($request); } catch (ValidationException $e) { // Field-level errors foreach ($e->getFieldErrors() as $field => $message) { echo "{$field}: {$message}" . PHP_EOL; } echo $e->getErrorDetail(); // general validation message } catch (PuntoPostException $e) { echo $e->getStatusCode(); // HTTP status code echo $e->getErrorType(); // e.g. UNAUTHORIZED, FORBIDDEN, NOT_FOUND ('' if absent) echo $e->getErrorTitle(); // error title ('' if absent) echo $e->getErrorDetail(); // error description ('' if absent) echo $e->getErrorInstance(); // context: authentication, parcel, etc. ('' if absent) echo $e->getRawBody(); // raw response body }
Custom HTTP client
The SDK is designed so that you can replace the HTTP client with the one from your framework by implementing
HttpClientInterface:
namespace PuntoPost\Sdk\Http; interface HttpClientInterface { public function request( string $method, string $url, array $headers = [], ?string $body = null ): HttpResponse; }
Symfony HttpClient adapter
Install symfony/http-client if you haven't already:
composer require symfony/http-client
Create the adapter in your project:
<?php namespace App\PuntoPost; use PuntoPost\Sdk\Http\HttpClientInterface; use PuntoPost\Sdk\Http\HttpResponse; use Symfony\Contracts\HttpClient\HttpClientInterface as SymfonyHttpClientInterface; class SymfonyHttpClientAdapter implements HttpClientInterface { private SymfonyHttpClientInterface $client; public function __construct(SymfonyHttpClientInterface $client) { $this->client = $client; } public function request( string $method, string $url, array $headers = [], ?string $body = null ): HttpResponse { $options = ['headers' => $headers]; if ($body !== null) { $options['body'] = $body; } $response = $this->client->request($method, $url, $options); return new HttpResponse( $response->getStatusCode(), $response->getContent(false), $response->getHeaders(false) ); } }
Use it in Symfony with dependency injection:
use PuntoPost\Sdk\V1\PuntoPostClient; use App\PuntoPost\SymfonyHttpClientAdapter; use Symfony\Contracts\HttpClient\HttpClientInterface; class MyService { private PuntoPostClient $puntoPost; public function __construct(HttpClientInterface $httpClient) { $this->puntoPost = new PuntoPostClient( 'https://api.host.com', new SymfonyHttpClientAdapter($httpClient) ); } }
Or register it in services.yaml:
App\PuntoPost\SymfonyHttpClientAdapter: arguments: $client: '@http_client' App\MyService: arguments: $httpClient: '@App\PuntoPost\SymfonyHttpClientAdapter'
Laravel HTTP client adapter
<?php namespace App\PuntoPost; use PuntoPost\Sdk\Http\HttpClientInterface; use PuntoPost\Sdk\Http\HttpResponse; use Illuminate\Http\Client\Factory as HttpFactory; class LaravelHttpClientAdapter implements HttpClientInterface { private HttpFactory $http; public function __construct(HttpFactory $http) { $this->http = $http; } public function request( string $method, string $url, array $headers = [], ?string $body = null ): HttpResponse { $response = $this->http ->withHeaders($headers) ->send(strtoupper($method), $url, ['body' => $body]); return new HttpResponse( $response->status(), $response->body(), $response->headers() ); } }