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.0.0(3d ago)01MITPHPPHP &gt;=7.4

Since Mar 18Pushed 3d 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 today

READMEChangelogDependencies (3)Versions (2)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)
    - [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)
- [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->getName();
    echo $pudo->getDescription();
    echo $pudo->getSchedule();
    echo $pudo->isEnabled() ? 'active' : 'inactive';
    echo $pudo->getCreatedAt()->format('Y-m-d');
}
```

### 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->getName();
    echo $pudo->getSchedule();                    // opening hours as free text
    echo $pudo->isEnabled() ? 'active' : 'inactive';
    echo $pudo->getCreatedAt()->format('Y-m-d');

    // 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->getName();                        // display name
echo $pudo->getSchedule();                    // opening hours as free text
echo $pudo->isEnabled() ? 'active' : 'inactive';
echo $pudo->getCreatedAt()->format('Y-m-d');
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**`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
);

$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> `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
);

$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> `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
);

$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).

### 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)

    // 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();

    // 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->getName();
        echo $origin->getDescription();
        echo $origin->getSchedule();
        echo $origin->isEnabled() ? 'active' : 'inactive';
        echo $origin->getCreatedAt()->format('Y-m-d');
        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->getName();
    echo $destination->getDescription();
    echo $destination->getSchedule();
    echo $destination->isEnabled() ? 'active' : 'inactive';
    echo $destination->getCreatedAt()->format('Y-m-d');
    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
    }
}
```

---

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:

```
