puntopost/php-sdk

Official PHP SDK for the PuntoPost parcel delivery API

Maintainers

Package info

github.com/puntopost/php-sdk

Homepage

pkg:composer/puntopost/php-sdk

Statistics

Installs: 810

Dependents: 0

Suggesters: 0

Stars: 2

Open Issues: 0

v1.1.0 2026-04-11 20:58 UTC

This package is auto-updated.

Last update: 2026-05-11 21:16:44 UTC


README

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

CI Latest Version PHP Version License

Table of contents

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 paginationgetNext() returns a ready-to-use ListPudosRequest built automatically from the API's next-page URL. Pass it directly to the next call:

$response = $client->merchant()->listPudos(
    ListPudosRequest::byPostalCode('06600', 5)
);

while ($response->getNext() !== null) {
    $response = $client->merchant()->listPudos($response->getNext());

    foreach ($response->getItems() as $pudo) {
        echo $pudo->getName() . PHP_EOL;
    }
}

You can also start pagination manually by passing a Pagination cursor as the third argument:

$response = $client->merchant()->listPudos(
    ListPudosRequest::byPostalCode(
        '06600',             // postalCode
        5,                   // radiusKm (optional)
        new Pagination(0, 5) // cursor (optional)
    )
);

Get PUDO details

Parameter Type Required Description
id string Yes PUDO ID
use PuntoPost\Sdk\V1\Request\GetPudoRequest;

$response = $client->merchant()->getPudo(new GetPudoRequest(
    'PUDO_ID' // id
));
$pudo = $response->getDetail();

echo $pudo->getId();                          // PUDO ID
echo $pudo->getExternalId();                  // Short id to display
echo $pudo->getType();                        // 'pudo', 'logistic' or 'merchant'
echo $pudo->getName();                        // display name
echo $pudo->getSchedule();                    // opening hours as free text
echo $pudo->getPhone();                       // phone number
echo $pudo->isEnabled() ? 'active' : 'inactive';
echo $pudo->getCreatedAt()->format('Y-m-d');  

foreach ($pudo->getScheduleItems() as $item) { // structured schedule
    echo $item->getDay();   // 'mon', 'tue', ... 'sun'
    echo $item->getStart(); // e.g. '09:00'
    echo $item->getEnd();   // e.g. '18:00'
}

echo $pudo->getAddress()->getPostalCode();
echo $pudo->getAddress()->getCity();
echo $pudo->getAddress()->getAddress();       // street and number
$coordinate = $pudo->getAddress()->getCoordinate();
echo $coordinate->getLatitude();
echo $coordinate->getLongitude();

Create a C2C parcel (Consumer to Consumer)

A customer drops off the parcel at an origin PUDO and another customer picks it up at a destination PUDO.

CreateC2CParcelRequest

Parameter Type Required Description
merchantId string Yes Your Merchant ID
content ParcelContentData Yes Description and optional content details
sender PersonData Yes Customer dropping off the parcel
receiver PersonData Yes Customer picking up the parcel
destinationId string Yes PUDO ID where the receiver will collect

ParcelContentData

Parameter Type Required Description
description string Yes Short description of the parcel contents
declaredValue DeclaredValue No Declared monetary value. See DeclaredValue fields below
imageUrl string No URL of an image representing the contents
weightKg float No Weight of the parcel in kilograms

DeclaredValue — use the named constructor DeclaredValue::mxn(amount) instead of instantiating directly.

Field Type Required Description
value float Yes Monetary amount (e.g. 250.0)
currency string Yes Currency code. Currently only 'MXN' is supported via DeclaredValue::mxn(value)

PersonData

Parameter Type Required Description
firstName string Yes First name
lastName string Yes Last name
email string Yes Contact email address
phone string No Contact phone number (e.g. +525512345678)
postalCode string No Postal code of the person's address
use PuntoPost\Sdk\V1\Request\CreateC2CParcelRequest;
use PuntoPost\Sdk\V1\Request\DTO\DeclaredValue;
use PuntoPost\Sdk\V1\Request\DTO\ParcelContentData;
use PuntoPost\Sdk\V1\Request\DTO\PersonData;

$request = new CreateC2CParcelRequest(
    'MERCHANT_ID',                         // merchantId
    new ParcelContentData(
        'Programming book',                // description 
        DeclaredValue::mxn(250.0),         // declaredValue (optional)
        'https://example.com/img.jpg',     // imageUrl (optional)
        1.2                                // weightKg (optional)
    ),
    new PersonData(                        // sender
        'Juan',                            //   firstName
        'García',                          //   lastName
        'juan@example.com',                //   email
        '+525512345678',                   //   phone (optional)
        '06600'                            //   postalCode (optional)
    ),
    new PersonData(                        // receiver
        'Ana',                             //   firstName
        'López',                           //   lastName
        'ana@example.com',                 //   email
        '+525587654321',                   //   phone (optional)
        '44100'                            //   postalCode (optional)
    ),
    'DESTINATION_PUDO_ID'                  // destinationId — Pudo ID
);

