# Own-fleet pickup and delivery

Own-fleet collection and last mile use the same APIs. Create a pickup or delivery order → print the local label → track → subscribe to event notifications → cancel a test order.

`type` selects the stop: `D` delivery, `P` pickup. Complete the steps in sequence. A rate quote is optional and is not required before create.

Replace `YOUR_HOST` and `ACCESS_TOKEN` with values from your environment.

## 1. Authentication

```bash
curl -X POST https://YOUR_HOST/api/v1/user/login \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com","password":"your_password"}'
```

Place the returned `access_token` in the request header:

```
Authorization: Bearer ACCESS_TOKEN
```

GraphQL uses the same header on `POST /api/graphql`.

[REST handbook](/api/documentation#/paths/v1-user-login/post) · [GraphQL handbook](/api/graphql/documentation#/user/userLogin)

**Verification:** login returns `access_token`. Subsequent requests without this token return `401`.

## 2. Quote (optional)

This step is optional. It returns a quote only; no order is created. Create does not require a prior quote. Set `type` to `D` (delivery) or `P` (pickup).

**REST:** `POST /api/v1/orders/rate` — [REST handbook](/api/documentation#/paths/v1-orders-rate/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/orders/rate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "D",
    "from_postcode": "H9S2H9",
    "from_country": "CA",
    "to_postcode": "H4B2T5",
    "to_country": "CA",
    "packages": [{
      "weight": 1,
      "weight_unit": 2,
      "length": 30,
      "width": 20,
      "height": 10,
      "dimension_unit": 2
    }]
  }'
```

`weight_unit`: `1` g, `2` kg, `3` oz, `4` lb. `dimension_unit`: `1` mm, `2` cm, `3` m, `4` in.

```json
{
  "result": true,
  "shipping_price": "6.99",
  "currency": "CAD",
  "price_details": {
    "shipping_fee": "6.99",
    "tax_details": [{ "tax_name": "HST", "tax_rate": "13.00", "tax": "0.91" }]
  }
}
```

**GraphQL** (JSON scalar — no selection set) (`ordersRate` ([GraphQL handbook](/api/graphql/documentation#/orders/ordersRate))):

```graphql
mutation {
  ordersRate(
    type: "D"
    from_postcode: "H9S2H9"
    from_country: "CA"
    to_postcode: "H4B2T5"
    to_country: "CA"
    packages: [{ weight: 1, weight_unit: 2, length: 30, width: 20, height: 10, dimension_unit: 2 }]
  )
}
```

**Verification:** if you run this call, `result` is true and `shipping_price` is a number. Repeat with `type` `P` to quote a pickup. An empty price means the postcode is not in an active region. Creation does not depend on this step.

## 3. Create the order

**REST:** `POST /api/v1/client/orderCreate` — [REST handbook](/api/documentation#/paths/v1-client-orderCreate/post)

The request must include `Idempotency-Key` so a retry cannot create a second order.

### Delivery (`type` `D`)

```bash
curl -X POST https://YOUR_HOST/api/v1/client/orderCreate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: dev-local-001" \
  -d '{
    "type": "D",
    "need_pick_up": 0,
    "ref": "DEV-LOCAL-001",
    "name": "Jane Recipient",
    "telephone": "5555555555",
    "email": "jane@example.com",
    "address_1": "2667 boul des sources",
    "city": "Pointe-Claire",
    "province": "QC",
    "postcode": "H9S2H9",
    "country": "Canada",
    "packages": 1,
    "packagesDetail": [{
      "ref": "PKG-001",
      "weight": 1,
      "weight_unit": 2,
      "length": 30,
      "width": 20,
      "height": 10,
      "dimension_unit": 2
    }],
    "delivery_instruction": "Leave at the side door"
  }'
```

### Pickup (`type` `P`)

Same endpoint. The address is the collection stop.

```bash
curl -X POST https://YOUR_HOST/api/v1/client/orderCreate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: dev-pickup-001" \
  -d '{
    "type": "P",
    "need_pick_up": 1,
    "ref": "DEV-PICKUP-001",
    "name": "Jane Sender",
    "telephone": "5555555555",
    "email": "jane@example.com",
    "address_1": "2667 boul des sources",
    "city": "Pointe-Claire",
    "province": "QC",
    "postcode": "H9S2H9",
    "country": "Canada",
    "packages": 1,
    "packagesDetail": [{
      "ref": "PKG-P-001",
      "weight": 1,
      "weight_unit": 2,
      "length": 30,
      "width": 20,
      "height": 10,
      "dimension_unit": 2
    }],
    "pickup_instruction": "Ring the side bell"
  }'
