PHPackages                             puntopost/php-sdk - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [API Development](/categories/api)
4. /
5. puntopost/php-sdk

ActiveLibrary[API Development](/categories/api)

puntopost/php-sdk
=================

Official PHP SDK for the PuntoPost parcel delivery API

v1.2.0(3w ago)21.2kMITPHPPHP &gt;=7.4

Since Mar 18Pushed 3w agoCompare

[ Source](https://github.com/puntopost/php-sdk)[ Packagist](https://packagist.org/packages/puntopost/php-sdk)[ Docs](https://puntopost.mx)[ RSS](/packages/puntopost-php-sdk/feed)WikiDiscussions main Synced 3w ago

READMEChangelog (3)Dependencies (13)Versions (5)Used By (0)

PuntoPost PHP SDK
=================

[](#puntopost-php-sdk)

Official PHP SDK for the PuntoPost API. Integrate parcel delivery services directly into your application.

[![CI](https://github.com/puntopost/php-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/puntopost/php-sdk/actions/workflows/ci.yml/badge.svg)[![Latest Version](https://camo.githubusercontent.com/a0bd82eea0562648aecd0efd677d339d8fbcbc9469dfb4642eb4d32de4c445d0/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f70756e746f706f73742f7068702d73646b2e737667)](https://packagist.org/packages/puntopost/php-sdk)[![PHP Version](https://camo.githubusercontent.com/b655b9bfc53accd58c14ebcd600ff7eb930f54410aa9b57ae955d968ed95803d/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f70756e746f706f73742f7068702d73646b2e737667)](https://packagist.org/packages/puntopost/php-sdk)[![License](https://camo.githubusercontent.com/48f598515f2a57202384ee371b860b50de619a947a6c4053629603e5bdf2211b/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f70756e746f706f73742f7068702d73646b2e737667)](LICENSE)

---

Table of contents
-----------------

[](#table-of-contents)

- [Requirements](#requirements)
- [Installation](#installation)
- [Basic setup](#basic-setup)
- [Authentication](#authentication)
    - [Login](#login)
- [Merchant API](#merchant-api)
    - [Get merchant details](#get-merchant-details)
    - [Check if a postal code has coverage](#check-if-a-postal-code-has-coverage)
    - [Get all postal codes with coverage](#get-all-postal-codes-with-coverage)
    - [List PUDOs by coordinate](#list-pudos-by-coordinate)
    - [List PUDOs by postal code](#list-pudos-by-postal-code)
    - [Get PUDO details](#get-pudo-details)
    - [Create a C2C parcel](#create-a-c2c-parcel-consumer-to-consumer)
    - [Create a B2C parcel](#create-a-b2c-parcel-business-to-consumer)
    - [Create a C2B parcel](#create-a-c2b-parcel-consumer-to-business)
    - [Download a parcel label](#download-a-parcel-label)
    - [List merchant parcels](#list-merchant-parcels)
    - [Get parcel details](#get-parcel-details)
    - [Mark a parcel as ready for pickup](#mark-a-parcel-as-ready-for-pickup)
    - [Cancel a parcel](#cancel-a-parcel)
- [Web API](#web-api)
    - [Download a parcel tracking QR](#download-a-parcel-tracking-qr)
- [Webhooks](#webhooks)
    - [Handling known events](#handling-known-events)
    - [Unknown or future events](#unknown-or-future-events)
- [Error handling](#error-handling)
- [Custom HTTP client](#custom-http-client)
    - [Symfony HttpClient adapter](#symfony-httpclient-adapter)
    - [Laravel HTTP client adapter](#laravel-http-client-adapter)

---

Requirements
------------

[](#requirements)

- PHP &gt;= 7.4 (compatible up to PHP 8.5+)
- `ext-curl` (only required when using the built-in HTTP client)
- `ext-json`

---

Installation
------------

[](#installation)

```
composer require puntopost/php-sdk
```

---

Basic setup
-----------

[](#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
--------------

[](#authentication)

### Login

[](#login)

Authenticates the user and stores the JWT automatically for all subsequent requests.

ParameterTypeRequiredDescription`username``string`YesAccount username`password``string`YesAccount 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
------------

[](#merchant-api)

### Get merchant details

[](#get-merchant-details)

ParameterTypeRequiredDescription`id``string`YesYour 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

[](#check-if-a-postal-code-has-coverage)

ParameterTypeRequiredDescription`postalCode``string`YesPostal 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

[](#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

[](#list-pudos-by-coordinate)

Search PUDOs around a geographic point using `ListPudosRequest::byCoordinate()`.

ParameterTypeRequiredDescription`coordinate``Coordinate`YesCenter point for the search. See `Coordinate` fields below`radiusKm``int`NoSearch radius in kilometres around the coordinate. Uses the API default if omitted`cursor``Pagination`NoPagination cursor to fetch a specific page. See `Pagination` fields below**`Coordinate`**

FieldTypeRequiredDescription`latitude``float`YesLatitude of the center point`longitude``float`YesLongitude of the center point**`Pagination`**

FieldTypeRequiredDescription`offset``int`YesNumber of items to skip (0 for the first page)`limit``int`YesMaximum 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

[](#list-pudos-by-postal-code)

Search PUDOs within a postal code area using `ListPudosRequest::byPostalCode()`.

ParameterTypeRequiredDescription`postalCode``string`YesPostal code to search in (e.g. `'06600'`)`radiusKm``int`NoSearch radius in kilometres around the postal code center. Uses the API default if omitted`cursor``Pagination`NoPagination cursor to fetch a specific page. See `Pagination` fields below**`Pagination`**

FieldTypeRequiredDescription`offset``int`YesNumber of items to skip (0 for the first page)`limit``int`YesMaximum 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

[](#get-pudo-details)

ParameterTypeRequiredDescription`id``string`YesPUDO 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)

[](#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`**

ParameterTypeRequiredDescription`merchantId``string`YesYour Merchant ID`content``ParcelContentData`YesDescription and optional content details`sender``PersonData`YesCustomer dropping off the parcel`receiver``PersonData`YesCustomer picking up the parcel`destinationId``string`YesPUDO ID where the receiver will collect`merchantReference``string`NoYour own reference for the parcel (e.g. order ID). Max 100 chars**`ParcelContentData`**

ParameterTypeRequiredDescription`description``string`YesShort description of the parcel contents`declaredValue``DeclaredValue`NoDeclared monetary value. See `DeclaredValue` fields below`imageUrl``string`NoURL of an image representing the contents`weightKg``float`NoWeight of the parcel in kilograms**`DeclaredValue`** — use the named constructor `DeclaredValue::mxn(amount)` instead of instantiating directly.

FieldTypeRequiredDescription`value``float`YesMonetary amount (e.g. `250.0`)`currency``string`YesCurrency code. Currently only `'MXN'` is supported via `DeclaredValue::mxn(value)`**`PersonData`**

ParameterTypeRequiredDescription`firstName``string`YesFirst name`lastName``string`YesLast name`email``string`YesContact email address`phone``string`NoContact phone number (e.g. `+525512345678`)`postalCode``string`NoPostal 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 `Parcel` object contains exactly the same fields as the response from [Get parcel details](#get-parcel-details).

### Create a B2C parcel (Business to Consumer)

[](#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`**

ParameterTypeRequiredDescription`merchantId``string`YesYour Merchant ID`content``ParcelContentData`YesDescription and optional content details`receiver``PersonData`YesCustomer picking up the parcel`originId``string`YesYour depot - PUDO ID`destinationId``string`YesPUDO ID where the customer collects`merchantReference``string`NoYour own reference for the parcel (e.g. order ID). Max 100 chars> `ParcelContentData` and `PersonData` fields are the same as in [C2C](#create-a-c2c-parcel-consumer-to-consumer).

```
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 `Parcel` object contains exactly the same fields as the response from [Get parcel details](#get-parcel-details).

### Create a C2B parcel (Consumer to Business)

[](#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`**

ParameterTypeRequiredDescription`merchantId``string`YesYour Merchant ID`content``ParcelContentData`YesDescription and optional content details`sender``PersonData`YesCustomer sending the parcel`destinationId``string`YesYour depot - PUDO ID`merchantReference``string`NoYour own reference for the parcel (e.g. order ID). Max 100 chars> `ParcelContentData` and `PersonData` fields are the same as in [C2C](#create-a-c2c-parcel-consumer-to-consumer).

```
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 `Parcel` object contains exactly the same fields as the response from [Get parcel details](#get-parcel-details).

### Download a parcel label

[](#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 `label` field is `null`.

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](#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:

ParameterTypeRequiredDescription`identifier``string`YesLabel identifier of the parcel (e.g. `'MXL0000000001'`)```
$labelResponse = $client->merchant()->getParcelLabel(new GetParcelLabelRequest(
    'MXL0000000001' // identifier — the parcel's label
));
```

### List merchant parcels

[](#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`**

ParameterTypeRequiredDescription`merchantId``string`YesYour Merchant ID`dateMin``DateTimeImmutable`NoLower bound (inclusive) on the parcel creation date. Only the `Y-m-d` portion is sent`dateMax``DateTimeImmutable`NoUpper bound (inclusive) on the parcel creation date. Only the `Y-m-d` portion is sent`statuses``string[]`NoFilter by one or more parcel statuses. Use the constants on `ParcelStatus``query``string`NoFree-text search across tracking, content description, and sender/receiver full names`limit``int`NoMaximum parcels to return (API default: 500)`offset``int`NoStarting 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 `ParcelSummary` objects — a lightweight view containing the same identifying/status fields as a full `Parcel` detail, but without `qrTracking`, `qrLabel`, `statusHistory` or `merchantReference`. Call [Get parcel details](#get-parcel-details) when you need the full payload.

### Get parcel details

[](#get-parcel-details)

ParameterTypeRequiredDescription`identifier``string`YesParcel 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_` and `RETURN_FAIL_` only apply to **C2C parcels**. B2C and C2B shipments will never transition into those states.

ConstantDescription`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

[](#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.

ParameterTypeRequiredDescription`identifier``string`YesParcel 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

[](#cancel-a-parcel)

Cancels a parcel. Only valid while the parcel has not yet entered transit (i.e. before it leaves the origin PUDO).

ParameterTypeRequiredDescription`identifier``string`YesParcel 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
-------

[](#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

[](#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.

ParameterTypeRequiredDescription`identifier``string`YesParcel 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](#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
--------

[](#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

[](#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` valueTyped 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

[](#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 throw `InvalidArgumentException` regardless of the strategy.

---

Error handling
--------------

[](#error-handling)

All exceptions extend `PuntoPostException`. There are only two types:

ClassWhen 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
------------------

[](#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

[](#symfony-httpclient-adapter)

Install `symfony/http-client` if you haven't already:

```
composer require symfony/http-client
```

Create the adapter in your project:

```