$response = $client->merchant()->createC2CParcel($request);
$parcel   = $response->getDetail();

echo $parcel->getId();
echo $parcel->getTracking();

The returned Parcel object 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

ParcelContentData and PersonData fields are the same as in C2C.

use PuntoPost\Sdk\V1\Request\CreateB2CParcelRequest;
use PuntoPost\Sdk\V1\Request\DTO\DeclaredValue;
use PuntoPost\Sdk\V1\Request\DTO\ParcelContentData;
use PuntoPost\Sdk\V1\Request\DTO\PersonData;

$request = new CreateB2CParcelRequest(
    'MERCHANT_ID',                      // merchantId
    new ParcelContentData(
        'Smartphone',                   // description
        DeclaredValue::mxn(3500.0)      // declaredValue (optional)
    ),
    new PersonData(                     // receiver
        'María', 'Pérez',
        'maria@example.com',
        '+525511223344'                 // phone (optional)
    ),
    'ORIGIN_PUDO_ID',                   // originId - Your PUDO ID
    'DESTINATION_PUDO_ID'               // destinationId - PUDO ID
);

$response = $client->merchant()->createB2CParcel($request);
$parcel   = $response->getDetail();

The returned Parcel object 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

ParcelContentData and PersonData fields are the same as in C2C.

use PuntoPost\Sdk\V1\Request\CreateC2BParcelRequest;
use PuntoPost\Sdk\V1\Request\DTO\ParcelContentData;
use PuntoPost\Sdk\V1\Request\DTO\PersonData;

$request = new CreateC2BParcelRequest(
    'MERCHANT_ID',                  // merchantId
    new ParcelContentData(
        'Product return'            // description
    ),
    new PersonData(                 // sender
        'Carlos', 'Ruiz',
        'carlos@example.com'
    ),
    'DESTINATION_PUDO_ID'           // destinationId - Your PUDO ID
);

$response = $client->merchant()->createC2BParcel($request);
$parcel   = $response->getDetail();

The returned Parcel object contains exactly the same fields as the response from Get parcel details.

Get parcel details

Parameter Type Required Description
identifier string Yes Parcel ID, tracking number, or label — any of the three is accepted
use PuntoPost\Sdk\Exception\PuntoPostException;
use PuntoPost\Sdk\V1\Request\GetParcelRequest;