```

| Field | Meaning |
|---|---|
| `type` | `D` delivery, or `P` pickup |
| `need_pick_up` | `0` — already at the warehouse. `1` — a driver must collect the parcel |
| `ref` | External reference used for lookup and reconciliation |
| `name` / address | Delivery: recipient. Pickup: collection stop |
| `auto_deduplication` | `1` refuses a second package with the same package `ref` |

**GraphQL:** `clientOrderCreate` ([GraphQL handbook](/api/graphql/documentation#/client/clientOrderCreate)) (JSON scalar).

```json
{ "result": true, "id": 12345, "tracking_number": "SR123456789012", "orders_status_id": 2 }
```

`orders_status_id` `2` is New. Store `id`, `tracking_number`, and `ref`.

**Verification:** send the **same** body with the same `Idempotency-Key` again. You must get the same `id`, and must not create a second order.

The order is **not** created when:

| `code` | Handling |
|---|---|
| `INSUFFICIENT_BALANCE` | `insufficient_balance` contains required / available / shortfall. Recharge, then retry. |
| `OUT_OF_DELIVERY_AREA` | The address is outside the service area and the business deletes those orders. Submit an address within the service area. |
| `IDEMPOTENCY_CONFLICT` | The same idempotency key was reused with a different request body. Issue a new key. |

A kept out-of-area order can still return `result: true` with `shipping_price: null` and a `warning`. Read that field.

## 4. Retrieve the order

```bash
curl https://YOUR_HOST/api/v1/orders/list \
  -H "Authorization: Bearer ACCESS_TOKEN"

curl https://YOUR_HOST/api/v1/orders/12345 \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

