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
This package is auto-updated.
Last update: 2026-05-28 08:04:36 UTC
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
- Get merchant details
- Check if a postal code has coverage
- Get all postal codes with coverage
- List PUDOs by coordinate
- List PUDOs by postal code
- Get PUDO details
- Create a C2C parcel
- Create a B2C parcel
- Create a C2B parcel
- Download a parcel label
- List merchant parcels
- Get parcel details
- Mark a parcel as ready for pickup
- Cancel a parcel
- Web 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 |
merchantReference |
string |
No | Your own reference for the parcel (e.g. order ID). Max 100 chars |
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 'ORDER-12345' // merchantReference (optional) — your order 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 |
merchantReference |
string |
No | Your own reference for the parcel (e.g. order ID). Max 100 chars |
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 'ORDER-12345' // merchantReference (optional) — your order 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 |
merchantReference |
string |
No | Your own reference for the parcel (e.g. order ID). Max 100 chars |
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 'ORDER-12345' // merchantReference (optional) — your order ID ); $response = $client->merchant()->createC2BParcel($request); $parcel = $response->getDetail();
The returned
Parcelobject contains exactly the same fields as the response from Get parcel details.
Download a parcel label
Downloads the printable label for a parcel. The server returns either a PNG image or a PDF document depending on your merchant configuration; the response object exposes both the raw bytes and the actual content type so you can save the file or stream it back to the user.
Note: Only B2C parcels have a label that can be downloaded — they are the only flow where the merchant prints a shipping label. Calling this on a C2C or C2B parcel will fail because the
labelfield isnull.
The most natural flow is to chain it right after creating the parcel using the static factory
GetParcelLabelRequest::fromParcelResponse(), which reads $parcel->getLabel() for you and throws
InvalidArgumentException if it is null:
use PuntoPost\Sdk\V1\Request\GetParcelLabelRequest; $parcelResponse = $client->merchant()->createB2CParcel($createRequest); $labelResponse = $client->merchant()->getParcelLabel( GetParcelLabelRequest::fromParcelResponse($parcelResponse) ); $labelResponse->getContent(); // raw binary (string) — PNG bytes or PDF bytes $labelResponse->getContentType(); // e.g. 'application/pdf' or 'image/png' $labelResponse->getExtension(); // 'pdf' or 'png' — convenience for naming the file // Save to disk $label = $parcelResponse->getDetail()->getLabel(); file_put_contents("{$label}.{$labelResponse->getExtension()}", $labelResponse->getContent());
If you already have a Parcel (e.g. obtained from Get parcel details or from a webhook), use
GetParcelLabelRequest::fromParcel($parcel) instead. And if you only have the label identifier as a string, use the
plain constructor:
| Parameter | Type | Required | Description |
|---|---|---|---|
identifier |
string |
Yes | Label identifier of the parcel (e.g. 'MXL0000000001') |
$labelResponse = $client->merchant()->getParcelLabel(new GetParcelLabelRequest( 'MXL0000000001' // identifier — the parcel's label ));
List merchant parcels
Returns a paginated list of parcels for the given merchant. All filters are optional; when omitted, the API defaults apply (no date filter, no status filter, no text search, default page size).
ListMerchantParcelsRequest
| Parameter | Type | Required | Description |
|---|---|---|---|
merchantId |
string |
Yes | Your Merchant ID |
dateMin |
DateTimeImmutable |
No | Lower bound (inclusive) on the parcel creation date. Only the Y-m-d portion is sent |
dateMax |
DateTimeImmutable |
No | Upper bound (inclusive) on the parcel creation date. Only the Y-m-d portion is sent |
statuses |
string[] |
No | Filter by one or more parcel statuses. Use the constants on ParcelStatus |
query |
string |
No | Free-text search across tracking, content description, and sender/receiver full names |
limit |
int |
No | Maximum parcels to return (API default: 500) |
offset |
int |
No | Starting index for pagination (API default: 0) |
use PuntoPost\Sdk\V1\Request\ListMerchantParcelsRequest; use PuntoPost\Sdk\V1\Response\Model\Enum\ParcelStatus; $response = $client->merchant()->listMerchantParcels(new ListMerchantParcelsRequest( 'MERCHANT_ID', // merchantId new DateTimeImmutable('2026-03-01'), // dateMin (optional) new DateTimeImmutable('2026-03-31'), // dateMax (optional) [ParcelStatus::CREATED, ParcelStatus::IN_ORIGIN_POINT], // statuses (optional) 'juan', // query (optional) 100, // limit (optional) 0 // offset (optional) )); echo $response->getTotal(); // total number of parcels matching the filters (across all pages) foreach ($response->getItems() as $parcel) { echo $parcel->getId(); echo $parcel->getTracking(); echo $parcel->getLabel(); // nullable echo $parcel->getContent()->getDescription(); echo $parcel->getStatus()->getValue(); echo $parcel->getSender()->getFirstName(); echo $parcel->getReceiver()->getFirstName(); echo $parcel->getDestination()->getName(); $origin = $parcel->getOrigin(); // nullable if ($origin !== null) { echo $origin->getName(); } echo $parcel->getCreatedAt()->format('Y-m-d H:i:s'); $expireAt = $parcel->getExpireAt(); // nullable if ($expireAt !== null) { echo $expireAt->format('Y-m-d H:i:s'); } }
Items in the list are
ParcelSummaryobjects — a lightweight view containing the same identifying/status fields as a fullParceldetail, but withoutqrTracking,qrLabel,statusHistoryormerchantReference. Call Get parcel details when you need the full payload.
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) echo $parcel->getMerchantReference(); // your own reference passed on create (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 } }
Web API
Public endpoints that do not require authentication. Use them to expose tracking artifacts (e.g. the QR image) to your end users directly from your frontend or to embed them in transactional emails.
Download a parcel tracking QR
Downloads the tracking QR code (PNG) for a parcel by ID, tracking number, or label. The returned value is the raw PNG bytes — write them to disk, stream them to the browser, or attach them to an email.
| Parameter | Type | Required | Description |
|---|---|---|---|
identifier |
string |
Yes | Parcel ID, tracking number, or label — any of the three is accepted |
use PuntoPost\Sdk\V1\Request\GetParcelTrackingQrRequest; $png = $client->web()->getParcelTrackingQr(new GetParcelTrackingQrRequest( 'MXT0000000001' // identifier — parcel ID, tracking number, or label )); file_put_contents('MXT0000000001-qr.png', $png);
If you already have a Parcel (e.g. from Get parcel details or from a webhook), use the static
factory to build the request from the parcel's tracking number:
$png = $client->web()->getParcelTrackingQr( GetParcelTrackingQrRequest::fromParcelResponse($parcelResponse) );
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() ); } }