{
  "openapi": "3.1.0",
  "info": {
    "title": "Pošta bez Hranic API",
    "version": "1.0.0",
    "summary": "Tracking, couriers, and shipment management API",
    "description": "> ⚠️ **Beta — work in progress.** This documentation is in beta and\n> runs against the **live production API**. Every example here makes\n> a real call against `https://api.postabezhranic.cz`. The endpoints\n> themselves are stable, but the docs are still being refined —\n> expect occasional inaccuracies, missing edge cases, or wording\n> tweaks. If something looks off, please report it to your PBH\n> contact.\n\nWelcome to the **Pošta bez Hranic (PBH) API**. This API lets you track\nparcels, list available couriers, and integrate shipment management\ninto your own systems.\n\n## Quick start\n\n1. Obtain your **API key** and **userId** from your PBH account manager.\n2. Send a `POST` request to one of the endpoints below with:\n   - HTTP header `Authorization: Bearer <your-api-key>`\n   - JSON body containing `userId` and endpoint-specific parameters.\n3. Try any endpoint right here on this page — click **\"Test Request\"**\n   in the right panel, paste your credentials, and execute.\n\n## Authentication\n\nEvery request requires **both** of the following:\n\n| Where        | What                                                          |\n| ------------ | ------------------------------------------------------------- |\n| HTTP header  | `Authorization: Bearer <api-key>`                             |\n| JSON body    | `userId` — your numeric client ID (e.g. `12345`)              |\n\nThe `userId` in the body is required for legacy compatibility and must\nmatch the account associated with your API key. Mismatched values\nreturn `Parameter 'userId' is invalid`.\n\n> **Note:** `userId` accepts both `12345` (integer) and `\"12345\"`\n> (string) for compatibility, but integer is recommended.\n\n## Base URL\n\nAll endpoints are served from:\n\n```\nhttps://api.postabezhranic.cz\n```\n\n## Rate limits\n\n- **1 000 requests / hour** per API key\n- **100 000 requests / day** per API key\n\nThe API does **not** currently return rate-limit headers\n(`X-RateLimit-Remaining` etc.). Clients should self-throttle.\n\nSee [Rate limits](#tag/rate-limits) for guidance on staying under the\ncap when polling.\n\n## Error handling\n\nThe API returns two distinct error shapes — see\n[Error handling](#tag/errors) for the full reference and parsing\nrecipes.\n",
    "contact": {
      "name": "Pošta bez Hranic",
      "url": "https://www.postabezhranic.cz"
    },
    "license": {
      "name": "Proprietary",
      "url": "https://www.postabezhranic.cz"
    }
  },
  "servers": [
    {
      "url": "https://api.postabezhranic.cz",
      "description": "Production"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Tracking",
      "description": "Endpoints for retrieving the status and history of parcels handled\nby PBH. Two complementary endpoints:\n\n- [`/tracking-changes`](#tag/Tracking/operation/getTrackingChanges)\n  — paginated stream of status changes since a given timestamp or\n  ID. Use this for **periodic synchronization**.\n- [`/tracking-details`](#tag/Tracking/operation/getTrackingDetails)\n  — full event history for one or more specific parcels. Use this\n  when you need the **detailed event timeline**.\n\n### Recommended sync pattern\n\n1. Periodically (e.g. every 5 minutes) call `/tracking-changes`\n   with the `last_id` from your previous response as `from_id`.\n2. Persist the new statuses in your system.\n3. For parcels where the customer wants the full timeline, call\n   `/tracking-details` on demand.\n\nThis pattern keeps your database in sync with minimal API traffic.\n"
    },
    {
      "name": "Couriers",
      "description": "Information about the couriers (carriers) PBH integrates with —\ndelivery countries, supported services, and per-courier limits\n(weight, insurance, COD).\n"
    },
    {
      "name": "Shipments",
      "description": "Endpoints for the shipment workflow — generating handover\nprotocols, return labels (coming soon), and other documents\nsurrounding parcel dispatch.\n"
    },
    {
      "name": "Status codes",
      "description": "Reference: the 18 unified PBH status codes used across all\ncouriers, plus how to interpret raw `courier_status_code` values\nfrom external carriers.\n"
    },
    {
      "name": "Rate limits",
      "description": "Quotas and best practices for staying within them.\n"
    },
    {
      "name": "Errors",
      "description": "Both error shapes used by the API, with parsing guidance.\n"
    },
    {
      "name": "Changelog",
      "description": "Notable changes to the API.\n"
    }
  ],
  "paths": {
    "/tracking-details": {
      "post": {
        "operationId": "getTrackingDetails",
        "summary": "Get full tracking history for parcels",
        "tags": [
          "Tracking"
        ],
        "description": "Returns the full delivery history for one or more parcels. Each\nhistory entry contains both the unified PBH status code\n(`pbh_status_code`) and the original courier status code\n(`courier_status_code`), letting integrators choose between a\nstable common vocabulary or carrier-specific detail.\n\n### Behaviour for unknown parcels\n\nUnknown parcel numbers are returned in `data` with\n`parcel_info: \"Parcel not found\"` and **no** `courier` or\n`history` fields. The overall HTTP status is still `200`. Always\ncheck `parcel_info` before reading `courier`/`history`.\n\n### Empty history\n\nNewly created parcels that have not yet been picked up by the\ncourier may return an empty `history: []` array. This is normal —\npoll again later or wait for the next status change in\n`/tracking-changes`.\n\n### Multiple events with the same `pbh_status_code`\n\nSome couriers report tracking events at finer granularity than the\n18 PBH status codes. In those cases you will see multiple\nconsecutive `history` entries sharing the same `pbh_status_code`\nbut with different `courier_status_code` / `courier_description`\nvalues. This is by design — pick whichever level of detail your\nintegration needs.\n\n### Recommended usage\n\nUse this endpoint **on demand** when a customer wants the detailed\ntimeline. For ongoing synchronization, prefer\n[`/tracking-changes`](#tag/Tracking/operation/getTrackingChanges).\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TrackingDetailsRequest"
              },
              "examples": {
                "singleParcel": {
                  "summary": "Single parcel",
                  "value": {
                    "userId": 12345,
                    "parcel_numbers": [
                      "12345-60546861"
                    ]
                  }
                },
                "twoParcels": {
                  "summary": "Two parcels (batch)",
                  "value": {
                    "userId": 12345,
                    "parcel_numbers": [
                      "12345-2235",
                      "12345-60546861"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Tracking data returned successfully.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TrackingDetailsResponse"
                },
                "examples": {
                  "deliveredParcel": {
                    "summary": "Parcel with history",
                    "value": {
                      "status": "ok",
                      "statusInfo": "",
                      "data": [
                        {
                          "parcel_number": "12345-60546861",
                          "parcel_info": "",
                          "client_reference": "",
                          "courier": {
                            "courier_number": 81,
                            "name": "GR-ACS",
                            "tracking_number": "63580826806",
                            "tracking_url": "https://www.speedy.bg/en/track-shipment?shipmentNumber=63580826806",
                            "public_tracking_url": "https://tracking.postabezhranic.cz/detail-12345-60546861"
                          },
                          "history": [
                            {
                              "event_time": "2026-05-07T09:35:39Z",
                              "pbh_status_code": "DATA_SENT",
                              "pbh_status_description": "Data předány přepravci",
                              "courier_status_code": "PBH_DATA_RECEIVED",
                              "courier_description": "Předání dat o zásilce"
                            },
                            {
                              "event_time": "2026-05-07T09:35:41Z",
                              "pbh_status_code": "DATA_SENT",
                              "pbh_status_description": "Data předány přepravci",
                              "courier_status_code": "PBH_DATA_SENT",
                              "courier_description": "Předání dat cílovému přepravci"
                            },
                            {
                              "event_time": "2026-05-07T10:35:41Z",
                              "pbh_status_code": "DATA_SENT",
                              "pbh_status_description": "Data předány přepravci",
                              "courier_status_code": "148",
                              "courier_description": "Shipment data received"
                            }
                          ]
                        }
                      ]
                    }
                  },
                  "mixedFoundAndNotFound": {
                    "summary": "Mix of valid and unknown parcels",
                    "value": {
                      "status": "ok",
                      "statusInfo": "",
                      "data": [
                        {
                          "parcel_number": "12345-2235",
                          "parcel_info": "",
                          "client_reference": null,
                          "courier": {
                            "courier_number": 233,
                            "name": "NL-PostNL",
                            "tracking_number": "3SDOKC004940623",
                            "tracking_url": "https://mailingtechnology.com/tracking/?tn=3SDOKC004940623",
                            "public_tracking_url": "https://tracking.postabezhranic.cz/detail-12345-2235"
                          },
                          "history": []
                        },
                        {
                          "parcel_number": "12345-99999999",
                          "parcel_info": "Parcel not found"
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/tracking-changes": {
      "post": {
        "operationId": "getTrackingChanges",
        "summary": "List parcels whose status changed since a given point",
        "tags": [
          "Tracking"
        ],
        "description": "Returns a **paginated stream of status changes** that occurred\nafter a given ID or timestamp. Designed for periodic\nsynchronization: you keep one cursor (`last_id`) and on each sync\nfetch only what is new.\n\nEach entry contains the **latest** PBH status code at the moment\nof the event. If you need the full event history for a specific\nparcel, follow up with\n[`/tracking-details`](#tag/Tracking/operation/getTrackingDetails).\n\n### Pagination\n\n1. On your **first call**, use `from_datetime` (e.g. midnight\n   today, or your last known sync time).\n2. The response contains `meta.has_more` and `meta.last_id`.\n3. On every **subsequent call**, pass `from_id: <last_id from\n   previous response>` until `has_more: false`.\n4. **Persist `last_id`** between sync runs — using `from_id` is\n   safer than `from_datetime` because it guarantees no gaps or\n   duplicates even if multiple changes share the same timestamp.\n\n### Sync interval\n\nA sync every **5 minutes** is a good default. With 1 000\nrequests/hour you can poll every ~4 seconds in the worst case, but\nexcessive polling wastes quota — see\n[Rate limits](#tag/rate-limits).\n\n### Time range\n\nProvide `to_datetime` to bound the search window. Useful for\nbackfilling a specific period without flooding your sync queue.\n\n### Required parameters\n\nAt least one of `from_id` or `from_datetime` is required. Omitting\nboth returns a [business error](#tag/errors) with HTTP `200` and\n`status: \"error\"`.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TrackingChangesRequest"
              },
              "examples": {
                "firstSyncByDatetime": {
                  "summary": "First sync — by timestamp",
                  "value": {
                    "userId": 12345,
                    "from_datetime": "2026-05-01 00:00:00",
                    "limit": 100
                  }
                },
                "continueSyncById": {
                  "summary": "Continue sync — by last_id cursor",
                  "value": {
                    "userId": 12345,
                    "from_id": 1735687158,
                    "limit": 100
                  }
                },
                "boundedWindow": {
                  "summary": "Backfill a specific time window",
                  "value": {
                    "userId": 12345,
                    "from_datetime": "2026-05-07 09:30:00",
                    "to_datetime": "2026-05-07 09:40:00",
                    "limit": 1000
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Either the page of status changes (`status: \"ok\"`) **or** a\nbusiness error (`status: \"error\"`) with HTTP `200`. Check\n`status` before processing `data`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/TrackingChangesResponse"
                    },
                    {
                      "$ref": "#/components/schemas/BusinessError"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                },
                "examples": {
                  "success": {
                    "summary": "Page of status changes",
                    "value": {
                      "status": "ok",
                      "statusInfo": "",
                      "data": {
                        "meta": {
                          "count": 4,
                          "limit": 10,
                          "has_more": false,
                          "last_id": 1735517364
                        },
                        "statuses": [
                          {
                            "parcel_number": "12345-60546861",
                            "client_reference": "",
                            "status_code": "DATA_SENT",
                            "status_datetime": "2026-05-07 09:35:39"
                          },
                          {
                            "parcel_number": "12345-60546861",
                            "client_reference": "",
                            "status_code": "DATA_SENT",
                            "status_datetime": "2026-05-07 09:35:41"
                          },
                          {
                            "parcel_number": "12345-60546862",
                            "client_reference": "",
                            "status_code": "DATA_SENT",
                            "status_datetime": "2026-05-07 09:39:04"
                          },
                          {
                            "parcel_number": "12345-60546862",
                            "client_reference": "",
                            "status_code": "DATA_SENT",
                            "status_datetime": "2026-05-07 09:39:05"
                          }
                        ]
                      }
                    }
                  },
                  "missingFromParam": {
                    "summary": "Business error — missing from_id and from_datetime",
                    "value": {
                      "status": "error",
                      "statusInfo": "At least one of parameters (from_id, from_datetime) must be provided"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/get-couriers": {
      "post": {
        "operationId": "getCouriers",
        "summary": "List available couriers and their supported services",
        "tags": [
          "Couriers"
        ],
        "description": "Returns the full catalogue of couriers PBH integrates with for\nyour account — currently ~110 entries spanning 25 countries.\n\nFor each courier you get:\n- **Identity**: `name`, `number` (stable ID, also returned in\n  `/tracking-details` as `courier_number`), `delivery_country`.\n- **Capabilities**: `delivery_type` (home / parcelshop),\n  `multiparcels_allowed`, return-label support, direct label\n  print.\n- **Add-on services** (e.g. cash on delivery, SMS notice, fragile\n  handling) with their prices.\n- **Limits**: maximum weight, insurance value, and COD amount.\n\n### When to call this\n\n- On client onboarding, to populate a courier picker UI.\n- Periodically (e.g. daily) to detect new couriers or limit\n  changes.\n\nThere is no pagination — the full list is returned in a single\nresponse.\n\n### `status: upcoming` couriers\n\nCouriers in the `upcoming` state are visible for planning purposes\nbut **cannot** yet be used to create shipments. Filter them out in\nUI flows where the user selects a delivery option.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/GetCouriersRequest"
              },
              "examples": {
                "basic": {
                  "summary": "List all couriers",
                  "value": {
                    "userId": 12345
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Full list of couriers.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GetCouriersResponse"
                },
                "examples": {
                  "sample": {
                    "summary": "First 3 couriers (truncated)",
                    "value": {
                      "status": "ok",
                      "statusInfo": "Success",
                      "data": [
                        {
                          "name": "Cargus",
                          "status": "active",
                          "delivery_country": "RO",
                          "number": 54,
                          "delivery_type": "home",
                          "multiparcels_allowed": 1,
                          "return_labels_premade": 0,
                          "return_labels_ondemand": 0,
                          "direct_label_print": 1,
                          "services": [],
                          "limits": {
                            "max_weight": 50000,
                            "max_insurance": 1800,
                            "max_cod": 5000
                          }
                        },
                        {
                          "name": "Magyar Posta",
                          "status": "active",
                          "delivery_country": "HU",
                          "number": 60,
                          "delivery_type": "home",
                          "multiparcels_allowed": 1,
                          "return_labels_premade": 0,
                          "return_labels_ondemand": 1,
                          "direct_label_print": 1,
                          "services": [
                            {
                              "service": "pm",
                              "price": 0,
                              "note": ""
                            },
                            {
                              "service": "pp",
                              "price": 0,
                              "note": ""
                            },
                            {
                              "service": "cs",
                              "price": 0,
                              "note": ""
                            }
                          ],
                          "limits": {
                            "max_weight": 40000,
                            "max_insurance": 200000,
                            "max_cod": 1000000
                          }
                        },
                        {
                          "name": "DHL",
                          "status": "active",
                          "delivery_country": "DE",
                          "number": 6,
                          "delivery_type": "home",
                          "multiparcels_allowed": 0,
                          "return_labels_premade": 1,
                          "return_labels_ondemand": 1,
                          "direct_label_print": 1,
                          "services": [
                            {
                              "service": "postfiliale",
                              "price": 0,
                              "note": ""
                            },
                            {
                              "service": "packstation",
                              "price": 0,
                              "note": ""
                            }
                          ],
                          "limits": {
                            "max_weight": 30000,
                            "max_insurance": 2500,
                            "max_cod": 3500
                          }
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/handover-protocol": {
      "post": {
        "operationId": "createHandoverProtocol",
        "summary": "Generate a parcels handover protocol (PDF)",
        "tags": [
          "Shipments"
        ],
        "description": "Generates a **handover protocol** — the document a courier signs\nwhen picking up parcels from the sender — for the given list of\nPBH parcels. The PDF is returned as a base64-encoded string in\n`data.handoverProtocolBase64`.\n\n### Request shape\n\nThis endpoint nests `parcelNumbers` under `data` (camelCase),\nunlike [`/tracking-details`](#tag/Tracking/operation/getTrackingDetails)\nwhich uses `parcel_numbers` at the root (snake_case). Pay attention\nto the structure.\n\n### All-or-nothing semantics\n\nIf **any** parcel number in the request does not exist for your\naccount, the entire request fails with:\n\n```json\n{ \"status\": \"error\", \"statusInfo\": \"PARCEL_NOT_FOUND\" }\n```\n\nNo partial protocol is generated. Validate parcel numbers\nclient-side (or call\n[`/tracking-details`](#tag/Tracking/operation/getTrackingDetails)\nfirst) if you want graceful per-parcel handling.\n\n### Empty list\n\nSending `parcelNumbers: []` returns a **template / blank** protocol\nPDF, not an error. This is by design — useful for previewing the\ndocument format.\n\n### Typical workflow\n\n1. Create shipments (separate endpoint).\n2. Just before courier pickup, call this endpoint with all parcel\n   numbers being handed over.\n3. Decode `handoverProtocolBase64` to PDF and print or display.\n4. Have the courier driver sign the printed copy.\n\n### PDF format\n\nPDF 1.4, A4 portrait, typically 40–80 KB depending on parcel count.\nThe PDF includes parcel numbers, courier names, and a signature\nline.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/HandoverProtocolRequest"
              },
              "examples": {
                "singleParcel": {
                  "summary": "Single parcel",
                  "value": {
                    "userId": 12345,
                    "data": {
                      "parcelNumbers": [
                        "12345-60546861"
                      ]
                    }
                  }
                },
                "multipleParcels": {
                  "summary": "Multiple parcels (batch handover)",
                  "value": {
                    "userId": 12345,
                    "data": {
                      "parcelNumbers": [
                        "12345-60546861",
                        "12345-60546862",
                        "12345-60546863"
                      ]
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Either the generated PDF (`status: \"ok\"`) **or** a business\nerror (`status: \"error\"`) — both come back with HTTP `200`.\nCheck `status` before reading `data`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/HandoverProtocolResponse"
                    },
                    {
                      "$ref": "#/components/schemas/BusinessError"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                },
                "examples": {
                  "success": {
                    "summary": "Successful PDF generation",
                    "value": {
                      "status": "ok",
                      "statusInfo": "ok",
                      "data": {
                        "handoverProtocolBase64": "JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9UeXBlIC9QYWdlCi9QYXJlbnQgMSAwIFIK..."
                      }
                    }
                  },
                  "parcelNotFound": {
                    "summary": "Unknown parcel in batch",
                    "value": {
                      "status": "error",
                      "statusInfo": "PARCEL_NOT_FOUND"
                    }
                  },
                  "missingParameter": {
                    "summary": "parcelNumbers not provided",
                    "value": {
                      "status": "error",
                      "statusInfo": "Parameter `parcelNumbers` is required",
                      "statusCode": "MISSING_PARAMETER"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/storno-courier-label": {
      "post": {
        "operationId": "stornoCourierLabel",
        "summary": "Cancel an unused courier label",
        "tags": [
          "Shipments"
        ],
        "description": "Cancels a previously generated courier label that you no longer\nintend to use. Once cancelled, the parcel will not be billed\n(provided the cancellation happens before the billing period\ncloses).\n\n### When you can cancel\n\nA label can be cancelled only while **all** of the following hold:\n\n- The label was created with a courier (i.e. has a `courier_number`\n  assigned — `parcels_info: \"Parcel not found\"` or PBH-internal\n  drafts cannot be cancelled).\n- The parcel has **not yet been invoiced** to your account.\n- The billing period is still open.\n\n### When you cannot cancel\n\nThe endpoint **does not** stop physical delivery. If you have\nalready handed the parcel to the courier, cancelling the label\nhere does not guarantee the parcel will not be delivered — and\ndelivered parcels are still charged.\n\n### Multi-parcel shipments\n\nFor multi-parcel shipments, you must call this endpoint **once\nper parcel** — there is no batch variant. Cancelling only some\nparcels of a multi-parcel shipment may leave the shipment in an\ninconsistent state; cancel all of them or none.\n\n### Response\n\nAlways returns HTTP `200`. The `status` field distinguishes\nsuccess from failure:\n\n- `status: \"ok\"` — cancellation succeeded;\n  `statusCode: \"PARCEL_SUCCESSFULLY_CANCELLED\"`.\n- `status: \"error\"` — cancellation refused; see `statusCode`\n  values below.\n\n### Known error `statusCode` values\n\n| `statusCode`                          | Meaning                                                            |\n| ------------------------------------- | ------------------------------------------------------------------ |\n| `PARCEL_NOT_FOUND`                    | Parcel number does not exist for your account.                     |\n| `PARCEL_ALREADY_INVOICED`             | Already billed — past the cancellation window.                     |\n| `PARCEL_NOT_CREATED_WITH_COURIER_LABEL` | Parcel is a draft / has no courier label assigned yet.           |\n| `MISSING_PARAMETER`                   | Required field missing in request body.                            |\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/StornoCourierLabelRequest"
              },
              "examples": {
                "basic": {
                  "summary": "Cancel a single parcel",
                  "value": {
                    "userId": 12345,
                    "data": {
                      "parcelNumber": "12345-2235"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Either a successful cancellation (`status: \"ok\"`) or a refused\ncancellation (`status: \"error\"`) — both come back with HTTP\n`200`. Inspect `status` and `statusCode`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/StornoCourierLabelResponse"
                    },
                    {
                      "$ref": "#/components/schemas/BusinessError"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                },
                "examples": {
                  "success": {
                    "summary": "Successful cancellation",
                    "value": {
                      "status": "ok",
                      "statusInfo": "Parcel 12345-2235 has been successfully cancelled",
                      "statusCode": "PARCEL_SUCCESSFULLY_CANCELLED"
                    }
                  },
                  "notFound": {
                    "summary": "Parcel not found",
                    "value": {
                      "status": "error",
                      "statusInfo": "Parcel 12345-99999999 not found",
                      "statusCode": "PARCEL_NOT_FOUND"
                    }
                  },
                  "alreadyInvoiced": {
                    "summary": "Already invoiced (past cancellation window)",
                    "value": {
                      "status": "error",
                      "statusInfo": "This parcel has been already invoiced",
                      "statusCode": "PARCEL_ALREADY_INVOICED"
                    }
                  },
                  "noCourierLabel": {
                    "summary": "Parcel has no courier label",
                    "value": {
                      "status": "error",
                      "statusInfo": "This parcel wasn't created with courier label and cannot be cancelled",
                      "statusCode": "PARCEL_NOT_CREATED_WITH_COURIER_LABEL"
                    }
                  },
                  "missingParameter": {
                    "summary": "Missing parcelNumber",
                    "value": {
                      "status": "error",
                      "statusInfo": "Parameter `parcelNumber` is required",
                      "statusCode": "MISSING_PARAMETER"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/courier-data-validator": {
      "post": {
        "operationId": "validateCourierData",
        "summary": "Validate courier-specific shipment fields before creating a label",
        "tags": [
          "Shipments"
        ],
        "description": "Validates per-courier shipment fields (recipient ZIP, house\nnumber, courier-specific identifiers like DHL postnumber, etc.)\n**before** you attempt to create a shipment. Useful for surfacing\naddress / data errors in your UI without burning a label-creation\ncall.\n\n### How it works\n\nSend `courier_number` plus any arbitrary fields you want\nvalidated. The server replies with a `true` / `false` / `null`\nresult per field:\n\n- `true` — value is valid.\n- `false` — value is invalid.\n- `null` — validation is **not available** for this field (the\n  courier does not validate it, your account does not have the\n  validator enabled, or the validator hit an internal error).\n  Treat `null` as \"unknown — proceed but be prepared for\n  downstream failure.\"\n\nFields the courier does not know about may be **omitted entirely**\nfrom the response instead of being returned as `null`.\n\n### Coverage today\n\nCurrently only **courier `191`** (DHL — parcellocker, DE) is\nintegrated. Calls for other couriers succeed but typically return\nno validation results. Coverage will expand over time. To know\nwhich fields a given courier supports, the only authoritative\nsource today is to **send a probe request and inspect which keys\ncome back** — this will be improved (see related note in\n[`/get-couriers`](#tag/Couriers/operation/getCouriers)).\n\n### Recommended usage\n\n1. In your shipment-creation UI, debounce field edits and call\n   this endpoint with the fields you want to validate.\n2. Display `true` as a green checkmark, `false` as an inline\n   error, `null` as a neutral \"?\" or simply hide.\n3. Do **not** block submission on `null` — it does not mean\n   invalid.\n\n### Errors\n\nReturns HTTP `200` with `status: \"error\"` if `courier_number` is\nmissing. The error envelope here does **not** include a\n`statusCode` field (inconsistency with `/handover-protocol` and\n`/storno-courier-label` — to be unified in API v2).\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CourierDataValidatorRequest"
              },
              "examples": {
                "dhlGermany": {
                  "summary": "Validate DHL DE parcellocker fields",
                  "value": {
                    "userId": 12345,
                    "data": {
                      "courier_number": 191,
                      "de_dhl_postnumber": "858080206",
                      "zip": "10115",
                      "house_number": "12a"
                    }
                  }
                },
                "probeUnknownCourier": {
                  "summary": "Probe which fields a courier validates",
                  "value": {
                    "userId": 12345,
                    "data": {
                      "courier_number": 191,
                      "zip": "",
                      "house_number": "",
                      "de_dhl_postnumber": ""
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Either a validation result (`status: \"ok\"`) or a business\nerror (`status: \"error\"`). Inspect `status` first.\n",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/CourierDataValidatorResponse"
                    },
                    {
                      "$ref": "#/components/schemas/BusinessError"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                },
                "examples": {
                  "allValid": {
                    "summary": "All fields valid",
                    "value": {
                      "status": "ok",
                      "statusInfo": "",
                      "data": {
                        "de_dhl_postnumber": true,
                        "zip": true,
                        "house_number": true
                      }
                    }
                  },
                  "mixedResult": {
                    "summary": "Mixed valid / invalid / unavailable",
                    "value": {
                      "status": "ok",
                      "statusInfo": "",
                      "data": {
                        "de_dhl_postnumber": true,
                        "zip": false,
                        "house_number": null
                      }
                    }
                  },
                  "onlyCourierNumber": {
                    "summary": "No fields to validate — no `data` in response",
                    "value": {
                      "status": "ok",
                      "statusInfo": ""
                    }
                  },
                  "missingCourierNumber": {
                    "summary": "Missing required `courier_number`",
                    "value": {
                      "status": "error",
                      "statusInfo": "Missing courier number"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/get-labels": {
      "post": {
        "operationId": "getLabels",
        "summary": "Generate parcel labels as a PDF (A6 / 4-per-A4 / 9-per-A4)",
        "tags": [
          "Shipments"
        ],
        "description": "Generates the shipping labels for one or more existing parcels as\na single PDF, in one of three layouts.\n\nLabels are normally returned automatically when a shipment is\ncreated (in A6 format). Use this endpoint when you need to\nre-fetch labels later, batch-print multiple parcels onto a single\nA4 sheet, or reuse a partially-printed sheet by offsetting the\nstarting position.\n\n### Layouts\n\n| `printLayout` | Description                                        |\n| ------------- | -------------------------------------------------- |\n| `a6`          | One label per A6 page (the default native format). |\n| `4a4`         | 4 labels arranged on one A4 (2 × 2).               |\n| `9a4`         | 9 smaller labels on one A4 (3 × 3).                |\n\nPre-cut adhesive label sheets for `4a4` and `9a4` are sold as\nstandard office supplies.\n\n### `startLabelNumber` (offset on first sheet)\n\nUseful when you already partially used a `4a4` / `9a4` sheet and\nwant to print the next label starting from a specific position\ninstead of position 1 (top-left). 1-indexed, fills left-to-right\nthen top-to-bottom.\n\n**Quirk:** only values `1..4` are accepted, even for `9a4`.\nPositions 5–9 on a 9a4 sheet are currently unreachable through\nthis parameter. This is an API limitation, not a documentation\nerror.\n\n### Constraints\n\n- Parcels must have a courier label already created — drafts\n  without an assigned courier label will fail with\n  `INVALID_PARCEL_NUMBER`.\n- The full request fails if **any** parcel number is invalid (no\n  partial PDF is generated).\n\n### Errors\n\nAll errors are returned with HTTP `200` and `status: \"error\"`.\nKnown `statusCode` values for this endpoint:\n\n| `statusCode`                       | Meaning                                                                  |\n| ---------------------------------- | ------------------------------------------------------------------------ |\n| `ERROR_NOT_SET_OR_EMPTY_PARAMETER` | A required parameter (`parcelNumbers`, `printLayout`) is missing/empty.  |\n| `UNKNOWN_LAYOUT`                   | `printLayout` is not one of `a6` / `4a4` / `9a4`.                        |\n| `INVALID_PARCEL_NUMBER`            | One of the parcel numbers does not exist or has no courier label.        |\n| `INVALID_START_LABEL_NUMBER`       | `startLabelNumber` is outside `1..4` or was passed with `a6` layout.     |\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/GetLabelsRequest"
              },
              "examples": {
                "a6Single": {
                  "summary": "Single label on A6 (default format)",
                  "value": {
                    "userId": 12345,
                    "data": {
                      "parcelNumbers": [
                        "12345-2345"
                      ],
                      "printLayout": "a6"
                    }
                  }
                },
                "fourPerA4": {
                  "summary": "4 labels on one A4 sheet, starting from top-left",
                  "value": {
                    "userId": 12345,
                    "data": {
                      "parcelNumbers": [
                        "12345-2345",
                        "12345-2260",
                        "12345-2258",
                        "12345-2236"
                      ],
                      "printLayout": "4a4",
                      "startLabelNumber": "1"
                    }
                  }
                },
                "nineOnA4WithOffset": {
                  "summary": "9 labels per A4, starting from position 3",
                  "value": {
                    "userId": 12345,
                    "data": {
                      "parcelNumbers": [
                        "12345-2345",
                        "12345-2260",
                        "12345-2258",
                        "12345-2236",
                        "12345-1418",
                        "12345-805"
                      ],
                      "printLayout": "9a4",
                      "startLabelNumber": "3"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Either the generated PDF (`status: \"ok\"`) **or** a business\nerror (`status: \"error\"`). Check `status` before reading\n`data.base64pdf`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/GetLabelsResponse"
                    },
                    {
                      "$ref": "#/components/schemas/BusinessError"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "status"
                  }
                },
                "examples": {
                  "success": {
                    "summary": "Labels generated successfully",
                    "value": {
                      "status": "ok",
                      "statusInfo": "Success",
                      "data": {
                        "base64pdf": "JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9UeXBlIC9QYWdlCi9QYXJlbnQgMSAwIFIK..."
                      }
                    }
                  },
                  "invalidParcel": {
                    "summary": "One parcel number unknown",
                    "value": {
                      "status": "error",
                      "statusInfo": "Invalid parcel \"12345-99999999\"",
                      "statusCode": "INVALID_PARCEL_NUMBER"
                    }
                  },
                  "unknownLayout": {
                    "summary": "printLayout not one of allowed values",
                    "value": {
                      "status": "error",
                      "statusInfo": "Unknown layout value. Available layouts: a6, 4a4, 9a4",
                      "statusCode": "UNKNOWN_LAYOUT"
                    }
                  },
                  "invalidStartLabel": {
                    "summary": "startLabelNumber out of range or used with a6",
                    "value": {
                      "status": "error",
                      "statusInfo": "Parameter \"startLabelNumber\" can only be combined with 4a4, 9a4 layouts with one of these values: 1, 2, 3, 4",
                      "statusCode": "INVALID_START_LABEL_NUMBER"
                    }
                  },
                  "missingParameter": {
                    "summary": "Missing required parameter",
                    "value": {
                      "status": "error",
                      "statusInfo": "Parameter `printLayout` must be set and not be empty",
                      "statusCode": "ERROR_NOT_SET_OR_EMPTY_PARAMETER"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "API key",
        "description": "Provide your API key in the `Authorization` header as a Bearer token:\n\n```\nAuthorization: Bearer <your-api-key>\n```\n\n**In addition**, every request body must contain `userId` — see the\nauthentication section in the introduction.\n"
      }
    },
    "schemas": {
      "TrackingDetailsRequest": {
        "type": "object",
        "title": "TrackingDetailsRequest",
        "required": [
          "userId",
          "parcel_numbers"
        ],
        "properties": {
          "userId": {
            "type": "integer",
            "description": "Your numeric client ID. Must match the account associated with\nyour API key.\n",
            "example": 12345
          },
          "parcel_numbers": {
            "type": "array",
            "minItems": 1,
            "description": "PBH parcel numbers to fetch. Each in the form `{userId}-{number}`.\nParcels that do not belong to your account are returned with\n`parcel_info: \"Parcel not found\"` (no error is raised for the\nwhole request).\n",
            "items": {
              "type": "string",
              "example": "12345-2235"
            },
            "example": [
              "12345-2235",
              "12345-60546861"
            ]
          }
        }
      },
      "CourierInfo": {
        "type": "object",
        "title": "CourierInfo",
        "description": "The courier that handled (or will handle) a parcel, along with\ntracking links.\n",
        "required": [
          "courier_number",
          "name",
          "tracking_number",
          "tracking_url",
          "public_tracking_url"
        ],
        "properties": {
          "courier_number": {
            "type": "integer",
            "description": "Stable numeric identifier of the courier. Use this value to\ncross-reference with [`POST /get-couriers`](#tag/Couriers/operation/getCouriers),\nwhere it appears as `number`.\n",
            "example": 3
          },
          "name": {
            "type": "string",
            "description": "Human-readable courier name including delivery country prefix\nwhere ambiguous (e.g. `CZ-Balíkovna na adresu`, `DE-DHL`).\n",
            "example": "CZ-Balíkovna na adresu"
          },
          "tracking_number": {
            "type": "string",
            "description": "Courier-side tracking number for the parcel.",
            "example": "DR2650811575U"
          },
          "tracking_url": {
            "type": "string",
            "format": "uri",
            "description": "Direct link to the courier's own public tracking page.\n",
            "example": "https://www.balikovna.cz/cs/sledovat-balik/-/balik/DR2650811575U"
          },
          "public_tracking_url": {
            "type": "string",
            "format": "uri",
            "description": "Public PBH tracking page — unified UI usable for end-customers\nregardless of underlying courier. Safe to embed in customer emails.\n",
            "example": "https://tracking.postabezhranic.cz/detail-12345-2235"
          }
        }
      },
      "PbhStatusCode": {
        "type": "string",
        "title": "PbhStatusCode",
        "description": "Unified PBH status code. PBH consolidates the (often very detailed)\nstatus codes of individual couriers into a stable set of 18 codes,\nso client integrations do not have to know each courier's vocabulary.\n\n| Code                  | Human-readable (CZ)                        |\n| --------------------- | ------------------------------------------ |\n| `DATA_RECEIVED`       | Přijata data k zásilce                     |\n| `DATA_SENT`           | Data předány přepravci                     |\n| `DATA_ERROR`          | Chyba v datech                             |\n| `HANDED_OVER`         | Zásilka přijata do přepravy                |\n| `PROCESSED`           | Zásilka zpracována na depu PBH             |\n| `LOADED_TO_CAR`       | Zásilka v přepravě                         |\n| `IN_TRANSIT`          | Zásilka u přepravce                        |\n| `WILL_BE_DELIVERED`   | Bude doručována dnes                       |\n| `READY_TO_PICK_UP`    | Připravena k vyzvednutí                    |\n| `ATTEMPT_FAIL`        | Neúspěšný pokus o doručení                 |\n| `DELIVERED`           | Doručeno                                   |\n| `RETURNING`           | Probíhá vrácení                            |\n| `RETURN_PROCESSED`    | Nepřevzatá — Zpracováno PBH                |\n| `RETURN_STOCKED`      | Nepřevzatá — Uloženo na depu PBH           |\n| `RETURN_RESEND`       | Nepřevzatá — Znovu odesláno                |\n| `RETURNED_TO_SENDER`  | Nepřevzatá — Předáno klientovi             |\n| `CLAIM`               | Reklamace                                  |\n| `STORNO`              | Storno                                     |\n\nThe same `pbh_status_code` may appear multiple times in a parcel's\nhistory if the underlying courier reports multiple sub-events that map\nto the same PBH code.\n",
        "enum": [
          "DATA_RECEIVED",
          "DATA_SENT",
          "DATA_ERROR",
          "HANDED_OVER",
          "PROCESSED",
          "LOADED_TO_CAR",
          "IN_TRANSIT",
          "WILL_BE_DELIVERED",
          "READY_TO_PICK_UP",
          "ATTEMPT_FAIL",
          "DELIVERED",
          "RETURNING",
          "RETURN_PROCESSED",
          "RETURN_STOCKED",
          "RETURN_RESEND",
          "RETURNED_TO_SENDER",
          "CLAIM",
          "STORNO"
        ],
        "example": "IN_TRANSIT"
      },
      "HistoryEvent": {
        "type": "object",
        "title": "HistoryEvent",
        "description": "A single tracking event for a parcel. Each event carries both the\nunified PBH status code and the original courier status code, so\nintegrators can choose the level of detail they need.\n",
        "required": [
          "event_time",
          "pbh_status_code",
          "pbh_status_description",
          "courier_status_code",
          "courier_description"
        ],
        "properties": {
          "event_time": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 timestamp of the event. **Time component may be\n`00:00:00Z`** when the courier reports only a date (common for\nevents sourced from external carriers' batch updates). PBH-internal\nevents always include the precise time.\n",
            "example": "2026-05-12T09:27:24Z"
          },
          "pbh_status_code": {
            "$ref": "#/components/schemas/PbhStatusCode"
          },
          "pbh_status_description": {
            "type": "string",
            "description": "Czech human-readable label for `pbh_status_code`.",
            "example": "Bude doručována dnes"
          },
          "courier_status_code": {
            "type": "string",
            "description": "Original status code from the underlying courier. The format\nvaries by courier — Česká pošta uses codes like `21`, `42`, `-M`;\nPBH-internal events use `PBH_*` prefixed codes.\n",
            "example": "53"
          },
          "courier_description": {
            "type": "string",
            "description": "Original courier-provided description (in the courier's language).",
            "example": "Doručování zásilky."
          }
        }
      },
      "Parcel": {
        "type": "object",
        "title": "Parcel",
        "description": "A single parcel entry returned by `/tracking-details`. May represent\neither a known parcel (with `courier` and `history`) or a\n\"not found\" placeholder (when the parcel number does not exist for\nthis account — see `parcel_info`).\n",
        "required": [
          "parcel_number",
          "parcel_info"
        ],
        "properties": {
          "parcel_number": {
            "type": "string",
            "description": "PBH parcel number — your client ID followed by a sequential number,\nseparated by a dash. Format: `{userId}-{number}`.\n",
            "example": "12345-2235"
          },
          "parcel_info": {
            "type": "string",
            "description": "Free-text status indicator. **Critical:** if this is set to\n`\"Parcel not found\"`, the parcel does not exist for your account\nand the `courier` / `history` / `client_reference` fields are\n**absent**. For valid parcels this field is normally empty\n(`\"\"`).\n",
            "example": ""
          },
          "client_reference": {
            "type": [
              "string",
              "null"
            ],
            "description": "Your own reference passed when creating the shipment (e.g.\ninternal order number). May be `null` or `\"\"` if you did not\nprovide one. Absent when `parcel_info` is `\"Parcel not found\"`.\n",
            "example": "obj-2024-001"
          },
          "courier": {
            "description": "Courier handling the parcel. Absent when `parcel_info` is\n`\"Parcel not found\"`.\n",
            "$ref": "#/components/schemas/CourierInfo"
          },
          "history": {
            "type": "array",
            "description": "Chronologically ordered list of tracking events. May be empty for\nfreshly created parcels that have not yet been picked up. Absent\nwhen `parcel_info` is `\"Parcel not found\"`.\n",
            "items": {
              "$ref": "#/components/schemas/HistoryEvent"
            }
          }
        }
      },
      "TrackingDetailsResponse": {
        "type": "object",
        "title": "TrackingDetailsResponse",
        "required": [
          "status",
          "statusInfo",
          "data"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ok"
            ],
            "description": "Always `\"ok\"` for a successful response.",
            "example": "ok"
          },
          "statusInfo": {
            "type": "string",
            "description": "Always empty (`\"\"`) on success.",
            "example": ""
          },
          "data": {
            "type": "array",
            "description": "One entry per requested parcel, in the **same order as the\nrequest**. Entries for unknown parcel numbers contain\n`parcel_info: \"Parcel not found\"` instead of `courier`/`history`.\n",
            "items": {
              "$ref": "#/components/schemas/Parcel"
            }
          }
        }
      },
      "TrackingChangesRequest": {
        "type": "object",
        "title": "TrackingChangesRequest",
        "description": "At least **one** of `from_id` or `from_datetime` must be provided —\nthe server returns a business error otherwise.\n",
        "required": [
          "userId"
        ],
        "properties": {
          "userId": {
            "type": "integer",
            "description": "Your numeric client ID.",
            "example": 12345
          },
          "from_id": {
            "type": "integer",
            "description": "Cursor — fetch only status changes with ID **greater than** this\nvalue. On the first call, omit this and use `from_datetime`\ninstead. On subsequent calls, pass the `last_id` from the\nprevious response.\n\n**Preferred over `from_datetime` for ongoing sync** — guarantees\nno gaps or duplicates even if multiple changes share the same\ntimestamp.\n",
            "example": 1735687158
          },
          "from_datetime": {
            "type": "string",
            "pattern": "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$",
            "description": "Fetch only status changes from this timestamp onward (inclusive).\nFormat: `YYYY-MM-DD HH:MM:SS` in server local time\n(Europe/Prague).\n",
            "example": "2026-05-01 00:00:00"
          },
          "to_datetime": {
            "type": "string",
            "pattern": "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$",
            "description": "Optional upper bound. Fetch only status changes up to this\ntimestamp (inclusive). Useful for backfilling a specific period.\n",
            "example": "2026-05-07 09:40:00"
          },
          "limit": {
            "type": "integer",
            "minimum": 1,
            "maximum": 1000,
            "default": 100,
            "description": "Maximum number of status changes per page. Default `100`,\nmaximum `1000`. Use `meta.has_more` and `meta.last_id` to\npaginate.\n",
            "example": 100
          }
        }
      },
      "TrackingChangesMeta": {
        "type": "object",
        "title": "TrackingChangesMeta",
        "description": "Pagination metadata for `/tracking-changes` responses.",
        "required": [
          "count",
          "limit",
          "has_more",
          "last_id"
        ],
        "properties": {
          "count": {
            "type": "integer",
            "minimum": 0,
            "description": "Number of items in this `statuses` array.",
            "example": 5
          },
          "limit": {
            "type": "integer",
            "minimum": 1,
            "maximum": 1000,
            "description": "The `limit` that was applied (echo of request parameter, or default `100`).",
            "example": 100
          },
          "has_more": {
            "type": "boolean",
            "description": "`true` if more results exist beyond this page. To fetch them, set\n`from_id` to `last_id` (below) in your next request.\n",
            "example": true
          },
          "last_id": {
            "type": "integer",
            "description": "ID of the last status in this page. Pass this as `from_id` in your\nnext request to continue paginating. When `has_more` is `false`,\npersist this value and use it as `from_id` on your next sync run.\n",
            "example": 1735687158
          }
        }
      },
      "TrackingStatusChange": {
        "type": "object",
        "title": "TrackingStatusChange",
        "description": "A single status change for a parcel, as returned by\n`/tracking-changes`. This is a flat, lightweight shape designed for\nbulk synchronization — if you need the full event history for a given\nparcel, follow up with\n[`/tracking-details`](#tag/Tracking/operation/getTrackingDetails).\n",
        "required": [
          "parcel_number",
          "client_reference",
          "status_code",
          "status_datetime"
        ],
        "properties": {
          "parcel_number": {
            "type": "string",
            "description": "PBH parcel number.",
            "example": "12345-60546861"
          },
          "client_reference": {
            "type": [
              "string",
              "null"
            ],
            "description": "Your own reference passed when creating the shipment. May be\n`null` or `\"\"`.\n",
            "example": ""
          },
          "status_code": {
            "$ref": "#/components/schemas/PbhStatusCode"
          },
          "status_datetime": {
            "type": "string",
            "description": "Timestamp of the status change in `YYYY-MM-DD HH:MM:SS` format\n(server local time, Europe/Prague). **Note:** this format differs\nfrom `event_time` in `/tracking-details`, which uses ISO 8601\nUTC.\n",
            "pattern": "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$",
            "example": "2026-05-07 09:35:39"
          }
        }
      },
      "TrackingChangesResponse": {
        "type": "object",
        "title": "TrackingChangesResponse",
        "required": [
          "status",
          "statusInfo",
          "data"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ok"
            ],
            "example": "ok"
          },
          "statusInfo": {
            "type": "string",
            "example": ""
          },
          "data": {
            "type": "object",
            "required": [
              "meta",
              "statuses"
            ],
            "properties": {
              "meta": {
                "$ref": "#/components/schemas/TrackingChangesMeta"
              },
              "statuses": {
                "type": "array",
                "description": "Status changes ordered by ID ascending (oldest first). May be\nempty if no changes occurred in the requested window.\n",
                "items": {
                  "$ref": "#/components/schemas/TrackingStatusChange"
                }
              }
            }
          }
        }
      },
      "BusinessError": {
        "type": "object",
        "title": "BusinessError",
        "description": "**Business-error envelope** — returned with HTTP `200 OK` when the\nrequest was parsed but failed validation. Distinct from the\n\"auth-style\" error (plain JSON string with HTTP 400) returned for\nauthentication and basic request errors.\n\nTwo sub-shapes occur in the wild:\n\n1. **Just `status` + `statusInfo`** — `statusInfo` may double as a\n   machine-readable code (e.g. `\"PARCEL_NOT_FOUND\"`).\n2. **`status` + `statusInfo` + `statusCode`** — `statusInfo` is\n   human-readable, `statusCode` is machine-readable\n   (e.g. `\"MISSING_PARAMETER\"`).\n\nClients should check `statusCode` first if present, otherwise match\n`statusInfo` against known values.\n",
        "required": [
          "status",
          "statusInfo"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "error"
            ],
            "example": "error"
          },
          "statusInfo": {
            "type": "string",
            "description": "Either a human-readable message or a machine-readable code\n(varies by endpoint).\n",
            "example": "At least one of parameters (from_id, from_datetime) must be provided"
          },
          "statusCode": {
            "type": "string",
            "description": "Optional machine-readable error code. Present on some endpoints\n(notably `/handover-protocol` and `/storno-courier-label`).\n\nKnown values:\n- `MISSING_PARAMETER` — required field absent from request body\n  (`/handover-protocol`, `/storno-courier-label`).\n- `ERROR_NOT_SET_OR_EMPTY_PARAMETER` — required field missing or\n  empty (`/get-labels`). Functionally the same as\n  `MISSING_PARAMETER` but with a different code.\n- `PARCEL_NOT_FOUND` — parcel does not exist\n  (`/storno-courier-label`).\n- `INVALID_PARCEL_NUMBER` — parcel does not exist or has no\n  courier label (`/get-labels`). Functionally overlaps with\n  `PARCEL_NOT_FOUND`.\n- `PARCEL_ALREADY_INVOICED` — parcel is past the cancellation\n  window.\n- `PARCEL_NOT_CREATED_WITH_COURIER_LABEL` — parcel is a draft\n  with no courier label and cannot be cancelled.\n- `UNKNOWN_LAYOUT` — `printLayout` outside the allowed set on\n  `/get-labels`.\n- `INVALID_START_LABEL_NUMBER` — `startLabelNumber` outside\n  `1..4` or combined with `a6` layout.\n",
            "example": "MISSING_PARAMETER"
          }
        }
      },
      "GetCouriersRequest": {
        "type": "object",
        "title": "GetCouriersRequest",
        "required": [
          "userId"
        ],
        "properties": {
          "userId": {
            "type": "integer",
            "description": "Your numeric client ID.",
            "example": 12345
          }
        }
      },
      "CourierService": {
        "type": "object",
        "title": "CourierService",
        "description": "An optional add-on service offered by a courier (e.g. cash on\ndelivery, fragile handling, SMS notice). Each courier has its own\ncatalogue of services.\n",
        "required": [
          "service",
          "price",
          "note"
        ],
        "properties": {
          "service": {
            "type": "string",
            "description": "Courier-specific service identifier. Examples: `cod` (cash on\ndelivery), `fragile`, `sms_notice`, `packstation`, `pm`, `pp`, `cs`.\n",
            "example": "sms_notice"
          },
          "price": {
            "type": "number",
            "minimum": 0,
            "description": "Price of the service (in courier's local currency). May be `0`\nwhen the service is included in the base price.\n",
            "example": 1.5
          },
          "note": {
            "type": "string",
            "description": "Free-text note about the service. Often empty.",
            "example": ""
          }
        }
      },
      "CourierLimits": {
        "type": "object",
        "title": "CourierLimits",
        "description": "Per-parcel limits enforced by the courier. Exceeding any of these\ncauses shipment creation to fail downstream.\n",
        "required": [
          "max_weight",
          "max_insurance",
          "max_cod"
        ],
        "properties": {
          "max_weight": {
            "type": "integer",
            "minimum": 0,
            "description": "Maximum parcel weight in **grams**.",
            "example": 50000
          },
          "max_insurance": {
            "type": "number",
            "minimum": 0,
            "description": "Maximum insurance value (in courier's local currency, typically\nEUR for international or local currency for domestic couriers).\n",
            "example": 1800
          },
          "max_cod": {
            "type": "number",
            "minimum": 0,
            "description": "Maximum Cash-on-Delivery amount (in courier's local currency).\n`0` means COD is not supported by this courier.\n",
            "example": 5000
          }
        }
      },
      "Courier": {
        "type": "object",
        "title": "Courier",
        "description": "A courier (carrier) PBH integrates with, including its delivery\ncountry, supported delivery type, services, and limits.\n",
        "required": [
          "name",
          "status",
          "delivery_country",
          "number",
          "delivery_type",
          "multiparcels_allowed",
          "return_labels_premade",
          "return_labels_ondemand",
          "direct_label_print",
          "services",
          "limits"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "Human-readable courier name (without country prefix).",
            "example": "Cargus"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "upcoming"
            ],
            "description": "- `active` — fully available; you can create shipments now.\n- `upcoming` — integration in progress, not yet available for\n  production traffic.\n",
            "example": "active"
          },
          "delivery_country": {
            "type": "string",
            "minLength": 2,
            "maxLength": 2,
            "description": "ISO 3166-1 alpha-2 country code where this courier delivers.\nExamples: `CZ`, `SK`, `DE`, `RO`, `GR`.\n",
            "example": "RO"
          },
          "number": {
            "type": "integer",
            "description": "Stable numeric identifier — corresponds to `courier_number` in\n`/tracking-details` responses.\n",
            "example": 54
          },
          "delivery_type": {
            "type": "string",
            "enum": [
              "home",
              "parcelshop"
            ],
            "description": "- `home` — delivery to recipient's address.\n- `parcelshop` — delivery to a pickup point / locker (recipient\n  collects).\n",
            "example": "home"
          },
          "multiparcels_allowed": {
            "type": "integer",
            "enum": [
              0,
              1
            ],
            "description": "`1` if this courier accepts multi-parcel shipments (multiple\npackages under one shipment), `0` otherwise.\n",
            "example": 1
          },
          "return_labels_premade": {
            "type": "integer",
            "enum": [
              0,
              1
            ],
            "description": "`1` if pre-made return labels are supported (printed with the\noutbound shipment), `0` otherwise.\n",
            "example": 0
          },
          "return_labels_ondemand": {
            "type": "integer",
            "enum": [
              0,
              1
            ],
            "description": "`1` if return labels can be generated on demand after the\noriginal shipment, `0` otherwise.\n",
            "example": 0
          },
          "direct_label_print": {
            "type": "integer",
            "enum": [
              0,
              1
            ],
            "description": "`1` if labels are generated directly by PBH (no extra wait for\ncourier API), `0` otherwise.\n",
            "example": 1
          },
          "services": {
            "type": "array",
            "description": "Optional add-on services supported by this courier. May be empty.\n",
            "items": {
              "$ref": "#/components/schemas/CourierService"
            }
          },
          "limits": {
            "$ref": "#/components/schemas/CourierLimits"
          }
        }
      },
      "GetCouriersResponse": {
        "type": "object",
        "title": "GetCouriersResponse",
        "required": [
          "status",
          "statusInfo",
          "data"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ok"
            ],
            "example": "ok"
          },
          "statusInfo": {
            "type": "string",
            "example": "Success"
          },
          "data": {
            "type": "array",
            "description": "All couriers available to your account, including those still in\nthe `upcoming` state. The list is typically ~100 entries long.\n",
            "items": {
              "$ref": "#/components/schemas/Courier"
            }
          }
        }
      },
      "HandoverProtocolRequest": {
        "type": "object",
        "title": "HandoverProtocolRequest",
        "description": "**Note:** parcel numbers are nested under `data.parcelNumbers` (not\nat the root) — this differs from\n[`/tracking-details`](#tag/Tracking/operation/getTrackingDetails),\nwhere `parcel_numbers` sits at the root and uses snake_case.\n",
        "required": [
          "userId",
          "data"
        ],
        "properties": {
          "userId": {
            "type": "integer",
            "description": "Your numeric client ID.",
            "example": 12345
          },
          "data": {
            "type": "object",
            "required": [
              "parcelNumbers"
            ],
            "properties": {
              "parcelNumbers": {
                "type": "array",
                "description": "Parcels to include in the handover protocol. **All parcels\nmust exist** — if any one of them is unknown, the entire\nrequest fails with `PARCEL_NOT_FOUND` (no partial PDF).\n\nPassing an empty array `[]` returns a template / blank\nprotocol PDF rather than an error.\n",
                "items": {
                  "type": "string",
                  "example": "12345-60546861"
                },
                "example": [
                  "12345-60546861",
                  "12345-60546862"
                ]
              }
            }
          }
        }
      },
      "HandoverProtocolResponse": {
        "type": "object",
        "title": "HandoverProtocolResponse",
        "required": [
          "status",
          "statusInfo",
          "data"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ok"
            ],
            "example": "ok"
          },
          "statusInfo": {
            "type": "string",
            "enum": [
              "ok"
            ],
            "description": "Always `\"ok\"` on success.",
            "example": "ok"
          },
          "data": {
            "type": "object",
            "required": [
              "handoverProtocolBase64"
            ],
            "properties": {
              "handoverProtocolBase64": {
                "type": "string",
                "contentEncoding": "base64",
                "contentMediaType": "application/pdf",
                "description": "The handover protocol as a base64-encoded PDF (PDF 1.4). To\nsave it to disk in JavaScript:\n\n```js\nconst bytes = Uint8Array.from(atob(handoverProtocolBase64), c => c.charCodeAt(0));\nconst blob = new Blob([bytes], { type: 'application/pdf' });\n// Trigger download, open in new tab, or attach to email.\n```\n\nOr in Python:\n\n```python\nimport base64\npdf_bytes = base64.b64decode(handover_protocol_base64)\nopen('handover.pdf', 'wb').write(pdf_bytes)\n```\n\nTypical size: ~40–80 KB per protocol depending on parcel count.\n",
                "example": "JVBERi0xLjQKJeLjz9MK..."
              }
            }
          }
        }
      },
      "StornoCourierLabelRequest": {
        "type": "object",
        "title": "StornoCourierLabelRequest",
        "description": "**Note:** `parcelNumber` is nested under `data` (camelCase), and is\nsingular — only **one** parcel can be cancelled per request. For\nmulti-parcel shipments, send one request per parcel.\n",
        "required": [
          "userId",
          "data"
        ],
        "properties": {
          "userId": {
            "type": "integer",
            "description": "Your numeric client ID.",
            "example": 12345
          },
          "data": {
            "type": "object",
            "required": [
              "parcelNumber"
            ],
            "properties": {
              "parcelNumber": {
                "type": "string",
                "description": "PBH parcel number to cancel. Format `{userId}-{number}`.\n",
                "example": "12345-2235"
              }
            }
          }
        }
      },
      "StornoCourierLabelResponse": {
        "type": "object",
        "title": "StornoCourierLabelResponse",
        "description": "Successful cancellation envelope. Always returned with HTTP `200` —\ninspect `status` and `statusCode` to confirm.\n",
        "required": [
          "status",
          "statusInfo",
          "statusCode"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ok"
            ],
            "example": "ok"
          },
          "statusInfo": {
            "type": "string",
            "description": "Human-readable confirmation message.",
            "example": "Parcel 12345-2235 has been successfully cancelled"
          },
          "statusCode": {
            "type": "string",
            "enum": [
              "PARCEL_SUCCESSFULLY_CANCELLED"
            ],
            "example": "PARCEL_SUCCESSFULLY_CANCELLED"
          }
        }
      },
      "CourierDataValidatorRequest": {
        "type": "object",
        "title": "CourierDataValidatorRequest",
        "required": [
          "userId",
          "data"
        ],
        "properties": {
          "userId": {
            "type": "integer",
            "description": "Your numeric client ID.",
            "example": 12345
          },
          "data": {
            "type": "object",
            "required": [
              "courier_number"
            ],
            "description": "Validation payload. `courier_number` is required; all other keys\nare arbitrary courier-specific field names you want to validate.\nThe server returns a per-field result with the same keys.\n",
            "properties": {
              "courier_number": {
                "type": "integer",
                "description": "Numeric ID of the courier whose fields you want to validate.\nMatch against `number` from\n[`/get-couriers`](#tag/Couriers/operation/getCouriers).\n\n**Currently supported:** only courier `191`\n(DHL — parcellocker, DE). Additional couriers will be added\nover time. Sending unsupported couriers does not produce an\nerror — the server simply returns no validation results for\nthe requested fields.\n",
                "example": 191
              },
              "de_dhl_postnumber": {
                "type": "string",
                "description": "DHL Germany postnumber (for parcellockers). Supported by\ncourier `191`.\n",
                "example": "858080206"
              },
              "zip": {
                "type": "string",
                "description": "Postal code (ZIP). Validation availability varies by courier.",
                "example": "10115"
              },
              "house_number": {
                "type": "string",
                "description": "House / street number. Validation availability varies by\ncourier — may return `null` even on supported couriers.\n",
                "example": "12a"
              }
            },
            "additionalProperties": {
              "type": "string",
              "description": "Additional courier-specific field name (e.g. street, city,\nrecipient name). Server will echo it back in `data` with a\n`true`/`false`/`null` result if the courier supports it, or\nomit it from the response otherwise.\n"
            }
          }
        }
      },
      "CourierDataValidatorResponse": {
        "type": "object",
        "title": "CourierDataValidatorResponse",
        "description": "Validation result envelope. **Note:** the `data` field is **absent**\nentirely when the request contained only `courier_number` (no fields\nto validate).\n",
        "required": [
          "status",
          "statusInfo"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ok"
            ],
            "example": "ok"
          },
          "statusInfo": {
            "type": "string",
            "example": ""
          },
          "data": {
            "type": "object",
            "description": "Per-field validation result. Keys match the fields you sent in\nthe request `data`, minus `courier_number`.\n\nValues:\n- `true` — field value is valid (passed validation).\n- `false` — field value is invalid.\n- `null` — validation is not available for this field (either\n  the courier does not support validating this field, validation\n  is not configured for your account, or the validator\n  encountered an internal error).\n\nFields not supported by the requested courier may be **omitted\nentirely** from the response rather than returned as `null`.\n",
            "additionalProperties": {
              "type": [
                "boolean",
                "null"
              ]
            },
            "example": {
              "de_dhl_postnumber": true,
              "zip": false,
              "house_number": null
            }
          }
        }
      },
      "GetLabelsRequest": {
        "type": "object",
        "title": "GetLabelsRequest",
        "required": [
          "userId",
          "data"
        ],
        "properties": {
          "userId": {
            "type": "integer",
            "description": "Your numeric client ID.",
            "example": 12345
          },
          "data": {
            "type": "object",
            "required": [
              "parcelNumbers",
              "printLayout"
            ],
            "properties": {
              "parcelNumbers": {
                "type": "array",
                "minItems": 1,
                "description": "One or more PBH parcel numbers. Labels are concatenated into\na single PDF in the order given. Parcels must already have a\ncourier label assigned — newly-drafted parcels without labels\nwill fail.\n",
                "items": {
                  "type": "string",
                  "example": "12345-2345"
                },
                "example": [
                  "12345-2345",
                  "12345-2260",
                  "12345-2258"
                ]
              },
              "printLayout": {
                "type": "string",
                "enum": [
                  "a6",
                  "4a4",
                  "9a4"
                ],
                "description": "Page layout for the returned PDF:\n\n- `a6` — one label per A6 page (default format, used when\n  labels are returned automatically at shipment creation).\n- `4a4` — 4 A6 labels arranged on a single A4 sheet\n  (2 columns × 2 rows).\n- `9a4` — 9 small labels on a single A4 sheet\n  (3 columns × 3 rows).\n\nPre-cut adhesive label sheets for `4a4` and `9a4` are sold\nas standard office supplies.\n",
                "example": "9a4"
              },
              "startLabelNumber": {
                "type": [
                  "string",
                  "integer"
                ],
                "enum": [
                  "1",
                  "2",
                  "3",
                  "4",
                  1,
                  2,
                  3,
                  4
                ],
                "description": "Position (1-indexed) on the first A4 sheet where the first\nlabel is placed. Useful when reusing a partially-printed\nsheet.\n\n**Important constraints:**\n\n- Only valid with `printLayout: \"4a4\"` or\n  `printLayout: \"9a4\"`. Sending it with `a6` returns an\n  error.\n- **Currently capped at `1..4`** even for `9a4` (despite\n  the 9-position grid) — positions 5–9 on a 9a4 sheet are\n  unreachable. This is an API quirk; see\n  [Errors](#tag/errors) for the documented limitation.\n- Accepted as both string (`\"3\"`) and integer (`3`).\n\nOptional — defaults to `1` (top-left) when omitted on\n`4a4`/`9a4`.\n",
                "example": "3"
              }
            }
          }
        }
      },
      "GetLabelsResponse": {
        "type": "object",
        "title": "GetLabelsResponse",
        "required": [
          "status",
          "statusInfo",
          "data"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ok"
            ],
            "example": "ok"
          },
          "statusInfo": {
            "type": "string",
            "enum": [
              "Success"
            ],
            "description": "Always `\"Success\"` on success (note capitalization — differs from other endpoints that use `\"ok\"` or `\"\"`).",
            "example": "Success"
          },
          "data": {
            "type": "object",
            "required": [
              "base64pdf"
            ],
            "properties": {
              "base64pdf": {
                "type": "string",
                "contentEncoding": "base64",
                "contentMediaType": "application/pdf",
                "description": "Base64-encoded PDF containing all requested labels in the\nchosen layout. Typical size: ~50–80 KB per A4 sheet.\n\nDecoding (JavaScript):\n\n```js\nconst bytes = Uint8Array.from(atob(base64pdf), c => c.charCodeAt(0));\nconst blob = new Blob([bytes], { type: 'application/pdf' });\nwindow.open(URL.createObjectURL(blob));\n```\n\nOr in Python:\n\n```python\nimport base64, pathlib\npathlib.Path('labels.pdf').write_bytes(base64.b64decode(base64pdf))\n```\n",
                "example": "JVBERi0xLjQKJeLjz9MK..."
              }
            }
          }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Authentication or validation error. **Note:** the response body is a\nJSON-encoded **plain string**, not an object — e.g.\n`\"Authorization by api key is required\"`. Parse with\n`JSON.parse(body)` to extract the message.\n\nCommon messages:\n- `\"Authorization by api key is required\"` — missing\n  `Authorization` header.\n- `\"Parameter apiKey is invalid.\"` — Bearer token does not match a\n  valid key.\n- `` \"Parameter `userId` is invalid\" `` — `userId` missing or does\n  not match the authenticated account.\n",
        "content": {
          "application/json": {
            "schema": {
              "type": "string",
              "description": "Human-readable error message as a JSON string."
            },
            "examples": {
              "missingAuth": {
                "summary": "Missing Authorization header",
                "value": "Authorization by api key is required"
              },
              "invalidKey": {
                "summary": "Invalid API key",
                "value": "Parameter apiKey is invalid."
              },
              "invalidUserId": {
                "summary": "userId mismatch",
                "value": "Parameter `userId` is invalid"
              }
            }
          }
        }
      }
    }
  },
  "x-tagGroups": [
    {
      "name": "Endpoints",
      "tags": [
        "Tracking",
        "Couriers",
        "Shipments"
      ]
    },
    {
      "name": "Reference",
      "tags": [
        "Status codes",
        "Rate limits",
        "Errors",
        "Changelog"
      ]
    }
  ]
}