**REST:** `GET /api/v1/orders/{orderId}` — [REST handbook](/api/documentation#/paths/v1-orders-orderId/get)

**GraphQL:** `orders` ([GraphQL handbook](/api/graphql/documentation#/orders/orders))

**Verification:** the order belongs to the authenticated account. `ref` matches the value you created. `tracking_number` matches step 3.

## 5. Print the local label

**REST:** `POST /api/v1/shipping/getShippingLabel` — [REST handbook](/api/documentation#/paths/v1-shipping-getShippingLabel/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/shipping/getShippingLabel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "SR123456789012",
    "type": "TRACKING_NUMBER",
    "base64": 1,
    "hide_sender_address": 0,
    "hide_receiver_address": 0
  }'
```

`type`: `TRACKING_NUMBER`, `ORDER_ID`, or `REF`. `base64: 0` (default) streams a PDF. `base64: 1` makes the whole response body a top-level JSON string holding the base64 PDF, not an object with a `pdf_data` field. Call `POST /api/v2/shipping/getShippingLabel` instead if you want the label inside a regular JSON object.

**GraphQL:** `shippingGetShippingLabel` ([GraphQL handbook](/api/graphql/documentation#/shipping/shippingGetShippingLabel)). `shippingGetShippingLabelV2` ([GraphQL handbook](/api/graphql/documentation#/shipping/shippingGetShippingLabelV2)) always returns JSON (`pdf_data`).

**Verification:** the PDF opens. A delivery label shows the recipient; a pickup label shows the collection address. A hidden address field is rendered blank on the label.

## 6. Track

Public tracking endpoint; no access token is required.

```bash
curl https://YOUR_HOST/api/v1/tracking/SR123456789012
```

[REST handbook](/api/documentation#/operations/getPublicTracking) · [GraphQL handbook](/api/graphql/documentation#/tracking/trackingPublic)

The same URL accepts your `ref` when it was stored as an external number.

**GraphQL** (typed — needs a selection set):

```graphql
query {
  trackingPublic(trackingNumber: "SR123456789012") {
    result
    deliveried
    returntosender
    rejectedbyrecipient
    postcode
    data {
      tracking_event_status_id
      tracking_event_key
      description
      updated_at_localized
    }
    proofs { file_id type full_url signed_url }
  }
}
```

Branch on `tracking_event_status_id`, not on `description` (that string follows `Accept-Language`):

| `tracking_event_status_id` | Side | Meaning |
|---|---|---|
| `100` | both | Order received |
| `300` / `301` | delivery | In facility |
| `450` | delivery | Out for delivery |
| `500` | delivery | Delivered |
| `501` | delivery | Delivery failed, needs a new plan |
| `460` | pickup | Out for pickup |
| `510` | pickup | Picked up |
| `512` | pickup | Pickup failed, try later |
| `513` | pickup | Pickup problem |

`data` is newest first. Treat the first row as current. `deliveried: true` after `500`.

On `500` or `510`, `proofs[]` may carry `type` `1` (signature) or `2` (photo), plus `file_id` and `signed_url`. A photo uploaded after that event is not on that payload — subscribe to `pod.files_updated` in step 7.

**Verification:** right after create, the newest event is `100` and `deliveried` is false. An unknown number is `result: false` / 404 — display a not-found state; do not synthesize tracking events.

## 7. Configure event notifications

Configure the callback URLs required by this flow:

| Setting | Event | When |
|---|---|---|
| `order_create_webhook_url` | `order.created` | Persist `id` and `tracking_number` |
| `order_status_change_webhook_url` | `order.status_change` | Customer-facing status |
| `tracking_event_webhook_url` | `tracking.event` | Pickup or delivery timeline |
| `pod_files_webhook_url` | `pod.files_updated` | Photo/signature after pickup or delivery |
| `order_cancel_failed_webhook_url` | `order.cancel_failed` | A cancel you sent was refused |

**REST:** `PUT /api/v1/webhook-settings` — [REST handbook](/api/documentation#/paths/v1-webhook-settings/put)

**GraphQL:** `webhookSettingsUpdate` ([GraphQL handbook](/api/graphql/documentation#/webhooks/webhookSettingsUpdate)).

```bash
curl -X PUT https://YOUR_HOST/api/v1/webhook-settings \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "order_create_webhook_url": "https://erp.example.com/hooks/superroute",
    "order_status_change_webhook_url": "https://erp.example.com/hooks/superroute",
    "tracking_event_webhook_url": "https://erp.example.com/hooks/superroute",
    "webhook_sign_secret": "YOUR_LONG_RANDOM_SECRET",
    "webhook_verify_ssl": 1
  }'
```

Verify **v2** over the raw body: `HMAC_SHA256(timestamp + "." + raw_body, secret)` against `X-Webhook-Signature-V2`. Deduplicate on `X-Webhook-Event-Id`. Answer **2xx in under 3 seconds**.

```php
$raw = file_get_contents('php://input');
$ts  = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE_V2'] ?? '';
$expected = hash_hmac('sha256', $ts . '.' . $raw, $sharedSecret);
if (!hash_equals($expected, $sig)) {
    http_response_code(401);
    exit;
}
```

**Verification:** create one test order and see `order.created` with the same `id` / `tracking_number`. An invalid signature must be rejected by the receiver with `401`. A second delivery of the same `X-Webhook-Event-Id` must not be processed twice.

## 8. Cancel a test order

**REST:** `POST /api/v1/orders/cancel` — [REST handbook](/api/documentation#/paths/v1-orders-cancel/post) — exactly one of `order_id`, `tracking_number`, `external_tracking_number`.

```bash
curl -X POST https://YOUR_HOST/api/v1/orders/cancel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tracking_number":"SR123456789012"}'
```

Already cancelled: `200` with `already_cancelled: true`. **GraphQL:** `ordersCancel` ([GraphQL handbook](/api/graphql/documentation#/orders/ordersCancel)).

**Verification:** public tracking no longer treats the shipment as active. If cancel is refused (`409`, for example `ORDER_ALREADY_IN_DELIVERY`), `order.cancel_failed` fires.

## 9. Batch create (optional)

- `POST /api/v1/client/batchOrderCreate` — [REST handbook](/api/documentation#/paths/v1-client-batchOrderCreate/post) — waits until every row has been processed.
- `POST /api/v1/client/batchOrderCreateAsync` — [REST handbook](/api/documentation#/paths/v1-client-batchOrderCreateAsync/post) — returns a job identifier immediately; poll `GET /api/v1/client/async/{id}` — [REST handbook](/api/documentation#/paths/v1-client-async-id/get) or take `order.create_async`.

Same fields as step 3, as an array of orders. Each row may be `type` `D` or `P`. **GraphQL:** `clientBatchOrderCreate` ([GraphQL handbook](/api/graphql/documentation#/client/clientBatchOrderCreate)) / `clientBatchOrderCreateAsync` ([GraphQL handbook](/api/graphql/documentation#/client/clientBatchOrderCreateAsync)).

**Verification:** each row has its own `result`. Use the asynchronous endpoint when submitting more than approximately 100 rows.

## Test checklist

Use a test `ref` such as `DEV-LOCAL-001` / `DEV-PICKUP-001`:

- [ ] (Optional) Quote returns a price for an in-area postcode with `type` `D`.
- [ ] (Optional) Quote returns a price for an in-area postcode with `type` `P`.
- [ ] Delivery create returns `id` + `tracking_number`; the same `Idempotency-Key` does not create a second order.
- [ ] Pickup create returns `id` + `tracking_number`; `need_pick_up` is `1`.
- [ ] List / detail shows the order under this account.
- [ ] Local label PDF opens and shows the recipient or the collection address.
- [ ] Public tracking returns the timeline without a token; newest event is `100`.
- [ ] `order.created` arrives; v2 signature verifies.
- [ ] Cancel returns `result: true` (or `already_cancelled: true` on retry).