try {
    $response = $client->merchant()->getParcel(new GetParcelRequest(
        'MXT0000000001' // identifier
    ));
    $parcel = $response->getDetail();

    // identifiers & tracking
    echo $parcel->getId();           // parcel ID
    echo $parcel->getTracking();     // tracking number
    echo $parcel->getQrTracking();   // URL of the PNG QR code for tracking
    echo $parcel->getLabel();        // label identifier (nullable)
    echo $parcel->getQrLabel();      // URL of the PNG QR code for the label (nullable)

    // dates
    echo $parcel->getCreatedAt()->format('Y-m-d H:i:s');
    $expireAt = $parcel->getExpireAt();
    echo $expireAt !== null ? $expireAt->format('Y-m-d H:i:s') : 'no expiry'; // nullable

    // status
    echo $parcel->getStatus()->getValue();

    // content
    echo $parcel->getContent()->getDescription();
    echo $parcel->getContent()->getWeightKg();      // nullable
    echo $parcel->getContent()->getImageUrl();       // nullable
    $declaredValue = $parcel->getContent()->getDeclaredValue(); // nullable
    if ($declaredValue !== null) {
        echo $declaredValue->getValue();    // e.g. 250.0
        echo $declaredValue->getCurrency(); // e.g. 'MXN'
    }

    // sender
    echo $parcel->getSender()->getFirstName();
    echo $parcel->getSender()->getLastName();
    echo $parcel->getSender()->getEmail();
    echo $parcel->getSender()->getPhone();      // nullable
    echo $parcel->getSender()->getPostalCode(); // nullable

    // receiver
    echo $parcel->getReceiver()->getFirstName();
    echo $parcel->getReceiver()->getLastName();
    echo $parcel->getReceiver()->getEmail();
    echo $parcel->getReceiver()->getPhone();      // nullable
    echo $parcel->getReceiver()->getPostalCode(); // nullable

    // origin PUDO (nullable — absent on B2C parcels)
    $origin = $parcel->getOrigin();
    if ($origin !== null) {
        echo $origin->getId();
        echo $origin->getExternalId();
        echo $origin->getType();              // 'pudo', 'logistic' or 'merchant'
        echo $origin->getName();
        echo $origin->getDescription();
        echo $origin->getSchedule();
        echo $origin->getPhone();
        echo $origin->isEnabled() ? 'active' : 'inactive';
        echo $origin->getCreatedAt()->format('Y-m-d');
        foreach ($origin->getScheduleItems() as $item) {
            echo $item->getDay() . ': ' . $item->getStart() . '-' . $item->getEnd();
        }
        echo $origin->getAddress()->getPostalCode();
        echo $origin->getAddress()->getCity();
        echo $origin->getAddress()->getAddress();
        $originCoord = $origin->getAddress()->getCoordinate();
        if ($originCoord !== null) {
            echo $originCoord->getLatitude();
            echo $originCoord->getLongitude();
        }
    }

    // destination PUDO
    $destination = $parcel->getDestination();
    echo $destination->getId();
    echo $destination->getExternalId();
    echo $destination->getType();              // 'pudo', 'logistic' or 'merchant'
    echo $destination->getName();
    echo $destination->getDescription();
    echo $destination->getSchedule();
    echo $destination->getPhone();
    echo $destination->isEnabled() ? 'active' : 'inactive';
    echo $destination->getCreatedAt()->format('Y-m-d');
    foreach ($destination->getScheduleItems() as $item) {
        echo $item->getDay() . ': ' . $item->getStart() . '-' . $item->getEnd();
    }
    echo $destination->getAddress()->getPostalCode();
    echo $destination->getAddress()->getCity();
    echo $destination->getAddress()->getAddress();
    $destCoord = $destination->getAddress()->getCoordinate();
    if ($destCoord !== null) {
        echo $destCoord->getLatitude();
        echo $destCoord->getLongitude();
    }

    // status history (chronological list of status transitions)
    foreach ($parcel->getStatusHistory() as $entry) {
        echo $entry->getStatus()->getValue();        // status at that point in time
        echo $entry->getWhen()->format('Y-m-d H:i:s'); // when the transition happened
    }
} catch (PuntoPostException $e) {
    echo $e->getStatusCode(); // 401, 403, 404, etc.
}

The ParcelStatus object returned by getStatus() provides typed helper methods to check each status:

use PuntoPost\Sdk\V1\Response\Model\Enum\ParcelStatus;

$status = $parcel->getStatus();

if ($status->isDelivered()) {
    echo 'Parcel delivered';
}

// Or compare the raw value against a constant
if ($status->getValue() === ParcelStatus::IN_ORIGIN_POINT) {
    echo 'At origin point';
}

Available statuses

Note: Statuses prefixed with RETURN_ and RETURN_FAIL_ only apply to C2C parcels. B2C and C2B shipments will never transition into those states.

Constant Description
CREATED Parcel registered; not yet at origin PUDO
IN_ORIGIN_POINT Parcel dropped off at the origin PUDO
IN_TRANSIT_DEPOT In transit between origin PUDO and sorting depot
IN_DEPOT Arrived at sorting depot
IN_TRANSIT_DESTINATION In transit from sorting depot to destination PUDO
IN_DESTINATION_POINT Arrived at destination PUDO; awaiting collection
IN_REROUTED_POINT Redirected to an alternative PUDO
DELIVERED Collected by the recipient — final state
RETURN_IN_DESTINATION_POINT Return initiated; parcel at the destination PUDO
RETURN_IN_TRANSIT_DEPOT Return in transit to sorting depot
RETURN_IN_DEPOT Return arrived at sorting depot
RETURN_IN_TRANSIT_ORIGIN Return in transit from depot to origin PUDO
RETURN_IN_ORIGIN_POINT Return arrived at origin PUDO
RETURN_IN_REROUTED_POINT Return redirected to an alternative PUDO
RETURN_DELIVERED Return collected by the merchant — final return state
RETURN_FAIL_IN_ORIGIN_POINT Return failed; parcel held at origin PUDO
RETURN_FAIL_IN_TRANSIT_DEPOT Return failed; parcel in transit to depot
RETURN_FAIL_IN_DEPOT Return failed; parcel held at depot
RETURN_FAIL_DELIVERED Return failed but delivered back — review required
INCIDENCE An issue has been flagged on this parcel
CANCELLED Parcel cancelled - final state
LOST Parcel reported as lost - final incidence state

Mark a parcel as ready for pickup

Notifies the system that the parcel is prepared and ready to be collected at the origin PUDO. Only valid when the parcel is in created status.

