# Tracking

Two ways to follow a shipment: **poll** the `tracking` endpoint, or let the
platform **push** status changes to your endpoint. Push is the better
default — no polling loops, updates arrive as they happen.

The two channels use **different status vocabularies** — see
[Polling statuses](#polling-statuses) and [Webhook statuses](#webhook-statuses).
Don't share one status mapping between them.

## Polling — the `tracking` endpoint

Query by the **carrier waybill number** (returned by `order_send`):

```php
$tracking = $client->getTracking($waybillNumber);

foreach ($tracking->getEvents() as $event) {
    $event->getStatus();          // normalized status, e.g. IN_TRANSIT
    $event->getStatusOriginal();  // the carrier's raw status
    $event->getDescription();
    $event->getPlace();
    $event->getUpdatedAt();       // ISO 8601 in UTC, e.g. 2026-07-21T08:15:00+00:00
}
```

Each event carries both a **normalized status** (consistent across carriers)
and the carrier's original status. Build your logic on the normalized one;
show the original to support staff. Events are returned newest first.

Without the SDK: `POST tracking/<waybill_number>/` — see the
[API v2 reference](/reference/v2-legacy).

### Polling statuses

| Status | Meaning |
|---|---|
| `NEW` | Order created, the parcel is not with the carrier yet |
| `PICKUP` | Posted / picked up by the courier |
| `IN_TRANSIT` | On the way — includes out for delivery and waiting at a pickup point |
| `DELIVERED` | Delivered |
| `DELIVERED_NO_SYSTEM` | Delivered, confirmed outside the carrier's system |
| `EXCEPTION` | Something needs attention — failed delivery attempt, notice left, unrecognized carrier status |
| `RETURNED_TO_SHIPPER` | Returned to the sender (including parcels not collected in time) |
| `CANCELLED` | Cancelled |
| `CORRECTION` | Complaint / correction case — shown as cancelled in the panel |
| `UNKNOWN` | Status could not be determined |

## Push tracking webhooks

Tell the platform where to send status changes, in one of two ways:

- **Per order** — send `push_tracking_url` with the order. With the
  [SDK](/sdk): `OrderRequest::create()->...->setPushTrackingUrl('https://example.com/apaczka/tracking')`.
- **Per WebAPI app** — set a default tracking webhook URL in the WebAPI
  settings of the customer panel. It applies to every order created with
  that app's credentials; a per-order `push_tracking_url` takes precedence.

The platform then POSTs a **signed JSON notification** to that URL on every
status change. The notification is signed with the `app_id` / `app_secret`
of the WebAPI app that created the order — always verify the signature
before processing
([why](/guides/best-practices#verify-webhook-signatures)). With the
[SDK](/sdk):

```php
use AlsendoOne\SDK\Exception\WebhookVerificationException;
use AlsendoOne\SDK\Webhook\PushTrackingWebhook;

$webhook = new PushTrackingWebhook('your_app_id', 'your_app_secret');

try {
    $notification = $webhook->parse(file_get_contents('php://input'));
} catch (WebhookVerificationException $e) {
    http_response_code(400);   // signature mismatch — do not process
    exit;
}

foreach ($notification->getStatuses() as $status) {
    updateShipmentStatus(
        $notification->getOrderNumber(),   // the order id returned by order_send
        $status->getStatus(),              // e.g. DELIVERED
        $status->getUpdatedAt()
    );
}

http_response_code(200);   // acknowledge the notification
```

### Payload

```json
{
  "signature": "3f1c…e9a0",
  "response": {
    "orderNumber": "123456789",
    "statuses": [
      {
        "status": "ON_THE_WAY",
        "updatedAt": "2026-10-06T00:45:00",
        "trackingOperatorStatuses": [
          {
            "status": "IT",
            "description": "In transit",
            "place": "Warszawa",
            "updatedAt": "2026-10-06T00:45:00"
          }
        ]
      }
    ]
  }
}
```

- `orderNumber` — the Apaczka order `id` (as returned by `order_send`), not
  the waybill number. Use it to match the notification to your order.
- `statuses[].status` — the normalized status, see
  [Webhook statuses](#webhook-statuses).
- `statuses[].trackingOperatorStatuses` — the carrier's original events
  behind that status (`status`, `description`, `place`, `updatedAt`).

### Verifying the signature without the SDK

The signature is HMAC-SHA256 over `{app_id}::{json}:` — the same scheme as
[request signing](/legacy/hmac-signing), with the route and expiry left
empty — where `{json}` is the `response` object re-encoded with unescaped
unicode, slashes and line terminators:

```php
$body = json_decode(file_get_contents('php://input'), true);

$json = json_encode(
    $body['response'] ?? null,
    JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_LINE_TERMINATORS
);
$expected = hash_hmac('sha256', sprintf('%s::%s:', $appId, $json), $appSecret);

if (!is_string($body['signature'] ?? null) || !hash_equals($expected, $body['signature'])) {
    http_response_code(400);   // signature mismatch — do not process
    exit;
}
```

The encoding flags matter: without them, Polish characters and slashes are
escaped differently and the signature never matches.

### Webhook statuses

Webhooks use their own status set. The right-hand column is the closest
[polling status](#polling-statuses).

| Webhook status | Meaning | Polling equivalent |
|---|---|---|
| `NEW` | Order registered | `NEW` |
| `READY_TO_SEND` | Ready to be sent | `NEW` |
| `ADVISING` | — | `NEW` |
| `POSTED` | Posted / handed over to the carrier | `PICKUP` |
| `ON_THE_WAY` | On the way to the recipient | `IN_TRANSIT` |
| `OUT_FOR_DELIVERY` | Handed to the courier for delivery | `IN_TRANSIT` |
| `READY_TO_PICKUP` | Ready for collection at a pickup point | `IN_TRANSIT` |
| `DELIVERED` | Delivered | `DELIVERED` |
| `WAITING_FOR_PAYOUT` | Delivered, cash on delivery awaiting payout | `DELIVERED` |
| `SENT` | Cash on delivery paid out | `DELIVERED` |
| `AVIZO` | Delivery notice left (awizo) | `EXCEPTION` |
| `REMINDER_SENT` | Reminder sent to the recipient | `EXCEPTION` |
| `OTHER` | Carrier status not recognized | `EXCEPTION` |
| `UNKNOWN` | Status unknown | `EXCEPTION` |
| `PICKUP_EXPIRED` | Not collected in time | `RETURNED_TO_SHIPPER` |
| `RETURNED` | Returned to the sender | `RETURNED_TO_SHIPPER` |
| `CANCELED` | Cancelled | `CANCELLED` |
| `CLAIMED` | Complaint filed | `CORRECTION` |

Watch the spelling: the webhook sends `CANCELED` (one L), polling returns
`CANCELLED`. And `SENT` means a **COD payout**, not that the parcel was sent.

### Things to know

- **Respond with HTTP 200.** Until your endpoint returns 200 the platform
  keeps redelivering the notification; after a 200 the next one arrives
  with the next status change. Make your handler idempotent.
- Push tracking is registered only for supported carriers; `statuses` may be
  an empty array — acknowledge it with 200 and move on.
- Timestamps are `Y-m-d\TH:i:s` **without an offset, in Europe/Warsaw time**.
