{
  "openapi": "3.0.0",
  "paths": {
    "/auth/me": {
      "get": {
        "operationId": "AuthController_me_v1",
        "parameters": [],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Get current authenticated user profile",
        "tags": [
          "Company API: Your key"
        ],
        "description": "**Company key:** any company key may call this.",
        "x-parcelops-scope": null
      }
    },
    "/public/host-config": {
      "get": {
        "operationId": "CustomDomainsController_hostConfig_v1",
        "parameters": [
          {
            "name": "host",
            "required": true,
            "in": "query",
            "description": "The browser's window.location.hostname",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "404": {
            "description": "No courier has this host (the same answer for every host that is not a courier's)"
          }
        },
        "summary": "The portal config (what /public/{slug}/config returns) of the courier whose custom domain is exactly this host",
        "tags": [
          "Public (no key needed)"
        ],
        "security": []
      }
    },
    "/customer/me": {
      "get": {
        "description": "Your user, your customer account (`linkedCustomer`) and the courier you book with: `tenant.name` is the courier company's trading name (nothing else about the courier is returned), for example to show \"Connected to <courier>\" in a shop plugin.",
        "operationId": "CustomerPortalController_getMe_v1",
        "parameters": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "id": "9cd23164-8242-4b41-bca8-bfa2b57d586a",
                  "email": "thabo@acmetrading.example",
                  "firstName": "Thabo",
                  "lastName": "Mokoena",
                  "phone": null,
                  "createdAt": "2026-09-28T18:21:20.981Z",
                  "linkedCustomer": {
                    "id": "41a8bc60-faff-4a1c-ae4f-3ef7f294052f",
                    "accountNumber": "ACC-1001",
                    "companyName": "Acme Trading (Pty) Ltd",
                    "firstName": null,
                    "lastName": null,
                    "email": "accounts@acmetrading.example",
                    "phone": "+27 11 000 0000",
                    "customerType": "ACCOUNT",
                    "paymentTermDays": 30,
                    "billingCycle": "MONTHLY",
                    "creditLimit": null,
                    "vatNumber": "4000000000",
                    "billingAddress": "12 Sample Avenue",
                    "billingCity": "Johannesburg",
                    "billingProvince": "Gauteng",
                    "billingPostal": "2001",
                    "group": {
                      "id": "561c424c-3fd5-41d9-8a5a-8023bd5ca2a3",
                      "name": "Acme Holdings"
                    }
                  },
                  "tenant": {
                    "name": "Ace Couriers"
                  }
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Get my profile and linked customer account",
        "tags": [
          "Account & users"
        ]
      },
      "patch": {
        "operationId": "CustomerPortalController_updateProfile_v1",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateProfileDto"
              },
              "example": {
                "firstName": "Thabo",
                "lastName": "Mokoena"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "id": "9cd23164-8242-4b41-bca8-bfa2b57d586a",
                  "email": "thabo@acmetrading.example",
                  "firstName": "Thabo",
                  "lastName": "Mokoena",
                  "phone": null
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Update my profile (name, phone)",
        "tags": [
          "Account & users"
        ]
      }
    },
    "/customer/shipments": {
      "get": {
        "operationId": "CustomerPortalController_getShipments_v1",
        "parameters": [
          {
            "name": "page",
            "required": false,
            "in": "query",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "limit",
            "required": false,
            "in": "query",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "search",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "dateFrom",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "dateTo",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "customerReference",
            "required": false,
            "in": "query",
            "description": "Only the shipments with exactly this customerReference (the whole reference, in any case): look a shop order up by its number.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "serviceLevel",
            "required": false,
            "in": "query",
            "schema": {
              "enum": [
                "SAME_DAY",
                "OVERNIGHT",
                "ECONOMY",
                "ECONOMY_REGIONAL",
                "ECONOMY_ROAD",
                "BUDGET",
                "INTERNATIONAL"
              ],
              "type": "string"
            }
          },
          {
            "name": "status",
            "required": false,
            "in": "query",
            "schema": {
              "enum": [
                "DRAFT",
                "CREATED",
                "COLLECTION_ASSIGNED",
                "COLLECTED",
                "CHECKED_IN",
                "AT_HUB",
                "IN_TRANSIT",
                "AT_DESTINATION_HUB",
                "OUT_FOR_DELIVERY",
                "DELIVERED",
                "PARTIALLY_DELIVERED",
                "FAILED_DELIVERY",
                "RETURNED_TO_HUB",
                "REDELIVERY_SCHEDULED",
                "RETURNED_TO_SENDER",
                "LOST",
                "CANCELLED",
                "HELD"
              ],
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "items": [
                    {
                      "id": "c6459b89-8531-4ec5-b15d-908549caa221",
                      "waybillNumber": "DCS-002199",
                      "status": "OUT_FOR_DELIVERY",
                      "serviceLevel": "OVERNIGHT",
                      "createdAt": "2026-09-29T05:41:57.702Z",
                      "slaDeadline": "2026-09-30T15:00:00.000Z",
                      "slaBreached": false,
                      "recipientName": "Thandi Mokoena",
                      "recipientStreet": "83 Sample Road",
                      "recipientSuburb": "Sandton",
                      "recipientCity": "Johannesburg",
                      "recipientProvince": "Gauteng",
                      "senderStreet": "12 Sample Avenue",
                      "senderSuburb": "Marshalltown",
                      "senderCity": "Johannesburg",
                      "chargeableWeightKg": "6",
                      "totalCharge": "178.7",
                      "customerReference": "PO-2026-0042",
                      "externalOrderRef": null,
                      "pod": null,
                      "pieces": [
                        {
                          "pieceNumber": 1,
                          "actualWeightKg": 4.5,
                          "volumetricWeightKg": null,
                          "lengthCm": 40,
                          "widthCm": 30,
                          "heightCm": 25,
                          "deliveredAt": null
                        }
                      ],
                      "trackingUrl": "https://demo-couriers.parcelops.co.za/online/track?q=DCS-002199"
                    }
                  ],
                  "total": 141,
                  "page": 1,
                  "limit": 1,
                  "pages": 141
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "List my shipments",
        "tags": [
          "Shipments"
        ],
        "description": "Your shipments, newest first (by creation). Filters combine. `search` matches the waybill number, your `customerReference` and the recipient name (partial, case-insensitive): use it to find a shipment's `id` from either number. `dateFrom` / `dateTo` (`YYYY-MM-DD`) filter on creation date, `dateTo` inclusive. See *Pagination*. `status` takes any shipment status, `PARTIALLY_DELIVERED` included (see *Partial delivery*). Each of `pieces[]` carries `pieceNumber` and `deliveredAt` (null until that parcel is delivered). `customerReference` keeps only the shipments whose `customerReference` is exactly that (the whole reference, in any case; part of one matches nothing): the way to find a shop order's shipment by its number. Each item also carries `trackingUrl` (the courier's public tracking page for it, the link the webhooks carry, to show your customer) and `externalOrderRef` (your shop's order, or null)."
      }
    },
    "/customer/shipments/status-summary": {
      "get": {
        "operationId": "CustomerPortalController_getShipmentStatusSummary_v1",
        "parameters": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": [
                  {
                    "status": "COLLECTED",
                    "count": 1
                  },
                  {
                    "status": "AT_HUB",
                    "count": 1
                  },
                  {
                    "status": "IN_TRANSIT",
                    "count": 1
                  }
                ]
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Get active shipment counts grouped by status",
        "tags": [
          "Shipments"
        ],
        "description": "How many of your shipments are at each status, counting only shipments still on the move: `DELIVERED`, `CANCELLED`, `RETURNED_TO_SENDER` and `LOST` are left out."
      }
    },
    "/customer/shipments/{id}": {
      "get": {
        "operationId": "CustomerPortalController_getShipment_v1",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The shipment id: the UUID ParcelOps assigns (`shipmentId` in webhooks, `id` in list responses). A waybill number or your own reference is not accepted here; look those up with `GET /customer/shipments?search=`."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "id": "85895757-1342-4b42-86a6-66fdc2be2b58",
                  "waybillNumber": "DCS-002186",
                  "status": "DELIVERED",
                  "serviceLevel": "OVERNIGHT",
                  "customerReference": "PO-82785",
                  "createdAt": "2026-09-28T05:45:00.000Z",
                  "collectionDate": "2026-09-28T06:25:00.000Z",
                  "deliveredAt": "2026-09-29T05:41:58.018Z",
                  "slaDeadline": "2026-09-29T15:25:00.000Z",
                  "slaBreached": false,
                  "senderName": "Acme Trading (Pty) Ltd",
                  "senderCompany": "Acme Trading (Pty) Ltd",
                  "senderPhone": "+27 11 000 0000",
                  "senderEmail": null,
                  "senderStreet": "12 Sample Avenue",
                  "senderComplex": null,
                  "senderSuburb": "Marshalltown",
                  "senderCity": "Johannesburg",
                  "senderProvince": "Gauteng",
                  "senderPostalCode": "2001",
                  "recipientName": "Thandi Mokoena",
                  "recipientCompany": "Sample Electronics (Pty) Ltd",
                  "recipientPhone": "+27 82 000 0000",
                  "recipientEmail": null,
                  "recipientStreet": "83 Sample Road",
                  "recipientComplex": null,
                  "recipientSuburb": "Sandton",
                  "recipientCity": "Johannesburg",
                  "recipientProvince": "Gauteng",
                  "recipientPostalCode": "2196",
                  "pieces": [
                    {
                      "pieceNumber": 1,
                      "actualWeightKg": 0.8,
                      "volumetricWeightKg": null,
                      "lengthCm": 40,
                      "widthCm": 30,
                      "heightCm": 20,
                      "deliveredAt": "2026-09-29T05:41:58.018Z"
                    },
                    {
                      "pieceNumber": 2,
                      "actualWeightKg": 11.8,
                      "volumetricWeightKg": null,
                      "lengthCm": null,
                      "widthCm": null,
                      "heightCm": null,
                      "deliveredAt": "2026-09-29T05:41:58.018Z"
                    }
                  ],
                  "actualWeightKg": "12.6",
                  "volumetricWeightKg": "16.6",
                  "chargeableWeightKg": "16.6",
                  "baseCharge": "473.1",
                  "surchargesTotal": "21.29",
                  "totalCharge": "494.39",
                  "vatAmount": "74.16",
                  "surcharges": [
                    {
                      "code": "FUEL",
                      "description": "Fuel surcharge (4.5%)",
                      "amount": "21.29"
                    }
                  ],
                  "events": [
                    {
                      "status": "CREATED",
                      "description": "Waybill created",
                      "hubCode": null,
                      "createdAt": "2026-09-28T05:45:00.000Z"
                    },
                    {
                      "status": "COLLECTED",
                      "description": "Collected from sender",
                      "hubCode": null,
                      "createdAt": "2026-09-28T06:25:00.000Z"
                    },
                    {
                      "status": "CHECKED_IN",
                      "description": "Checked in at Johannesburg Hub",
                      "hubCode": "JNB",
                      "createdAt": "2026-09-28T08:15:00.000Z"
                    },
                    {
                      "status": "OUT_FOR_DELIVERY",
                      "description": "Out for delivery with Sipho Dlamini",
                      "hubCode": null,
                      "createdAt": "2026-09-28T08:15:01.000Z"
                    },
                    {
                      "status": "DELIVERED",
                      "description": "Delivered — signed by Thandi Mokoena, confirmed manually (no scan)",
                      "hubCode": null,
                      "createdAt": "2026-09-29T05:41:58.019Z"
                    }
                  ],
                  "pod": {
                    "recipientName": "Thandi Mokoena",
                    "capturedAt": "2026-09-29T05:41:58.013Z"
                  },
                  "documents": [],
                  "specialInstructions": null,
                  "declaredValue": null
                }
              }
            }
          },
          "400": {
            "description": "Bad Request",
            "content": {
              "application/json": {
                "example": {
                  "message": "Validation failed (uuid is expected)",
                  "error": "Bad Request",
                  "statusCode": 400
                }
              }
            }
          },
          "404": {
            "description": "Not Found",
            "content": {
              "application/json": {
                "example": {
                  "message": "Shipment not found",
                  "error": "Not Found",
                  "statusCode": 404
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Get shipment detail + tracking timeline",
        "tags": [
          "Shipments"
        ],
        "description": "One shipment with its tracking timeline (`events`, oldest first), pieces, surcharges, POD and documents.\n\nThe fields below are the contract. The response also carries other fields used by ParcelOps's own screens; they are not part of the contract and may change without notice, so do not depend on them.\n\n`id`, `waybillNumber`, `status`, `serviceLevel`, `customerReference`, `createdAt`, `collectionDate`, `deliveredAt`, `slaDeadline`, `slaBreached`, \nsender and recipient `…Name` `…Company` `…Phone` `…Email` `…Street` `…Complex` `…Suburb` `…City` `…Province` `…PostalCode`, \n`pieces[]` (`pieceNumber`, `actualWeightKg`, `lengthCm`, `widthCm`, `heightCm`, `deliveredAt`), `actualWeightKg`, `volumetricWeightKg`, `chargeableWeightKg`, \n`baseCharge`, `surchargesTotal`, `totalCharge` (excl. VAT), `vatAmount`, `surcharges[]` (`code`, `description`, `amount`), \n`events[]` (`status`, `description`, `hubCode`, `createdAt`), `pod` (`recipientName`, `capturedAt`, or `null` until the shipment is `DELIVERED`), `documents[]`, `specialInstructions`, `declaredValue`, \n`trackingUrl` (the courier's public tracking page for the shipment, the same link the webhooks carry), and what a booking sent: `source` (`API`, `WOOCOMMERCE`, `SHOPIFY`, `BULK_UPLOAD` for a shipment booked from a spreadsheet uploaded in the customer portal, or `null` for any other shipment not booked through the API), `externalOrderRef` and `externalOrderUrl` (or `null`).\n\nWeights and money are decimal strings; timestamps are ISO 8601 UTC.\n\nA shipment the courier had **no rate** for when it was booked has **no price yet**: `baseCharge`, `surchargesTotal`, `totalCharge` and `vatAmount` are `null`, `surcharges` is empty, and `priceStatus` is `\"PENDING\"`. Once the courier prices it (or decides there is no charge) they are the price (`\"0\"` for no charge) and `priceStatus` is `\"PRICED\"`. A shipment that always had a rate carries no `priceStatus`.\n\nA piece's `deliveredAt` is when that parcel was delivered, or `null` while it is not; every parcel of a `DELIVERED` shipment has one. A `DELIVERED` shipment's own `deliveredAt` is the day it counts as delivered on. The courier can correct it when the signed proof reached them days late; the corrected day is what this returns from then on, while the shipment's timeline line for the delivery keeps the time it was recorded, and no second `shipment.delivered` webhook is sent for a correction. A shipment with `status: \"PARTIALLY_DELIVERED\"` has some parcels delivered and the rest still on their way (see *Partial delivery*).\n\nA courier can **delete** a shipment that should never have been made (never delivered or invoiced). **No e-mail is sent, and no `shipment.*` event; if you subscribed to `booking.cancelled` it is sent once for the booking**: the shipment id then answers `404`, it is no longer listed by `GET /customer/shipments`, tracking by its link or number answers `404`, and the booking it came from reads `CANCELLED` on `GET /customer/bookings`. The same `customerReference` can then be booked again, which makes a new shipment with a new id. If you hold a shipment id that now answers `404`, check `GET /customer/bookings` before assuming an error."
      }
    },
    "/customer/shipments/{id}/pod": {
      "get": {
        "operationId": "CustomerPortalController_getPod_v1",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The shipment id: the UUID ParcelOps assigns (`shipmentId` in webhooks, `id` in list responses). A waybill number or your own reference is not accepted here; look those up with `GET /customer/shipments?search=`."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "signatureUrl": "https://…/parcelops-pods/…/signature.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260929T054549Z&X-Amz-Expires=3600&X-Amz-Signature=…&X-Amz-SignedHeaders=host",
                  "capturedAt": "2026-09-29T05:41:58.013Z",
                  "recipientName": "Thandi Mokoena",
                  "photoUrls": [
                    "https://…/parcelops-pods/…/photo-….jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260929T054549Z&X-Amz-Expires=3600&X-Amz-Signature=…&X-Amz-SignedHeaders=host"
                  ]
                }
              }
            }
          },
          "404": {
            "description": "Not Found",
            "content": {
              "application/json": {
                "example": {
                  "message": "POD not found",
                  "error": "Not Found",
                  "statusCode": 404
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Get POD signature URL for a delivered shipment",
        "tags": [
          "Shipments"
        ],
        "description": "Proof of delivery for a delivered shipment.\n\n- `signatureUrl` is a pre-signed HTTPS link to the signature image (PNG). It is valid for **one hour** and needs **no** `Authorization` header: the link carries its own signature. Download the image if you need to keep it, and call this endpoint again for a fresh link. It is `null` when the POD was recorded without a signature image.\n- `photoUrls` are pre-signed links, in the same form, to the photos the driver took at handover (up to three, usually JPEG). The driver's phone sends them just after the proof, so they can appear a minute or two after the `shipment.delivered` webhook. The list is empty when there are none, or when the courier does not share delivery photos with customers.\n- Until the shipment is delivered this returns **`404`** with `{\"message\": \"POD not found\"}`. The `shipment.delivered` webhook tells you when to call it."
      }
    },
    "/customer/shipments/{id}/label": {
      "get": {
        "operationId": "CustomerPortalController_getLabel_v1",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The shipment id: the UUID ParcelOps assigns (`shipmentId` in webhooks, `id` in list responses). A waybill number or your own reference is not accepted here; look those up with `GET /customer/shipments?search=`."
          }
        ],
        "responses": {
          "200": {
            "description": "The label PDF",
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "Not Found",
            "content": {
              "application/json": {
                "example": {
                  "message": "Shipment not found",
                  "error": "Not Found",
                  "statusCode": 404
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Download waybill label PDF (100×150 mm)",
        "tags": [
          "Shipments"
        ],
        "description": "The waybill label as a PDF (100 × 150 mm), `Content-Type: application/pdf`."
      }
    },
    "/customer/shipments/{id}/cancel": {
      "post": {
        "operationId": "CustomerPortalController_cancelShipment_v1",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The shipment id: the UUID ParcelOps assigns (`shipmentId` in webhooks, `id` in list responses). A waybill number or your own reference is not accepted here; look those up with `GET /customer/shipments?search=`."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "success": true
                }
              }
            }
          },
          "400": {
            "description": "Bad Request",
            "content": {
              "application/json": {
                "example": {
                  "message": "A driver has been assigned to collect this shipment. Please contact us to cancel.",
                  "error": "Bad Request",
                  "statusCode": 400
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Cancel a shipment (only allowed before collection)",
        "tags": [
          "Shipments"
        ],
        "description": "Cancels a shipment while it is still `CREATED` (no driver assigned to collect). Anything later is refused with `400`; contact the courier. A booking the courier has not yet confirmed cannot be cancelled through the API.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "reason": "Customer changed the order"
              }
            }
          }
        }
      }
    },
    "/customer/wallet": {
      "get": {
        "operationId": "CustomerPortalController_walletBalance_v1",
        "parameters": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "customerId": "a0d01191-b95c-4f20-9214-af3c47b77e2e",
                  "accountNumber": "TST001",
                  "companyName": "Sample Traders (Pty) Ltd",
                  "isWallet": true,
                  "balance": 1566.9,
                  "overdraftLimit": 0,
                  "available": 1566.9,
                  "lowBalanceAlert": null,
                  "isLow": false
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "My account balance, available funds and low-balance flag",
        "tags": [
          "Wallet & top-ups"
        ],
        "description": "The balance of a prepaid wallet account (`isWallet: true`). `available` is `balance` plus `overdraftLimit`: what bookings can still spend. `isLow` turns true once `balance` is at or under `lowBalanceAlert`. Amounts are rand, as JSON numbers."
      }
    },
    "/customer/wallet/ledger": {
      "get": {
        "operationId": "CustomerPortalController_walletLedger_v1",
        "parameters": [
          {
            "name": "page",
            "required": false,
            "in": "query",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "limit",
            "required": false,
            "in": "query",
            "schema": {
              "type": "number"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "data": [
                    {
                      "id": "e3918bc2-c0aa-4ee9-9f5f-2c4c5e4f4497",
                      "tenantId": "deded7d1-5bbd-4e49-8834-8ab32162456f",
                      "customerId": "a0d01191-b95c-4f20-9214-af3c47b77e2e",
                      "type": "CHARGE",
                      "amount": -144.9,
                      "balanceAfter": 1566.9,
                      "description": "Waybill ACE-000574 — Sea Point",
                      "source": "WAYBILL",
                      "sourceRef": "ACE-000574",
                      "bookingId": null,
                      "waybillId": "e5dcbc9b-d35b-4265-b4bf-44238f521574",
                      "reversesId": null,
                      "createdById": "927a4169-8ac7-48a1-a277-211ba0862231",
                      "createdAt": "2026-09-28T07:53:54.126Z"
                    },
                    {
                      "id": "a1666481-fe72-481f-928c-a20aae59b6d2",
                      "tenantId": "deded7d1-5bbd-4e49-8834-8ab32162456f",
                      "customerId": "a0d01191-b95c-4f20-9214-af3c47b77e2e",
                      "type": "CHARGE",
                      "amount": -193.2,
                      "balanceAfter": 1711.8,
                      "description": "Waybill WB-20260513-D56232 re-weighed — additional R 193.20",
                      "source": "WAYBILL",
                      "sourceRef": "settle-6bfbdaaa-9093-4cfb-8dad-95d4b1707e3f-1",
                      "bookingId": null,
                      "waybillId": "6bfbdaaa-9093-4cfb-8dad-95d4b1707e3f",
                      "reversesId": null,
                      "createdById": "927a4169-8ac7-48a1-a277-211ba0862231",
                      "createdAt": "2026-09-23T14:19:00.918Z"
                    },
                    {
                      "id": "237d19dc-b0bd-4d3a-8896-27903586cbdb",
                      "tenantId": "deded7d1-5bbd-4e49-8834-8ab32162456f",
                      "customerId": "a0d01191-b95c-4f20-9214-af3c47b77e2e",
                      "type": "CHARGE",
                      "amount": -115,
                      "balanceAfter": 1905,
                      "description": "Booking BK-20260901-7QUY5L — Rosebank",
                      "source": "BOOKING",
                      "sourceRef": "BK-20260901-7QUY5L",
                      "bookingId": "5678369a-5082-4c82-9379-7effd1d3489a",
                      "waybillId": null,
                      "reversesId": null,
                      "createdById": "e2a8edaf-99fb-4350-8b07-747fca9f94d9",
                      "createdAt": "2026-09-01T19:20:55.813Z"
                    }
                  ],
                  "total": 13,
                  "page": 1,
                  "limit": 3
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "My account statement — every top-up and charge, newest first",
        "tags": [
          "Wallet & top-ups"
        ],
        "description": "Every top-up and charge on a wallet account, newest first. `amount` is negative for a charge; `balanceAfter` is the balance once the entry applied. Paginated with `page` and `limit` (default `50`), but the entries are in **`data`**, not `items`: `{ data, total, page, limit }`."
      }
    },
    "/customer/wallet/topup": {
      "post": {
        "description": "Starts an online payment into the wallet through the courier's own payment gateway (PayFast or Paystack, named in `gateway`) and returns `redirectUrl`: send the payer's browser there. The payer comes back to `returnUrl` (or `cancelUrl`) through a page on the courier's own host that names your site and links to it (with Paystack, after the courier's payment result page). Use an `https` URL. The wallet is credited when the gateway confirms the payment, not when the payer comes back, so read `GET /customer/wallet` rather than trusting the return. `400` if the courier has not switched on online payments.\n\nThe balance is credited when PayFast confirms the payment, not when the redirect happens.",
        "operationId": "CustomerPortalController_walletTopUp_v1",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WalletTopUpDto"
              },
              "example": {
                "amount": 500,
                "returnUrl": "https://yourshop.example/paid",
                "cancelUrl": "https://yourshop.example/cancelled"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "redirectUrl": "https://www.payfast.co.za/eng/process?merchant_id=…&merchant_key=…&return_url=https%3A%2F%2Fyourshop.example%2Fpaid&cancel_url=https%3A%2F%2Fyourshop.example%2Fcancelled&notify_url=https%3A%2F%2Fapi.parcelops.co.za%2Fapi%2Fv1%2Fpublic%2Fdemo-couriers%2Fpayfast%2Fnotify&name_first=John&name_last=Smith&m_payment_id=TU-a0d01191-b95c-4f20-9214-af3c47b77e2e-mumcov0i&amount=500.00&item_name=Account%20top-up%20%E2%80%94%20Sample%20Traders%20(Pty)%20Ltd&signature=…"
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Start a PayFast top-up — returns the redirect URL",
        "tags": [
          "Wallet & top-ups"
        ]
      }
    },
    "/customer/quote": {
      "post": {
        "operationId": "CustomerPortalController_getQuotes_v1",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CustomerQuoteDto"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": [
                  {
                    "serviceLevel": "OVERNIGHT",
                    "available": true,
                    "billableWeight": 6,
                    "baseCharge": 171,
                    "fuelSurcharge": 7.7,
                    "townshipSurcharge": 0,
                    "remoteSurcharge": 0,
                    "insuranceSurcharge": 0,
                    "totalCharge": 178.7,
                    "currency": "ZAR",
                    "tariffId": "140c1a6f-69e0-408d-8433-9019b147d421",
                    "tariffName": "Overnight Express",
                    "lineItems": [
                      {
                        "description": "Freight (6.00 kg @ R 28.50/kg)",
                        "amount": 171
                      },
                      {
                        "description": "Fuel surcharge (4.5%)",
                        "amount": 7.7
                      }
                    ],
                    "rateType": "PER_KG",
                    "ringApplied": null,
                    "surchargeLines": [
                      {
                        "code": "FUEL",
                        "description": "Fuel surcharge (4.5%)",
                        "amount": 7.7,
                        "calcType": "PERCENTAGE"
                      }
                    ],
                    "offered": true
                  },
                  {
                    "serviceLevel": "ECONOMY_REGIONAL",
                    "available": false,
                    "totalCharge": 0,
                    "billableWeight": 0,
                    "tariffName": null,
                    "lineItems": [],
                    "reason": "No tariff configured",
                    "offered": false
                  }
                ]
              }
            }
          },
          "400": {
            "description": "Bad Request",
            "content": {
              "application/json": {
                "example": {
                  "message": [
                    "originPostalCode must be a string"
                  ],
                  "error": "Bad Request",
                  "statusCode": 400
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Get shipping quotes for all service levels",
        "tags": [
          "Quotes & booking"
        ],
        "description": "Prices a consignment for every service level. One entry per service level; `available: false` with a `reason` when there is no tariff for that route. `offered` says whether the courier offers the service at all: `false` for one no rate card prices anywhere (leave it out of a list of choices); an offered service with no price on this route has the reason \"Not on your price list for this route\". Amounts are rand, **excluding VAT**, as JSON numbers. Quoting is optional: `POST /customer/book` prices the booking itself. `collectionDate` (optional, `YYYY-MM-DD`, from today to 30 days ahead in South African time) prices the quote at the rates in force on that day: a price change the courier has scheduled before it is already in the quote. Left out, or today, it is priced as now. Only the rate cards' dated prices are dated; fuel, surcharges and special rates are as now. `lineItems` is the price line by line, and `surchargeLines` the charges after the freight, each with a `code`: `FUEL`, and the courier's own, such as a town surcharge (`TOWN`, \"Town surcharge: <town>\", on a delivery to the town and, marked \"(collection)\", on a collection from it) and a toll (`TOLL`) on a lane and weight band that has one. `totalCharge` includes every line. Fuel is levied on the freight alone. More codes may appear over time: show them by `description` rather than expecting a fixed list. Where the courier prices a lane between two of its outlying towns via its depot (two legs from the depot less a transfer discount), `tariffName` describes the two legs (\"Via Cape Town: two legs less 10% transfer discount\"), `tariffId` is the delivery town's card (or null when a special rate priced that leg), and the freight line of `lineItems` gives each leg. `originProvince` and `destProvince` are optional and may be a name (`Gauteng`) or a code (`GP`, `WC`, `KZN`, `EC`, `FS`, `LP`, `MP`, `NW`, `NC`; also `GT`, `NL` and `ZA-GP`), so a shop can send the address as it holds it. They do not change the price: the courier's own suburb list decides where an address is. To check an address first, use `POST /customer/address-check`."
      }
    },
    "/customer/booking-options": {
      "get": {
        "description": "`dangerousGoodsCheck`: a person booking must confirm the parcels hold no dangerous goods (send `dangerousGoodsConfirmed: true` with the booking). `liabilityCover`: the courier insures on a declared value (send `declaredValue` on the pieces; the quote prices it). `specialRequests`: the courier lets customers choose some extra services (each quote lists them as `extraOptions`). `collectionTimes` and `vehicleNeeds`: whether the courier's own Book page asks for the collection window and a special vehicle (a booking may still send them).",
        "operationId": "CustomerPortalController_bookingOptions_v1",
        "parameters": [],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "What the Book page offers besides the route and parcels",
        "tags": [
          "Account & users"
        ]
      }
    },
    "/customer/suburbs": {
      "get": {
        "operationId": "CustomerPortalController_searchSuburbs_v1",
        "parameters": [
          {
            "name": "q",
            "required": true,
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Search the suburb list by name (from 2 letters) or a 4-digit postal code: the places an address can be priced on",
        "tags": [
          "Account & users"
        ]
      }
    },
    "/customer/address-check": {
      "post": {
        "description": "Checks an address before you quote or book, for example at a shop's checkout (\"We could not find that suburb\"). Nothing is saved. Send at least one of `suburb`, `city` or `postalCode`; `province` may be a name or a code (`GP`, `WC`, `KZN`, `EC`, `FS`, `LP`, `MP`, `NW`, `NC`; also `GT`, `NL` and `ZA-GP`). Needs the **Book parcels** permission.\n\n- `recognised`: the courier's suburb list has a place of that name on that postal code (or, with no postal code, of that name on one postal code only, after the province and city you sent), or the courier has the place as one of its own area names.\n- `normalised`: the address as the suburb list writes it (`suburb`, `city`, `province` by name, `postalCode`), or as you sent it, tidied, when it is not recognised. Fields it cannot fill are `null`.\n- `suggestions`: up to 5 places, in the same shape, that are probably what was meant (the same name on other postal codes, other places on the postal code, names that start the same). Empty when there are none.\n- `priced`: whether the courier prices parcels to the address: it is in one of the courier's zones that your account's rate cards (or the courier's standard ones) price, or a card prices any destination. A town the courier delivers on request is not priced. It is a guide, not a promise: it can say `true` where a quote from your collection address finds no price, or `false` where one does (province-restricted cards, special rates and lane overrides are not read). `POST /customer/quote` is the authority on prices, so do not refuse an address on `priced: false` alone.\n- `warnings` (optional, added 7 Nov 2026): present **only when there is something to say**, so an answer without it reads exactly as before. Each warning has `code`, `message` (plain words you can show) and `suggestion` (an address in the same shape as `normalised`, or `null`). Today the one code is `POSTAL_CODE_MISMATCH`: the suburb list has the suburb only in one province and the postal code only in other provinces (for example a Western Cape town sent with a Johannesburg postal code), the province (or a town other than the suburb itself) you sent agrees with the suburb, and nothing you sent agrees with the postal code. The courier prices an address from its suburb and postal code, so such an address may be priced for the wrong place: show the message and offer the suggestion (the same suburb on its own postal code). `suggestion` is `null` when the list has the suburb on many postal codes. It is a warning, never a refusal: quoting and booking the address as sent still work. Nothing is said when the list does not know the suburb or the postal code, has the suburb in more than one province, or when your town or province agrees with the postal code (the suburb may then be the part that is wrong, or a place the list does not know). New codes may be added: ignore a code you do not know.\n- Unknown fields are refused (`400`), as on every endpoint.\n\nTells you whether the courier knows the address (`recognised`), how its suburb list writes it (`normalised`), up to 5 `suggestions` when it does not, and whether the courier prices parcels to it (`priced`: a guide, never a promise; `POST /customer/quote` is the authority on prices). Send at least one of suburb, city or postalCode; the province may be a name or a code (GP, WC, KZN...). Nothing is saved. A shop can use it for a checkout message such as \"We could not find that suburb\".",
        "operationId": "CustomerPortalController_checkAddress_v1",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CustomerAddressCheckDto"
              },
              "example": {
                "suburb": "Sea Point",
                "city": "Cape Town",
                "postalCode": "8005",
                "province": "WC"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The check",
            "content": {
              "application/json": {
                "examples": {
                  "recognised": {
                    "summary": "A place the list knows, on its postal code, that the courier prices",
                    "value": {
                      "recognised": true,
                      "normalised": {
                        "suburb": "Sea Point",
                        "city": "Cape Town",
                        "province": "Western Cape",
                        "postalCode": "8005"
                      },
                      "suggestions": [
                        {
                          "suburb": "Sea Point",
                          "city": "Cape Town",
                          "province": "Western Cape",
                          "postalCode": "8001"
                        }
                      ],
                      "priced": true
                    }
                  },
                  "notRecognised": {
                    "summary": "A known place on another postal code: not recognised, with what it might be",
                    "value": {
                      "recognised": false,
                      "normalised": {
                        "suburb": "Sea Point",
                        "city": null,
                        "province": null,
                        "postalCode": "8009"
                      },
                      "suggestions": [
                        {
                          "suburb": "Sea Point",
                          "city": "Cape Town",
                          "province": "Western Cape",
                          "postalCode": "8001"
                        },
                        {
                          "suburb": "Sea Point",
                          "city": "Cape Town",
                          "province": "Western Cape",
                          "postalCode": "8005"
                        }
                      ],
                      "priced": false
                    }
                  },
                  "postalCodeMismatch": {
                    "summary": "A suburb sent with a postal code from another province: the answer as usual, plus a warning",
                    "value": {
                      "recognised": false,
                      "normalised": {
                        "suburb": "Hermanus",
                        "city": "Hermanus",
                        "province": "Western Cape",
                        "postalCode": "2016"
                      },
                      "suggestions": [
                        {
                          "suburb": "Hermanus",
                          "city": "Hermanus",
                          "province": "Western Cape",
                          "postalCode": "7200"
                        }
                      ],
                      "priced": false,
                      "warnings": [
                        {
                          "code": "POSTAL_CODE_MISMATCH",
                          "message": "Hermanus is in the Western Cape, but 2016 is a Johannesburg postal code. Use Hermanus 7200?",
                          "suggestion": {
                            "suburb": "Hermanus",
                            "city": "Hermanus",
                            "province": "Western Cape",
                            "postalCode": "7200"
                          }
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Bad Request",
            "content": {
              "application/json": {
                "example": {
                  "message": "Send at least one of suburb, city or postalCode.",
                  "error": "Bad Request",
                  "statusCode": 400
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Check an address before quoting or booking",
        "tags": [
          "Account & users"
        ]
      }
    },
    "/customer/book": {
      "post": {
        "description": "Creates a **booking request**. The courier confirms it, which creates the shipment; see *Booking a shipment* in the introduction. On an account the courier **accepts automatically**, that happens in this same request (below).\n\n- `status` is `SUBMITTED` for account and prepaid-wallet customers (a wallet is debited in the same transaction), or `PENDING_PAYMENT` for customers who pay per booking, when the response also carries the bank details and `eftReference`. On an account accepted automatically it is `CONFIRMED`.\n- **Accepted automatically.** Some couriers accept an account's bookings the moment they are made (account and prepaid-wallet customers; never one that pays per booking). The response is the usual one with `status: \"CONFIRMED\"`, plus:\n  - `waybillNumber`: the shipment's waybill number. On an account whose waybill number is your reference (below) it is your `customerReference`, in capitals: never a number of the courier's.\n  - `waybillId`: the shipment's id, the `{id}` of `GET /customer/shipments/{id}` (and `shipmentId` in webhooks).\n  - `collection`: `scheduledDate` (`YYYY-MM-DD`, the day the courier collects) and `status` (`PENDING`, waiting for a driver, or `ASSIGNED`, given to the area's driver).\n  - `collectionDate` (optional, `YYYY-MM-DD`, from today to 30 days ahead in South African time) is the day to collect. A weekend, a public holiday, or today once the depot's cut-off time has passed, moves on to the next working day (Monday to Friday, not a public holiday); the response's `collection.scheduledDate` is the day it was scheduled. Left out, it is today when the courier's depot for your address has not passed its cut-off time, else the next working day. A day already past, or more than 30 days ahead, is refused (`400`).\n  - A booking the courier cannot accept automatically is **not lost**: it is booked (`201`) with `status: \"SUBMITTED\"` and `autoAcceptError: { code, message }`, and waits for the courier to accept it by hand, as every booking does on other accounts. `code` is `CREDIT_LIMIT_EXCEEDED` when it would take your account over its credit limit, else `NEEDS_REVIEW` (for example, your reference is already a waybill number at the courier). Once accepted it appears on `GET /customer/bookings` and in the `shipment.created` webhook; its waybill number may then be the courier's own, with your reference kept as the `customerReference`. If the courier cancels it instead, `booking.cancelled` says so.\n  - The shipment is created exactly once however often it is accepted (a staff member pressing Accept at the same moment is refused).\n- **Store `referenceNumber`.** It is the booking's identifier. Once the courier confirms, `GET /customer/bookings` shows the shipment's `waybillId` and waybill number against it, and the shipment carries your `customerReference` (or the `referenceNumber` if you sent none).\n- Some couriers set an account up so that the waybill number **is** your `customerReference` (in capitals), for partners who send their own waybill numbers; `GET /customer/bookings` shows the waybill number once the booking is accepted. **On such an account only**, the reference is checked when you book:\n  - it is required: a booking without one is refused (`400`, `code: \"REFERENCE_REQUIRED\"`);\n  - it must be 3 to 30 letters, digits and hyphens, starting with a letter or digit (`400`, `code: \"INVALID_WAYBILL_REFERENCE\"`);\n  - it may not be one you already have, on an open booking (not cancelled or expired) or on one of your shipments (`409`, `code: \"DUPLICATE_REFERENCE\"`). The refusal names the original booking: its `referenceNumber`, and its `waybillId` and `waybillNumber` once the courier has accepted it (`null` until then, and `referenceNumber` is `null` for a shipment that was not booked through the API). **A retry that gets `DUPLICATE_REFERENCE` is safe**: nothing new was booked, and the original is the booking you have.\n- The price is always ParcelOps's, never the request's. A quote is not required. `quotedCharge` is an optional price lock: if today's price is more than R 0.02 above it the booking is refused (`400`); if it is the same or lower, the booking goes ahead at today's price.\n- `customerReference` is yours and is not required to be unique. It is **not** an idempotency key: without `idempotencyKey`, sending the same request twice creates two bookings.\n- **Safe retries: `idempotencyKey`** (optional, in the body; see *Safe retries* in the introduction). Send a key of your own (8 to 100 characters, trimmed; for example your shop and order number) and a retry of the same booking with the same key returns the **first call's response again** with HTTP **`200`** and the header **`Idempotent-Replay: true`**: no second booking, waybill, collection, wallet debit, notification or webhook. Two calls with one key at the same moment make one booking; the other call waits for it and gets the same answer. A key used before for a **different** booking (other pieces, recipient, address, service, collection day or window, reference, instructions, emails, vehicle needs or `quotedCharge`) is refused with `409` `IDEMPOTENCY_KEY_REUSED`, naming that booking. A key that is blank, too short, too long or not printable text is `400` `INVALID_IDEMPOTENCY_KEY`. A call refused before it booked (a `400`, no funds, over the credit limit) keeps nothing, so the key is free for the next try. Keys belong to your account (another customer's key of the same text is theirs) and **never expire**. A replay is a snapshot of the first answer, not the booking's state today (it still says `SUBMITTED` after the courier has cancelled the booking): read the current state from `GET /customer/bookings?search=<referenceNumber>` or `GET /customer/shipments/{id}`.\n- `source` (optional): which system made the booking, `API` (the default for a key), `WOOCOMMERCE` or `SHOPIFY`; any other value is refused (`400`). `externalOrderRef` (optional, at most 100 characters, for example `\"Woo #1042\"`) is your shop's order as your shop names it, and `externalOrderUrl` (optional, `https://` only, at most 500 characters, with no user name or password in it) a link to it. The courier's staff see them beside the waybill; your recipient never does. They come back on the shipment and in its webhooks (`externalOrderRef`). Put the order number in `customerReference` too if you want it on the courier's documents.\n- `warnings` (optional, added 7 Nov 2026): present **only when there is something to say**; a booking without it reads exactly as before. Each has `address` (`sender` or `recipient`), `code`, `message` and `suggestion`, as on `POST /customer/address-check` (today `POSTAL_CODE_MISMATCH`: the suburb is in one province and the postal code in another, so the address may be priced for the wrong place). The booking is made as sent either way; to correct an address, cancel and book again, or tell the courier. Check addresses with `POST /customer/address-check` before booking to avoid it. The warnings are part of the booking's answer, so a retry with the same `idempotencyKey` returns them exactly as first sent (a booking made before warnings existed replays without them, and so does, rarely, a retry that finds the first call still unfinished after a minute and gets the answer rebuilt from the booking).\n- `senderProvince` and `recipientProvince` may be a province code (`GP`, `WC`, `KZN`, `EC`, `FS`, `LP`, `MP`, `NW`, `NC`; also `GT`, `NL` and `ZA-GP`): it is stored as the province's name. Any other value is stored exactly as sent.\n- Money fields are decimal **strings** in rand: `totalCharge` excludes VAT, `totalInclVat` is what is collected.\n- An account customer with a credit limit is refused (`400`, `code: \"CREDIT_LIMIT_EXCEEDED\"`) when the booking would take what it owes and has booked past the limit. `GET /customer/statement` shows the limit, what is used and what is available.\n- **Towns delivered on request.** For a few towns the courier gives no price online: `POST /customer/quote` shows every service as `available: false` with `onRequest: true` and a `reason` telling you to contact the courier, and a booking is handled exactly like a route with no rate (above): booked and priced by the courier afterwards on an account key, refused with `NO_RATE` (`400`) for anyone else. Where the courier has recorded when a town is delivered, a quote answer also carries `deliveryDays` (for example `\"Mon, Wed, Fri\"`). Both fields are absent for every other town.\n- A booking on a route and service the courier has no rate for is **not turned away** on an account customer's API key: it is booked (`201`), and the courier prices it afterwards. The response is the same shape as any booking, with `pricesHidden: true` and **no amounts** (there is no price yet), and `status` `SUBMITTED` or, on an account accepted automatically, `CONFIRMED` with the shipment as usual. Nothing for you to do or retry; the price appears on your invoice once the courier has set it. `POST /customer/quote` still shows that service as `available: false`. A booking is refused with `NO_RATE` (`400`) only for a customer who pays when booking (a wallet or pay-per-booking account), where a price is needed to take the money. The reads then say the price is not set yet: `GET /customer/bookings` and `GET /customer/shipments` give `totalCharge: null` with `priceStatus: \"PENDING\"` (and the shipment detail no amounts), and give the courier's price with `priceStatus: \"PRICED\"` once it is set (or `0` with `\"PRICED\"` if the courier decides there is no charge). A booking that did have a rate carries no `priceStatus` and reads exactly as it always has. To keep a mis-mapped route from filling the courier's queue, an account may make **50 bookings without a rate in an hour**; past that the booking is refused with `429` and `code: \"NO_RATE_LIMIT\"`, and nothing is booked.\n- A key whose portal user lacks **View rates & quotes** gets `pricesHidden: true` and no amounts, unless the booking must be paid before it goes ahead.\n- `needs` (optional) says what the vehicle must have for this parcel, such as `ROOF_RACK` or `TAIL_LIFT`: the keys (or labels) `GET /customer/equipment` lists. Leave it out when nothing special is needed; a value the courier does not offer is refused (`400`).\n- `collectionDate` (optional, `YYYY-MM-DD`): the day you want the parcel collected, from today to 30 days ahead. On an account accepted automatically the collection is scheduled on it (or the next working day, as above); on other accounts the courier sees it when confirming. The booking is priced at the rates in force on that day (on an automatically accepted account, the day it is scheduled for), so quote with the same `collectionDate` and send that quote's total as `quotedCharge`: a `quotedCharge` taken for another day is refused with `400` (re-quote) only when the courier has scheduled a price change between the two days.\n\nWith an idempotency key (send `idempotencyKey` in the body) a retry is safe: the same key with the same booking returns the first call's response again with HTTP 200 and the header `Idempotent-Replay: true`, and makes nothing new. The same key with a different booking is a 409 `IDEMPOTENCY_KEY_REUSED`. A replay is a snapshot of the first answer, not the booking's state today: read the current state from `GET /customer/bookings?search=<referenceNumber>` or `GET /customer/shipments/{id}`. Without a key the booking behaves as it always has.",
        "operationId": "CustomerPortalController_createBooking_v1",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CustomerBookDto"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A repeat of a booking you made with the same idempotencyKey: the first call's response, unchanged, with the header Idempotent-Replay: true. Nothing new was booked",
            "content": {
              "application/json": {
                "examples": {
                  "replay": {
                    "summary": "The same booking again, with the same key",
                    "value": {
                      "referenceNumber": "BK-20261026-JXJOFT",
                      "status": "SUBMITTED",
                      "paymentRequired": false,
                      "totalCharge": "50",
                      "vatAmount": "7.5",
                      "totalInclVat": "57.5",
                      "bankName": null,
                      "bankAccountNo": null,
                      "bankBranchCode": null,
                      "bankAccountHolder": null,
                      "eftReference": null
                    }
                  }
                }
              }
            }
          },
          "201": {
            "description": "Booking request created (and, on an account accepted automatically, the shipment and its collection)",
            "content": {
              "application/json": {
                "examples": {
                  "submitted": {
                    "summary": "Waiting for the courier to confirm it",
                    "value": {
                      "referenceNumber": "BK-20260929-Y4BLXI",
                      "status": "SUBMITTED",
                      "paymentRequired": false,
                      "totalCharge": "178.7",
                      "vatAmount": "26.8",
                      "totalInclVat": "205.5",
                      "bankName": null,
                      "bankAccountNo": null,
                      "bankBranchCode": null,
                      "bankAccountHolder": null,
                      "eftReference": null
                    }
                  },
                  "acceptedAutomatically": {
                    "summary": "Accepted automatically: the shipment, numbered with your reference, and its collection",
                    "value": {
                      "referenceNumber": "BK-20260930-O3D5Y7",
                      "status": "CONFIRMED",
                      "paymentRequired": false,
                      "totalCharge": "50",
                      "vatAmount": "7.5",
                      "totalInclVat": "57.5",
                      "bankName": null,
                      "bankAccountNo": null,
                      "bankBranchCode": null,
                      "bankAccountHolder": null,
                      "eftReference": null,
                      "waybillNumber": "9351424",
                      "waybillId": "9404261c-efa0-46a9-a17a-b584249b39dd",
                      "collection": {
                        "scheduledDate": "2026-10-01",
                        "status": "PENDING"
                      }
                    }
                  },
                  "noRate": {
                    "summary": "A route and service the courier has no rate for, on an account customer's key: booked, with no amounts. The courier prices it afterwards",
                    "value": {
                      "referenceNumber": "BK-20261019-N0RT3X",
                      "status": "CONFIRMED",
                      "paymentRequired": false,
                      "pricesHidden": true,
                      "bankName": null,
                      "bankAccountNo": null,
                      "bankBranchCode": null,
                      "bankAccountHolder": null,
                      "eftReference": null,
                      "waybillNumber": "9351425",
                      "waybillId": "3f1c6a2e-5b7d-4e08-9a41-0c2d7e8b9f10",
                      "collection": {
                        "scheduledDate": "2026-10-20",
                        "status": "PENDING"
                      }
                    }
                  },
                  "waitsForCourier": {
                    "summary": "On such an account, one that could not be accepted automatically: it waits for the courier",
                    "value": {
                      "referenceNumber": "BK-20260930-VGQTSF",
                      "status": "SUBMITTED",
                      "paymentRequired": false,
                      "totalCharge": "50",
                      "vatAmount": "7.5",
                      "totalInclVat": "57.5",
                      "bankName": null,
                      "bankAccountNo": null,
                      "bankBranchCode": null,
                      "bankAccountHolder": null,
                      "eftReference": null,
                      "autoAcceptError": {
                        "code": "NEEDS_REVIEW",
                        "message": "This booking waits for your courier to accept it."
                      }
                    }
                  },
                  "waitsCreditLimit": {
                    "summary": "On such an account, one that would pass the credit limit: it waits for the courier",
                    "value": {
                      "referenceNumber": "BK-20260930-KV3JYS",
                      "status": "SUBMITTED",
                      "paymentRequired": false,
                      "totalCharge": "50",
                      "vatAmount": "7.5",
                      "totalInclVat": "57.5",
                      "bankName": null,
                      "bankAccountNo": null,
                      "bankBranchCode": null,
                      "bankAccountHolder": null,
                      "eftReference": null,
                      "autoAcceptError": {
                        "code": "CREDIT_LIMIT_EXCEEDED",
                        "message": "This booking would take your account over its credit limit, so it waits for your courier to accept it."
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Validation failed (a collectionDate already past, say), the price rose above quotedCharge, the credit limit would be passed, there is no rate for the route (a wallet or pay-per-booking customer only), the idempotency key is not valid, or (on an account whose waybill number is your reference) the reference is missing or cannot be a waybill number",
            "content": {
              "application/json": {
                "examples": {
                  "invalidIdempotencyKey": {
                    "summary": "An idempotency key that is blank, or shorter than 8 characters",
                    "value": {
                      "statusCode": 400,
                      "error": "Bad Request",
                      "code": "INVALID_IDEMPOTENCY_KEY",
                      "message": "idempotencyKey must be 8 to 100 characters (letters, digits, spaces and the usual punctuation) once spaces at either end are removed. Leave it out to book without one."
                    }
                  },
                  "source": {
                    "summary": "A source that is not one of the list",
                    "value": {
                      "message": [
                        "source must be one of: API, WOOCOMMERCE, SHOPIFY"
                      ],
                      "error": "Bad Request",
                      "statusCode": 400
                    }
                  },
                  "priceLock": {
                    "summary": "The price rose above quotedCharge",
                    "value": {
                      "message": "The price for this collection and delivery address is R 134.02 excl. VAT, not the R 1.00 quoted. Please re-quote to continue.",
                      "error": "Bad Request",
                      "statusCode": 400
                    }
                  },
                  "creditLimit": {
                    "summary": "An account customer at its credit limit",
                    "value": {
                      "statusCode": 400,
                      "error": "Bad Request",
                      "code": "CREDIT_LIMIT_EXCEEDED",
                      "message": "This booking would take your account over its credit limit of R 5,000.00. Please settle your statement or contact your courier."
                    }
                  },
                  "noRate": {
                    "summary": "No rate for the route and service",
                    "value": {
                      "statusCode": 400,
                      "error": "Bad Request",
                      "code": "NO_RATE",
                      "message": "There is no rate for Marshalltown → Sandton (Economy Regional). Please contact Demo Couriers."
                    }
                  },
                  "referenceRequired": {
                    "summary": "No customerReference, on an account whose waybill number is your reference",
                    "value": {
                      "statusCode": 400,
                      "error": "Bad Request",
                      "code": "REFERENCE_REQUIRED",
                      "message": "This account's waybills take your reference as their number, so customerReference is required."
                    }
                  },
                  "invalidReference": {
                    "summary": "A customerReference that cannot be a waybill number, on such an account",
                    "value": {
                      "statusCode": 400,
                      "error": "Bad Request",
                      "code": "INVALID_WAYBILL_REFERENCE",
                      "message": "This account's waybills take your reference as their number, and \"PO 42/7\" cannot be one: it may contain only letters, digits and hyphens."
                    }
                  },
                  "collectionDate": {
                    "summary": "A collectionDate already past, or not written YYYY-MM-DD",
                    "value": {
                      "message": [
                        "collectionDate must be a date written YYYY-MM-DD, from today to 30 days ahead (South African time)"
                      ],
                      "error": "Bad Request",
                      "statusCode": 400
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "The idempotency key was used for a different booking, or its first call has not finished; or, on an account whose waybill number is your reference, you already have this reference. Nothing new was booked",
            "content": {
              "application/json": {
                "examples": {
                  "idempotencyKeyReused": {
                    "summary": "The key was used before for a different booking",
                    "value": {
                      "statusCode": 409,
                      "error": "Conflict",
                      "code": "IDEMPOTENCY_KEY_REUSED",
                      "referenceNumber": "BK-20261026-JXJOFT",
                      "message": "This idempotency key was already used for a different booking (BK-20261026-JXJOFT). Use a new key for a new booking, or send exactly the same booking to get BK-20261026-JXJOFT back."
                    }
                  },
                  "idempotencyKeyInProgress": {
                    "summary": "The first call with this key is still being made (rare: it waits up to 15 seconds for it first)",
                    "value": {
                      "statusCode": 409,
                      "error": "Conflict",
                      "code": "IDEMPOTENCY_KEY_IN_PROGRESS",
                      "referenceNumber": "BK-20261026-JXJOFT",
                      "message": "A booking with this idempotency key (BK-20261026-JXJOFT) is still being made. Send the same request again in a few seconds to get it."
                    }
                  },
                  "duplicate": {
                    "summary": "Booked already, and accepted by the courier",
                    "value": {
                      "statusCode": 409,
                      "error": "Conflict",
                      "code": "DUPLICATE_REFERENCE",
                      "message": "You have already booked 9351424 (booking BK-20260929-Y4BLXI, waybill 9351424), so nothing new was booked.",
                      "referenceNumber": "BK-20260929-Y4BLXI",
                      "waybillId": "c6459b89-8531-4ec5-b15d-908549caa221",
                      "waybillNumber": "9351424"
                    }
                  },
                  "duplicateNotYetAccepted": {
                    "summary": "Booked already, not yet accepted",
                    "value": {
                      "statusCode": 409,
                      "error": "Conflict",
                      "code": "DUPLICATE_REFERENCE",
                      "message": "You have already booked 9351424 (booking BK-20260929-Y4BLXI), so nothing new was booked.",
                      "referenceNumber": "BK-20260929-Y4BLXI",
                      "waybillId": null,
                      "waybillNumber": null
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Too many bookings without a rate in the last hour (50). Nothing was booked",
            "content": {
              "application/json": {
                "examples": {
                  "noRateLimit": {
                    "summary": "An account that has booked its hour's allowance of routes the courier has no rate for",
                    "value": {
                      "statusCode": 429,
                      "error": "Too Many Requests",
                      "code": "NO_RATE_LIMIT",
                      "message": "50 bookings without a rate have been made in the last hour, which is the most allowed. Ask Demo Couriers to set up the rate for these routes, then book again."
                    }
                  }
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Submit a booking request",
        "tags": [
          "Quotes & booking"
        ]
      }
    },
    "/customer/bookings": {
      "get": {
        "operationId": "CustomerPortalController_getBookings_v1",
        "parameters": [
          {
            "name": "page",
            "required": false,
            "in": "query",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "limit",
            "required": false,
            "in": "query",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "search",
            "required": false,
            "in": "query",
            "description": "A booking reference (BK-…) or your customerReference, matched whole, in any case",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "status",
            "required": false,
            "in": "query",
            "schema": {
              "enum": [
                "SUBMITTED",
                "PENDING_PAYMENT",
                "PAYMENT_RECEIVED",
                "CONFIRMED",
                "CANCELLED",
                "EXPIRED"
              ],
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "items": [
                    {
                      "id": "fcdee8a7-1317-4adb-b3c4-52429229a877",
                      "referenceNumber": "BK-20260929-Y4BLXI",
                      "customerReference": "PO-2026-0042",
                      "status": "CONFIRMED",
                      "serviceLevel": "OVERNIGHT",
                      "totalCharge": "178.7",
                      "recipientName": "Thandi Mokoena",
                      "recipientSuburb": "Sandton",
                      "recipientCity": "Johannesburg",
                      "createdAt": "2026-09-29T05:40:22.483Z",
                      "waybillId": "c6459b89-8531-4ec5-b15d-908549caa221",
                      "waybill": {
                        "waybillNumber": "DCS-002199",
                        "status": "FAILED_DELIVERY"
                      }
                    },
                    {
                      "id": "48c3863e-6a5e-4c88-b921-2e28d7ef0151",
                      "referenceNumber": "BK-20260929-JEYWG4",
                      "customerReference": null,
                      "status": "SUBMITTED",
                      "serviceLevel": "OVERNIGHT",
                      "totalCharge": "134.02",
                      "recipientName": "Thandi Mokoena",
                      "recipientSuburb": "Sandton",
                      "recipientCity": "Johannesburg",
                      "createdAt": "2026-09-29T05:40:22.501Z",
                      "waybillId": null,
                      "waybill": null
                    },
                    {
                      "id": "0d1f7a52-3b1c-4c2e-8a6e-6d2f0b3c9e11",
                      "referenceNumber": "BK-20261019-N0RT3X",
                      "customerReference": "PO-2026-0077",
                      "status": "CONFIRMED",
                      "serviceLevel": "ECONOMY",
                      "totalCharge": null,
                      "priceStatus": "PENDING",
                      "recipientName": "Thandi Mokoena",
                      "recipientSuburb": "Nowhere Park",
                      "recipientCity": "Nowhere",
                      "createdAt": "2026-10-19T08:12:40.101Z",
                      "waybillId": "3f1c6a2e-5b7d-4e08-9a41-0c2d7e8b9f10",
                      "waybill": {
                        "waybillNumber": "9351425",
                        "status": "CREATED",
                        "handedToDepot": null
                      }
                    }
                  ],
                  "total": 5,
                  "page": 1,
                  "limit": 3,
                  "pages": 2
                }
              }
            }
          },
          "400": {
            "description": "Bad Request",
            "content": {
              "application/json": {
                "example": {
                  "message": "status must be one of: SUBMITTED, PENDING_PAYMENT, PAYMENT_RECEIVED, CONFIRMED, CANCELLED, EXPIRED",
                  "error": "Bad Request",
                  "statusCode": 400
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "List my booking requests",
        "tags": [
          "Quotes & booking"
        ],
        "description": "Your booking requests, newest first. `waybillId` and `waybill` fill in when the courier confirms a booking (at once, on an account accepted automatically); until then they are `null`. Every item carries your `customerReference` (`null` if you sent none) and `collectionDate` (`YYYY-MM-DD`, the day the booking asked to be collected on; `null` if it named none). See *Pagination*.\n\n- `totalCharge` is the booking's price excl. VAT. For a booking the courier had **no rate** for it is **`null`**, with `priceStatus: \"PENDING\"`, until the courier prices it; after that it is the price (`\"0\"` if the courier decides there is no charge), with `priceStatus: \"PRICED\"`. Never read a `0` there as free: a booking with a rate carries no `priceStatus`, and one without a price carries `null`, not `\"0\"`.\n- `search` finds a booking by its `referenceNumber` or by your `customerReference`, matched whole and in any case (part of a reference matches nothing).\n- `status` keeps one status: `SUBMITTED`, `PENDING_PAYMENT`, `PAYMENT_RECEIVED`, `CONFIRMED`, `CANCELLED` or `EXPIRED`. Any other value is refused with `400`.\n- After a `POST /customer/book` that timed out, send it again with the same `idempotencyKey` (see *Safe retries*). Without one, check `GET /customer/bookings?search=<your customerReference>` before booking again: a retry of a booking that did go through books the parcel twice."
      }
    },
    "/customer/invoices": {
      "get": {
        "operationId": "CustomerPortalController_getInvoices_v1",
        "parameters": [
          {
            "name": "page",
            "required": false,
            "in": "query",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "limit",
            "required": false,
            "in": "query",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "search",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "status",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "dateFrom",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "dateTo",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "items": [
                    {
                      "id": "c0317ac8-e020-478e-900d-cf847b7c8167",
                      "invoiceNumber": "INV-000011",
                      "status": "PAID",
                      "periodStart": "2026-08-01T06:00:00.000Z",
                      "periodEnd": "2026-09-01T05:59:59.999Z",
                      "subtotal": "17387.19",
                      "vatAmount": "2608.06",
                      "totalAmount": "19995.25",
                      "amountPaid": "19995.25",
                      "amountOutstanding": "0",
                      "dueDate": "2026-10-01T06:00:00.000Z",
                      "issueDate": "2026-09-01T06:00:00.000Z",
                      "sentAt": null,
                      "_count": {
                        "lineItems": 37
                      }
                    },
                    {
                      "id": "36581683-8f1a-49ef-9e3f-d14aab7bae16",
                      "invoiceNumber": "INV-000006",
                      "status": "PAID",
                      "periodStart": "2026-07-01T06:00:00.000Z",
                      "periodEnd": "2026-08-01T05:59:59.999Z",
                      "subtotal": "14385.43",
                      "vatAmount": "2157.8",
                      "totalAmount": "16543.23",
                      "amountPaid": "16543.23",
                      "amountOutstanding": "0",
                      "dueDate": "2026-08-31T06:00:00.000Z",
                      "issueDate": "2026-08-01T06:00:00.000Z",
                      "sentAt": null,
                      "_count": {
                        "lineItems": 41
                      }
                    }
                  ],
                  "total": 3,
                  "page": 1,
                  "limit": 2,
                  "pages": 2
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "List my invoices",
        "tags": [
          "Invoices & statements"
        ],
        "description": "Your invoices, newest first. `status` filters (`ISSUED`, `PARTIALLY_PAID`, `PAID`, `OVERDUE`, …); `search` matches the invoice number (partial); `dateFrom` / `dateTo` (`YYYY-MM-DD`) filter on the issue date, `dateTo` inclusive. See *Pagination*."
      }
    },
    "/customer/invoices/{id}": {
      "get": {
        "operationId": "CustomerPortalController_getInvoice_v1",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The invoice id: the UUID ParcelOps assigns (`shipmentId` in webhooks, `id` in list responses). A waybill number or your own reference is not accepted here; look those up with `GET /customer/shipments?search=`."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "id": "c0317ac8-e020-478e-900d-cf847b7c8167",
                  "tenantId": "224f424c-0d91-4cdc-bc49-d791fe44f478",
                  "customerId": "41a8bc60-faff-4a1c-ae4f-3ef7f294052f",
                  "invoiceNumber": "INV-000011",
                  "invoiceType": "STANDARD",
                  "parentInvoiceId": null,
                  "status": "PAID",
                  "issueDate": "2026-09-01T06:00:00.000Z",
                  "dueDate": "2026-10-01T06:00:00.000Z",
                  "periodStart": "2026-08-01T06:00:00.000Z",
                  "periodEnd": "2026-09-01T05:59:59.999Z",
                  "subtotal": "17387.19",
                  "vatAmount": "2608.06",
                  "totalAmount": "19995.25",
                  "amountPaid": "19995.25",
                  "amountOutstanding": "0",
                  "pdfKey": null,
                  "pdfUrl": null,
                  "notes": null,
                  "payfastPaymentId": null,
                  "paymentLinkToken": null,
                  "sentAt": null,
                  "sentTo": null,
                  "createdAt": "2026-09-01T06:00:00.000Z",
                  "updatedAt": "2026-09-28T18:21:18.158Z",
                  "lineItems": [
                    {
                      "id": "734f1e73-d2c6-4f2f-9f47-32cecc1ca9eb",
                      "invoiceId": "c0317ac8-e020-478e-900d-cf847b7c8167",
                      "waybillId": "07f3317c-74c6-4888-a1c0-fe0ec4fdfb09",
                      "waybillNumber": "DCS-000879",
                      "description": "Economy Road — Umhlanga Ridge, Durban",
                      "serviceLevel": "ECONOMY",
                      "pieces": 2,
                      "unitPrice": "96.56",
                      "amount": "96.56",
                      "actualWeightKg": "4.9",
                      "baseRate": "92.4",
                      "chargeableWeightKg": "6.6",
                      "customerReference": null,
                      "destinationHubCode": "JNB",
                      "insurancePremium": null,
                      "originHubCode": "JNB",
                      "otherCharges": null,
                      "outlyingCharge": null,
                      "recipientCity": "Durban",
                      "recipientName": "Ravi Govender",
                      "recipientSuburb": "Umhlanga Ridge",
                      "senderName": "Acme Trading (Pty) Ltd",
                      "vatAmount": "14.48",
                      "waybillDate": "2026-08-01T06:51:00.000Z",
                      "isCredited": true
                    },
                    {
                      "id": "f466eb50-d4d2-4fd8-8560-3636d60e75ef",
                      "invoiceId": "c0317ac8-e020-478e-900d-cf847b7c8167",
                      "waybillId": "c80d4560-81b5-43f6-9ebd-497acc6e2636",
                      "waybillNumber": "DCS-000897",
                      "description": "Economy Road — Bedfordview, Johannesburg",
                      "serviceLevel": "ECONOMY",
                      "pieces": 1,
                      "unitPrice": "88.83",
                      "amount": "88.83",
                      "actualWeightKg": "3.5",
                      "baseRate": "85",
                      "chargeableWeightKg": "4.8",
                      "customerReference": null,
                      "destinationHubCode": "JNB",
                      "insurancePremium": null,
                      "originHubCode": "JNB",
                      "otherCharges": null,
                      "outlyingCharge": null,
                      "recipientCity": "Johannesburg",
                      "recipientName": "Sibusiso Khumalo",
                      "recipientSuburb": "Bedfordview",
                      "senderName": "Acme Trading (Pty) Ltd",
                      "vatAmount": "13.32",
                      "waybillDate": "2026-08-01T11:46:00.000Z",
                      "isCredited": false
                    },
                    {
                      "id": "0edfff84-6098-4c47-a95b-4e8a78676d24",
                      "invoiceId": "c0317ac8-e020-478e-900d-cf847b7c8167",
                      "waybillId": "4d89e76d-1147-48f5-8281-88738ea9aa43",
                      "waybillNumber": "DCS-000910",
                      "description": "Overnight Express — Sandton, Johannesburg",
                      "serviceLevel": "OVERNIGHT",
                      "pieces": 1,
                      "unitPrice": "253.22",
                      "amount": "253.22",
                      "actualWeightKg": "3.5",
                      "baseRate": "125.4",
                      "chargeableWeightKg": "4.4",
                      "customerReference": null,
                      "destinationHubCode": "JNB",
                      "insurancePremium": "122.18",
                      "originHubCode": "JNB",
                      "otherCharges": null,
                      "outlyingCharge": null,
                      "recipientCity": "Johannesburg",
                      "recipientName": "Thandi Mokoena",
                      "recipientSuburb": "Sandton",
                      "senderName": "Acme Trading (Pty) Ltd",
                      "vatAmount": "37.98",
                      "waybillDate": "2026-08-03T08:44:00.000Z",
                      "isCredited": false
                    }
                  ],
                  "receipts": [
                    {
                      "id": "41343023-82bf-4c33-af46-7cf3db147156",
                      "tenantId": "224f424c-0d91-4cdc-bc49-d791fe44f478",
                      "invoiceId": "c0317ac8-e020-478e-900d-cf847b7c8167",
                      "amount": "19995.25",
                      "paymentMethod": "EFT",
                      "reference": "EFT ACME 0011",
                      "receiptDate": "2026-09-14T06:00:00.000Z",
                      "notes": null,
                      "createdById": "21de14f7-5e7d-4c47-947a-a7cbc0e9093b",
                      "createdAt": "2026-09-28T18:21:20.278Z"
                    }
                  ],
                  "creditNotes": [
                    {
                      "id": "18c027de-747a-462f-9ca0-eb5c8841bd87",
                      "creditNoteNumber": "CN-000001",
                      "type": "PARTIAL",
                      "totalAmount": "111.04",
                      "issuedAt": "2026-09-05T06:00:00.000Z",
                      "appliedAmount": "0.00"
                    }
                  ]
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Get invoice detail",
        "tags": [
          "Invoices & statements"
        ],
        "description": "One invoice with its `lineItems` (one per shipment, with its `waybillId` and `waybillNumber`), `receipts` (payments against it) and `creditNotes`. Amounts are decimal strings in rand."
      }
    },
    "/customer/invoices/{id}/pdf": {
      "get": {
        "operationId": "CustomerPortalController_downloadInvoicePdf_v1",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The invoice id: the UUID ParcelOps assigns (`shipmentId` in webhooks, `id` in list responses). A waybill number or your own reference is not accepted here; look those up with `GET /customer/shipments?search=`."
          }
        ],
        "responses": {
          "200": {
            "description": "The invoice PDF",
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "Not Found",
            "content": {
              "application/json": {
                "example": {
                  "message": "Invoice not found",
                  "error": "Not Found",
                  "statusCode": 404
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Download invoice PDF",
        "tags": [
          "Invoices & statements"
        ],
        "description": "The invoice as a PDF, `Content-Type: application/pdf`."
      }
    },
    "/customer/invoices/{id}/xlsx": {
      "get": {
        "operationId": "CustomerPortalController_downloadInvoiceXlsx_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Download the invoice as Excel (.xlsx)",
        "tags": [
          "Invoices & statements"
        ]
      }
    },
    "/customer/bookings/{referenceNumber}/pay": {
      "post": {
        "operationId": "CustomerPortalController_initiateBookingPayment_v1",
        "parameters": [
          {
            "name": "referenceNumber",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "redirectUrl": "https://www.payfast.co.za/eng/process?merchant_id=…&merchant_key=…&return_url=https%3A%2F%2Fyourshop.example%2Fpaid&cancel_url=https%3A%2F%2Fyourshop.example%2Fcancelled&notify_url=https%3A%2F%2Fapi.parcelops.co.za%2Fapi%2Fv1%2Fpublic%2Fdemo-couriers%2Fpayfast%2Fnotify&name_first=Anele&name_last=Dube&m_payment_id=BK-20260929-Y4BLXI&amount=205.50&item_name=Parcel%20delivery%20%E2%80%94%20BK-20260929-Y4BLXI&signature=…"
                }
              }
            }
          },
          "400": {
            "description": "Bad Request",
            "content": {
              "application/json": {
                "example": {
                  "message": "This booking has already been paid",
                  "error": "Bad Request",
                  "statusCode": 400
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Initiate PayFast payment for a prepaid booking — returns redirect URL",
        "tags": [
          "Quotes & booking"
        ],
        "description": "For a booking at `PENDING_PAYMENT` (customers who pay per booking): starts an online payment of its `totalInclVat` and returns `redirectUrl`, as the other payment endpoints do. The courier confirms the booking once the payment clears. Account and wallet bookings are settled when they are made and are refused with `400`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "returnUrl": "https://yourshop.example/paid",
                "cancelUrl": "https://yourshop.example/cancelled"
              }
            }
          }
        }
      }
    },
    "/customer/invoices/{id}/pay": {
      "post": {
        "operationId": "CustomerPortalController_initiateInvoicePayment_v1",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The invoice id: the UUID ParcelOps assigns (`shipmentId` in webhooks, `id` in list responses). A waybill number or your own reference is not accepted here; look those up with `GET /customer/shipments?search=`."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "redirectUrl": "https://www.payfast.co.za/eng/process?merchant_id=…&merchant_key=…&return_url=https%3A%2F%2Fyourshop.example%2Fpaid&cancel_url=https%3A%2F%2Fyourshop.example%2Fcancelled&notify_url=https%3A%2F%2Fapi.parcelops.co.za%2Fapi%2Fv1%2Fpublic%2Fdemo-couriers%2Fpayfast%2Fnotify&name_first=John&name_last=Smith&m_payment_id=INV-000017-C1&amount=303.45&item_name=Invoice%20INV-000017-C1&signature=…"
                }
              }
            }
          },
          "400": {
            "description": "Bad Request",
            "content": {
              "application/json": {
                "example": {
                  "message": "This invoice is not payable",
                  "error": "Bad Request",
                  "statusCode": 400
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Initiate PayFast payment for an invoice — returns redirect URL",
        "tags": [
          "Invoices & statements"
        ],
        "description": "Starts an online payment of what is outstanding on the invoice and returns `redirectUrl`, as `POST /customer/wallet/topup` does. The invoice is marked paid when the gateway confirms the payment. `400` for an invoice that is paid, cancelled or credited, or has nothing outstanding.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "returnUrl": "https://yourshop.example/paid",
                "cancelUrl": "https://yourshop.example/cancelled"
              }
            }
          }
        }
      }
    },
    "/customer/credit-notes/{id}": {
      "get": {
        "operationId": "CustomerPortalController_getCreditNote_v1",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The credit note id: the UUID ParcelOps assigns (`shipmentId` in webhooks, `id` in list responses). A waybill number or your own reference is not accepted here; look those up with `GET /customer/shipments?search=`."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "id": "18c027de-747a-462f-9ca0-eb5c8841bd87",
                  "creditNoteNumber": "CN-000001",
                  "type": "PARTIAL",
                  "reason": "Shipment billed at Overnight rate — agreed Economy rate applied retrospectively",
                  "subtotal": "96.56",
                  "vatAmount": "14.48",
                  "totalAmount": "111.04",
                  "issuedAt": "2026-09-05T06:00:00.000Z",
                  "invoiceId": "c0317ac8-e020-478e-900d-cf847b7c8167",
                  "invoiceNumber": "INV-000011",
                  "lineItems": [
                    {
                      "amount": 96.56,
                      "vatAmount": 14.48,
                      "description": "Credit: Umhlanga Ridge, Durban",
                      "waybillNumber": "DCS-000879"
                    }
                  ]
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Get credit note detail",
        "tags": [
          "Invoices & statements"
        ]
      }
    },
    "/customer/statement": {
      "get": {
        "operationId": "CustomerPortalController_getStatement_v1",
        "parameters": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "customer": {
                    "id": "41a8bc60-faff-4a1c-ae4f-3ef7f294052f",
                    "accountNumber": "ACC-1001",
                    "companyName": "Acme Trading (Pty) Ltd",
                    "firstName": null,
                    "lastName": null,
                    "paymentTermDays": 30,
                    "creditLimit": null,
                    "customerType": "ACCOUNT"
                  },
                  "summary": {
                    "totalInvoiced": 40667.32,
                    "totalPaid": 40667.32,
                    "totalOutstanding": 0,
                    "creditOnAccount": 0,
                    "netBalance": 0
                  },
                  "credit": null,
                  "ageBuckets": {
                    "current": 0,
                    "days30": 0,
                    "days60": 0,
                    "days90": 0,
                    "days120Plus": 0
                  },
                  "invoices": [
                    {
                      "id": "dd7ea8ad-b825-403e-9e84-932e14d26f3b",
                      "invoiceNumber": "INV-000001",
                      "status": "PAID",
                      "issueDate": "2026-07-01T06:00:00.000Z",
                      "dueDate": "2026-07-31T06:00:00.000Z",
                      "totalAmount": "4128.84",
                      "amountPaid": "4128.84",
                      "amountOutstanding": "0",
                      "ageDays": 90
                    },
                    {
                      "id": "36581683-8f1a-49ef-9e3f-d14aab7bae16",
                      "invoiceNumber": "INV-000006",
                      "status": "PAID",
                      "issueDate": "2026-08-01T06:00:00.000Z",
                      "dueDate": "2026-08-31T06:00:00.000Z",
                      "totalAmount": "16543.23",
                      "amountPaid": "16543.23",
                      "amountOutstanding": "0",
                      "ageDays": 59
                    },
                    {
                      "id": "c0317ac8-e020-478e-900d-cf847b7c8167",
                      "invoiceNumber": "INV-000011",
                      "status": "PAID",
                      "issueDate": "2026-09-01T06:00:00.000Z",
                      "dueDate": "2026-10-01T06:00:00.000Z",
                      "totalAmount": "19995.25",
                      "amountPaid": "19995.25",
                      "amountOutstanding": "0",
                      "ageDays": 28
                    }
                  ],
                  "bankDetails": {
                    "accountName": "Demo Couriers SA",
                    "accountHolder": "Demo Couriers SA",
                    "bankName": "Example Bank",
                    "bankAccountNo": "000 000 0000",
                    "bankBranchCode": "000000",
                    "reference": "ACC-1001",
                    "accountsEmail": "accounts@democouriers.example"
                  }
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Get account statement with age analysis",
        "tags": [
          "Invoices & statements"
        ],
        "description": "Your account statement: every invoice, what has been invoiced and paid, what is outstanding by age, and money held for you. `ageBuckets` splits what is outstanding by the invoice's age in days: `current` (up to 30), `days30` (31 to 60), `days60` (61 to 90), `days90` (91 to 120) and `days120Plus` (over 120). `summary.creditOnAccount` is credit your courier holds for you (an overpayment, or a credit note on a paid invoice), and `summary.netBalance` is what is outstanding less that credit. `summary.balanceDue` is what paying the statement asks for and settles: what is left of the balance brought forward plus the open invoices, less the credit on account they can use (a payment uses the credit on the oldest invoices first; credit never pays the balance brought forward). `credit` is your credit limit, what is used (owed and booked but not yet invoiced) and what is available, or `null` when the account has no limit. `openingBalance` is the balance brought forward from before your courier moved to ParcelOps (`amount`, `asAtDate`, `reference`, `amountPaid`, `amountOutstanding`, `ageDays`), or `null`: it is aged from its `asAtDate`, counts in `summary.totalOutstanding`, `summary.totalPaid` and `credit.used`, and a statement payment settles it before any invoice. `summary.openingBalance` is its amount (0 when there is none). `bankDetails` is how to pay the courier by EFT: its bank account (`accountName`, `accountHolder` (the name the account is held in, or `null`), `bankName`, `bankAccountNo`, `bankBranchCode`), the `reference` to quote (your account number) and the `accountsEmail` to send proof of payment to; `null` when the courier has not given bank details."
      }
    },
    "/customer/statement/pay": {
      "post": {
        "operationId": "CustomerPortalController_payStatementBalance_v1",
        "parameters": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "redirectUrl": "https://www.payfast.co.za/eng/process?merchant_id=…&merchant_key=…&return_url=https%3A%2F%2Fyourshop.example%2Fpaid&cancel_url=https%3A%2F%2Fyourshop.example%2Fcancelled&notify_url=https%3A%2F%2Fapi.parcelops.co.za%2Fapi%2Fv1%2Fpublic%2Fdemo-couriers%2Fpayfast%2Fnotify&name_first=John&name_last=Smith&m_payment_id=STMT-TST001&amount=1937.03&item_name=Account%20Balance%20%E2%80%94%20Sample%20Traders%20(Pty)%20Ltd&signature=…"
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Initiate PayFast payment for the total outstanding statement balance",
        "tags": [
          "Invoices & statements"
        ],
        "description": "Starts an online payment of the whole outstanding balance on the account (its open invoices, oldest paid first) and returns `redirectUrl`, as `POST /customer/wallet/topup` does.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "returnUrl": "https://yourshop.example/paid",
                "cancelUrl": "https://yourshop.example/cancelled"
              }
            }
          }
        }
      }
    },
    "/customer/statement/pdf": {
      "get": {
        "operationId": "CustomerPortalController_downloadStatementPdf_v1",
        "parameters": [],
        "responses": {
          "200": {
            "description": "The statement PDF",
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Download account statement as PDF",
        "tags": [
          "Invoices & statements"
        ],
        "description": "The account statement as a PDF, `Content-Type: application/pdf`."
      }
    },
    "/customer/addresses": {
      "get": {
        "description": "Returns every saved address on your account as a plain array, sorted by label. `q` narrows it to the addresses whose label, name, company, street, suburb, city or postal code contains the text (any case). Send `page` or `limit` to get one page instead, wrapped like the other paged lists: `{ \"items\": [ … ], \"total\": 141, \"page\": 1, \"limit\": 20, \"pages\": 8 }` (`page` one-based, default 1; `limit` default 20, at most 100). Without any of them the response is the plain array, as it always was.",
        "operationId": "CustomerPortalController_getAddresses_v1",
        "parameters": [
          {
            "name": "q",
            "required": false,
            "in": "query",
            "description": "Search label, name, company, street, suburb, city and postal code.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "page",
            "required": false,
            "in": "query",
            "description": "One-based page; sending it (or limit) returns the paged envelope.",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "limit",
            "required": false,
            "in": "query",
            "description": "Page size, default 20, at most 100; sending it (or page) returns the paged envelope.",
            "schema": {
              "type": "number"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": [
                  {
                    "id": "4a76cfd4-4655-4875-9668-9d43f27ab41d",
                    "tenantId": "224f424c-0d91-4cdc-bc49-d791fe44f478",
                    "label": "Main warehouse",
                    "name": "Anele Dube",
                    "company": "Harbour Traders (Pty) Ltd",
                    "street": "12 Sample Road",
                    "complex": null,
                    "suburb": "Marshalltown",
                    "city": "Johannesburg",
                    "province": "Gauteng",
                    "postalCode": "2001",
                    "phone": "0110000000",
                    "email": null,
                    "type": "SENDER",
                    "isDepot": false,
                    "isActive": true,
                    "customerId": "41a8bc60-faff-4a1c-ae4f-3ef7f294052f",
                    "createdAt": "2026-09-29T07:24:08.921Z",
                    "updatedAt": "2026-09-29T07:24:08.935Z"
                  }
                ]
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "List my saved addresses",
        "tags": [
          "Address book"
        ]
      },
      "post": {
        "operationId": "CustomerPortalController_createAddress_v1",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateAddressBookEntryDto"
              },
              "example": {
                "label": "Main warehouse",
                "name": "Anele Dube",
                "company": "Harbour Traders (Pty) Ltd",
                "street": "12 Sample Road",
                "suburb": "Marshalltown",
                "city": "Johannesburg",
                "province": "Gauteng",
                "postalCode": "2001",
                "phone": "0820000000",
                "type": "SENDER"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "example": {
                  "id": "4a76cfd4-4655-4875-9668-9d43f27ab41d",
                  "tenantId": "224f424c-0d91-4cdc-bc49-d791fe44f478",
                  "label": "Main warehouse",
                  "name": "Anele Dube",
                  "company": "Harbour Traders (Pty) Ltd",
                  "street": "12 Sample Road",
                  "complex": null,
                  "suburb": "Marshalltown",
                  "city": "Johannesburg",
                  "province": "Gauteng",
                  "postalCode": "2001",
                  "phone": "0820000000",
                  "email": null,
                  "type": "SENDER",
                  "isDepot": false,
                  "isActive": true,
                  "customerId": "41a8bc60-faff-4a1c-ae4f-3ef7f294052f",
                  "createdAt": "2026-09-29T07:24:08.921Z",
                  "updatedAt": "2026-09-29T07:24:08.921Z"
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Add a saved address",
        "tags": [
          "Address book"
        ]
      }
    },
    "/customer/addresses/{id}": {
      "patch": {
        "operationId": "CustomerPortalController_updateAddress_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateAddressBookEntryDto"
              },
              "example": {
                "phone": "0110000000"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "id": "4a76cfd4-4655-4875-9668-9d43f27ab41d",
                  "tenantId": "224f424c-0d91-4cdc-bc49-d791fe44f478",
                  "label": "Main warehouse",
                  "name": "Anele Dube",
                  "company": "Harbour Traders (Pty) Ltd",
                  "street": "12 Sample Road",
                  "complex": null,
                  "suburb": "Marshalltown",
                  "city": "Johannesburg",
                  "province": "Gauteng",
                  "postalCode": "2001",
                  "phone": "0110000000",
                  "email": null,
                  "type": "SENDER",
                  "isDepot": false,
                  "isActive": true,
                  "customerId": "41a8bc60-faff-4a1c-ae4f-3ef7f294052f",
                  "createdAt": "2026-09-29T07:24:08.921Z",
                  "updatedAt": "2026-09-29T07:24:08.935Z"
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Update a saved address",
        "tags": [
          "Address book"
        ]
      },
      "delete": {
        "operationId": "CustomerPortalController_deleteAddress_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "success": true
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Delete a saved address",
        "tags": [
          "Address book"
        ]
      }
    },
    "/customer/api-keys": {
      "get": {
        "operationId": "CustomerPortalController_listApiKeys_v1",
        "parameters": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": [
                  {
                    "id": "70285d43-2c01-4835-b2a4-a6f5bcf7d85e",
                    "name": "Warehouse system",
                    "keyPrefix": "po_EXAMPLE",
                    "scopes": [],
                    "createdAt": "2026-09-29T07:24:08.951Z",
                    "lastUsedAt": null
                  },
                  {
                    "id": "130c8a61-dd66-4c3f-b6cb-d4e8794e0f60",
                    "name": "Docs capture",
                    "keyPrefix": "po_EXAMPLE2",
                    "scopes": [],
                    "createdAt": "2026-09-29T05:40:22.353Z",
                    "lastUsedAt": "2026-09-29T05:46:53.580Z"
                  }
                ]
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "List my API keys (masked)",
        "tags": [
          "API keys & webhooks"
        ]
      }
    },
    "/customer/webhooks": {
      "get": {
        "operationId": "CustomerPortalController_listWebhooks_v1",
        "parameters": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": [
                  {
                    "id": "0b127e50-dd99-49b7-8035-791e888678a0",
                    "name": "Order status → my WMS",
                    "url": "https://example.com/hooks/parcelops",
                    "events": [
                      "shipment.collected",
                      "shipment.out_for_delivery",
                      "shipment.delivered"
                    ],
                    "isActive": false,
                    "createdAt": "2026-09-29T07:24:09.107Z",
                    "previousSecretExpiresAt": null,
                    "_count": {
                      "deliveries": 0
                    }
                  },
                  {
                    "id": "247c53b2-614a-45f8-ac21-bad4c87e363e",
                    "name": "Docs capture",
                    "url": "https://capture.invalid/parcelops",
                    "events": [
                      "shipment.collected",
                      "shipment.out_for_delivery",
                      "shipment.delivered"
                    ],
                    "isActive": true,
                    "createdAt": "2026-09-29T05:41:57.351Z",
                    "previousSecretExpiresAt": null,
                    "_count": {
                      "deliveries": 4
                    }
                  }
                ]
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "List my webhooks",
        "tags": [
          "API keys & webhooks"
        ]
      },
      "post": {
        "operationId": "CustomerPortalController_createWebhook_v1",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateWebhookDto"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Webhook created",
            "content": {
              "application/json": {
                "example": {
                  "id": "b1f5a2c4-9d3e-4f60-8a71-2c5e6d7f8a90",
                  "tenantId": "224f424c-0d91-4cdc-bc49-d791fe44f478",
                  "customerId": "41a8bc60-faff-4a1c-ae4f-3ef7f294052f",
                  "name": "Order status to my WMS",
                  "url": "https://hooks.example.com/parcelops",
                  "events": [
                    "shipment.created",
                    "shipment.collected",
                    "shipment.out_for_delivery",
                    "shipment.delivered",
                    "shipment.failed_delivery",
                    "booking.cancelled"
                  ],
                  "isActive": true,
                  "previousSecretExpiresAt": null,
                  "createdAt": "2026-09-29T06:00:00.000Z",
                  "updatedAt": "2026-09-29T06:00:00.000Z",
                  "secret": "5b0e9f6c1d2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4"
                }
              }
            }
          },
          "400": {
            "description": "Bad Request",
            "content": {
              "application/json": {
                "example": {
                  "message": [
                    "each event must be one of: shipment.created, shipment.collected, shipment.out_for_delivery, shipment.delivered, shipment.partially_delivered, shipment.failed_delivery, booking.cancelled, invoice.issued, invoice.paid"
                  ],
                  "error": "Bad Request",
                  "statusCode": 400
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Create a webhook",
        "tags": [
          "API keys & webhooks"
        ],
        "description": "Subscribes an HTTPS endpoint to events. The response carries the signing `secret` **once**; no other endpoint returns it. See *Webhooks* in the introduction."
      }
    },
    "/customer/webhooks/{id}": {
      "patch": {
        "operationId": "CustomerPortalController_updateWebhook_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateWebhookDto"
              },
              "example": {
                "isActive": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "id": "0b127e50-dd99-49b7-8035-791e888678a0",
                  "tenantId": "224f424c-0d91-4cdc-bc49-d791fe44f478",
                  "customerId": "41a8bc60-faff-4a1c-ae4f-3ef7f294052f",
                  "name": "Order status → my WMS",
                  "url": "https://example.com/hooks/parcelops",
                  "previousSecretExpiresAt": null,
                  "events": [
                    "shipment.collected",
                    "shipment.out_for_delivery",
                    "shipment.delivered"
                  ],
                  "isActive": false,
                  "createdAt": "2026-09-29T07:24:09.107Z",
                  "updatedAt": "2026-09-29T07:24:09.123Z"
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Update a webhook (url, events, isActive)",
        "tags": [
          "API keys & webhooks"
        ]
      },
      "delete": {
        "operationId": "CustomerPortalController_deleteWebhook_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "success": true
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Delete a webhook",
        "tags": [
          "API keys & webhooks"
        ]
      }
    },
    "/customer/webhooks/{id}/deliveries": {
      "get": {
        "operationId": "CustomerPortalController_getWebhookDeliveries_v1",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The webhook id: the UUID ParcelOps assigns (`shipmentId` in webhooks, `id` in list responses). A waybill number or your own reference is not accepted here; look those up with `GET /customer/shipments?search=`."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": [
                  {
                    "id": "829f8d44-8dc6-4b51-9858-0aed5123f26d",
                    "event": "webhook.test",
                    "status": "SUCCESS",
                    "httpStatus": 200,
                    "responseBody": "",
                    "attemptCount": 1,
                    "createdAt": "2026-09-29T07:24:09.252Z",
                    "deliveredAt": "2026-09-29T07:24:10.301Z"
                  }
                ]
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Get last 100 delivery attempts for a webhook",
        "tags": [
          "API keys & webhooks"
        ],
        "description": "The last 100 deliveries to this webhook, newest first, with each one's outcome, HTTP status, the first 500 characters of your response, and attempts made."
      }
    },
    "/customer/webhooks/{id}/test": {
      "post": {
        "operationId": "CustomerPortalController_sendTestEvent_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "success": true
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Send a test event to a webhook",
        "tags": [
          "API keys & webhooks"
        ]
      }
    },
    "/customer/webhooks/{id}/rotate-secret": {
      "post": {
        "description": "Returns the new secret once. For the next 24 hours every delivery is also signed with the old secret, in X-Parcelops-Signature-Previous, so you can deploy the new one without rejecting any event.",
        "operationId": "CustomerPortalController_rotateWebhookSecret_v1",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The webhook id: the UUID ParcelOps assigns (`shipmentId` in webhooks, `id` in list responses). A waybill number or your own reference is not accepted here; look those up with `GET /customer/shipments?search=`."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "id": "b1f5a2c4-9d3e-4f60-8a71-2c5e6d7f8a90",
                  "secret": "e3c1…new…9a7f",
                  "previousSecretExpiresAt": "2026-09-30T06:00:00.000Z"
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Rotate the signing secret",
        "tags": [
          "API keys & webhooks"
        ]
      }
    },
    "/customer/settings/general": {
      "get": {
        "operationId": "CustomerPortalController_getPortalSettings_v1",
        "parameters": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "settings": {
                    "requireShipmentApproval": false,
                    "autoAddAddresses": true,
                    "hideCollectionDetails": false,
                    "autoRemoveTrackingInfo": false,
                    "customTrackingRefLabel": ""
                  },
                  "logoUrl": null
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Get portal general settings + logo URL",
        "tags": [
          "Portal settings"
        ]
      }
    },
    "/customer/settings/shipments": {
      "get": {
        "operationId": "CustomerPortalController_getShipmentSettings_v1",
        "parameters": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "settings": {
                    "trackingPrefix": "",
                    "requireCustomReference": false,
                    "defaultCollectionFrom": "",
                    "defaultCollectionUntil": "",
                    "packagingPresets": []
                  }
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Get portal shipment settings",
        "tags": [
          "Portal settings"
        ]
      }
    },
    "/customer/notification-preferences": {
      "get": {
        "operationId": "CustomerPortalController_getNotificationPreferences_v1",
        "parameters": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "events": {
                    "collected": {
                      "sender": true,
                      "recipient": true
                    },
                    "outForDelivery": {
                      "sender": true,
                      "recipient": true
                    },
                    "delivered": {
                      "sender": true,
                      "recipient": true
                    },
                    "failedDelivery": {
                      "sender": true,
                      "recipient": true
                    }
                  },
                  "global": {
                    "disableBillingEmails": false
                  }
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Get my notification preferences",
        "tags": [
          "Portal settings"
        ]
      }
    },
    "/customer/account/users": {
      "get": {
        "operationId": "CustomerPortalController_listAccountUsers_v1",
        "parameters": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": [
                  {
                    "id": "9cd23164-8242-4b41-bca8-bfa2b57d586a",
                    "firstName": "Thabo",
                    "lastName": "Mokoena",
                    "email": "thabo@acmetrading.example",
                    "customerRole": "ACCOUNT_OWNER",
                    "customerPermissions": null,
                    "costCentreId": null,
                    "costCentre": null,
                    "lastLoginAt": "2026-09-29T07:24:07.734Z",
                    "createdAt": "2026-09-28T18:21:20.981Z"
                  }
                ]
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "List all portal users on this account",
        "tags": [
          "Account & users"
        ],
        "description": "The portal users on your account. Needs the **Manage account users** permission (see *Permissions*), as does `GET /customer/account/users/invites`. Users are invited, changed and removed signed in to the portal, never with a key."
      }
    },
    "/customer/account/users/invites": {
      "get": {
        "operationId": "CustomerPortalController_listPendingInvites_v1",
        "parameters": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": [
                  {
                    "id": "0c9c0b6d-60ba-45e5-a523-80c3592c358c",
                    "email": "new.colleague.mumcoub4@harbourtraders.example",
                    "role": "STAFF",
                    "expiresAt": "2026-10-06T07:24:10.497Z",
                    "createdAt": "2026-09-29T07:24:10.498Z",
                    "invitedBy": {
                      "firstName": "Thabo",
                      "lastName": "Mokoena"
                    }
                  }
                ]
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "List pending invites for this account",
        "tags": [
          "Account & users"
        ]
      }
    },
    "/customer/account/cost-centres": {
      "get": {
        "operationId": "CustomerPortalController_listCostCentres_v1",
        "parameters": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": [
                  {
                    "id": "56a3ca0c-77da-4bdf-a1fb-6474cdd0d11d",
                    "name": "Warehouse (Germiston)",
                    "code": "WH-OUB4",
                    "isActive": true,
                    "createdAt": "2026-09-29T07:24:11.159Z",
                    "_count": {
                      "users": 0,
                      "bookings": 0,
                      "waybills": 0
                    }
                  }
                ]
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "List cost centres for this account",
        "tags": [
          "Account & users"
        ]
      },
      "post": {
        "operationId": "CustomerPortalController_createCostCentre_v1",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateCostCentreDto"
              },
              "example": {
                "name": "Warehouse",
                "code": "WH-OUB4"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "example": {
                  "id": "56a3ca0c-77da-4bdf-a1fb-6474cdd0d11d",
                  "name": "Warehouse",
                  "code": "WH-OUB4",
                  "isActive": true,
                  "createdAt": "2026-09-29T07:24:11.159Z"
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Create a cost centre",
        "tags": [
          "Account & users"
        ],
        "description": "Cost centres split an account's shipments and invoices by department or branch. `code` is optional and unique on the account. Changing cost centres needs the **Manage cost centres** permission; listing them needs none."
      }
    },
    "/customer/account/cost-centres/{id}": {
      "patch": {
        "operationId": "CustomerPortalController_updateCostCentre_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateCostCentreDto"
              },
              "example": {
                "name": "Warehouse (Germiston)"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "id": "56a3ca0c-77da-4bdf-a1fb-6474cdd0d11d",
                  "name": "Warehouse (Germiston)",
                  "code": "WH-OUB4",
                  "isActive": true,
                  "updatedAt": "2026-09-29T07:24:11.164Z"
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Update a cost centre",
        "tags": [
          "Account & users"
        ]
      },
      "delete": {
        "operationId": "CustomerPortalController_deleteCostCentre_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "success": true
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Delete or deactivate a cost centre",
        "tags": [
          "Account & users"
        ]
      }
    },
    "/public/{tenantSlug}/config": {
      "get": {
        "operationId": "PublicCustomerConfigController_getConfig_v1",
        "parameters": [
          {
            "name": "tenantSlug",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "id": "224f424c-0d91-4cdc-bc49-d791fe44f478",
                  "name": "Demo Couriers SA",
                  "slug": "workpack-demo",
                  "logoUrl": null,
                  "primaryColor": "#2563eb",
                  "email": "hello@democouriers.example",
                  "supportEmail": "support@democouriers.example",
                  "phone": "+27 10 000 0000",
                  "address": "1 Sample Street, Braamfontein",
                  "city": "Johannesburg",
                  "country": "ZA",
                  "payfastEnabled": false,
                  "accountsEmail": "accounts@democouriers.example"
                }
              }
            }
          }
        },
        "summary": "Get tenant branding config for the customer portal login screen",
        "tags": [
          "Public (no key needed)"
        ],
        "security": []
      }
    },
    "/customer/invite/{token}": {
      "get": {
        "operationId": "CustomerInviteController_getInviteDetails_v1",
        "parameters": [
          {
            "name": "token",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "email": "new.colleague.mumcoub4@harbourtraders.example",
                  "role": "STAFF",
                  "customerName": "Acme Trading (Pty) Ltd",
                  "tenantName": "Demo Couriers SA",
                  "tenantLogoUrl": null,
                  "tenantSlug": "workpack-demo"
                }
              }
            }
          }
        },
        "summary": "Get invite details (public — for the accept invite page)",
        "tags": [
          "Public (no key needed)"
        ],
        "security": [],
        "description": "No key needed. What an invitation is for, looked up by the token in the invitation email: for building your own accept page."
      }
    },
    "/customer/invite/{token}/accept": {
      "post": {
        "operationId": "CustomerInviteController_acceptInvite_v1",
        "parameters": [
          {
            "name": "token",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AcceptCustomerInviteDto"
              },
              "example": {
                "firstName": "Sipho",
                "lastName": "Ndlovu",
                "password": "at-least-8-characters"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "success": true,
                  "email": "new.colleague.mumcoub4@harbourtraders.example"
                }
              }
            }
          }
        },
        "summary": "Accept an invite and create an account",
        "tags": [
          "Public (no key needed)"
        ],
        "security": [],
        "description": "No key needed. Accepts an invitation and creates the portal user with the password given."
      }
    },
    "/customer/signed-pods": {
      "get": {
        "operationId": "CustomerSignedPodController_list_v1",
        "parameters": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "example": {
                  "unmatched": [
                    {
                      "id": "0b4f9a8e-2c1d-4e5f-8a9b-1c2d3e4f5a6b",
                      "number": "WB-77120",
                      "fileName": "WB-77120_20260929_141502.pdf",
                      "pageCount": 1,
                      "signedBy": "T Mokoena",
                      "deliveredAt": "2026-09-29T12:05:00.000Z",
                      "filedAt": "2026-09-29T15:51:00.000Z"
                    }
                  ],
                  "recent": [
                    {
                      "shipmentId": "c6459b89-8531-4ec5-b15d-908549caa221",
                      "waybillNumber": "DCS-002199",
                      "customerReference": "PO-2026-0042",
                      "fileName": "DCS-002199_20260929_141502.pdf",
                      "pageCount": 2,
                      "filedAt": "2026-09-29T15:51:00.000Z"
                    }
                  ]
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Your signed PODs: those not matched to a shipment, then the newest filed against your shipments",
        "tags": [
          "Shipments"
        ],
        "description": "Your signed PODs as the courier filed them. `unmatched`: signed PODs whose number the courier could not match to one of your shipments (the number is as written on the paper); download them with `GET /customer/signed-pods/unmatched/{id}`. Once the courier links one to its shipment it moves there and leaves this list. `recent`: the newest 50 filed against your shipments; download those with `GET /customer/shipments/{shipmentId}/signed-pod`."
      }
    },
    "/customer/signed-pods/unmatched/{id}": {
      "get": {
        "operationId": "CustomerSignedPodController_downloadUnmatched_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The signed POD PDF",
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "Not Found",
            "content": {
              "application/json": {
                "example": {
                  "message": "Signed POD not found",
                  "error": "Not Found",
                  "statusCode": 404
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Download a signed POD that was not matched to a shipment, as a PDF",
        "tags": [
          "Shipments"
        ],
        "description": "A signed POD the courier could not match to one of your shipments, as a PDF (`Content-Type: application/pdf`). `404` for any id that is not one of your own."
      }
    },
    "/customer/shipments/{id}/pod/pdf": {
      "get": {
        "operationId": "CustomerSignedPodController_downloadPodPdf_v1",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The shipment id: the UUID ParcelOps assigns (`shipmentId` in webhooks, `id` in list responses). A waybill number or your own reference is not accepted here; look those up with `GET /customer/shipments?search=`."
          }
        ],
        "responses": {
          "200": {
            "description": "The POD PDF",
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "Not Found",
            "content": {
              "application/json": {
                "example": {
                  "message": "POD not found",
                  "error": "Not Found",
                  "statusCode": 404
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Download the proof of delivery of a delivered shipment as a PDF",
        "tags": [
          "Shipments"
        ],
        "description": "The proof of delivery as a PDF (`Content-Type: application/pdf`, with the file name in `Content-Disposition`): the courier's own branded POD, the same document the delivered email attaches, with who signed, when, the signature when one was captured and, if the courier shares them, the delivery photos.\n\n- Until the shipment is delivered, and for one delivered with no POD recorded, this returns **`404`** with `{\"message\": \"POD not found\"}`."
      }
    },
    "/customer/shipments/{id}/signed-pod": {
      "get": {
        "operationId": "CustomerSignedPodController_download_v1",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The shipment id: the UUID ParcelOps assigns (`shipmentId` in webhooks, `id` in list responses). A waybill number or your own reference is not accepted here; look those up with `GET /customer/shipments?search=`."
          }
        ],
        "responses": {
          "200": {
            "description": "The signed POD PDF",
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "Not Found",
            "content": {
              "application/json": {
                "example": {
                  "message": "No signed POD has been filed for this shipment yet",
                  "error": "Not Found",
                  "statusCode": 404
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Download the signed proof of delivery (the scanned paper copy) as a PDF",
        "tags": [
          "Shipments"
        ],
        "description": "The signed proof of delivery: the paper copy the receiver signed, scanned by the courier once the driver brought it back, as a PDF (`Content-Type: application/pdf`, with the file name in `Content-Disposition`). It is the POD to keep and invoice from.\n\n- One PDF per shipment, all its signed pages in scan order. When the paper was scanned more than once, the newest copy is returned.\n- **`404`** with `{\"message\": \"No signed POD has been filed for this shipment yet\"}` until the courier files it, which is usually a day or more after delivery. The `pod` from `GET /customer/shipments/{id}/pod` is there from the moment of delivery."
      }
    },
    "/rating/quote": {
      "post": {
        "description": "**Company key scope:** `finance:read`. The key's owner also needs their own permission for this endpoint.\n\nReturns billable weight, line-item breakdown, and total charge in ZAR. Optionally pass customerId to apply customer-specific tariff rates.",
        "operationId": "RatingController_quote_v1",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/QuoteRequestDto"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Calculate a freight quote",
        "tags": [
          "Company API: Finance"
        ],
        "x-parcelops-scope": "finance:read"
      }
    },
    "/rating/all-quotes": {
      "post": {
        "description": "**Company key scope:** `finance:read`. The key's owner also needs their own permission for this endpoint.\n\nFan-out quote: calls the rating engine for every service level and returns results as an array. Available options have available=true with full pricing; unavailable ones have available=false and a reason. Each says whether the workspace offers the service at all (offered: a live rate card prices it): an offered service with no card for this route says \"Not on your price list for this route\", one offered nowhere \"No tariff configured\" (the booking forms hide those).",
        "operationId": "RatingController_allQuotes_v1",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AllQuotesRequestDto"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Get pricing for all service levels in one call",
        "tags": [
          "Company API: Finance"
        ],
        "x-parcelops-scope": "finance:read"
      }
    },
    "/rating/tariffs": {
      "get": {
        "description": "**Company key scope:** `rates:read`. The key's owner also needs their own permission for this endpoint.\n\nEvery card with its customer links. serviceLevels and destZoneCodes are the sets the card prices (a card saved the old way shows its single service and destination); an empty destZoneCodes means any destination. serviceLevel and destZoneCode are kept for older clients: the first service in enum order, and the destination code, null for any, or \"*MULTI*\" for several. All filters are optional and combine.",
        "operationId": "RatingController_listTariffs_v1",
        "parameters": [
          {
            "name": "search",
            "required": false,
            "in": "query",
            "description": "Name contains (case-insensitive)",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "customerId",
            "required": false,
            "in": "query",
            "description": "Cards linked to this customer",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "standard",
            "required": false,
            "in": "query",
            "description": "true = standard cards (no customer links); false = linked cards",
            "schema": {
              "enum": [
                "true",
                "false"
              ],
              "type": "string"
            }
          },
          {
            "name": "serviceLevel",
            "required": false,
            "in": "query",
            "description": "Cards that price this service",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "zone",
            "required": false,
            "in": "query",
            "description": "Cards with this zone among their destinations, or as their origin",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "active",
            "required": false,
            "in": "query",
            "description": "Filter on isActive",
            "schema": {
              "enum": [
                "true",
                "false"
              ],
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "List rate cards for this tenant",
        "tags": [
          "Company API: Rates"
        ],
        "x-parcelops-scope": "rates:read"
      }
    },
    "/rating/surcharge-config": {
      "get": {
        "operationId": "RatingController_getSurchargeConfig_v1",
        "parameters": [],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Get the tenant-wide event-based surcharge configuration",
        "tags": [
          "Company API: Rates"
        ],
        "description": "**Company key scope:** `rates:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "rates:read"
      }
    },
    "/rating/dim-config/{customerId}": {
      "get": {
        "operationId": "RatingController_getDimConfigs_v1",
        "parameters": [
          {
            "name": "customerId",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Get volumetric DIM divisors for a customer",
        "tags": [
          "Company API: Rates"
        ],
        "description": "**Company key scope:** `rates:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "rates:read"
      }
    },
    "/rating/tariffs/{id}/tiers": {
      "get": {
        "operationId": "RatingController_listTiers_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "List volume-tiered pricing tiers for a tariff",
        "tags": [
          "Company API: Rates"
        ],
        "description": "**Company key scope:** `rates:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "rates:read"
      }
    },
    "/rating/lane-overrides/{customerId}": {
      "get": {
        "operationId": "RatingController_getLaneOverrides_v1",
        "parameters": [
          {
            "name": "customerId",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "List special-rate lane overrides for a customer",
        "tags": [
          "Company API: Rates"
        ],
        "description": "**Company key scope:** `rates:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "rates:read"
      }
    },
    "/rating/tariffs/{id}/brackets": {
      "get": {
        "operationId": "RatingController_listBrackets_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "List sliding-scale KG brackets for a tariff",
        "tags": [
          "Company API: Rates"
        ],
        "description": "**Company key scope:** `rates:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "rates:read"
      }
    },
    "/rating/tariffs/{id}/ring-brackets": {
      "get": {
        "operationId": "RatingController_listRingBrackets_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "List ring-area brackets for a tariff",
        "tags": [
          "Company API: Rates"
        ],
        "description": "**Company key scope:** `rates:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "rates:read"
      }
    },
    "/rating/surcharges": {
      "get": {
        "operationId": "RatingController_listSurcharges_v1",
        "parameters": [],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "List tenant surcharge records",
        "tags": [
          "Company API: Rates"
        ],
        "description": "**Company key scope:** `rates:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "rates:read"
      }
    },
    "/invoices": {
      "get": {
        "description": "**Company key scope:** `finance:read`. The key's owner also needs their own permission for this endpoint.\n\nReturns invoices for the tenant, newest first. All filter params are optional and combinable. Use `dateFrom`/`dateTo` to filter by invoice issue date, not creation date.",
        "operationId": "InvoicingController_list_v1",
        "parameters": [
          {
            "name": "limit",
            "required": false,
            "in": "query",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "page",
            "required": false,
            "in": "query",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "dateTo",
            "required": false,
            "in": "query",
            "description": "Issue date on or before (YYYY-MM-DD).",
            "schema": {}
          },
          {
            "name": "dateFrom",
            "required": false,
            "in": "query",
            "description": "Issue date on or after (YYYY-MM-DD).",
            "schema": {}
          },
          {
            "name": "hubId",
            "required": false,
            "in": "query",
            "description": "Filter to invoices whose waybills originated at this hub.",
            "schema": {}
          },
          {
            "name": "status",
            "required": false,
            "in": "query",
            "description": "Filter by invoice status (DRAFT, ISSUED, PARTIALLY_PAID, PAID, OVERDUE, CANCELLED, CREDITED).",
            "schema": {}
          },
          {
            "name": "customerId",
            "required": false,
            "in": "query",
            "description": "Filter by customer UUID.",
            "schema": {}
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated invoice list."
          },
          "401": {
            "description": "Not authenticated."
          },
          "403": {
            "description": "Insufficient permissions."
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "List invoices",
        "tags": [
          "Company API: Finance"
        ],
        "x-parcelops-scope": "finance:read"
      }
    },
    "/invoices/credit-notes": {
      "get": {
        "description": "**Company key scope:** `finance:read`. The key's owner also needs their own permission for this endpoint.\n\nReturns all credit notes for the tenant, newest first. Optionally filter by the customer or original invoice they are linked to.",
        "operationId": "InvoicingController_listCreditNotes_v1",
        "parameters": [
          {
            "name": "limit",
            "required": false,
            "in": "query",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "page",
            "required": false,
            "in": "query",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "invoiceId",
            "required": false,
            "in": "query",
            "description": "Filter by the original invoice UUID.",
            "schema": {}
          },
          {
            "name": "customerId",
            "required": false,
            "in": "query",
            "description": "Filter by customer UUID.",
            "schema": {}
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of credit notes."
          },
          "401": {
            "description": "Not authenticated."
          },
          "403": {
            "description": "Insufficient permissions."
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "List credit notes",
        "tags": [
          "Company API: Finance"
        ],
        "x-parcelops-scope": "finance:read"
      }
    },
    "/invoices/credit-notes/{id}": {
      "get": {
        "description": "**Company key scope:** `finance:read`. The key's owner also needs their own permission for this endpoint.\n\nReturns full detail for a credit note including the original invoice reference, line-item snapshot, and re-billed waybill IDs.",
        "operationId": "InvoicingController_getCreditNote_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "description": "Credit note UUID.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Credit note detail."
          },
          "401": {
            "description": "Not authenticated."
          },
          "403": {
            "description": "Insufficient permissions."
          },
          "404": {
            "description": "Credit note not found."
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Get credit note detail",
        "tags": [
          "Company API: Finance"
        ],
        "x-parcelops-scope": "finance:read"
      }
    },
    "/invoices/credit-notes/{id}/pdf": {
      "get": {
        "description": "**Company key scope:** `finance:read`. The key's owner also needs their own permission for this endpoint.\n\nGenerates and streams the credit note as a PDF (red-headed layout with the original invoice reference and reason). The PDF is regenerated on every request to guarantee freshness.",
        "operationId": "InvoicingController_downloadCreditNotePdf_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "description": "Credit note UUID.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "PDF binary stream (Content-Type: application/pdf).",
            "content": {
              "application/pdf": {}
            }
          },
          "401": {
            "description": "Not authenticated."
          },
          "404": {
            "description": "Credit note not found."
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Download credit note PDF",
        "tags": [
          "Company API: Finance"
        ],
        "x-parcelops-scope": "finance:read"
      }
    },
    "/invoices/{id}": {
      "get": {
        "description": "**Company key scope:** `finance:read`. The key's owner also needs their own permission for this endpoint.\n\nReturns full invoice detail including all line items (one per waybill), surcharge sub-rows, payment receipts, and any associated credit notes. Line items include a full per-waybill charge breakdown.",
        "operationId": "InvoicingController_getOne_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "description": "Invoice UUID.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Invoice detail with line items, receipts, and credit notes."
          },
          "401": {
            "description": "Not authenticated."
          },
          "403": {
            "description": "Insufficient permissions."
          },
          "404": {
            "description": "Invoice not found."
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Get invoice detail",
        "tags": [
          "Company API: Finance"
        ],
        "x-parcelops-scope": "finance:read"
      }
    },
    "/invoices/{id}/pdf": {
      "get": {
        "description": "**Company key scope:** `finance:read`. The key's owner also needs their own permission for this endpoint.\n\nGenerates and streams the invoice as a PDF (professional navy-banner layout with full line items, VAT breakdown, and banking details). The PDF is regenerated on every call to guarantee freshness — a copy is also stored in MinIO for archival.",
        "operationId": "InvoicingController_downloadPdf_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "description": "Invoice UUID.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "PDF binary stream (Content-Type: application/pdf).",
            "content": {
              "application/pdf": {}
            }
          },
          "401": {
            "description": "Not authenticated."
          },
          "404": {
            "description": "Invoice not found."
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Download invoice PDF",
        "tags": [
          "Company API: Finance"
        ],
        "x-parcelops-scope": "finance:read"
      }
    },
    "/waybills": {
      "post": {
        "operationId": "WaybillsController_create_v1",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateWaybillDto"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Create a new waybill (auto-generates waybill number and calculates charges)",
        "tags": [
          "Company API: Waybills"
        ],
        "description": "**Company key scope:** `waybills:write`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "waybills:write"
      },
      "get": {
        "operationId": "WaybillsController_findAll_v1",
        "parameters": [
          {
            "name": "limit",
            "required": false,
            "in": "query",
            "description": "Rows per page, at most 100",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "page",
            "required": false,
            "in": "query",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "status",
            "required": false,
            "in": "query",
            "description": "One status",
            "schema": {
              "enum": [
                "DRAFT",
                "CREATED",
                "COLLECTION_ASSIGNED",
                "COLLECTED",
                "CHECKED_IN",
                "AT_HUB",
                "IN_TRANSIT",
                "AT_DESTINATION_HUB",
                "OUT_FOR_DELIVERY",
                "DELIVERED",
                "PARTIALLY_DELIVERED",
                "FAILED_DELIVERY",
                "RETURNED_TO_HUB",
                "REDELIVERY_SCHEDULED",
                "RETURNED_TO_SENDER",
                "LOST",
                "CANCELLED",
                "HELD"
              ],
              "type": "string"
            }
          },
          {
            "name": "qcStatus",
            "required": false,
            "in": "query",
            "description": "QC for billing",
            "schema": {
              "enum": [
                "PENDING",
                "PASSED",
                "QUERY"
              ],
              "type": "string"
            }
          },
          {
            "name": "customerId",
            "required": false,
            "in": "query",
            "description": "One customer (id)",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "search",
            "required": false,
            "in": "query",
            "description": "Part of the waybill number, reference, order number, sender or recipient name; or a piece sticker exactly",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "recipientPhone",
            "required": false,
            "in": "query",
            "description": "Part of the recipient's phone number",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "recipientName",
            "required": false,
            "in": "query",
            "description": "Part of the recipient's name",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "overdueOnly",
            "required": false,
            "in": "query",
            "description": "true: past the SLA deadline and not finished (replaces status)",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "dateFrom",
            "required": false,
            "in": "query",
            "description": "From this South African day (YYYY-MM-DD), inclusive, on dateBasis",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "dateTo",
            "required": false,
            "in": "query",
            "description": "To this South African day (YYYY-MM-DD), inclusive, on dateBasis",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "dateBasis",
            "required": false,
            "in": "query",
            "description": "Which date dateFrom/dateTo read: captured (default), collected (the COLLECTED line on its timeline) or delivered",
            "schema": {
              "enum": [
                "captured",
                "collected",
                "delivered"
              ],
              "type": "string"
            }
          },
          {
            "name": "zoneId",
            "required": false,
            "in": "query",
            "description": "The recipient's area (To area): a zone id, or NONE for none of your areas",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "fromZoneId",
            "required": false,
            "in": "query",
            "description": "The sender's area (From area): a zone id, or NONE for none of your areas",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "town",
            "required": false,
            "in": "query",
            "description": "Part of the recipient's town, any case",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "serviceLevel",
            "required": false,
            "in": "query",
            "description": "One service level",
            "schema": {
              "enum": [
                "SAME_DAY",
                "OVERNIGHT",
                "ECONOMY",
                "ECONOMY_REGIONAL",
                "ECONOMY_ROAD",
                "BUDGET",
                "INTERNATIONAL"
              ],
              "type": "string"
            }
          },
          {
            "name": "serviceProductId",
            "required": false,
            "in": "query",
            "description": "One of your product codes (id)",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "hubId",
            "required": false,
            "in": "query",
            "description": "A hub (id), as hubRole says",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "hubRole",
            "required": false,
            "in": "query",
            "description": "The hub as origin, destination or either (any, the default)",
            "schema": {
              "enum": [
                "any",
                "origin",
                "destination"
              ],
              "type": "string"
            }
          },
          {
            "name": "driverId",
            "required": false,
            "in": "query",
            "description": "A driver (id), as driverRole says",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "driverRole",
            "required": false,
            "in": "query",
            "description": "The driver who collected it, is delivering it, or either (any, the default)",
            "schema": {
              "enum": [
                "any",
                "collection",
                "delivery"
              ],
              "type": "string"
            }
          },
          {
            "name": "agentId",
            "required": false,
            "in": "query",
            "description": "An agent (id) on any leg: collection, line haul or delivery",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "vehicleId",
            "required": false,
            "in": "query",
            "description": "On a manifest carried by this vehicle (id)",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "delivered",
            "required": false,
            "in": "query",
            "description": "true: delivered; false: not (yet) delivered",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "invoiced",
            "required": false,
            "in": "query",
            "description": "true: on an issued invoice; false: not billed yet (or only on a draft)",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "pod",
            "required": false,
            "in": "query",
            "description": "true: a proof of delivery is recorded or a signed POD is filed; false: neither",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "capturedById",
            "required": false,
            "in": "query",
            "description": "Captured by this user (id)",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "customerUserId",
            "required": false,
            "in": "query",
            "description": "Booked by this customer's user (id), on the portal or with their API key",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "numbers",
            "required": false,
            "in": "query",
            "description": "Waybill numbers separated by newlines, commas, semicolons or spaces (at most 500)",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "List waybills with filters",
        "tags": [
          "Company API: Waybills"
        ],
        "description": "**Company key scope:** `waybills:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "waybills:read"
      }
    },
    "/waybills/import": {
      "post": {
        "operationId": "WaybillsController_importWaybills_v1",
        "parameters": [
          {
            "name": "dryRun",
            "required": true,
            "in": "query",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "skipErrors",
            "required": true,
            "in": "query",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "overrideCreditLimit",
            "required": true,
            "in": "query",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "201": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Import waybills from CSV. dryRun=true validates without creating.",
        "tags": [
          "Company API: Waybills"
        ],
        "description": "**Company key scope:** `waybills:write`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "waybills:write"
      }
    },
    "/waybills/quick-find": {
      "get": {
        "operationId": "WaybillsController_quickFind_v1",
        "parameters": [
          {
            "name": "q",
            "required": true,
            "in": "query",
            "description": "Waybill number or customer reference",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Find a waybill by waybill number or customer reference — returns id + waybillNumber for navigation",
        "tags": [
          "Company API: Waybills"
        ],
        "description": "**Company key scope:** `waybills:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "waybills:read"
      }
    },
    "/waybills/{id}/documents": {
      "post": {
        "operationId": "WaybillsController_uploadDocument_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "type",
            "required": true,
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "reference",
            "required": true,
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "notes",
            "required": true,
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "201": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Upload a document (signed POD, GRV, customs etc.) and attach it to a waybill",
        "tags": [
          "Company API: Waybills"
        ],
        "description": "**Company key scope:** `waybills:write`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "waybills:write"
      }
    },
    "/waybills/{id}": {
      "get": {
        "operationId": "WaybillsController_findOne_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "404": {
            "description": "Waybill not found"
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Get waybill with full detail (pieces, events, surcharges)",
        "tags": [
          "Company API: Waybills"
        ],
        "description": "**Company key scope:** `waybills:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "waybills:read"
      }
    },
    "/waybills/{id}/status": {
      "patch": {
        "operationId": "WaybillsController_updateStatus_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateStatusDto"
              }
            }
          }
        },
        "responses": {
          "400": {
            "description": "Invalid status transition, or a status that must go through its own flow"
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Manually transition waybill status (validated against state machine; statuses with their own flow — cancel, collection, dispatch, POD, re-delivery — are refused)",
        "tags": [
          "Company API: Waybills"
        ],
        "description": "**Company key scope:** `waybills:write`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "waybills:write"
      }
    },
    "/waybills/{id}/piece-labels.pdf": {
      "get": {
        "operationId": "WaybillsController_pieceLabelsPdf_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Per-piece sticker labels (100×150 mm, one barcode per box) — multi-piece scanning",
        "tags": [
          "Company API: Waybills"
        ],
        "description": "**Company key scope:** `waybills:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "waybills:read"
      }
    },
    "/waybills/{id}/piece-labels.zpl": {
      "get": {
        "operationId": "WaybillsController_pieceLabelsZpl_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Per-piece stickers as ZPL for Zebra thermal printers (4×6 label stock)",
        "tags": [
          "Company API: Waybills"
        ],
        "description": "**Company key scope:** `waybills:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "waybills:read"
      }
    },
    "/waybills/{id}/label.pdf": {
      "get": {
        "operationId": "WaybillsController_labelPdf_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "PDF binary"
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Download waybill label as PDF (100×150 mm)",
        "tags": [
          "Company API: Waybills"
        ],
        "description": "**Company key scope:** `waybills:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "waybills:read"
      }
    },
    "/waybills/{id}/label.zpl": {
      "get": {
        "operationId": "WaybillsController_labelZpl_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "ZPL text"
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Download waybill label as ZPL (Zebra thermal printer)",
        "tags": [
          "Company API: Waybills"
        ],
        "description": "**Company key scope:** `waybills:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "waybills:read"
      }
    },
    "/waybills/{id}/pod": {
      "post": {
        "operationId": "WaybillsController_submitPod_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "x-captured-at",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "x-pod-photos",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "X-Pod-Photos",
            "in": "header",
            "description": "Delivery photos that follow this proof (POST /waybills/{id}/pod/photos, 0-3). The customer's delivered email waits a few minutes for them.",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "X-Captured-At",
            "in": "header",
            "description": "ISO time the driver captured this on the phone",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SubmitPodDto"
              }
            }
          }
        },
        "responses": {
          "400": {
            "description": "A partial delivery (deliveredPieces): code POD_NO_PARCELS (none named), POD_UNKNOWN_PARCEL (not one of this waybill's), POD_REASON_REQUIRED (parcels left out need undeliveredReason)"
          },
          "404": {
            "description": "Waybill not found"
          },
          "409": {
            "description": "code TRANSFERRED: the stop was moved to another driver after this proof was captured; code POD_RACED: another proof was recorded for it a moment ago"
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Submit proof of delivery (signature capture)",
        "tags": [
          "Company API: Waybills"
        ],
        "description": "**Company key scope:** `waybills:write`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "waybills:write"
      },
      "get": {
        "operationId": "WaybillsController_getPod_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "404": {
            "description": "POD not found"
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Get proof of delivery with presigned signature URL",
        "tags": [
          "Company API: Waybills"
        ],
        "description": "**Company key scope:** `waybills:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "waybills:read"
      }
    },
    "/waybills/{id}/pod/photos": {
      "post": {
        "description": "**Company key scope:** `waybills:write`. The key's owner also needs their own permission for this endpoint.\n\nAfter the proof is recorded. JPEG or PNG, at most 3 per POD, 3 MB each. Sending the same photoId again stores it once. Never shown on public tracking.",
        "operationId": "WaybillsController_addPodPhoto_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "x-captured-at",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "X-Captured-At",
            "in": "header",
            "description": "ISO time the driver captured this on the phone",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PodPhotoDto"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "{ waybillId, photoId, photoCount }"
          },
          "400": {
            "description": "code POD_PHOTO_TYPE: not a JPEG or PNG"
          },
          "404": {
            "description": "Waybill not found, or not held by this driver"
          },
          "409": {
            "description": "code POD_NOT_RECORDED, POD_PHOTO_LIMIT, or TRANSFERRED (the stop was moved to another driver)"
          },
          "413": {
            "description": "code POD_PHOTO_TOO_LARGE"
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Add a delivery photo to the proof of delivery (the driver who holds the stop, or staff)",
        "tags": [
          "Company API: Waybills"
        ],
        "x-parcelops-scope": "waybills:write"
      }
    },
    "/waybills/{id}/cancel": {
      "post": {
        "operationId": "WaybillsController_cancel_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "400": {
            "description": "Cannot cancel from current status"
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Cancel a waybill (shortcut — validates allowed transitions)",
        "tags": [
          "Company API: Waybills"
        ],
        "description": "**Company key scope:** `waybills:write`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "waybills:write"
      }
    },
    "/waybills/reports/returns": {
      "get": {
        "operationId": "WaybillsController_returnsReport_v1",
        "parameters": [
          {
            "name": "from",
            "required": true,
            "in": "query",
            "schema": {
              "example": "2026-06-01",
              "type": "string"
            }
          },
          {
            "name": "to",
            "required": true,
            "in": "query",
            "schema": {
              "example": "2026-06-30",
              "type": "string"
            }
          },
          {
            "name": "customerId",
            "required": false,
            "in": "query",
            "schema": {}
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Returns analysis report — failed deliveries, returned to hub/sender, re-delivery rate",
        "tags": [
          "Company API: Reports"
        ],
        "description": "**Company key scope:** `reports:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "reports:read"
      }
    },
    "/waybills/reports/productivity": {
      "get": {
        "operationId": "WaybillsController_productivityReport_v1",
        "parameters": [
          {
            "name": "from",
            "required": true,
            "in": "query",
            "schema": {
              "example": "2026-05-01",
              "type": "string"
            }
          },
          {
            "name": "to",
            "required": true,
            "in": "query",
            "schema": {
              "example": "2026-05-31",
              "type": "string"
            }
          },
          {
            "name": "userId",
            "required": false,
            "in": "query",
            "schema": {}
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Agent productivity report — waybills created and deliveries dispatched per user for a date range",
        "tags": [
          "Company API: Reports"
        ],
        "description": "**Company key scope:** `reports:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "reports:read"
      }
    },
    "/waybills/reports/transit-times": {
      "get": {
        "operationId": "WaybillsController_transitTimesReport_v1",
        "parameters": [
          {
            "name": "from",
            "required": true,
            "in": "query",
            "schema": {
              "example": "2026-05-01",
              "type": "string"
            }
          },
          {
            "name": "to",
            "required": true,
            "in": "query",
            "schema": {
              "example": "2026-05-31",
              "type": "string"
            }
          },
          {
            "name": "customerId",
            "required": false,
            "in": "query",
            "schema": {}
          },
          {
            "name": "serviceLevel",
            "required": false,
            "in": "query",
            "schema": {}
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Transit times / SLA compliance report — on-time delivery rate by service level and customer",
        "tags": [
          "Company API: Reports"
        ],
        "description": "**Company key scope:** `reports:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "reports:read"
      }
    },
    "/customer/equipment": {
      "get": {
        "description": "What a booking can say the vehicle needs (`needs` on `POST /customer/book`): the five standard tags (`ROOF_RACK`, `CANOPY`, `TAIL_LIFT`, `TARPAULIN`, `TRAILER`) and any the courier added. Send the `key`.\n\n`oversizeThresholdCm` is the courier's oversize length in cm (or `null`): a parcel with a side that long or longer usually needs a roof rack, and the customer portal ticks it for you.\n\nThe `needs` a booking may carry (`POST /customer/book`): each tag's `key` and `label`. Send keys (or labels). `oversizeThresholdCm` is the courier's oversize length, or null: the portal ticks \"Roof rack\" for a parcel with a side that long.",
        "operationId": "CustomerEquipmentController_list_v1",
        "parameters": [],
        "responses": {
          "200": {
            "description": "The tags, and the oversize length",
            "content": {
              "application/json": {
                "example": {
                  "tags": [
                    {
                      "key": "ROOF_RACK",
                      "label": "Roof rack"
                    },
                    {
                      "key": "CANOPY",
                      "label": "Canopy"
                    },
                    {
                      "key": "TAIL_LIFT",
                      "label": "Tail lift"
                    },
                    {
                      "key": "TARPAULIN",
                      "label": "Tarpaulin"
                    },
                    {
                      "key": "TRAILER",
                      "label": "Trailer"
                    },
                    {
                      "key": "COLD_BOX",
                      "label": "Cold box"
                    }
                  ],
                  "oversizeThresholdCm": 250
                }
              }
            }
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "What a booking can say the vehicle needs",
        "tags": [
          "Quotes & booking"
        ]
      }
    },
    "/bookings/{id}": {
      "get": {
        "operationId": "BookingController_getBookingById_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "customerId",
            "required": false,
            "in": "query",
            "description": "A public booking priced on the standard rate cards: the account Accept is to make it on, so priceCheck prices on that account, as Accept will. Ignored when the booking was priced as an account (Accept takes no other).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Get booking request detail by id",
        "tags": [
          "Company API: Bookings"
        ],
        "description": "**Company key scope:** `bookings:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "bookings:read"
      }
    },
    "/bookings": {
      "get": {
        "operationId": "BookingController_listBookings_v1",
        "parameters": [
          {
            "name": "dateFrom",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "dateTo",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "status",
            "required": false,
            "in": "query",
            "schema": {
              "enum": [
                "SUBMITTED",
                "PENDING_PAYMENT",
                "PAYMENT_RECEIVED",
                "CONFIRMED",
                "CANCELLED",
                "EXPIRED"
              ],
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "List booking requests",
        "tags": [
          "Company API: Bookings"
        ],
        "description": "**Company key scope:** `bookings:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "bookings:read"
      }
    },
    "/bookings/{id}/confirm": {
      "patch": {
        "description": "**Company key scope:** `bookings:write`. The key's owner also needs their own permission for this endpoint.\n\nWhen the courier has switched on \"email the waybill when staff accept an online booking\" (Settings, Waybills), the reply also says whether the waybill was emailed to the sender (waybillEmail: { sent, to } or { sent: false, reason }).",
        "operationId": "BookingController_confirmBooking_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ConfirmBookingDto"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Confirm payment received and create waybill",
        "tags": [
          "Company API: Bookings"
        ],
        "x-parcelops-scope": "bookings:write"
      }
    },
    "/bookings/{id}/payment-received": {
      "patch": {
        "operationId": "BookingController_markPaymentReceived_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MarkPaymentReceivedDto"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Mark payment as received (an EFT, say) without creating the waybill yet; audited",
        "tags": [
          "Company API: Bookings"
        ],
        "description": "**Company key scope:** `bookings:write`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "bookings:write"
      }
    },
    "/bookings/{id}/cancel": {
      "patch": {
        "operationId": "BookingController_cancelBooking_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Cancel a booking request",
        "tags": [
          "Company API: Bookings"
        ],
        "description": "**Company key scope:** `bookings:write`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "bookings:write"
      }
    },
    "/bookings/{id}/reweigh": {
      "patch": {
        "operationId": "BookingController_reweighBooking_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ReweighBookingDto"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Re-weigh a booking — records measurement discrepancy and balance due",
        "tags": [
          "Company API: Bookings"
        ],
        "description": "**Company key scope:** `bookings:write`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "bookings:write"
      }
    },
    "/customers": {
      "get": {
        "operationId": "CustomersController_findAll_v1",
        "parameters": [
          {
            "name": "search",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "isActive",
            "required": false,
            "in": "query",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "customerType",
            "required": false,
            "in": "query",
            "schema": {
              "enum": [
                "ACCOUNT",
                "PREPAID",
                "WALLET"
              ],
              "type": "string"
            }
          },
          {
            "name": "page",
            "required": false,
            "in": "query",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "limit",
            "required": false,
            "in": "query",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "repId",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "accountStatus",
            "required": false,
            "in": "query",
            "description": "Account statuses, comma separated (ACTIVE, PAYMENT_ARRANGEMENT, STOP_CREDIT, HANDED_OVER, CLOSED).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "List customers with optional search and pagination",
        "tags": [
          "Company API: Customers"
        ],
        "description": "**Company key scope:** `customers:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "customers:read"
      }
    },
    "/customers/{id}": {
      "get": {
        "operationId": "CustomersController_findOne_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "404": {
            "description": "Customer not found"
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Get customer with addresses and tariffs",
        "tags": [
          "Company API: Customers"
        ],
        "description": "**Company key scope:** `customers:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "customers:read"
      }
    },
    "/customers/{id}/statement": {
      "get": {
        "operationId": "CustomersController_getStatement_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Get customer statement with age analysis",
        "tags": [
          "Company API: Customers"
        ],
        "description": "**Company key scope:** `customers:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "customers:read"
      }
    },
    "/customers/{id}/statement/pdf": {
      "get": {
        "operationId": "CustomersController_getStatementPdf_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Download the customer statement as a PDF",
        "tags": [
          "Company API: Customers"
        ],
        "description": "**Company key scope:** `customers:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "customers:read"
      }
    },
    "/collections": {
      "post": {
        "operationId": "CollectionsController_create_v1",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateCollectionDto"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Schedule a new collection",
        "tags": [
          "Company API: Collections"
        ],
        "description": "**Company key scope:** `collections:write`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "collections:write"
      },
      "get": {
        "operationId": "CollectionsController_findAll_v1",
        "parameters": [
          {
            "name": "page",
            "required": false,
            "in": "query",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "limit",
            "required": false,
            "in": "query",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "date",
            "required": false,
            "in": "query",
            "description": "Filter by exact date YYYY-MM-DD",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "dateFrom",
            "required": false,
            "in": "query",
            "description": "Filter scheduledDate >= YYYY-MM-DD",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "dateTo",
            "required": false,
            "in": "query",
            "description": "Filter scheduledDate <= YYYY-MM-DD",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "status",
            "required": false,
            "in": "query",
            "schema": {
              "enum": [
                "PENDING",
                "ASSIGNED",
                "EN_ROUTE",
                "COLLECTED",
                "CHECKED_IN",
                "MISSED",
                "CANCELLED",
                "REJECTED"
              ],
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "List collections with optional filters",
        "tags": [
          "Company API: Collections"
        ],
        "description": "**Company key scope:** `collections:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "collections:read"
      }
    },
    "/collections/{id}": {
      "get": {
        "operationId": "CollectionsController_findOne_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "404": {
            "description": "Collection not found"
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Get a single collection by ID",
        "tags": [
          "Company API: Collections"
        ],
        "description": "**Company key scope:** `collections:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "collections:read"
      }
    },
    "/collections/{id}/assign": {
      "patch": {
        "operationId": "CollectionsController_assignDriver_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AssignDriverDto"
              }
            }
          }
        },
        "responses": {
          "409": {
            "description": "EQUIPMENT_MISSING: the vehicle lacks equipment a parcel needs. canOverride true: send again with overrideEquipment true. canOverride false: the workspace blocks it."
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Assign a driver to a collection",
        "tags": [
          "Company API: Collections"
        ],
        "description": "**Company key scope:** `collections:write`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "collections:write"
      }
    },
    "/collections/{id}/complete": {
      "patch": {
        "operationId": "CollectionsController_complete_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "x-captured-at",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CompleteCollectionBodyDto"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Mark collection as completed (parcel in hand)",
        "tags": [
          "Company API: Collections"
        ],
        "description": "**Company key scope:** `collections:write`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "collections:write"
      }
    },
    "/collections/{id}/cancel": {
      "patch": {
        "operationId": "CollectionsController_cancel_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Cancel a collection",
        "tags": [
          "Company API: Collections"
        ],
        "description": "**Company key scope:** `collections:write`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "collections:write"
      }
    },
    "/pod/missing-report": {
      "get": {
        "description": "**Company key scope:** `reports:read`. The key's owner also needs their own permission for this endpoint.\n\nDelivered waybills with a POD exception, grouped by delivery agent: **no electronic POD** (no signature captured) and/or **physical POD unconfirmed** (not yet confirmed in debrief). With `view=hard-copies`: delivered waybills with **no signed POD scanned and filed** (Missing hard copies). Aged by delivery date. Defaults to the last 30 days.",
        "operationId": "PodController_report_v1",
        "parameters": [
          {
            "name": "from",
            "required": false,
            "in": "query",
            "description": "Delivered on or after (YYYY-MM-DD). Defaults to 30 days ago.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "to",
            "required": false,
            "in": "query",
            "description": "Delivered on or before (YYYY-MM-DD). Defaults to today.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "driverId",
            "required": false,
            "in": "query",
            "description": "Limit to one delivery driver/agent (UUID).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "view",
            "required": false,
            "in": "query",
            "description": "`exceptions`: no electronic POD and/or the physical POD not confirmed in debrief. `hard-copies`: delivered with no signed POD scanned and filed yet (the paper still outstanding).",
            "schema": {
              "enum": [
                "exceptions",
                "hard-copies"
              ],
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Missing-POD-by-agent report",
        "tags": [
          "Company API: Reports"
        ],
        "x-parcelops-scope": "reports:read"
      }
    },
    "/pod/missing-report/export": {
      "get": {
        "operationId": "PodController_exportReport_v1",
        "parameters": [
          {
            "name": "from",
            "required": false,
            "in": "query",
            "description": "Delivered on or after (YYYY-MM-DD). Defaults to 30 days ago.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "to",
            "required": false,
            "in": "query",
            "description": "Delivered on or before (YYYY-MM-DD). Defaults to today.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "driverId",
            "required": false,
            "in": "query",
            "description": "Limit to one delivery driver/agent (UUID).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "view",
            "required": false,
            "in": "query",
            "description": "`exceptions`: no electronic POD and/or the physical POD not confirmed in debrief. `hard-copies`: delivered with no signed POD scanned and filed yet (the paper still outstanding).",
            "schema": {
              "enum": [
                "exceptions",
                "hard-copies"
              ],
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Download the missing-POD report as CSV",
        "tags": [
          "Company API: Reports"
        ],
        "description": "**Company key scope:** `reports:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "reports:read"
      }
    },
    "/pod/{waybillId}/pdf": {
      "get": {
        "operationId": "PodController_pdf_v1",
        "parameters": [
          {
            "name": "waybillId",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "PDF stream.",
            "content": {
              "application/pdf": {}
            }
          },
          "404": {
            "description": "No POD recorded."
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Download the proof-of-delivery PDF for a waybill",
        "tags": [
          "Company API: Waybills"
        ],
        "description": "**Company key scope:** `waybills:read`. The key's owner also needs their own permission for this endpoint.",
        "x-parcelops-scope": "waybills:read"
      }
    },
    "/customer/signed-pods/batches/{batchId}/combined": {
      "get": {
        "operationId": "CustomerPodBatchController_combined_v1",
        "parameters": [
          {
            "name": "batchId",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "ApiKey": []
          }
        ],
        "summary": "Download all your signed PODs of one batch as one PDF",
        "tags": [
          "Shipments"
        ]
      }
    },
    "/quotes": {
      "get": {
        "description": "**Company key scope:** `finance:read`. The key's owner also needs their own permission for this endpoint.\n\nReturns all quotes for the authenticated tenant, newest first. Filter by status and/or creation date range. All filters are optional and combinable.",
        "operationId": "QuotesController_list_v1",
        "parameters": [
          {
            "name": "status",
            "required": false,
            "in": "query",
            "description": "Filter by quote status.",
            "schema": {
              "enum": [
                "DRAFT",
                "SENT",
                "ACCEPTED",
                "EXPIRED",
                "CANCELLED",
                "CONVERTED"
              ],
              "type": "string"
            }
          },
          {
            "name": "dateFrom",
            "required": false,
            "in": "query",
            "description": "Created on or after this date (YYYY-MM-DD).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "dateTo",
            "required": false,
            "in": "query",
            "description": "Created on or before this date (YYYY-MM-DD).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Array of quote summaries."
          },
          "401": {
            "description": "Not authenticated."
          },
          "403": {
            "description": "Insufficient permissions — requires FINANCE VIEW level."
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "List quotes",
        "tags": [
          "Company API: Finance"
        ],
        "x-parcelops-scope": "finance:read"
      }
    },
    "/quotes/{id}": {
      "get": {
        "description": "**Company key scope:** `finance:read`. The key's owner also needs their own permission for this endpoint.\n\nReturns full detail for a single quote including line-item breakdown, customer info, and payment status, and `editLock`: null while it can be edited, otherwise `{ reason, message }` saying why not. Returns 404 if the quote does not exist or belongs to a different tenant.",
        "operationId": "QuotesController_findOne_v1",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "description": "Quote UUID.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Quote detail object."
          },
          "401": {
            "description": "Not authenticated."
          },
          "403": {
            "description": "Insufficient permissions."
          },
          "404": {
            "description": "Quote not found."
          }
        },
        "security": [
          {
            "CompanyKey": []
          }
        ],
        "summary": "Get quote detail",
        "tags": [
          "Company API: Finance"
        ],
        "x-parcelops-scope": "finance:read"
      }
    }
  },
  "info": {
    "title": "ParcelOps API",
    "version": "1.0.0",
    "contact": {
      "name": "ParcelOps",
      "url": "",
      "email": "api@parcelops.co.za"
    },
    "description": "The ParcelOps API: quote, book, track and pull documents for shipments, plus webhooks for live status pushes. Base URL: `https://api.parcelops.co.za/api/v1`.\n\n# Authentication\n\nAuthenticate every request with an API key sent as a Bearer token:\n\n```\nAuthorization: Bearer po_your_api_key\n```\n\nThere are two kinds of key, and each calls its own part of this reference:\n\n- **Customer keys**, for a courier's customers. Generate one under **Integrations → API Keys** in your customer portal. It calls the customer endpoints (`/customer/…`), sees its own customer account's shipments, invoices and documents and nothing else, and acts with the permissions of the portal user who created it (see *Permissions*). Revoke it there at any time.\n- **Company keys**, for a courier that runs ParcelOps, to connect its own website, warehouse system or ERP. A workspace admin creates one under **Settings → API Keys** in the ParcelOps dashboard, with the scopes it needs. It calls only the endpoints under **Company API** in this reference (see *Company API keys*).\n\nA key never manages credentials or people. Changing a password or two-factor, creating or revoking API keys, inviting, changing or removing users, and changing billing details or portal settings are done signed in to the portal: a request made with a key is refused with `403` and `\"code\": \"API_KEY_NOT_ALLOWED\"`, whatever its user may do, and those endpoints are not in this reference. Reading stays open: your profile and account, its users and invites, your keys and the portal settings.\n\n# Errors\n\nStandard HTTP status codes with a JSON body. `400` carries a descriptive validation message, `401` means a missing, expired or revoked key, `403` means the key may not do that (its account may not see that resource, the endpoint is for a signed-in user only (`\"code\": \"API_KEY_NOT_ALLOWED\"`), or a company key lacks the scope or the endpoint is not open to company keys), `404` an unknown resource. Timestamps are ISO 8601 (`YYYY-MM-DDTHH:mm:ssZ`).\n\n# Shipment statuses\n\nA shipment's `status` walks this lifecycle:\n\n| Status | Meaning |\n|---|---|\n| `CREATED` | Booked, not yet collected |\n| `COLLECTION_ASSIGNED` | A driver is assigned to collect |\n| `COLLECTED` | Picked up from the sender |\n| `CHECKED_IN` / `AT_HUB` | Received and scanned at a hub |\n| `IN_TRANSIT` | Moving between hubs on a manifest |\n| `AT_DESTINATION_HUB` | At the hub nearest the recipient |\n| `OUT_FOR_DELIVERY` | On a vehicle for final delivery |\n| `DELIVERED` | Delivered, POD captured |\n| `PARTIALLY_DELIVERED` | Some parcels delivered, the rest on their way (`pieces[].deliveredAt` says which); moves on like `FAILED_DELIVERY` until `DELIVERED` |\n| `FAILED_DELIVERY` | An attempt failed (reason recorded) |\n| `REDELIVERY_SCHEDULED` | A new attempt is scheduled |\n| `RETURNED_TO_HUB` / `RETURNED_TO_SENDER` | Coming back |\n| `HELD` / `LOST` / `CANCELLED` | Exceptions |\n\n# Identifiers\n\n| Identifier | Looks like | Where it comes from | Use it for |\n|---|---|---|---|\n| Shipment id | `c6459b89-8531-4ec5-b15d-908549caa221` | `id` in shipment lists; `shipmentId` in webhooks; `waybillId` on a confirmed booking | The `{id}` in every `/customer/shipments/{id}` endpoint (detail, POD, label, cancel) |\n| Waybill number | `DCS-002199` | Printed on the label; `waybillNumber` everywhere | Showing people; finding the id with `GET /customer/shipments?search=DCS-002199` |\n| Booking reference | `BK-20260929-Y4BLXI` | `referenceNumber` from `POST /customer/book` | Following a booking until the courier confirms it |\n| Your reference | anything you send | `customerReference` on the booking | Matching shipments and events to your own records; finding a booking with `GET /customer/bookings?search=` and a shipment with `GET /customer/shipments?customerReference=`. Not unique, not an idempotency key |\n| Idempotency key | `woo:3f9a1c:1042:1` | `idempotencyKey` you send in the body of `POST /customer/book` | Making a retry safe: see *Safe retries* |\n| Shop order | `Woo #1042` | `externalOrderRef` you send on `POST /customer/book` | The courier's staff see which shop order a waybill is; it comes back on the shipment and in its webhooks |\n\nA shipment id is a UUID; a path `{id}` that is not one is refused with `400 Validation failed (uuid is expected)`.\n\n# Permissions\n\nThis section is about customer keys; company keys are under *Company API keys*. A customer key acts as the portal user who created it, with that user's permissions (a key made before 29 September 2026 acts as the account owner). The account owner holds every permission; the owner sets everyone else's under **Account Users** in the portal. A request the user may not make is refused with `403` and `\"Insufficient permissions\"`. Create your integration's key as a user who holds what it needs.\n\n| Permission (as the portal names it) | What it opens |\n|---|---|\n| Book parcels | `POST /customer/quote`, `POST /customer/book`, `GET /customer/bookings`, `POST /customer/bookings/{referenceNumber}/pay` |\n| View invoices & statement | `GET /customer/invoices`, an invoice and its PDF, `GET /customer/credit-notes/{id}`, the statement and its PDF |\n| Pay invoices | `POST /customer/invoices/{id}/pay`, `POST /customer/statement/pay` |\n| Manage address book | Everything under `/customer/addresses` |\n| View dashboard & analytics | The portal's dashboard; no endpoint in this reference |\n| Manage account users | `GET /customer/account/users` and `GET /customer/account/users/invites` |\n| Manage cost centres | Creating, changing and deleting cost centres |\n| Change portal settings | `GET /customer/notification-preferences` |\n| Manage integrations & API keys | `GET /customer/api-keys`, and everything under `/customer/webhooks` |\n| View rates & quotes | No endpoint of its own. Without it, prices are left out of quotes, shipments and bookings. |\n\nShipments (list, detail, POD, label, cancel), the wallet, your profile, `GET /customer/settings/general`, `GET /customer/settings/shipments` and the list of cost centres need no permission.\n\nWhatever its user may do, a key never changes a password or two-factor, creates or revokes API keys, invites, changes or removes users, or changes billing details or portal settings: those are done signed in to the portal, and a key gets `403` with `\"code\": \"API_KEY_NOT_ALLOWED\"`.\n\n# Booking a shipment\n\n1. **Quote** (optional): `POST /customer/quote` prices every service level.\n2. **Book**: `POST /customer/book` creates a booking request and returns its `referenceNumber` and `status`: `SUBMITTED` for account and prepaid-wallet customers, `PENDING_PAYMENT` for customers who pay per booking.\n3. **The courier confirms it.** That creates the shipment, at status `CREATED`, and the booking becomes `CONFIRMED`. `GET /customer/bookings` then shows the shipment's `waybillId` and waybill number, the shipment carries your `customerReference` (or the booking reference if you sent none), and the `shipment.created` webhook fires. On an account the courier **accepts automatically**, this happens within the booking request itself: the response is `CONFIRMED` with the `waybillNumber`, `waybillId` and `collection`, or `SUBMITTED` with `autoAcceptError` when it waits for the courier after all (see `POST /customer/book`). A booking the courier cancels instead sends `booking.cancelled`.\n4. **Track it** with webhooks, or `GET /customer/shipments/{id}`.\n\nThe price is always calculated by ParcelOps, never taken from the request. A route and service with no rate is booked without a price on an account customer's key (see `POST /customer/book`: `pricesHidden: true`, no amounts, and the courier prices it) and refused (`400`, `code: \"NO_RATE\"`) for a wallet or pay-per-booking customer. Without an idempotency key, repeating a request creates another booking: send one (*Safe retries*), or after a timeout check `GET /customer/bookings?search=<your customerReference>` before retrying. `GET /customer/bookings` also takes `status`, to list only the bookings at one status. On an account whose waybill number is your reference, the reference is required and checked when you book, and a repeat is refused with `409` `DUPLICATE_REFERENCE` naming the booking you already have (see `POST /customer/book`).\n\n**Conditions of carriage.** A courier may publish its own conditions of carriage; its waybills name them and link to its public page (`https://<courier>/online/conditions`). People booking on the customer portal or the courier's booking page tick \"I accept the conditions of carriage\". **An API client is not asked:** by booking through the API you accept the courier's conditions of carriage under your agreement with the courier, and the booking records the version of the conditions in force when you booked. Nothing in the request or the response changes; `acceptConditions` and `conditionsVersion` on `POST /customer/book` are for the portal and may be left out.\n\n# Safe retries\n\nA booking request can time out after the booking was made. To retry without booking twice, send an **idempotency key** with `POST /customer/book`: send `idempotencyKey` in the body.\n\n- **Make one key per booking you mean**, from something you already have: for example `woo:<your shop>:<order id>:<attempt>`. 8 to 100 characters, and only ASCII: the letters A to Z (no accented letters such as é), digits, spaces and the printable symbols on an English keyboard; spaces at either end are removed. It must be a string (a number, an object or a list is refused with `400` `INVALID_IDEMPOTENCY_KEY`).\n- **The same key with the same booking** returns the first call's response again, unchanged, with HTTP `200` (the first call answered `201`) and the header `Idempotent-Replay: true`. Nothing new is made: no booking, waybill, collection, wallet debit, notification or webhook. This holds for any number of retries, and for calls made at the same moment (one books, the others wait for it and get its answer).\n- **The same key with a different booking** (other pieces, recipient, sender, address, phone, emails, service, collection day or window, special instructions, vehicle `needs`, `customerReference` or `quotedCharge`) is refused with `409` and `code: \"IDEMPOTENCY_KEY_REUSED\"`, naming the booking the key belongs to, and nothing is made. Text is compared without regard to case or extra spaces. Only `source` and the shop order fields do not count.\n- **A retry that changes the booking counts as a different booking and is refused with `409`, even when you meant the same parcel.** Send exactly the body of the first call. In particular these are different bookings: a `quotedCharge` that was re-quoted, added or dropped (do not re-quote before a retry, and send the same `quotedCharge` or none each time); a phone written `+27 21 000 0000` the first time and `021 000 0000` the next; changed special instructions, emails, `needs` or collection window. Build the body once, keep it with the key, and resend that.\n- **A call that was refused** before it booked (a `400`, not enough funds in a wallet, over the credit limit) keeps nothing: the key is free to try again.\n- **In the rare case** a first call has not finished 15 seconds later, a retry gets `409` with `code: \"IDEMPOTENCY_KEY_IN_PROGRESS\"`: try again in a few seconds.\n- Keys belong to your account: another customer of the courier can use the same text for its own bookings. **Keys never expire.**\n- **A replay is a snapshot** of the first answer, not the booking's state today: if the courier has since accepted it or cancelled it, the replay still says what the first answer said. Read the current state from `GET /customer/bookings?search=<referenceNumber>` or `GET /customer/shipments/{id}`.\n\n# Pagination\n\nThe shipment, booking and invoice lists take `page` (one-based, default `1`) and `limit` (default `20`, supported maximum `100`), return newest first, and wrap results in:\n\n```json\n{ \"items\": [ … ], \"total\": 141, \"page\": 1, \"limit\": 20, \"pages\": 8 }\n```\n\nA page past the end returns an empty `items`. The wallet ledger is paged the same way but returns `{ data, total, page, limit }` (default `limit` 50). API keys, webhooks, users, invites and cost centres are short lists returned whole, as a plain array. Addresses are too, unless you send `page` or `limit`: `GET /customer/addresses` then returns one page in the envelope above, and `q` searches either way.\n\n# Money, weights and time\n\nAmounts are rand. In quotes they are JSON numbers; on bookings, shipments and invoices they are decimal strings (for example `\"494.39\"`), so parse them as decimals. `totalCharge` excludes VAT; `vatAmount` and `totalInclVat` are given alongside. Weights are kilograms, dimensions centimetres. Timestamps are ISO 8601 in UTC.\n\n# Proof of delivery\n\n`GET /customer/shipments/{id}/pod` returns `recipientName`, `capturedAt`, `signatureUrl` and `photoUrls`: pre-signed HTTPS links to the signature image and to the driver's delivery photos, valid for one hour, fetched **without** an `Authorization` header. Until delivery it returns `404` (`\"POD not found\"`), a partially delivered shipment included. The `shipment.delivered` webhook carries who signed and when, so you know when to fetch it. A shipment delivered in parts returns the proof of the last part, plus `earlier`: each earlier part's `recipientName`, `capturedAt`, `signatureUrl` and `photoUrls`, oldest first.\n\n# Partial delivery\n\nA shipment of several parcels can be delivered in parts: the recipient takes some parcels and the rest come back to the courier's depot and go out again. In between, the shipment's `status` is `PARTIALLY_DELIVERED` (added October 2026), and it moves on as after a failed delivery attempt (`RETURNED_TO_HUB`, `REDELIVERY_SCHEDULED`, `OUT_FOR_DELIVERY`) until the rest is delivered and it is `DELIVERED`. Through all of that, `pieces[].deliveredAt` on the shipment says which parcels are delivered, and its `events` carry a line such as \"Partially delivered: 1 of 2 parcels\". The shipment's `pod` stays `null`, and `GET /customer/shipments/{id}/pod` stays `404`, until it is `DELIVERED`.\n\n- Handle a `status` you do not recognise as a shipment still in progress: new statuses are added rarely, and always announced here.\n- `GET /customer/shipments/status-summary` counts `PARTIALLY_DELIVERED` with the shipments still on the move.\n- Webhooks: `shipment.partially_delivered` is a separate event that you subscribe to. Nothing else changes: a webhook subscribed to `shipment.failed_delivery` is not sent anything for a partial delivery, and `shipment.delivered` is sent once, when the last parcel is delivered.\n- A one-parcel shipment is never partially delivered.\n\n# Webhooks\n\nSubscribe with `POST /customer/webhooks` (or under **Integrations → Webhooks** in the portal): a public HTTPS URL and the events you want. Events: `shipment.created`, `shipment.collected`, `shipment.out_for_delivery`, `shipment.delivered`, `shipment.partially_delivered`, `shipment.failed_delivery`, `booking.cancelled`, `invoice.issued`, `invoice.paid`. Any other name is refused. Every shipment event is sent for every shipment on your account, whether or not it has an email address. Each event's payload is under **Webhooks** in this reference.\n\nEvery delivery is a `POST` with a JSON body:\n\n| Field | Meaning |\n|---|---|\n| `version` | Payload version, `\"1\"`. Fields may be added within a version, never renamed or removed |\n| `id` | Unique id of this event |\n| `event` | The event name |\n| `timestamp` | When the event was queued (ISO 8601 UTC) |\n| `tenantSlug` | The courier account it came from |\n| `data` | The event's fields; for shipment events always `shipmentId`, `waybillNumber`, `status`, `occurredAt`, `customerReference`, `externalOrderRef` (your shop's order from the booking, or null) and `trackingUrl` (the courier's public tracking page), plus `bookingReference` and `collection` (created), `failureReason` (failed delivery), `deliveredAt`, `pod`, `podUrl` and `podUrlNeedsKey` (delivered), and `failureReason`, `piecesDelivered`, `piecesTotal`, `pieces` and `pod` (partially delivered); for `booking.cancelled`, `bookingReference`, `customerReference`, `status` and `occurredAt` |\n\n**Headers.** `X-Parcelops-Signature` is `sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your webhook secret. `X-Parcelops-Delivery-Id` identifies the delivery and stays the same across retries: use it to ignore duplicates. `X-Parcelops-Event` repeats the event name.\n\nVerify the signature over the raw bytes, before parsing, with a constant-time comparison:\n\n```js\nconst crypto = require('crypto');\nfunction verify(rawBody, header, secret) {\n  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');\n  return header && header.length === expected.length\n    && crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));\n}\n```\n\n**Secrets and rotation.** The secret is returned once, when the webhook is created. To rotate it, call `POST /customer/webhooks/{id}/rotate-secret`: it returns the new secret once, and for the next 24 hours every delivery also carries `X-Parcelops-Signature-Previous`, signed with the old secret. Accept a delivery if either header verifies against a secret you hold, deploy the new one, and the old one stops being used when the window ends.\n\n**Delivery and retries.** Answer with any 2xx within 15 seconds; redirects are not followed. Otherwise the delivery is retried after 1, 2, 4, 8, 16, 32, 64 minutes (8 attempts, about two hours in all), then marked failed. `GET /customer/webhooks/{id}/deliveries` shows the last 100 deliveries and their outcomes. A subscription is switched off automatically only after 72 hours without one successful delivery; switch it back on with `PATCH /customer/webhooks/{id}` and `{\"isActive\": true}`. Events are not guaranteed to arrive in order: use `status` and `occurredAt`, or fetch `GET /customer/shipments/{id}` for the current state. `POST /customer/webhooks/{id}/test` sends a `webhook.test` event.\n\n# Company API keys\n\nFor couriers that run ParcelOps: connect your own website, warehouse system or ERP to your workspace. A workspace admin creates the key under **Settings → API Keys → Company API keys** in the ParcelOps dashboard, with a name, the scopes it needs and, if you like, an expiry date. The key is shown once; ParcelOps keeps only a hash of it.\n\n- A company key **acts as the admin who created it**. Every request needs one of the key's scopes **and** that admin's own permission for the endpoint, so a key never does more than its owner can.\n- It is **deny by default**: it calls only the endpoints listed below (they are the **Company API** groups in this reference). Every other endpoint answers `403`, including settings, users, permissions, the audit log and the customer endpoints. A write scope includes its read scope.\n- It stops at once (`401`) when it expires or is revoked, when its owner is deactivated, removed or no longer a workspace admin, or when the workspace is suspended.\n- Work done with a key is recorded in the workspace's audit log under its owner, marked with the key's prefix.\n- `GET /auth/me` answers for any company key, with the admin it acts as.\n- **Vehicle equipment.** `POST /waybills` takes an optional `needs` (what the vehicle must have, such as `ROOF_RACK`). Once your workspace records equipment on its vehicles, `PATCH /collections/{id}/assign` answers `409` with `code: \"EQUIPMENT_MISSING\"` when the driver's vehicle lacks what the parcel needs, listing what is missing. With `canOverride: true`, send the same request again with `\"overrideEquipment\": true` to assign anyway (it is noted on the parcel's timeline and in the audit log); with `canOverride: false` the workspace does not allow it, so choose another driver.\n\n| Scope | What it opens | Endpoints |\n|---|---|---|\n| `waybills:read` | List, search and read waybills with their tracking events, labels and PODs | `GET /waybills`, `GET /waybills/quick-find`, `GET /waybills/{id}`, `GET /waybills/{id}/label.pdf`, `GET /waybills/{id}/label.zpl`, `GET /waybills/{id}/piece-labels.pdf`, `GET /waybills/{id}/piece-labels.zpl`, `GET /waybills/{id}/pod`, `GET /pod/{waybillId}/pdf` |\n| `waybills:write` | Create and import waybills, update their status, cancel them, and upload documents and PODs | `POST /waybills`, `POST /waybills/import`, `PATCH /waybills/{id}/status`, `POST /waybills/{id}/cancel`, `POST /waybills/{id}/documents`, `POST /waybills/{id}/pod`, `POST /waybills/{id}/pod/photos` |\n| `collections:read` | List and read collections | `GET /collections`, `GET /collections/{id}` |\n| `collections:write` | Schedule, assign, complete and cancel collections | `POST /collections`, `PATCH /collections/{id}/assign`, `PATCH /collections/{id}/complete`, `PATCH /collections/{id}/cancel` |\n| `bookings:read` | List and read online bookings | `GET /bookings`, `GET /bookings/{id}` |\n| `bookings:write` | Confirm, cancel, re-weigh and mark bookings paid | `PATCH /bookings/{id}/confirm`, `PATCH /bookings/{id}/cancel`, `PATCH /bookings/{id}/reweigh`, `PATCH /bookings/{id}/payment-received` |\n| `customers:read` | List and read customer accounts (with their addresses) and their statements | `GET /customers`, `GET /customers/{id}`, `GET /customers/{id}/statement`, `GET /customers/{id}/statement/pdf` |\n| `finance:read` | Read invoices, credit notes and quotes, and price a consignment | `GET /invoices`, `GET /invoices/{id}`, `GET /invoices/{id}/pdf`, `GET /invoices/credit-notes`, `GET /invoices/credit-notes/{id}`, `GET /invoices/credit-notes/{id}/pdf`, `GET /quotes`, `GET /quotes/{id}`, `POST /rating/quote`, `POST /rating/all-quotes` |\n| `rates:read` | Read tariffs with their tiers and brackets, surcharges, and customer DIM and lane settings | `GET /rating/tariffs`, `GET /rating/tariffs/{id}/tiers`, `GET /rating/tariffs/{id}/brackets`, `GET /rating/tariffs/{id}/ring-brackets`, `GET /rating/surcharges`, `GET /rating/surcharge-config`, `GET /rating/dim-config/{customerId}`, `GET /rating/lane-overrides/{customerId}` |\n| `reports:read` | Operational reports: missing PODs, returns, productivity and transit times | `GET /pod/missing-report`, `GET /pod/missing-report/export`, `GET /waybills/reports/returns`, `GET /waybills/reports/productivity`, `GET /waybills/reports/transit-times` |\n\n# Rate limits\n\nLimits apply to each client IP address and, for a request made with an API key, to the key as well, however many machines use it: up to 100 requests a second in a burst, and 2,000 a minute sustained. Past either, requests get `429 Too Many Requests`; wait and retry.\n\n# Testing\n\nThere is no shared test key: every request needs a key of your own, and every example in this reference is a real response.\n\n**To try the API now, start a free trial** at [parcelops.co.za/start-trial](https://www.parcelops.co.za/start-trial). You get your own courier workspace for 7 days, filled with sample customers, shipments and invoices. In its operations dashboard, open **Explore → Customer portal** to enter the portal as one of the sample customers, then create a key under **Integrations → API Keys**. Every request in this reference then works against that workspace, at the same base URL. Bookings there reach no real depot or driver, but an email address you enter on a booking is real, so use your own.\n\n**Trying a company key?** In the trial workspace's dashboard, open **Settings → API Keys** and create one under **Company API keys**.\n\n**Building for a courier that already uses ParcelOps?** Ask them for a test customer account, or ParcelOps for a test workspace that does not expire, and create your key in its customer portal. The API, webhooks and PODs behave exactly as in production."
  },
  "tags": [
    {
      "name": "Shipments",
      "description": "Track, cancel, and pull labels and PODs for your shipments"
    },
    {
      "name": "Quotes & booking",
      "description": "Price a consignment and book it"
    },
    {
      "name": "Wallet & top-ups",
      "description": "Prepaid balance, ledger and online top-ups (PayFast or Paystack)"
    },
    {
      "name": "Invoices & statements",
      "description": "Documents, PDFs and online payment"
    },
    {
      "name": "Address book",
      "description": "Saved senders and recipients"
    },
    {
      "name": "Account & users",
      "description": "Your profile and account, the users on it, and cost centres. Users are invited, changed and removed in the portal"
    },
    {
      "name": "API keys & webhooks",
      "description": "Your API keys (created and revoked in the portal) and webhook subscriptions"
    },
    {
      "name": "Portal settings",
      "description": "Branding, shipment defaults and notification preferences, to read. They are changed in the portal"
    },
    {
      "name": "Public (no key needed)",
      "description": "Unauthenticated tenant branding and invite endpoints"
    },
    {
      "name": "Company API: Waybills",
      "description": "`waybills:read`: List, search and read waybills with their tracking events, labels and PODs. `waybills:write`: Create and import waybills, update their status, cancel them, and upload documents and PODs. For company keys (see *Company API keys*)."
    },
    {
      "name": "Company API: Collections",
      "description": "`collections:read`: List and read collections. `collections:write`: Schedule, assign, complete and cancel collections. For company keys (see *Company API keys*)."
    },
    {
      "name": "Company API: Bookings",
      "description": "`bookings:read`: List and read online bookings. `bookings:write`: Confirm, cancel, re-weigh and mark bookings paid. For company keys (see *Company API keys*)."
    },
    {
      "name": "Company API: Customers",
      "description": "`customers:read`: List and read customer accounts (with their addresses) and their statements. For company keys (see *Company API keys*)."
    },
    {
      "name": "Company API: Finance",
      "description": "`finance:read`: Read invoices, credit notes and quotes, and price a consignment. For company keys (see *Company API keys*)."
    },
    {
      "name": "Company API: Rates",
      "description": "`rates:read`: Read tariffs with their tiers and brackets, surcharges, and customer DIM and lane settings. For company keys (see *Company API keys*)."
    },
    {
      "name": "Company API: Reports",
      "description": "`reports:read`: Operational reports: missing PODs, returns, productivity and transit times. For company keys (see *Company API keys*)."
    },
    {
      "name": "Company API: Your key",
      "description": "Who a company key acts as. Open to every company key."
    }
  ],
  "servers": [
    {
      "url": "https://api.parcelops.co.za/api/v1",
      "description": "Production"
    }
  ],
  "components": {
    "securitySchemes": {
      "ApiKey": {
        "scheme": "bearer",
        "bearerFormat": "po_<apiKey>",
        "type": "http",
        "description": "Customer portal API key (prefix: po_). Generate via Integrations → API Keys in the customer portal."
      },
      "CompanyKey": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "po_<companyKey>",
        "description": "Company API key (prefix po_). A workspace admin creates it under Settings → API Keys in the ParcelOps dashboard, with scopes."
      }
    },
    "schemas": {
      "UpdateProfileDto": {
        "type": "object",
        "properties": {
          "firstName": {
            "type": "string",
            "example": "Anele"
          },
          "lastName": {
            "type": "string",
            "example": "Dube"
          },
          "phone": {
            "type": "string",
            "example": "0820000000"
          }
        }
      },
      "WalletTopUpDto": {
        "type": "object",
        "properties": {
          "amount": {
            "type": "number",
            "example": 500,
            "description": "Amount in rand, two decimals max"
          },
          "returnUrl": {
            "type": "string",
            "example": "https://yourshop.example/wallet/success"
          },
          "cancelUrl": {
            "type": "string",
            "example": "https://yourshop.example/wallet/cancelled"
          }
        },
        "required": [
          "amount",
          "returnUrl",
          "cancelUrl"
        ]
      },
      "QuotePieceDto": {
        "type": "object",
        "properties": {
          "actualWeightKg": {
            "type": "number",
            "example": 4.5,
            "description": "Scale weight in kilograms"
          },
          "lengthCm": {
            "type": "number",
            "example": 40
          },
          "widthCm": {
            "type": "number",
            "example": 30
          },
          "heightCm": {
            "type": "number",
            "example": 25
          },
          "declaredValue": {
            "type": "number",
            "example": 1500,
            "description": "Declared value of this piece in rand (0 to 10,000,000, at most two decimals). The shipment is insured on the total of its pieces (at most R 10,000,000): a percentage of the value with a minimum premium, set by the courier. Omit it, or send 0, for no insurance."
          }
        },
        "required": [
          "actualWeightKg"
        ]
      },
      "CustomerQuoteDto": {
        "type": "object",
        "properties": {
          "originSuburb": {
            "type": "string",
            "example": "Sandton"
          },
          "originPostalCode": {
            "type": "string",
            "example": "2196"
          },
          "destSuburb": {
            "type": "string",
            "example": "Sea Point"
          },
          "destPostalCode": {
            "type": "string",
            "example": "8005"
          },
          "originCity": {
            "type": "string",
            "example": "Johannesburg",
            "description": "Town or city of the collection address. Optional: it prices the address when the suburb is not one the courier has mapped to a zone."
          },
          "destCity": {
            "type": "string",
            "example": "Cape Town",
            "description": "Town or city of the delivery address, used the same way."
          },
          "pieces": {
            "description": "At most 999 pieces",
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/QuotePieceDto"
            }
          },
          "collectionDate": {
            "type": "string",
            "example": "2026-10-20",
            "description": "The day you plan to have it collected, YYYY-MM-DD, from today to 30 days ahead (South African time). A day after today is priced at the rates in force then, so a price change the courier has scheduled before that day is already in the quote (only the rate cards' dated prices are dated: fuel, surcharges and special rates are as now). Left out, or today, it is priced as now."
          },
          "originProvince": {
            "type": "string",
            "example": "GP",
            "description": "Province of the collection address: a name (Gauteng) or a code (GP, WC, KZN, EC, FS, LP, MP, NW, NC; also GT, NL and ZA-GP). Optional and not used for the price: the courier's own suburb list decides where an address is. Accepted so a shop can send the address as it holds it."
          },
          "destProvince": {
            "type": "string",
            "example": "WC",
            "description": "Province of the delivery address, the same way."
          },
          "extraServices": {
            "example": [
              "AFH"
            ],
            "description": "Special requests: codes of the extra services the courier lets customers choose (each quote lists them for that service as `extraOptions`, priced). A quote prices the ones offered for each service into its total and lines (`extraServices` on the quote says which); a code not offered for a service is left out of that service.",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "originSuburb",
          "originPostalCode",
          "destSuburb",
          "destPostalCode",
          "pieces"
        ]
      },
      "CustomerAddressCheckDto": {
        "type": "object",
        "properties": {
          "suburb": {
            "type": "string",
            "example": "Sea Point",
            "description": "Suburb"
          },
          "city": {
            "type": "string",
            "example": "Cape Town",
            "description": "Town or city"
          },
          "postalCode": {
            "type": "string",
            "example": "8005",
            "description": "Postal code (4 digits)"
          },
          "province": {
            "type": "string",
            "example": "WC",
            "description": "Province: a name (Western Cape) or a code (WC, GP, KZN, EC, FS, LP, MP, NW, NC; also GT, NL and ZA-WC)."
          }
        }
      },
      "CustomerBookDto": {
        "type": "object",
        "properties": {
          "serviceLevel": {
            "type": "string",
            "enum": [
              "SAME_DAY",
              "OVERNIGHT",
              "ECONOMY",
              "ECONOMY_REGIONAL",
              "ECONOMY_ROAD",
              "BUDGET",
              "INTERNATIONAL"
            ],
            "example": "OVERNIGHT"
          },
          "senderName": {
            "type": "string",
            "example": "Anele Dube"
          },
          "senderCompany": {
            "type": "string",
            "example": "Harbour Traders (Pty) Ltd"
          },
          "senderStreet": {
            "type": "string",
            "example": "12 Sample Road"
          },
          "senderComplex": {
            "type": "string",
            "example": "Unit 4, Harbour Park"
          },
          "senderSuburb": {
            "type": "string",
            "example": "Marshalltown"
          },
          "senderCity": {
            "type": "string",
            "example": "Johannesburg"
          },
          "senderProvince": {
            "type": "string",
            "example": "Gauteng"
          },
          "senderPostalCode": {
            "type": "string",
            "example": "2001"
          },
          "senderPhone": {
            "type": "string",
            "example": "0820000000"
          },
          "senderEmail": {
            "type": "string",
            "example": "dispatch@harbourtraders.example"
          },
          "recipientName": {
            "type": "string",
            "example": "Thandi Nkosi"
          },
          "recipientCompany": {
            "type": "string"
          },
          "recipientStreet": {
            "type": "string",
            "example": "8 Sample Street"
          },
          "recipientComplex": {
            "type": "string"
          },
          "recipientSuburb": {
            "type": "string",
            "example": "Sea Point"
          },
          "recipientCity": {
            "type": "string",
            "example": "Cape Town"
          },
          "recipientProvince": {
            "type": "string",
            "example": "Western Cape"
          },
          "recipientPostalCode": {
            "type": "string",
            "example": "8005"
          },
          "recipientPhone": {
            "type": "string",
            "example": "0830000000"
          },
          "recipientEmail": {
            "type": "string",
            "example": "thandi@example.com"
          },
          "pieces": {
            "description": "At most 999 pieces",
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/QuotePieceDto"
            }
          },
          "specialInstructions": {
            "type": "string",
            "example": "Ring the buzzer at the gate"
          },
          "needs": {
            "example": [
              "ROOF_RACK"
            ],
            "description": "Equipment the vehicle must have: ROOF_RACK, CANOPY, TAIL_LIFT, TARPAULIN, TRAILER, or one of the courier's own tags (key or label). Leave it out when nothing special is needed.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "customerReference": {
            "type": "string",
            "example": "PO-2026-0042",
            "description": "Your own reference, shown on documents"
          },
          "collectionReadyTime": {
            "type": "string",
            "example": "09:00",
            "description": "Collection window: parcel ready from (HH:mm)"
          },
          "collectionClosingTime": {
            "type": "string",
            "example": "16:30",
            "description": "Collection window: premises close at (HH:mm)"
          },
          "collectionDate": {
            "type": "string",
            "example": "2026-10-02",
            "description": "The day to collect, YYYY-MM-DD: from today to 30 days ahead, South African time. On an account whose bookings the courier accepts automatically, the collection is scheduled on it (a weekend, a public holiday or today after the depot's cut-off moves on to the next working day); left out, that is today before the depot's cut-off, else the next working day. Otherwise the courier sees it when accepting the booking. The booking is priced at the rates in force on that day (the day it is scheduled for on an automatically accepted account), so send the same `collectionDate` to `POST /customer/quote` and use that quote's total as `quotedCharge`: a `quotedCharge` taken for another day can be refused (`400`, \"re-quote\") when the courier has scheduled a price change in between."
          },
          "quotedCharge": {
            "type": "number",
            "example": 245.5,
            "description": "The quoted charge you accepted, for price-lock validation"
          },
          "acceptConditions": {
            "type": "boolean",
            "example": true,
            "description": "The booker ticked \"I accept the conditions of carriage\". Required on the customer portal when the courier has conditions of carriage. Not needed with an API key: an API client accepts the courier's conditions by its agreement with the courier, and the booking records the version in force."
          },
          "conditionsVersion": {
            "type": "string",
            "example": "50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c",
            "description": "The version (hash) of the conditions of carriage the page showed. When sent and no longer current, the booking is refused with 409 CONDITIONS_CHANGED so the booker can read them again."
          },
          "idempotencyKey": {
            "type": "string",
            "example": "woo:3f9a1c:1042:1",
            "description": "Makes a retry safe: send the same key again and you get the booking the first call made, not a second one. 8 to 100 characters, ASCII only (letters A to Z with no accents, digits, spaces and printable symbols), trimmed; it must be a string, and a key that is blank or not a string is refused (400), so leave the field out to book without one. Send it in the body. Keys belong to your account and never expire. A retry that changes the booking counts as a different booking and is refused with a 409: a re-quoted, added or dropped quotedCharge, a phone written +27 21... instead of 021..., changed special instructions, emails, needs or collection window. Resend exactly the first body. A replay is a snapshot of the first answer, not the booking's state today: read the current state from GET /customer/bookings?search=<referenceNumber> or GET /customer/shipments/{id}. See \"Safe retries\" in the guide."
          },
          "source": {
            "type": "string",
            "enum": [
              "API",
              "WOOCOMMERCE",
              "SHOPIFY"
            ],
            "example": "WOOCOMMERCE",
            "description": "Which system made the booking: API (the default), WOOCOMMERCE or SHOPIFY. Any other value is refused. The courier's staff see it; your recipient never does."
          },
          "externalOrderRef": {
            "type": "string",
            "example": "Woo #1042",
            "description": "Your shop's own order, as your shop names it (at most 100 characters). Shown to the courier's staff beside the waybill, never to your recipient. Put the order number in `customerReference` too if you want it on documents and in tracking."
          },
          "externalOrderUrl": {
            "type": "string",
            "example": "https://shop.example/wp-admin/post.php?post=1042&action=edit",
            "description": "A link to that order in your shop, https only, at most 500 characters, with no user name or password in it (https://user:pw@host is refused). Staff only, like externalOrderRef."
          },
          "extraServices": {
            "example": [
              "AFH"
            ],
            "description": "Special requests: codes of the extra services the courier lets customers choose (each quote lists them for that service as `extraOptions`, priced). Priced by the courier into the booking (a code not offered for this shipment is refused: 400 EXTRA_NOT_OFFERED), and put on the waybill when it is accepted.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "dangerousGoodsConfirmed": {
            "type": "boolean",
            "description": "Portal bookings only: the booker confirmed that the parcels contain no dangerous or hazardous goods. When the courier asks for it (GET /customer/booking-options `dangerousGoodsCheck`), `true` is recorded on the booking (who and when) and `false` is refused (400 DANGEROUS_GOODS_NOT_CONFIRMED). API keys are not asked."
          }
        },
        "required": [
          "serviceLevel",
          "senderName",
          "senderStreet",
          "senderSuburb",
          "senderCity",
          "senderProvince",
          "senderPostalCode",
          "senderPhone",
          "recipientName",
          "recipientStreet",
          "recipientSuburb",
          "recipientCity",
          "recipientProvince",
          "recipientPostalCode",
          "recipientPhone",
          "pieces"
        ]
      },
      "CreateAddressBookEntryDto": {
        "type": "object",
        "properties": {}
      },
      "UpdateAddressBookEntryDto": {
        "type": "object",
        "properties": {}
      },
      "CreateWebhookDto": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "example": "Order status → my WMS"
          },
          "url": {
            "type": "string",
            "example": "https://example.com/hooks/parcelops",
            "description": "Public HTTPS endpoint"
          },
          "events": {
            "type": "array",
            "example": [
              "shipment.created",
              "shipment.collected",
              "shipment.out_for_delivery",
              "shipment.delivered",
              "shipment.failed_delivery",
              "booking.cancelled"
            ],
            "description": "The events to receive. Only these names exist; anything else is refused.",
            "items": {
              "type": "string",
              "enum": [
                "shipment.created",
                "shipment.collected",
                "shipment.out_for_delivery",
                "shipment.delivered",
                "shipment.partially_delivered",
                "shipment.failed_delivery",
                "booking.cancelled",
                "invoice.issued",
                "invoice.paid"
              ]
            }
          }
        },
        "required": [
          "name",
          "url",
          "events"
        ]
      },
      "UpdateWebhookDto": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "example": "Order status → my WMS"
          },
          "url": {
            "type": "string",
            "example": "https://example.com/hooks/parcelops",
            "description": "Public HTTPS endpoint"
          },
          "events": {
            "type": "array",
            "example": [
              "shipment.created",
              "shipment.collected",
              "shipment.out_for_delivery",
              "shipment.delivered",
              "shipment.failed_delivery",
              "booking.cancelled"
            ],
            "description": "The events to receive. Only these names exist; anything else is refused.",
            "items": {
              "type": "string",
              "enum": [
                "shipment.created",
                "shipment.collected",
                "shipment.out_for_delivery",
                "shipment.delivered",
                "shipment.partially_delivered",
                "shipment.failed_delivery",
                "booking.cancelled",
                "invoice.issued",
                "invoice.paid"
              ]
            }
          },
          "isActive": {
            "type": "boolean"
          }
        }
      },
      "CreateCostCentreDto": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "example": "Head office"
          },
          "code": {
            "type": "string",
            "example": "HO-01"
          }
        },
        "required": [
          "name"
        ]
      },
      "UpdateCostCentreDto": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "example": "Head office"
          },
          "code": {
            "type": "object",
            "example": "HO-01",
            "nullable": true,
            "description": "null clears the code"
          },
          "isActive": {
            "type": "boolean",
            "example": true
          }
        }
      },
      "AcceptCustomerInviteDto": {
        "type": "object",
        "properties": {
          "firstName": {
            "type": "string"
          },
          "lastName": {
            "type": "string"
          },
          "password": {
            "type": "string",
            "minLength": 8
          }
        }
      },
      "PieceDto": {
        "type": "object",
        "properties": {
          "actualWeight": {
            "type": "number",
            "description": "Actual weight in kg",
            "example": 2.5
          },
          "length": {
            "type": "number",
            "description": "Length in cm",
            "example": 30
          },
          "width": {
            "type": "number",
            "description": "Width in cm",
            "example": 20
          },
          "height": {
            "type": "number",
            "description": "Height in cm",
            "example": 10
          }
        },
        "required": [
          "actualWeight"
        ]
      },
      "QuoteRequestDto": {
        "type": "object",
        "properties": {
          "originSuburb": {
            "type": "string",
            "example": "Gardens"
          },
          "originPostalCode": {
            "type": "string",
            "example": "8001"
          },
          "destSuburb": {
            "type": "string",
            "example": "Sandton"
          },
          "destPostalCode": {
            "type": "string",
            "example": "2196"
          },
          "originCity": {
            "type": "string",
            "example": "Cape Town",
            "description": "Town or city of the collection address. Prices the address when neither its suburb nor its postal code is in one of the courier's zones."
          },
          "destCity": {
            "type": "string",
            "example": "Johannesburg",
            "description": "Town or city of the delivery address, used the same way."
          },
          "serviceLevel": {
            "type": "string",
            "enum": [
              "SAME_DAY",
              "OVERNIGHT",
              "ECONOMY",
              "ECONOMY_REGIONAL",
              "ECONOMY_ROAD",
              "BUDGET",
              "INTERNATIONAL"
            ],
            "example": "OVERNIGHT"
          },
          "pieces": {
            "description": "One or more parcels, at most 999",
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PieceDto"
            }
          },
          "customerId": {
            "type": "string",
            "description": "Customer ID for customer-specific tariff lookup"
          },
          "declaredValue": {
            "type": "number",
            "description": "Declared value for insurance (ZAR)",
            "example": 5000
          },
          "collectionDate": {
            "type": "string",
            "example": "2026-05-10",
            "description": "The day the shipment is collected (ships). It is priced at the rates in force that day, as a waybill with that collection date is. Default: today."
          }
        },
        "required": [
          "originSuburb",
          "originPostalCode",
          "destSuburb",
          "destPostalCode",
          "serviceLevel",
          "pieces"
        ]
      },
      "AllQuotesRequestDto": {
        "type": "object",
        "properties": {
          "originSuburb": {
            "type": "string",
            "example": "Sandton"
          },
          "originPostalCode": {
            "type": "string",
            "example": "2196"
          },
          "destSuburb": {
            "type": "string",
            "example": "La Lucia"
          },
          "destPostalCode": {
            "type": "string",
            "example": "4051"
          },
          "originCity": {
            "type": "string",
            "example": "Johannesburg",
            "description": "Town or city of the collection address (prices it when its suburb is in none of the zones)."
          },
          "destCity": {
            "type": "string",
            "example": "Durban",
            "description": "Town or city of the delivery address."
          },
          "customerId": {
            "type": "string",
            "description": "Customer ID to apply special rates"
          },
          "declaredValue": {
            "type": "number",
            "description": "Declared value for insurance"
          },
          "pieces": {
            "description": "One or more parcels, at most 999",
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PieceDto"
            }
          },
          "collectionDate": {
            "type": "string",
            "example": "2026-05-10",
            "description": "The day the shipment is collected (ships). Every service is priced at the rates in force that day, as a waybill with that collection date is. Default: today."
          }
        },
        "required": [
          "originSuburb",
          "destSuburb",
          "pieces"
        ]
      },
      "AddressDto": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "company": {
            "type": "string"
          },
          "phone": {
            "type": "string"
          },
          "email": {
            "type": "string"
          },
          "street": {
            "type": "string"
          },
          "complex": {
            "type": "string"
          },
          "suburb": {
            "type": "string"
          },
          "city": {
            "type": "string"
          },
          "province": {
            "type": "string"
          },
          "postalCode": {
            "type": "string"
          },
          "lat": {
            "type": "number"
          },
          "lng": {
            "type": "number"
          }
        },
        "required": [
          "name",
          "street",
          "suburb",
          "city",
          "province",
          "postalCode"
        ]
      },
      "CreatePieceDto": {
        "type": "object",
        "properties": {
          "weightKg": {
            "type": "number",
            "example": 2.5,
            "description": "Actual weight in kg"
          },
          "lengthCm": {
            "type": "number",
            "example": 30,
            "description": "Length in cm"
          },
          "widthCm": {
            "type": "number",
            "example": 20,
            "description": "Width in cm"
          },
          "heightCm": {
            "type": "number",
            "example": 10,
            "description": "Height in cm"
          },
          "description": {
            "type": "string",
            "example": "Electronics"
          }
        },
        "required": [
          "weightKg"
        ]
      },
      "SurchargeLineDto": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string",
            "example": "FUEL",
            "description": "Short code, e.g. FUEL / REMOTE / TOWNSHIP / CUSTOM"
          },
          "description": {
            "type": "string",
            "example": "Fuel surcharge"
          },
          "amount": {
            "type": "number",
            "example": 45.5
          }
        },
        "required": [
          "code",
          "description",
          "amount"
        ]
      },
      "CreateWaybillDto": {
        "type": "object",
        "properties": {
          "customerId": {
            "type": "string"
          },
          "serviceLevel": {
            "type": "string",
            "enum": [
              "SAME_DAY",
              "OVERNIGHT",
              "ECONOMY",
              "ECONOMY_REGIONAL",
              "ECONOMY_ROAD",
              "BUDGET",
              "INTERNATIONAL"
            ]
          },
          "serviceProductId": {
            "type": "string",
            "description": "The courier's own product code (Service Product) — drives agent rate matching"
          },
          "sender": {
            "$ref": "#/components/schemas/AddressDto"
          },
          "recipient": {
            "$ref": "#/components/schemas/AddressDto"
          },
          "deliveryType": {
            "type": "string",
            "enum": [
              "RESIDENTIAL",
              "BUSINESS",
              "LOCKER",
              "KIOSK",
              "PUDO_POINT"
            ],
            "default": "BUSINESS"
          },
          "pieces": {
            "description": "One entry per piece, at least 1 and at most 999.",
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CreatePieceDto"
            }
          },
          "declaredValue": {
            "type": "number",
            "example": 5000,
            "description": "Declared value (ZAR) for insurance"
          },
          "isDangerousGoods": {
            "type": "boolean",
            "default": false
          },
          "dgUnNumber": {
            "type": "string"
          },
          "customerReference": {
            "type": "string"
          },
          "orderNumber": {
            "type": "string"
          },
          "collectionDate": {
            "type": "string",
            "example": "2026-05-10"
          },
          "collectionClosingTime": {
            "type": "string",
            "example": "16:00",
            "description": "Latest time driver must collect (HH:MM). Only used when collectionDate is set."
          },
          "specialInstructions": {
            "type": "string"
          },
          "needs": {
            "example": [
              "ROOF_RACK"
            ],
            "description": "Equipment the vehicle must have: ROOF_RACK, CANOPY, TAIL_LIFT, TARPAULIN, TRAILER, or one of the courier's own tags (key or label). Leave it out when nothing special is needed.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "draft": {
            "type": "boolean",
            "default": false,
            "description": "Create as draft (not yet submitted)"
          },
          "overrideCreditLimit": {
            "type": "boolean",
            "default": false,
            "description": "Book past the customer's credit limit (finance administrators only; audited)"
          },
          "overridePrice": {
            "type": "number",
            "description": "The freight price (excl. VAT, before extra services; more than 0 and at most R 1,000,000.00, to the cent) for a route and service the rates have NO rate for. The same hand price Edit waybill sets: needs Finance: User, marks the waybill as priced by hand, and is written on its timeline and the audit log. Refused (400 RATE_EXISTS) when the route and service have a rate: the price then comes from the rates. Staff sessions in the ParcelOps dashboard only; never accepted on an API key.",
            "example": 450
          },
          "accountHoldReason": {
            "type": "string",
            "description": "Staff: book for an account on hold (Stop credit, Handed over, Closed) anyway, saying why (3 to 500 characters). Audited and written on the customer's debtors timeline."
          },
          "extraSurcharges": {
            "description": "Optional extra surcharges (e.g. Saturday, after-hours) to add on top of the rated charges",
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SurchargeLineDto"
            }
          }
        },
        "required": [
          "customerId",
          "serviceLevel",
          "sender",
          "recipient",
          "pieces"
        ]
      },
      "UpdateStatusDto": {
        "type": "object",
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "DRAFT",
              "CREATED",
              "COLLECTION_ASSIGNED",
              "COLLECTED",
              "CHECKED_IN",
              "AT_HUB",
              "IN_TRANSIT",
              "AT_DESTINATION_HUB",
              "OUT_FOR_DELIVERY",
              "DELIVERED",
              "PARTIALLY_DELIVERED",
              "FAILED_DELIVERY",
              "RETURNED_TO_HUB",
              "REDELIVERY_SCHEDULED",
              "RETURNED_TO_SENDER",
              "LOST",
              "CANCELLED",
              "HELD"
            ]
          },
          "description": {
            "type": "string",
            "example": "Parcel collected from sender"
          },
          "hubCode": {
            "type": "string",
            "example": "CPT"
          },
          "overrideCreditLimit": {
            "type": "boolean",
            "default": false,
            "description": "Make a draft live past the customer's credit limit (finance administrators only; audited)"
          },
          "accountHoldReason": {
            "type": "string",
            "description": "Making a draft live for an account on hold: why (3 to 500 characters). Audited and written on the customer's debtors timeline."
          }
        },
        "required": [
          "status"
        ]
      },
      "SubmitPodDto": {
        "type": "object",
        "properties": {
          "via": {
            "type": "string",
            "enum": [
              "SCAN",
              "MANUAL",
              "PAPER"
            ],
            "description": "How the parcel was confirmed at the door"
          },
          "scannedCode": {
            "type": "string",
            "description": "The barcode actually read, when via = SCAN"
          },
          "recipientName": {
            "type": "string",
            "example": "Jane Smith",
            "description": "Name of the person who signed for the parcel"
          },
          "signatureData": {
            "type": "string",
            "description": "Base64 PNG data URL of the signature (e.g. data:image/png;base64,...). Not needed on a photo-only proof (leftAt)."
          },
          "leftAt": {
            "type": "string",
            "example": "Security",
            "description": "A photo-only proof (no signature): where the parcel was left. One of Reception, Security, Neighbour, Mailbox, or \"Other: <a few words>\". The proof then needs at least one delivery photo, announced with X-Pod-Photos."
          },
          "notes": {
            "type": "string",
            "example": "Left at reception"
          },
          "deliveryCheck": {
            "type": "string",
            "example": "CODE",
            "enum": [
              "CODE",
              "ID"
            ],
            "description": "A parcel that needs a delivery code (a driver's proof): CODE when the server confirmed the code the recipient gave (POST /drivers/me/deliveries/{id}/code), or ID when the ID was checked instead (then idType and idLast4)."
          },
          "idType": {
            "type": "string",
            "example": "SA_ID",
            "description": "ID checked instead of a code: SA_ID, PASSPORT, DRIVERS_LICENCE or OTHER. Never a photo."
          },
          "idLast4": {
            "type": "string",
            "example": "1234",
            "description": "ID checked instead of a code: the last 4 characters of its number, and nothing more."
          },
          "lat": {
            "type": "number",
            "example": -26.2041
          },
          "lng": {
            "type": "number",
            "example": 28.0473
          },
          "deliveredPieces": {
            "example": [
              "JBL0000128590/1"
            ],
            "description": "A partial delivery: the parcels handed over, by piece id or sticker barcode. Leave it out when every parcel still out was delivered. Any parcel left out stays undelivered (the waybill is PARTIALLY_DELIVERED) and needs undeliveredReason.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "undeliveredReason": {
            "type": "string",
            "example": "No one home",
            "description": "Why the parcels left out of deliveredPieces were not delivered"
          }
        },
        "required": [
          "recipientName",
          "signatureData"
        ]
      },
      "PodPhotoDto": {
        "type": "object",
        "properties": {
          "photoData": {
            "type": "string",
            "description": "The photo as a base64 data URL (JPEG or PNG), e.g. data:image/jpeg;base64,..."
          },
          "photoId": {
            "type": "string",
            "description": "The phone's own id for this photo (UUID). A retried upload with the same id is stored once."
          }
        },
        "required": [
          "photoData"
        ]
      },
      "ConfirmBookingDto": {
        "type": "object",
        "properties": {
          "paymentReference": {
            "type": "string",
            "description": "Payment reference (EFT / PayFast ITN)"
          },
          "customerId": {
            "type": "string",
            "description": "Customer account ID to link the waybill to"
          },
          "collectionDate": {
            "type": "string",
            "description": "Collection date YYYY-MM-DD — auto-creates a PENDING collection if provided"
          },
          "overrideCreditLimit": {
            "type": "boolean",
            "default": false,
            "description": "Confirm past the customer's credit limit (finance administrators only; audited)"
          },
          "accountHoldReason": {
            "type": "string",
            "description": "Staff: book for an account on hold (Stop credit, Handed over, Closed) anyway, saying why (3 to 500 characters). Audited and written on the customer's debtors timeline."
          },
          "waybillNumberSource": {
            "type": "string",
            "enum": [
              "SEQUENCE"
            ],
            "description": "Number the waybill from the tenant's sequence although the customer's account uses its reference as the waybill number: staff's explicit choice after Accept refused the reference (it cannot be a waybill number, or a waybill already has it). The reference stays the waybill's customerReference; an internal event records why."
          },
          "acceptPriceChange": {
            "type": "boolean",
            "default": false,
            "description": "A public-quote booking re-prices on its account at Accept. When that price differs from what was booked (and paid), Accept is refused with 409 PRICE_CHANGED; true accepts at today's price and records the difference as an adjustment for the office to settle."
          },
          "acceptOnRequest": {
            "type": "boolean",
            "default": false,
            "description": "The destination is a town the courier delivers on request (no price is given online). Accept is refused with 409 ON_REQUEST_TOWN until staff confirm the price was agreed with the customer; true accepts at the rate card's price."
          },
          "onRequestPrice": {
            "type": "number",
            "description": "With acceptOnRequest: the FREIGHT price agreed with the customer for an on-request town, in rands excl VAT (extra services chosen are added on top). A JSON number with at most 2 decimals, no exponent form. Required with it."
          },
          "extraServices": {
            "description": "Extra services to add to the waybill (after hours, chain store…), by the code of a manual surcharge in the courier's catalogue, each priced by the catalogue on the account billed (`extraServiceOptions` of GET /bookings/:id lists them); a code not offered is refused (400 EXTRA_NOT_OFFERED). Not for a public-quote booking, which was paid at its booked price (400 PUBLIC_EXTRAS): add them on the waybill after Accept.",
            "example": [
              "AFH"
            ],
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "MarkPaymentReceivedDto": {
        "type": "object",
        "properties": {
          "paymentReference": {
            "type": "string",
            "description": "Bank reference or deposit slip number"
          }
        }
      },
      "ReweighBookingDto": {
        "type": "object",
        "properties": {
          "actualWeight": {
            "type": "number",
            "example": 3.5,
            "description": "Actual weighed weight in kg"
          },
          "actualLength": {
            "type": "number",
            "example": 40,
            "description": "Actual length in cm"
          },
          "actualWidth": {
            "type": "number",
            "example": 30
          },
          "actualHeight": {
            "type": "number",
            "example": 20
          },
          "notes": {
            "type": "string",
            "description": "Internal notes on discrepancy"
          }
        },
        "required": [
          "actualWeight"
        ]
      },
      "CreateCollectionDto": {
        "type": "object",
        "properties": {
          "waybillId": {
            "type": "string",
            "example": "uuid-of-waybill"
          },
          "customerId": {
            "type": "string",
            "example": "uuid-of-customer"
          },
          "scheduledDate": {
            "type": "string",
            "example": "2026-05-09T08:00:00.000Z",
            "description": "Scheduled date/time for collection"
          },
          "readyTime": {
            "type": "string",
            "example": "08:00",
            "description": "Earliest time parcel is ready for collection (HH:mm)"
          },
          "closingTime": {
            "type": "string",
            "example": "16:00",
            "description": "Closing time at sender (HH:mm)"
          },
          "notes": {
            "type": "string"
          },
          "driverId": {
            "type": "string",
            "example": "uuid-of-driver",
            "description": "Manually assign a driver; if omitted, auto-assigned by zone"
          },
          "isRecurring": {
            "type": "boolean",
            "example": true,
            "description": "Create a recurring collection series"
          },
          "recurrenceRule": {
            "type": "string",
            "example": "0 9 * * 1-5",
            "description": "Cron expression: min hour * * days-of-week (0=Sun)"
          },
          "recurrenceEndsAt": {
            "type": "string",
            "example": "2026-12-31T23:59:59.000Z",
            "description": "When the recurring series ends (inclusive)"
          }
        },
        "required": [
          "scheduledDate"
        ]
      },
      "AssignDriverDto": {
        "type": "object",
        "properties": {
          "overrideEquipment": {
            "type": "boolean",
            "description": "Go ahead after a 409 EQUIPMENT_MISSING warning (\"Dispatch anyway\"). Recorded on each waybill's timeline and in the audit log. Ignored when the workspace blocks instead of warning."
          },
          "driverId": {
            "type": "string",
            "example": "uuid-of-driver"
          }
        },
        "required": [
          "driverId"
        ]
      },
      "CompleteCollectionBodyDto": {
        "type": "object",
        "properties": {
          "lat": {
            "type": "number"
          },
          "lng": {
            "type": "number"
          },
          "via": {
            "type": "string",
            "enum": [
              "SCAN",
              "MANUAL",
              "PAPER"
            ]
          },
          "scannedCode": {
            "type": "string",
            "description": "The barcode actually read, when via = SCAN"
          },
          "collectedPieces": {
            "type": "number",
            "minimum": 1,
            "maximum": 999,
            "description": "Parcels the driver took (driver app, §216)"
          },
          "senderSignature": {
            "type": "string",
            "description": "The sender's signature, a PNG data URL (driver app, §216)"
          }
        }
      }
    }
  },
  "security": [
    {
      "ApiKey": []
    }
  ],
  "webhooks": {
    "shipment.created": {
      "post": {
        "summary": "shipment.created",
        "description": "A booking of yours became a shipment: the courier accepted it, or it was accepted automatically. It carries the shipment's id and waybill number (your `customerReference`, on an account whose waybill number is your reference), your booking's `bookingReference` (the `referenceNumber` from `POST /customer/book`) and, when one was made, the `collection`: `scheduledDate` (`YYYY-MM-DD`) and the collection window (`readyTime`, `closingTime`, `HH:mm` or null). Sent once per shipment, when a booking is accepted (yours, or one the courier accepts onto your account); a shipment the courier captures itself does not send it.",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "X-Parcelops-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "`sha256=` + hex HMAC-SHA256 of the raw body, keyed with your secret"
          },
          {
            "name": "X-Parcelops-Signature-Previous",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Present for 24 hours after a secret rotation: the same, keyed with the previous secret"
          },
          {
            "name": "X-Parcelops-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Stable across retries of the same delivery: deduplicate on it"
          },
          {
            "name": "X-Parcelops-Event",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The event name, as in the body"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "version": "1",
                "id": "f61eddff-a05a-4fbf-a8aa-60f52b093f6e",
                "event": "shipment.created",
                "timestamp": "2026-09-29T11:02:10.412Z",
                "tenantSlug": "demo-couriers",
                "data": {
                  "shipmentId": "9404261c-efa0-46a9-a17a-b584249b39dd",
                  "waybillNumber": "9351424",
                  "status": "CREATED",
                  "occurredAt": "2026-09-29T11:02:10.405Z",
                  "customerReference": "9351424",
                  "externalOrderRef": "Woo #1042",
                  "serviceLevel": "OVERNIGHT",
                  "senderCity": "Johannesburg",
                  "recipientName": "Thandi Mokoena",
                  "recipientCity": "Johannesburg",
                  "trackingUrl": "https://demo-couriers.parcelops.co.za/online/track?q=9351424",
                  "bookingReference": "BK-20260930-O3D5Y7",
                  "collection": {
                    "scheduledDate": "2026-10-01",
                    "readyTime": "09:00",
                    "closingTime": "16:30"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx within 15 seconds acknowledges the delivery"
          }
        }
      }
    },
    "shipment.collected": {
      "post": {
        "summary": "shipment.collected",
        "description": "The courier has collected the parcel from the sender.",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "X-Parcelops-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "`sha256=` + hex HMAC-SHA256 of the raw body, keyed with your secret"
          },
          {
            "name": "X-Parcelops-Signature-Previous",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Present for 24 hours after a secret rotation: the same, keyed with the previous secret"
          },
          {
            "name": "X-Parcelops-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Stable across retries of the same delivery: deduplicate on it"
          },
          {
            "name": "X-Parcelops-Event",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The event name, as in the body"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "version": "1",
                "id": "f61eddff-a05a-4fbf-a8aa-60f52b093f6e",
                "event": "shipment.collected",
                "timestamp": "2026-09-29T11:02:10.412Z",
                "tenantSlug": "demo-couriers",
                "data": {
                  "shipmentId": "c6459b89-8531-4ec5-b15d-908549caa221",
                  "waybillNumber": "DCS-002199",
                  "status": "COLLECTED",
                  "occurredAt": "2026-09-29T11:02:10.405Z",
                  "customerReference": "PO-2026-0042",
                  "externalOrderRef": null,
                  "serviceLevel": "OVERNIGHT",
                  "senderCity": "Johannesburg",
                  "recipientName": "Thandi Mokoena",
                  "recipientCity": "Johannesburg",
                  "trackingUrl": "https://demo-couriers.parcelops.co.za/online/track?q=DCS-002199"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx within 15 seconds acknowledges the delivery"
          }
        }
      }
    },
    "shipment.out_for_delivery": {
      "post": {
        "summary": "shipment.out_for_delivery",
        "description": "The parcel is on a vehicle for delivery today.",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "X-Parcelops-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "`sha256=` + hex HMAC-SHA256 of the raw body, keyed with your secret"
          },
          {
            "name": "X-Parcelops-Signature-Previous",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Present for 24 hours after a secret rotation: the same, keyed with the previous secret"
          },
          {
            "name": "X-Parcelops-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Stable across retries of the same delivery: deduplicate on it"
          },
          {
            "name": "X-Parcelops-Event",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The event name, as in the body"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "version": "1",
                "id": "f61eddff-a05a-4fbf-a8aa-60f52b093f6e",
                "event": "shipment.out_for_delivery",
                "timestamp": "2026-09-29T11:02:10.412Z",
                "tenantSlug": "demo-couriers",
                "data": {
                  "shipmentId": "c6459b89-8531-4ec5-b15d-908549caa221",
                  "waybillNumber": "DCS-002199",
                  "status": "OUT_FOR_DELIVERY",
                  "occurredAt": "2026-09-29T11:02:10.405Z",
                  "customerReference": "PO-2026-0042",
                  "externalOrderRef": null,
                  "serviceLevel": "OVERNIGHT",
                  "senderCity": "Johannesburg",
                  "recipientName": "Thandi Mokoena",
                  "recipientCity": "Johannesburg",
                  "trackingUrl": "https://demo-couriers.parcelops.co.za/online/track?q=DCS-002199"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx within 15 seconds acknowledges the delivery"
          }
        }
      }
    },
    "shipment.failed_delivery": {
      "post": {
        "summary": "shipment.failed_delivery",
        "description": "A delivery attempt failed. `failureReason` is what the driver or depot recorded.",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "X-Parcelops-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "`sha256=` + hex HMAC-SHA256 of the raw body, keyed with your secret"
          },
          {
            "name": "X-Parcelops-Signature-Previous",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Present for 24 hours after a secret rotation: the same, keyed with the previous secret"
          },
          {
            "name": "X-Parcelops-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Stable across retries of the same delivery: deduplicate on it"
          },
          {
            "name": "X-Parcelops-Event",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The event name, as in the body"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "version": "1",
                "id": "f61eddff-a05a-4fbf-a8aa-60f52b093f6e",
                "event": "shipment.failed_delivery",
                "timestamp": "2026-09-29T11:02:10.412Z",
                "tenantSlug": "demo-couriers",
                "data": {
                  "shipmentId": "c6459b89-8531-4ec5-b15d-908549caa221",
                  "waybillNumber": "DCS-002199",
                  "status": "FAILED_DELIVERY",
                  "occurredAt": "2026-09-29T11:02:10.405Z",
                  "customerReference": "PO-2026-0042",
                  "externalOrderRef": null,
                  "serviceLevel": "OVERNIGHT",
                  "senderCity": "Johannesburg",
                  "recipientName": "Thandi Mokoena",
                  "recipientCity": "Johannesburg",
                  "trackingUrl": "https://demo-couriers.parcelops.co.za/online/track?q=DCS-002199",
                  "failureReason": "Nobody home, gate locked"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx within 15 seconds acknowledges the delivery"
          }
        }
      }
    },
    "shipment.partially_delivered": {
      "post": {
        "summary": "shipment.partially_delivered",
        "description": "Some of a multi-parcel shipment's parcels were delivered and the rest were not (see *Partial delivery*). Sent only to webhooks subscribed to this event: a webhook subscribed to `shipment.failed_delivery` or `shipment.delivered` is not sent it. `piecesDelivered` of `piecesTotal` are delivered so far; `pieces[]` says which (`pieceNumber`, your `barcode` on the label, `deliveredAt` or null); `failureReason` is why the rest were not; `pod` is who signed for the parcels delivered. `shipment.delivered` follows once the rest is delivered.",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "X-Parcelops-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "`sha256=` + hex HMAC-SHA256 of the raw body, keyed with your secret"
          },
          {
            "name": "X-Parcelops-Signature-Previous",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Present for 24 hours after a secret rotation: the same, keyed with the previous secret"
          },
          {
            "name": "X-Parcelops-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Stable across retries of the same delivery: deduplicate on it"
          },
          {
            "name": "X-Parcelops-Event",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The event name, as in the body"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "version": "1",
                "id": "f61eddff-a05a-4fbf-a8aa-60f52b093f6e",
                "event": "shipment.partially_delivered",
                "timestamp": "2026-09-29T11:02:10.412Z",
                "tenantSlug": "demo-couriers",
                "data": {
                  "shipmentId": "c6459b89-8531-4ec5-b15d-908549caa221",
                  "waybillNumber": "DCS-002199",
                  "status": "PARTIALLY_DELIVERED",
                  "occurredAt": "2026-09-29T11:02:10.405Z",
                  "customerReference": "PO-2026-0042",
                  "externalOrderRef": null,
                  "serviceLevel": "OVERNIGHT",
                  "senderCity": "Johannesburg",
                  "recipientName": "Thandi Mokoena",
                  "recipientCity": "Johannesburg",
                  "trackingUrl": "https://demo-couriers.parcelops.co.za/online/track?q=DCS-002199",
                  "failureReason": "Nobody home, gate locked",
                  "piecesDelivered": 1,
                  "piecesTotal": 2,
                  "pieces": [
                    {
                      "pieceNumber": 1,
                      "barcode": "DCS-002199-01",
                      "deliveredAt": "2026-09-29T11:02:10.000Z"
                    },
                    {
                      "pieceNumber": 2,
                      "barcode": "DCS-002199-02",
                      "deliveredAt": null
                    }
                  ],
                  "pod": {
                    "recipientName": "Thandi Mokoena",
                    "capturedAt": "2026-09-29T11:02:10.000Z"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx within 15 seconds acknowledges the delivery"
          }
        }
      }
    },
    "shipment.delivered": {
      "post": {
        "summary": "shipment.delivered",
        "description": "Delivered, with who signed and when. `deliveredAt` is the day the shipment counts as delivered on (the same `deliveredAt` the shipment returns): it can be an earlier day than `occurredAt` when the signed paper reached the courier late. `podUrl` is where the proof is: the courier's public signed POD page for the shipment (a lasting link that needs no key, `podUrlNeedsKey: false`) when the courier shares POD links, otherwise `GET /customer/shipments/{shipmentId}/pod` on this API (`podUrlNeedsKey: true`: call it with your key; it answers links valid for one hour). Fetch the signature from `GET /customer/shipments/{shipmentId}/pod`.",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "X-Parcelops-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "`sha256=` + hex HMAC-SHA256 of the raw body, keyed with your secret"
          },
          {
            "name": "X-Parcelops-Signature-Previous",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Present for 24 hours after a secret rotation: the same, keyed with the previous secret"
          },
          {
            "name": "X-Parcelops-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Stable across retries of the same delivery: deduplicate on it"
          },
          {
            "name": "X-Parcelops-Event",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The event name, as in the body"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "version": "1",
                "id": "f61eddff-a05a-4fbf-a8aa-60f52b093f6e",
                "event": "shipment.delivered",
                "timestamp": "2026-09-29T11:02:10.412Z",
                "tenantSlug": "demo-couriers",
                "data": {
                  "shipmentId": "c6459b89-8531-4ec5-b15d-908549caa221",
                  "waybillNumber": "DCS-002199",
                  "status": "DELIVERED",
                  "occurredAt": "2026-09-29T11:02:10.405Z",
                  "customerReference": "PO-2026-0042",
                  "externalOrderRef": null,
                  "serviceLevel": "OVERNIGHT",
                  "senderCity": "Johannesburg",
                  "recipientName": "Thandi Mokoena",
                  "recipientCity": "Johannesburg",
                  "trackingUrl": "https://demo-couriers.parcelops.co.za/online/track?q=DCS-002199",
                  "deliveredAt": "2026-09-29T11:02:10.000Z",
                  "pod": {
                    "recipientName": "Thandi Mokoena",
                    "capturedAt": "2026-09-29T11:02:10.000Z"
                  },
                  "podUrl": "https://api.parcelops.co.za/api/v1/customer/shipments/c6459b89-8531-4ec5-b15d-908549caa221/pod",
                  "podUrlNeedsKey": true
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx within 15 seconds acknowledges the delivery"
          }
        }
      }
    },
    "booking.cancelled": {
      "post": {
        "summary": "booking.cancelled",
        "description": "The courier cancelled a booking of yours before it became a shipment, so it never will: `bookingReference` is its `referenceNumber`, with your `customerReference` (or null). A shipment already created is not a booking any more and does not send it when it is cancelled; but when the courier deletes a shipment that came from a booking of yours, this event is sent once for that booking (and the shipment id then answers 404).",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "X-Parcelops-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "`sha256=` + hex HMAC-SHA256 of the raw body, keyed with your secret"
          },
          {
            "name": "X-Parcelops-Signature-Previous",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Present for 24 hours after a secret rotation: the same, keyed with the previous secret"
          },
          {
            "name": "X-Parcelops-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Stable across retries of the same delivery: deduplicate on it"
          },
          {
            "name": "X-Parcelops-Event",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The event name, as in the body"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "version": "1",
                "id": "f61eddff-a05a-4fbf-a8aa-60f52b093f6e",
                "event": "booking.cancelled",
                "timestamp": "2026-09-29T11:02:10.412Z",
                "tenantSlug": "demo-couriers",
                "data": {
                  "bookingReference": "BK-20260930-VGQTSF",
                  "customerReference": "9351424",
                  "status": "CANCELLED",
                  "occurredAt": "2026-09-30T15:59:37.722Z"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx within 15 seconds acknowledges the delivery"
          }
        }
      }
    },
    "invoice.issued": {
      "post": {
        "summary": "invoice.issued",
        "description": "An invoice was issued to your account (sent when it is issued, never for a draft). Fetch it from `GET /customer/invoices/{invoiceId}`. `periodStart` and `periodEnd` are the billing period (YYYY-MM-DD); both are null on a supplementary invoice, which bills a charge that rose after its invoice.",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "X-Parcelops-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "`sha256=` + hex HMAC-SHA256 of the raw body, keyed with your secret"
          },
          {
            "name": "X-Parcelops-Signature-Previous",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Present for 24 hours after a secret rotation: the same, keyed with the previous secret"
          },
          {
            "name": "X-Parcelops-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Stable across retries of the same delivery: deduplicate on it"
          },
          {
            "name": "X-Parcelops-Event",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The event name, as in the body"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "version": "1",
                "id": "f61eddff-a05a-4fbf-a8aa-60f52b093f6e",
                "event": "invoice.issued",
                "timestamp": "2026-09-29T11:02:10.412Z",
                "tenantSlug": "demo-couriers",
                "data": {
                  "invoiceId": "0f3d2c1b-8e7a-4b6c-9d5e-1a2b3c4d5e6f",
                  "invoiceNumber": "INV-000016",
                  "totalAmount": 29199.71,
                  "waybillCount": 50,
                  "periodStart": "2026-09-01",
                  "periodEnd": "2026-09-30"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx within 15 seconds acknowledges the delivery"
          }
        }
      }
    },
    "invoice.paid": {
      "post": {
        "summary": "invoice.paid",
        "description": "An invoice has been paid in full.",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "X-Parcelops-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "`sha256=` + hex HMAC-SHA256 of the raw body, keyed with your secret"
          },
          {
            "name": "X-Parcelops-Signature-Previous",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Present for 24 hours after a secret rotation: the same, keyed with the previous secret"
          },
          {
            "name": "X-Parcelops-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Stable across retries of the same delivery: deduplicate on it"
          },
          {
            "name": "X-Parcelops-Event",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The event name, as in the body"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "version": "1",
                "id": "f61eddff-a05a-4fbf-a8aa-60f52b093f6e",
                "event": "invoice.paid",
                "timestamp": "2026-09-29T11:02:10.412Z",
                "tenantSlug": "demo-couriers",
                "data": {
                  "invoiceId": "0f3d2c1b-8e7a-4b6c-9d5e-1a2b3c4d5e6f",
                  "invoiceNumber": "INV-000016",
                  "totalAmount": 29199.71,
                  "amountPaid": 29199.71,
                  "paymentMethod": "EFT"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx within 15 seconds acknowledges the delivery"
          }
        }
      }
    }
  },
  "x-webhooks": {
    "shipment.created": {
      "post": {
        "summary": "shipment.created",
        "description": "A booking of yours became a shipment: the courier accepted it, or it was accepted automatically. It carries the shipment's id and waybill number (your `customerReference`, on an account whose waybill number is your reference), your booking's `bookingReference` (the `referenceNumber` from `POST /customer/book`) and, when one was made, the `collection`: `scheduledDate` (`YYYY-MM-DD`) and the collection window (`readyTime`, `closingTime`, `HH:mm` or null). Sent once per shipment, when a booking is accepted (yours, or one the courier accepts onto your account); a shipment the courier captures itself does not send it.",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "X-Parcelops-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "`sha256=` + hex HMAC-SHA256 of the raw body, keyed with your secret"
          },
          {
            "name": "X-Parcelops-Signature-Previous",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Present for 24 hours after a secret rotation: the same, keyed with the previous secret"
          },
          {
            "name": "X-Parcelops-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Stable across retries of the same delivery: deduplicate on it"
          },
          {
            "name": "X-Parcelops-Event",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The event name, as in the body"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "version": "1",
                "id": "f61eddff-a05a-4fbf-a8aa-60f52b093f6e",
                "event": "shipment.created",
                "timestamp": "2026-09-29T11:02:10.412Z",
                "tenantSlug": "demo-couriers",
                "data": {
                  "shipmentId": "9404261c-efa0-46a9-a17a-b584249b39dd",
                  "waybillNumber": "9351424",
                  "status": "CREATED",
                  "occurredAt": "2026-09-29T11:02:10.405Z",
                  "customerReference": "9351424",
                  "externalOrderRef": "Woo #1042",
                  "serviceLevel": "OVERNIGHT",
                  "senderCity": "Johannesburg",
                  "recipientName": "Thandi Mokoena",
                  "recipientCity": "Johannesburg",
                  "trackingUrl": "https://demo-couriers.parcelops.co.za/online/track?q=9351424",
                  "bookingReference": "BK-20260930-O3D5Y7",
                  "collection": {
                    "scheduledDate": "2026-10-01",
                    "readyTime": "09:00",
                    "closingTime": "16:30"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx within 15 seconds acknowledges the delivery"
          }
        }
      }
    },
    "shipment.collected": {
      "post": {
        "summary": "shipment.collected",
        "description": "The courier has collected the parcel from the sender.",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "X-Parcelops-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "`sha256=` + hex HMAC-SHA256 of the raw body, keyed with your secret"
          },
          {
            "name": "X-Parcelops-Signature-Previous",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Present for 24 hours after a secret rotation: the same, keyed with the previous secret"
          },
          {
            "name": "X-Parcelops-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Stable across retries of the same delivery: deduplicate on it"
          },
          {
            "name": "X-Parcelops-Event",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The event name, as in the body"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "version": "1",
                "id": "f61eddff-a05a-4fbf-a8aa-60f52b093f6e",
                "event": "shipment.collected",
                "timestamp": "2026-09-29T11:02:10.412Z",
                "tenantSlug": "demo-couriers",
                "data": {
                  "shipmentId": "c6459b89-8531-4ec5-b15d-908549caa221",
                  "waybillNumber": "DCS-002199",
                  "status": "COLLECTED",
                  "occurredAt": "2026-09-29T11:02:10.405Z",
                  "customerReference": "PO-2026-0042",
                  "externalOrderRef": null,
                  "serviceLevel": "OVERNIGHT",
                  "senderCity": "Johannesburg",
                  "recipientName": "Thandi Mokoena",
                  "recipientCity": "Johannesburg",
                  "trackingUrl": "https://demo-couriers.parcelops.co.za/online/track?q=DCS-002199"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx within 15 seconds acknowledges the delivery"
          }
        }
      }
    },
    "shipment.out_for_delivery": {
      "post": {
        "summary": "shipment.out_for_delivery",
        "description": "The parcel is on a vehicle for delivery today.",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "X-Parcelops-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "`sha256=` + hex HMAC-SHA256 of the raw body, keyed with your secret"
          },
          {
            "name": "X-Parcelops-Signature-Previous",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Present for 24 hours after a secret rotation: the same, keyed with the previous secret"
          },
          {
            "name": "X-Parcelops-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Stable across retries of the same delivery: deduplicate on it"
          },
          {
            "name": "X-Parcelops-Event",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The event name, as in the body"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "version": "1",
                "id": "f61eddff-a05a-4fbf-a8aa-60f52b093f6e",
                "event": "shipment.out_for_delivery",
                "timestamp": "2026-09-29T11:02:10.412Z",
                "tenantSlug": "demo-couriers",
                "data": {
                  "shipmentId": "c6459b89-8531-4ec5-b15d-908549caa221",
                  "waybillNumber": "DCS-002199",
                  "status": "OUT_FOR_DELIVERY",
                  "occurredAt": "2026-09-29T11:02:10.405Z",
                  "customerReference": "PO-2026-0042",
                  "externalOrderRef": null,
                  "serviceLevel": "OVERNIGHT",
                  "senderCity": "Johannesburg",
                  "recipientName": "Thandi Mokoena",
                  "recipientCity": "Johannesburg",
                  "trackingUrl": "https://demo-couriers.parcelops.co.za/online/track?q=DCS-002199"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx within 15 seconds acknowledges the delivery"
          }
        }
      }
    },
    "shipment.failed_delivery": {
      "post": {
        "summary": "shipment.failed_delivery",
        "description": "A delivery attempt failed. `failureReason` is what the driver or depot recorded.",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "X-Parcelops-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "`sha256=` + hex HMAC-SHA256 of the raw body, keyed with your secret"
          },
          {
            "name": "X-Parcelops-Signature-Previous",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Present for 24 hours after a secret rotation: the same, keyed with the previous secret"
          },
          {
            "name": "X-Parcelops-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Stable across retries of the same delivery: deduplicate on it"
          },
          {
            "name": "X-Parcelops-Event",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The event name, as in the body"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "version": "1",
                "id": "f61eddff-a05a-4fbf-a8aa-60f52b093f6e",
                "event": "shipment.failed_delivery",
                "timestamp": "2026-09-29T11:02:10.412Z",
                "tenantSlug": "demo-couriers",
                "data": {
                  "shipmentId": "c6459b89-8531-4ec5-b15d-908549caa221",
                  "waybillNumber": "DCS-002199",
                  "status": "FAILED_DELIVERY",
                  "occurredAt": "2026-09-29T11:02:10.405Z",
                  "customerReference": "PO-2026-0042",
                  "externalOrderRef": null,
                  "serviceLevel": "OVERNIGHT",
                  "senderCity": "Johannesburg",
                  "recipientName": "Thandi Mokoena",
                  "recipientCity": "Johannesburg",
                  "trackingUrl": "https://demo-couriers.parcelops.co.za/online/track?q=DCS-002199",
                  "failureReason": "Nobody home, gate locked"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx within 15 seconds acknowledges the delivery"
          }
        }
      }
    },
    "shipment.partially_delivered": {
      "post": {
        "summary": "shipment.partially_delivered",
        "description": "Some of a multi-parcel shipment's parcels were delivered and the rest were not (see *Partial delivery*). Sent only to webhooks subscribed to this event: a webhook subscribed to `shipment.failed_delivery` or `shipment.delivered` is not sent it. `piecesDelivered` of `piecesTotal` are delivered so far; `pieces[]` says which (`pieceNumber`, your `barcode` on the label, `deliveredAt` or null); `failureReason` is why the rest were not; `pod` is who signed for the parcels delivered. `shipment.delivered` follows once the rest is delivered.",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "X-Parcelops-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "`sha256=` + hex HMAC-SHA256 of the raw body, keyed with your secret"
          },
          {
            "name": "X-Parcelops-Signature-Previous",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Present for 24 hours after a secret rotation: the same, keyed with the previous secret"
          },
          {
            "name": "X-Parcelops-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Stable across retries of the same delivery: deduplicate on it"
          },
          {
            "name": "X-Parcelops-Event",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The event name, as in the body"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "version": "1",
                "id": "f61eddff-a05a-4fbf-a8aa-60f52b093f6e",
                "event": "shipment.partially_delivered",
                "timestamp": "2026-09-29T11:02:10.412Z",
                "tenantSlug": "demo-couriers",
                "data": {
                  "shipmentId": "c6459b89-8531-4ec5-b15d-908549caa221",
                  "waybillNumber": "DCS-002199",
                  "status": "PARTIALLY_DELIVERED",
                  "occurredAt": "2026-09-29T11:02:10.405Z",
                  "customerReference": "PO-2026-0042",
                  "externalOrderRef": null,
                  "serviceLevel": "OVERNIGHT",
                  "senderCity": "Johannesburg",
                  "recipientName": "Thandi Mokoena",
                  "recipientCity": "Johannesburg",
                  "trackingUrl": "https://demo-couriers.parcelops.co.za/online/track?q=DCS-002199",
                  "failureReason": "Nobody home, gate locked",
                  "piecesDelivered": 1,
                  "piecesTotal": 2,
                  "pieces": [
                    {
                      "pieceNumber": 1,
                      "barcode": "DCS-002199-01",
                      "deliveredAt": "2026-09-29T11:02:10.000Z"
                    },
                    {
                      "pieceNumber": 2,
                      "barcode": "DCS-002199-02",
                      "deliveredAt": null
                    }
                  ],
                  "pod": {
                    "recipientName": "Thandi Mokoena",
                    "capturedAt": "2026-09-29T11:02:10.000Z"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx within 15 seconds acknowledges the delivery"
          }
        }
      }
    },
    "shipment.delivered": {
      "post": {
        "summary": "shipment.delivered",
        "description": "Delivered, with who signed and when. `deliveredAt` is the day the shipment counts as delivered on (the same `deliveredAt` the shipment returns): it can be an earlier day than `occurredAt` when the signed paper reached the courier late. `podUrl` is where the proof is: the courier's public signed POD page for the shipment (a lasting link that needs no key, `podUrlNeedsKey: false`) when the courier shares POD links, otherwise `GET /customer/shipments/{shipmentId}/pod` on this API (`podUrlNeedsKey: true`: call it with your key; it answers links valid for one hour). Fetch the signature from `GET /customer/shipments/{shipmentId}/pod`.",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "X-Parcelops-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "`sha256=` + hex HMAC-SHA256 of the raw body, keyed with your secret"
          },
          {
            "name": "X-Parcelops-Signature-Previous",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Present for 24 hours after a secret rotation: the same, keyed with the previous secret"
          },
          {
            "name": "X-Parcelops-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Stable across retries of the same delivery: deduplicate on it"
          },
          {
            "name": "X-Parcelops-Event",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The event name, as in the body"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "version": "1",
                "id": "f61eddff-a05a-4fbf-a8aa-60f52b093f6e",
                "event": "shipment.delivered",
                "timestamp": "2026-09-29T11:02:10.412Z",
                "tenantSlug": "demo-couriers",
                "data": {
                  "shipmentId": "c6459b89-8531-4ec5-b15d-908549caa221",
                  "waybillNumber": "DCS-002199",
                  "status": "DELIVERED",
                  "occurredAt": "2026-09-29T11:02:10.405Z",
                  "customerReference": "PO-2026-0042",
                  "externalOrderRef": null,
                  "serviceLevel": "OVERNIGHT",
                  "senderCity": "Johannesburg",
                  "recipientName": "Thandi Mokoena",
                  "recipientCity": "Johannesburg",
                  "trackingUrl": "https://demo-couriers.parcelops.co.za/online/track?q=DCS-002199",
                  "deliveredAt": "2026-09-29T11:02:10.000Z",
                  "pod": {
                    "recipientName": "Thandi Mokoena",
                    "capturedAt": "2026-09-29T11:02:10.000Z"
                  },
                  "podUrl": "https://api.parcelops.co.za/api/v1/customer/shipments/c6459b89-8531-4ec5-b15d-908549caa221/pod",
                  "podUrlNeedsKey": true
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx within 15 seconds acknowledges the delivery"
          }
        }
      }
    },
    "booking.cancelled": {
      "post": {
        "summary": "booking.cancelled",
        "description": "The courier cancelled a booking of yours before it became a shipment, so it never will: `bookingReference` is its `referenceNumber`, with your `customerReference` (or null). A shipment already created is not a booking any more and does not send it when it is cancelled; but when the courier deletes a shipment that came from a booking of yours, this event is sent once for that booking (and the shipment id then answers 404).",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "X-Parcelops-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "`sha256=` + hex HMAC-SHA256 of the raw body, keyed with your secret"
          },
          {
            "name": "X-Parcelops-Signature-Previous",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Present for 24 hours after a secret rotation: the same, keyed with the previous secret"
          },
          {
            "name": "X-Parcelops-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Stable across retries of the same delivery: deduplicate on it"
          },
          {
            "name": "X-Parcelops-Event",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The event name, as in the body"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "version": "1",
                "id": "f61eddff-a05a-4fbf-a8aa-60f52b093f6e",
                "event": "booking.cancelled",
                "timestamp": "2026-09-29T11:02:10.412Z",
                "tenantSlug": "demo-couriers",
                "data": {
                  "bookingReference": "BK-20260930-VGQTSF",
                  "customerReference": "9351424",
                  "status": "CANCELLED",
                  "occurredAt": "2026-09-30T15:59:37.722Z"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx within 15 seconds acknowledges the delivery"
          }
        }
      }
    },
    "invoice.issued": {
      "post": {
        "summary": "invoice.issued",
        "description": "An invoice was issued to your account (sent when it is issued, never for a draft). Fetch it from `GET /customer/invoices/{invoiceId}`. `periodStart` and `periodEnd` are the billing period (YYYY-MM-DD); both are null on a supplementary invoice, which bills a charge that rose after its invoice.",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "X-Parcelops-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "`sha256=` + hex HMAC-SHA256 of the raw body, keyed with your secret"
          },
          {
            "name": "X-Parcelops-Signature-Previous",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Present for 24 hours after a secret rotation: the same, keyed with the previous secret"
          },
          {
            "name": "X-Parcelops-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Stable across retries of the same delivery: deduplicate on it"
          },
          {
            "name": "X-Parcelops-Event",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The event name, as in the body"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "version": "1",
                "id": "f61eddff-a05a-4fbf-a8aa-60f52b093f6e",
                "event": "invoice.issued",
                "timestamp": "2026-09-29T11:02:10.412Z",
                "tenantSlug": "demo-couriers",
                "data": {
                  "invoiceId": "0f3d2c1b-8e7a-4b6c-9d5e-1a2b3c4d5e6f",
                  "invoiceNumber": "INV-000016",
                  "totalAmount": 29199.71,
                  "waybillCount": 50,
                  "periodStart": "2026-09-01",
                  "periodEnd": "2026-09-30"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx within 15 seconds acknowledges the delivery"
          }
        }
      }
    },
    "invoice.paid": {
      "post": {
        "summary": "invoice.paid",
        "description": "An invoice has been paid in full.",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "X-Parcelops-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "`sha256=` + hex HMAC-SHA256 of the raw body, keyed with your secret"
          },
          {
            "name": "X-Parcelops-Signature-Previous",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Present for 24 hours after a secret rotation: the same, keyed with the previous secret"
          },
          {
            "name": "X-Parcelops-Delivery-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Stable across retries of the same delivery: deduplicate on it"
          },
          {
            "name": "X-Parcelops-Event",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The event name, as in the body"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "example": {
                "version": "1",
                "id": "f61eddff-a05a-4fbf-a8aa-60f52b093f6e",
                "event": "invoice.paid",
                "timestamp": "2026-09-29T11:02:10.412Z",
                "tenantSlug": "demo-couriers",
                "data": {
                  "invoiceId": "0f3d2c1b-8e7a-4b6c-9d5e-1a2b3c4d5e6f",
                  "invoiceNumber": "INV-000016",
                  "totalAmount": 29199.71,
                  "amountPaid": 29199.71,
                  "paymentMethod": "EFT"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx within 15 seconds acknowledges the delivery"
          }
        }
      }
    }
  }
}