Note: This action is only available with B2C shipments.

Parameter Type Required Description
identifier string Yes Parcel ID, tracking number, or label — any of the three is accepted
use PuntoPost\Sdk\V1\Request\MarkParcelReadyRequest;

$response = $client->merchant()->markParcelReady(new MarkParcelReadyRequest(
    'MXT0000000001' // identifier — parcel ID, tracking number, or label
));

echo $response->getStatusCode(); // 204
echo $response->isSuccess();     // true

Cancel a parcel

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

Parameter Type Required Description
identifier string Yes Parcel ID, tracking number, or label — any of the three is accepted
use PuntoPost\Sdk\Exception\PuntoPostException;
use PuntoPost\Sdk\V1\Request\CancelParcelRequest;

try {
    $response = $client->merchant()->cancelParcel(new CancelParcelRequest(
        'MXT0000000001' // identifier — parcel ID, tracking number, or label
    ));
    echo $response->getStatusCode(); // 204
} catch (PuntoPostException $e) {
    if ($e->getStatusCode() === 409) {
        // Parcel is already in transit or delivered — cannot be cancelled
        echo $e->getErrorType(); // e.g. STATUS_CONFLICT
    }
}

Webhooks

The SDK provides a WebhookHandler to parse incoming webhook payloads sent by the PuntoPost API to your application.

Pass the raw request body (JSON string) to parse() and get back a typed event object:

use PuntoPost\Sdk\V1\Webhook\WebhookHandler;
use PuntoPost\Sdk\V1\Webhook\Event\ParcelStatusChangedEvent;
use PuntoPost\Sdk\V1\Webhook\Event\ParcelOriginChangedEvent;
use PuntoPost\Sdk\V1\Webhook\Event\ParcelDestinationChangedEvent;
use PuntoPost\Sdk\V1\Webhook\Event\UnknownWebhookEvent;

$handler = new WebhookHandler();

// In plain PHP:
$event = $handler->parse(file_get_contents('php://input'));

// In Symfony / Laravel:
// $event = $handler->parse($request->getContent());

Handling known events

Use instanceof to determine the event type and access its typed data:

if ($event instanceof ParcelStatusChangedEvent) {
    echo $event->getId();                        // parcel ID
    echo $event->getTracking();                  // tracking number
    echo $event->getStatus()->getValue();        // e.g. 'in_destination_point'
    echo $event->getStatus()->isDelivered();     // false

    foreach ($event->getStatusHistory() as $entry) {
        echo $entry->getStatus()->getValue();
        echo $entry->getWhen()->format('Y-m-d H:i:s');
    }
}

if ($event instanceof ParcelOriginChangedEvent) {
    echo $event->getId();
    echo $event->getTracking();
    $origin = $event->getOrigin();              // PickUpDropOff
    echo $origin->getName();
    echo $origin->getAddress()->getPostalCode();
}

if ($event instanceof ParcelDestinationChangedEvent) {
    echo $event->getId();
    echo $event->getTracking();
    $destination = $event->getDestination();    // PickUpDropOff
    echo $destination->getName();
    echo $destination->getAddress()->getPostalCode();
}

Available event types

Event class event_type value Typed detail
ParcelStatusChangedEvent parcel_status_changed ParcelStatus + StatusHistoryEntry[]
ParcelOriginChangedEvent parcel_origin_changed PickUpDropOff (new origin)
ParcelDestinationChangedEvent parcel_destination_changed PickUpDropOff (new destination)

Unknown or future events

The JSON body must include event_type (string) and detail (object/array); otherwise parse() throws InvalidArgumentException.

If the API introduces new event types in the future, the SDK does not throw for unknown event_type values as long as those keys are present. The behaviour depends on the strategy you choose when creating the handler:

CAPTURE_UNKNOWN (default) — unknown events are returned as UnknownWebhookEvent, giving you access to the raw event_type and detail array:

$handler = new WebhookHandler(); // or explicit: new WebhookHandler(WebhookHandler::CAPTURE_UNKNOWN)

$event = $handler->parse($json);

if ($event instanceof UnknownWebhookEvent) {
    echo $event->getEventType(); // e.g. 'parcel_weight_updated'
    print_r($event->getDetail()); // raw associative array
}

IGNORE_UNKNOWN — unknown events are silently ignored and parse() returns null:

$handler = new WebhookHandler(WebhookHandler::IGNORE_UNKNOWN);

$event = $handler->parse($json); // null for unknown event types

Note: Invalid JSON, or a missing/invalid event_type / detail, will throw InvalidArgumentException regardless 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()
        );
    }
}