# E-Parcel API — integration guide for AI coding agents This guide is written for AI coding agents (Claude, Cursor, Copilot, Codex and similar) and the developers directing them. It is the playbook for building an integration with the E-Parcel Dispatcher API: what to build, the rules that keep the integration correct, copy-ready client code, and a test plan. Field-level detail lives in the API reference, published alongside this guide. If anything here disagrees with the live OpenAPI document at `/openapi.json`, the OpenAPI document wins. ## The API in one paragraph E-Parcel ships parcels inside **Finland**. You send one parcel (recipient, merchant sender, weight, dimensions, preferred delivery method) to `POST /api/v1/shipments`. In that same request E-Parcel picks the carrier, registers the parcel with it, and returns the carrier's tracking number **and a printable PDF label**. The call is **synchronous**: there is no queue, no webhook and nothing to poll. When the call returns, the outcome is final. ## Golden rules Follow these without exception. Each one prevents a real failure mode. 1. **Keep the API key on the server.** Read it from the environment (`EPARCEL_API_KEY`). Never hard-code it, commit it, log it, print it, or put it in browser or mobile code. If you are an agent and see a key in chat or a file, do not repeat it back. 2. **Build and test against the test environment** (`https://api-test.e-parcel.fi`) with a test key. Make the base URL configuration (`EPARCEL_BASE_URL`), never a constant. **Never run tests or scripts against production** (`https://api.e-parcel.fi`): every shipment routed there is a real, billable consignment. 3. **Send an `Idempotency-Key` on every single-shipment create**, derived deterministically from the parcel (for example `order-1001-parcel-1`), and reuse the *same* key on every retry of that parcel. A random key per attempt defeats the purpose. 4. **Use integers in grams and millimetres.** Convert kg→g and cm→mm and round **up**. Never send floats or strings for these fields. 5. **Always send both `recipient_email` and `recipient_phone`.** Every delivery tier that is currently active requires both; a parcel without them is rejected with `400`. 6. **Send `zipcode` as a string.** Finnish postal codes have leading zeros (`"00100"`). Converting to a number corrupts them. 7. **`country` must be `"FI"`.** No other destination is served. 8. **Persist the result immediately**: `shipment_id`, `tracking_number`, `carrier`, and the label. The label is inlined only in the create response (it can be re-downloaded later, but store the `shipment_id` or you cannot). 9. **Branch on the HTTP status and the response shape, never on the wording of `detail`.** Messages may change; status codes and fields will not. 10. **In a batch, check every item.** A batch returns `200` even when items fail. 11. **Do not invent endpoints.** There is no cancel, amend, list, search, pricing, tracking-events, webhook or pickup-point endpoint. If the requirements need one, stop and tell the developer; they should contact E-Parcel. ## Configuration | Setting | Value | |---|---| | `EPARCEL_BASE_URL` | `https://api-test.e-parcel.fi` while developing; `https://api.e-parcel.fi` in production | | `EPARCEL_API_KEY` | Issued by E-Parcel, one per environment. Sent as `Authorization: Bearer ` | | HTTP timeout | **60 s** for single creates. The server spends up to ~25 s trying carriers before answering, so a short client timeout turns successes into "unknown". | | Batch size | Up to 500 items, but a batch has no server-side deadline. Start with batches of ~50, measure, and keep each batch well inside your timeout. | The service publishes no rate limit. Send creates as orders become ready to ship, use the batch endpoint for bulk runs, and don't fan out hundreds of parallel single creates. ## Endpoints | Method | Path | Use it to | |---|---|---| | `POST` | `/api/v1/shipments` | Create and dispatch one parcel; returns tracking number + label | | `POST` | `/api/v1/shipments/batch` | Create and dispatch up to 500 parcels; per-item results | | `GET` | `/api/v1/shipments?customer_reference=…` | Find your shipments by your own reference when you no longer have the `shipment_id`; newest first | | `GET` | `/api/v1/shipments/{shipment_id}` | Read a shipment back (state, tracking number, dimensions, delivery type, hub scan status) | | `GET` | `/api/v1/shipments/{shipment_id}/label` | Re-download the label PDF | | `GET` | `/health` | Liveness, no auth | | `GET` | `/readyz` | Readiness (database reachable), no auth | All authenticated requests use `Authorization: Bearer `. A key only sees its own shipments; any other id returns `404`. ## Building the request One request = **one physical parcel**. An order shipped in three boxes is three shipments, each with its own idempotency key (for example `order-1001-parcel-1` … `-parcel-3`) and the same `customer_reference`. ### Field mapping | E-Parcel field | Required | Typical source in your system | Notes | |---|---|---|---| | `customer_reference` | no | Order number | ≤128 chars. Printed on the label and passed to the carrier — always set it. | | `recipient_name` | yes | Shipping first + last name (or company) | ≤256 chars | | `recipient_email` | in practice yes | Customer email | Must be a valid email address | | `recipient_phone` | in practice yes | Shipping phone | International format, e.g. `+358401234567`; ≤32 chars | | `address_line1` | yes | Street + house number | ≤256 chars | | `address_line2` | no | Apartment, stairwell, c/o | ≤256 chars; omit or `null` when empty | | `city` | yes | City | ≤128 chars | | `zipcode` | yes | Postal code | **String**, keep leading zeros | | `country` | yes | Country code | Must be `"FI"` | | `sender_name` | yes | Merchant / store name | Printed as the label's FROM | | `sender_address_line1` | yes | Merchant return address | | | `sender_address_line2` | no | | | | `sender_city` | yes | | | | `sender_zipcode` | yes | | String | | `sender_country` | yes | | Any ISO alpha-2 code. A non-`FI` sender is printed as the sender name at E-Parcel's Helsinki PO box. | | `sender_email`, `sender_phone` | no | Merchant contact | | | `weight_g` | yes | Packed weight | Integer grams, 1–50,000 | | `length_mm`, `width_mm`, `height_mm` | yes | Packed box size | Integer millimetres, 1–2,000 each, **any order** — the parcel is matched in any orientation | | `delivery_method` | no | Checkout shipping option | See below | Use the **packed** weight and the **outer box** dimensions. Parcels are measured again at the E-Parcel hub; a parcel that does not match its declaration can be relabelled or re-dispatched as a new shipment (visible as `scan_status` on the shipment). ### Delivery method `delivery_method` states the *most preferred* tier; E-Parcel may fall back down the chain when the parcel doesn't fit the preferred tier. | You send | Can be delivered as | |---|---| | *(omitted)* | any tier (best fit) | | `HOME` | letterbox or to the door, else locker or pickup | | `HOME_MAILBOX` | letterbox, else locker or pickup — never to the door | | `HOME_DOOR` | to the door, else locker or pickup — never letterbox | | `LOCKER` | parcel locker, else pickup point | | `PICKUP` | pickup point only | The tier actually used is returned as `delivery_type` by `GET /api/v1/shipments/{shipment_id}`. There is no field to choose a specific locker: the carrier allocates the locker that serves the recipient's postal code and notifies the recipient (which is why email and phone are required). `PICKUP` has no active route at the moment, so `PICKUP` alone returns `422`. Indicative size limits (configured server-side and subject to change — the API's answer is authoritative): | Tier | Roughly fits | |---|---| | Letterbox (`HOME_MAILBOX`) | up to 2 kg and 3 × 25 × 35 cm | | To the door (`HOME_DOOR`) | up to about 4–5 kg, mid-size boxes | | Parcel locker (`LOCKER`) | up to 25 kg | | Anything heavier than 25 kg | no route — `422` | ### Example request ```json { "customer_reference": "ORDER-1001", "recipient_name": "Anna Aalto", "recipient_email": "anna@example.com", "recipient_phone": "+358401234567", "address_line1": "Mannerheimintie 1", "address_line2": "A 12", "city": "Helsinki", "zipcode": "00100", "country": "FI", "sender_name": "Example Store Oy", "sender_email": "orders@example.com", "sender_phone": "+358401111111", "sender_address_line1": "Testikatu 5", "sender_city": "Turku", "sender_zipcode": "20100", "sender_country": "FI", "weight_g": 1340, "length_mm": 370, "width_mm": 370, "height_mm": 270, "delivery_method": "HOME" } ``` ## Handling the response ### Success ```json { "shipment_id": 42, "state": "ROUTED", "carrier": "POSTI", "carrier_number": 2, "tracking_number": "JJFI67863998958478726", "label_pdf_base64": "JVBERi0xLjQK...", "error": null } ``` `state == "ROUTED"` is the only success. `tracking_number` is the carrier's own id and is the barcode on the label. Base64-decode `label_pdf_base64` to get the PDF. ### Outcomes and what to do | Status | How to recognise it | Meaning | What your code should do | |---|---|---|---| | `200` | `state: "ROUTED"` | Shipped | Store `shipment_id`, `tracking_number`, `carrier`, label | | `400` | body has `missing: [...]` | Contact details missing for every tier that fits | Fix the data (ask the buyer for email/phone); don't retry unchanged | | `401` | `detail` only | Missing or invalid API key | Configuration error — alert a human, don't retry | | `404` | `detail` only | Unknown id, or not your shipment | Check the id you stored | | `422` | `detail` is an **array** of field errors | Request failed schema validation (e.g. `country` not `FI`, missing field, wrong type) | Bug in your mapping — fix the code | | `422` | `detail` is a string, **no** `shipment_id` | No routing rule fits this parcel (too heavy/large for every allowed tier, or `PICKUP` only) | Not shippable as specified — broaden `delivery_method` or route to a human; don't retry | | `422` | has `shipment_id` and `state: "UNROUTABLE"` | Every eligible carrier rejected the parcel | Not shippable as specified; don't retry | | `502` | has `shipment_id` and `state: "FAILED"` | Every carrier errored (temporary) | Retry later **with the same Idempotency-Key** — the same `shipment_id` is retried | | timeout / connection error / other `5xx` | no usable body | Outcome unknown | Retry **with the same Idempotency-Key** | A `422` is a verdict on the parcel; a `502` is a verdict on the carriers. ### Idempotency, precisely - Scope: per customer account. Length: up to 128 characters (longer → `422`). - The key identifies the parcel; **the body is not compared**. Reusing a key for a different parcel silently returns the first parcel's shipment. Never share keys between parcels. - Repeating a key after a `ROUTED` result returns the same shipment, tracking number and label — no second consignment. - After a `502`, repeating the key runs the carriers again for the same `shipment_id`. - After an "all carriers rejected" `422`, repeating the key replays that `422`. If you change the parcel (e.g. a different `delivery_method`), use a new key. - After a `400` or a "no route" / validation `422`, nothing was stored: fix the request and retry with the same key. - The batch endpoint does not support idempotency keys: a batch request carrying `Idempotency-Key` is rejected with `400` and nothing is created. Use single creates when safe retries matter. Suggested retry schedule for `502` and unknown outcomes: 5 s, 30 s, 2 min, 10 min, then hand the parcel to a human queue. ### Batches `POST /api/v1/shipments/batch` takes `{"shipments": [ ...up to 500 requests... ]}` and returns `{"shipments": [ ...one result per item, same order... ]}` with status `200`, even if items failed. Each item has the same shape as the single create response. A failed item has `state` `UNROUTABLE` or `FAILED`, an `error` string, and a real `shipment_id`. Only a request-shape problem (for example any item with `country` other than `FI`) rejects the whole batch with `422`. Retrying a failed batch item creates a new shipment, so retry failed items individually through the single endpoint with an idempotency key. ## Labels - PDF, one page, **101 × 200 mm** (thermal label stock). Print at 100 % / actual size, not "fit to page". - The barcode is the carrier tracking number. Print one label per parcel and attach it to that parcel. - Re-download any time with `GET /api/v1/shipments/{shipment_id}/label` (`application/pdf`). A shipment that never reached `ROUTED` has no label (`404`). ## After dispatch `GET /api/v1/shipments/{shipment_id}` returns the stored record: ```json { "shipment_id": 42, "customer_reference": "ORDER-1001", "state": "ROUTED", "final_carrier": "POSTI", "final_carrier_tracking_id": "JJFI67863998958478726", "weight_g": 1340, "length_mm": 370, "width_mm": 370, "height_mm": 270, "requested_delivery_method": "HOME", "delivery_type": "HOME_DOOR", "routing_rule": {"id": 4, "name": "Door delivery POSTI"}, "created_at": "2026-10-08T09:12:03.120000Z", "routed_at": "2026-10-08T09:12:05.871000Z", "scan_status": null, "scanned_at": null } ``` `scan_status` stays `null` until the parcel is scanned at the E-Parcel hub, then becomes one of `SCANNED_OK`, `SCANNED_NEW_LABEL`, `SCANNED_NEW_SERVICE`, `SCANNED_REJECTED` (replaced by a new shipment after re-measuring), `SCANNED_RETURNED` or `SCANNED_DESTRUCTED`. There are no webhooks; if you show hub arrival, poll a few times a day at most. The state never changes after the create call, so don't poll for it. Lost the `shipment_id`? `GET /api/v1/shipments?customer_reference=ORDER-1001` returns `{"shipments": [...]}`: your shipments with that reference, newest first, each in the shape above (`limit` defaults to 50, max 500). No match is `200` with an empty list. To recover from a lost create response, prefer retrying the create with the same `Idempotency-Key`: it returns the original shipment if one exists and creates it if not. There is no cancel endpoint. To stop a routed parcel, contact E-Parcel. ## Reference client — Python Uses [`httpx`](https://www.python-httpx.org/). Copy it, then adapt naming, logging and persistence to the codebase. ```python """Minimal E-Parcel Dispatcher client.""" from __future__ import annotations import base64 import os import time from typing import Any import httpx class EParcelError(Exception): """Non-2xx answer from E-Parcel. `body` is the parsed JSON (or text).""" def __init__(self, status: int, body: Any) -> None: super().__init__(f"E-Parcel answered {status}: {body}") self.status = status self.body = body @property def retryable(self) -> bool: # 502 = every carrier errored; other 5xx = infrastructure. Retry both # with the same Idempotency-Key. 4xx is final for this request. return self.status >= 500 class EParcelClient: def __init__(self, base_url: str | None = None, api_key: str | None = None, timeout: float = 60.0) -> None: self._http = httpx.Client( base_url=base_url or os.environ["EPARCEL_BASE_URL"], headers={"Authorization": f"Bearer {api_key or os.environ['EPARCEL_API_KEY']}"}, timeout=timeout, ) def create_shipment(self, shipment: dict[str, Any], idempotency_key: str) -> dict[str, Any]: response = self._http.post( "/api/v1/shipments", json=shipment, headers={"Idempotency-Key": idempotency_key} ) return _json_or_raise(response) def create_batch(self, shipments: list[dict[str, Any]]) -> list[dict[str, Any]]: response = self._http.post("/api/v1/shipments/batch", json={"shipments": shipments}) return _json_or_raise(response)["shipments"] def get_shipment(self, shipment_id: int) -> dict[str, Any]: return _json_or_raise(self._http.get(f"/api/v1/shipments/{shipment_id}")) def get_label(self, shipment_id: int) -> bytes: response = self._http.get(f"/api/v1/shipments/{shipment_id}/label") if response.status_code != 200: raise EParcelError(response.status_code, _body(response)) return response.content def ship_parcel(client: EParcelClient, shipment: dict[str, Any], idempotency_key: str, delays: tuple[float, ...] = (5, 30, 120)) -> dict[str, Any]: """Create one shipment, retrying unknown outcomes and 5xx with the same key.""" for delay in (*delays, None): try: result = client.create_shipment(shipment, idempotency_key) result["label_pdf"] = base64.b64decode(result.pop("label_pdf_base64")) return result except httpx.TransportError: if delay is None: # timeout or connection error: outcome unknown raise except EParcelError as exc: if not exc.retryable or delay is None: raise time.sleep(delay) raise AssertionError("unreachable") def _json_or_raise(response: httpx.Response) -> Any: if response.status_code == 200: return response.json() raise EParcelError(response.status_code, _body(response)) def _body(response: httpx.Response) -> Any: try: return response.json() except ValueError: return response.text ``` In a web request or job worker, prefer enqueueing the retries over sleeping inline. ## Reference client — TypeScript Node 18+ (built-in `fetch`), no dependencies. ```typescript export type DeliveryMethod = "HOME" | "HOME_MAILBOX" | "HOME_DOOR" | "LOCKER" | "PICKUP"; export interface ShipmentRequest { customer_reference?: string; recipient_name: string; recipient_email: string; recipient_phone: string; address_line1: string; address_line2?: string | null; city: string; zipcode: string; country: "FI"; sender_name: string; sender_email?: string | null; sender_phone?: string | null; sender_address_line1: string; sender_address_line2?: string | null; sender_city: string; sender_zipcode: string; sender_country: string; weight_g: number; // integer grams length_mm: number; // integer millimetres width_mm: number; height_mm: number; delivery_method?: DeliveryMethod; } export interface ShipmentResponse { shipment_id: number; state: "ROUTED" | "UNROUTABLE" | "FAILED"; carrier: string | null; carrier_number: number | null; tracking_number: string | null; label_pdf_base64: string | null; error: string | null; } export class EParcelError extends Error { constructor(public readonly status: number, public readonly body: unknown) { super(`E-Parcel answered ${status}: ${JSON.stringify(body)}`); } get retryable(): boolean { return this.status >= 500; } } export class EParcelClient { constructor( private readonly baseUrl = process.env.EPARCEL_BASE_URL!, private readonly apiKey = process.env.EPARCEL_API_KEY!, private readonly timeoutMs = 60_000, ) {} createShipment(shipment: ShipmentRequest, idempotencyKey: string): Promise { return this.request("POST", "/api/v1/shipments", shipment, { "Idempotency-Key": idempotencyKey }); } async createBatch(shipments: ShipmentRequest[]): Promise { const body = await this.request<{ shipments: ShipmentResponse[] }>( "POST", "/api/v1/shipments/batch", { shipments }); return body.shipments; } getShipment(shipmentId: number): Promise> { return this.request("GET", `/api/v1/shipments/${shipmentId}`); } async getLabel(shipmentId: number): Promise { const res = await this.fetch("GET", `/api/v1/shipments/${shipmentId}/label`); if (res.status !== 200) throw new EParcelError(res.status, await readBody(res)); return Buffer.from(await res.arrayBuffer()); } private async request(method: string, path: string, body?: unknown, headers: Record = {}): Promise { const res = await this.fetch(method, path, body, headers); if (res.status !== 200) throw new EParcelError(res.status, await readBody(res)); return (await res.json()) as T; } private fetch(method: string, path: string, body?: unknown, headers: Record = {}): Promise { return fetch(this.baseUrl + path, { method, headers: { Authorization: `Bearer ${this.apiKey}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }), ...headers, }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(this.timeoutMs), }); } } async function readBody(res: Response): Promise { const text = await res.text(); try { return JSON.parse(text); } catch { return text; } } ``` A network error or `AbortSignal` timeout rejects with a `TypeError` / `TimeoutError` (not `EParcelError`): treat it as "outcome unknown" and retry with the same idempotency key. To generate full types from the schema instead, run `npx openapi-typescript /openapi.json -o eparcel.d.ts`. ## Testing the integration ### Unit tests (no network) Mock the HTTP layer and cover each outcome with these bodies: | Case | Status | Body | |---|---|---| | Routed | `200` | the success example above | | Contact missing | `400` | `{"detail": "this parcel's routing tier requires recipient_email, recipient_phone", "field": "recipient_contact", "missing": ["recipient_email", "recipient_phone"]}` | | Bad key | `401` | `{"detail": "invalid API key"}` | | Validation | `422` | `{"detail": [{"type": "literal_error", "loc": ["body", "country"], "msg": "Input should be 'FI'"}]}` | | No route | `422` | `{"detail": "no routing rule matched this parcel"}` | | Carriers rejected | `422` | `{"detail": "all carriers rejected the shipment", "shipment_id": 42, "state": "UNROUTABLE"}` | | Carriers down | `502` | `{"detail": "all carriers unavailable", "shipment_id": 43, "state": "FAILED"}` | | Timeout | — | raise the HTTP library's timeout error | Assert that: the key and base URL come from configuration; the same `Idempotency-Key` is sent on every retry of a parcel; weights and dimensions are integers; `zipcode` stays a string; `400` / `422` are not retried; `502` and timeouts are; batch items are checked one by one. ### Live tests (test environment only) Run these once against `https://api-test.e-parcel.fi` with a test key, not in CI on every commit: each routed parcel is registered with a carrier. Prefix `customer_reference` with `TEST-`. Start from the example request above with a fresh `Idempotency-Key` per scenario unless noted. | # | Scenario | Change from the example request | Expected | |---|---|---|---| | 1 | Letterbox-size parcel | `1000 g`, `25 × 200 × 300 mm`, omit `delivery_method` | `200`, `ROUTED`, tracking number + label | | 2 | Door-size parcel | as in the example (`1340 g`, `370 × 370 × 270 mm`) | `200`, `ROUTED` | | 3 | Locker | `2500 g`, `300 × 250 × 200 mm`, `"delivery_method": "LOCKER"` | `200`, `ROUTED` | | 4 | Pickup only | `"delivery_method": "PICKUP"` | `422`, no route | | 5 | Too heavy | `30000 g` | `422`, no route | | 6 | Missing phone | omit `recipient_phone` | `400`, `missing: ["recipient_phone"]` | | 7 | Foreign destination | `"country": "SE"` | `422`, validation error on `country` | | 8 | Bad key | `Authorization: Bearer wrong` | `401` | | 9 | Idempotent retry | repeat scenario 2 with the **same** key | `200`, same `shipment_id` and tracking number | | 10 | Read back | `GET /api/v1/shipments/{id}` from scenario 2 | `200`, `delivery_type` set | | 11 | Label download | `GET /api/v1/shipments/{id}/label` | `200`, `application/pdf` | | 12 | Unknown id | `GET /api/v1/shipments/999999999` | `404` | | 13 | Mixed batch | batch of scenario 1 + scenario 5 | `200`; item 1 `ROUTED`, item 2 `UNROUTABLE` with `error` | ## Definition of done - [ ] API key and base URL come from configuration; the key never reaches logs, client-side code or version control. - [ ] Development and automated tests use the test environment only. - [ ] Every single create sends a deterministic `Idempotency-Key`, reused on retries; one key per physical parcel. - [ ] Client timeout ≥ 60 s; timeouts and `5xx` retried with backoff and the same key; `4xx` never retried unchanged. - [ ] Weights/dimensions converted to integer grams/millimetres, rounded up; `zipcode` kept as a string; both recipient email and phone sent. - [ ] `shipment_id`, `tracking_number`, `carrier` and the label stored before anything else can fail. - [ ] Batch results checked item by item; failed items surfaced or retried individually. - [ ] Unshippable parcels (`400`, `422`) reach a human with the reason. - [ ] Labels print at 101 × 200 mm, actual size. - [ ] Live scenario table above run once on the test environment and passing. ## Not supported (today) Destinations outside Finland · cancelling or amending a shipment · listing shipments (beyond lookup by `customer_reference`) · price quotes · choosing a specific locker or pickup point · webhooks or carrier tracking events · idempotency on batches. Ask E-Parcel if your integration needs one of these. ## Getting help Contact your E-Parcel contact with the `shipment_id`, your `customer_reference`, the time of the request and the response you got. **Never include the API key** in a support message or a chat with an AI assistant. --- # E-Parcel Dispatcher — API Reference **Version 0.1.0** Route parcels to the right carrier and get a printable label back — in a single request. ## How it works `POST /api/v1/shipments` does everything synchronously: it validates the parcel, matches it against the routing rules, calls each eligible carrier until one accepts, and renders the label from the accepting carrier's tracking number. There is no queue and nothing to poll — when the call returns, the outcome is final. Carriers are tried in the order the routing rules define. A carrier that *rejects* the parcel advances to the next; one that *errors* is retried with backoff before advancing. The response reports only the accepting carrier — which carriers were tried along the way is routing policy, not part of the API. ## Tracking numbers and labels The tracking number is always the **carrier's own** id — we never mint one. It is also the barcode on the label. `label_pdf_base64` is returned inline only at creation; afterwards, download the same PDF from `GET /api/v1/shipments/{shipment_id}/label`. ## Authentication Every endpoint outside `/health` and `/readyz` needs a bearer token: ``` Authorization: Bearer ``` Your API key is issued by E-Parcel and reaches everything under `/api/v1/*`. Contact us to rotate it or to get an additional key. ## Handling failures | Status | Meaning | Retry? | |---|---|---| | `400` | The parcel was rejected before routing (missing required contact details) | No — fix the request | | `401` | Missing or unrecognised API key | No | | `422` | The request failed validation (e.g. a destination `country` other than `FI`), no routing rule matched, or every carrier rejected the parcel | No — nothing will carry it as specified | | `502` | Every carrier errored and retries were exhausted | Yes, later | A `422` is a verdict on the parcel; a `502` is a verdict on the carriers. Send an `Idempotency-Key` on single-shipment creates so a retry after a timeout returns the original shipment instead of creating — and paying for — a second one. Retrying a `502` with the same key tries the carriers again on that same shipment. ## Batches `POST /api/v1/shipments/batch` takes up to 500 parcels and **never fails wholesale**: it returns `200` with one result per item, so inspect each item's `state` and `error` individually. ## Building with an AI agent Integrating through Claude, Cursor, GitHub Copilot or another AI coding agent? Give it the agent docs. They add what this reference leaves implicit: the rules a correct integration follows, copy-ready client code in Python and TypeScript, and a test plan. Every environment serves them, without authentication: | File | Path | Use it for | |---|---|---| | Index | `/llms.txt` | Agents that discover docs via the llms.txt convention | | Full docs | `/llms-full.txt` | One Markdown file: integration guide + this reference | | Claude skill | `/agents/e-parcel-api.zip` | Claude Code, Claude.ai, Claude Desktop | **Claude Code.** Install the skill in your project (use `~/.claude/skills` instead to have it in every project): ```bash curl -fsSL https://api.e-parcel.fi/agents/e-parcel-api.zip -o e-parcel-api.zip mkdir -p .claude/skills && unzip -o e-parcel-api.zip -d .claude/skills && rm e-parcel-api.zip ``` Start a new `claude` session. It loads the skill whenever you work on the integration; type `/e-parcel-api` to load it explicitly. On Windows, use `Expand-Archive e-parcel-api.zip -DestinationPath .claude\skills` instead of `unzip`. **Claude.ai and Claude Desktop.** Download `https://api.e-parcel.fi/agents/e-parcel-api.zip` and upload it as a custom skill: Customize → Skills → "+" (on older layouts Settings → Capabilities → Skills → Upload skill). Code execution must be enabled. Or add `/llms-full.txt` to a Project's knowledge instead. **Other agents.** Point the agent at `https://api.e-parcel.fi/llms-full.txt`, or add this to the `AGENTS.md` (or equivalent rules file) in your repository: ```markdown ## E-Parcel shipping API Before changing E-Parcel integration code, read https://api.e-parcel.fi/llms-full.txt and follow its golden rules. Develop and test against https://api-test.e-parcel.fi only. ``` A good first prompt: *"Integrate our order fulfilment with the E-Parcel API using the e-parcel-api skill. Target the test environment; the API key is in the `EPARCEL_API_KEY` environment variable."* Keep the API key out of the conversation: put it in an environment variable your code reads, never paste it into a prompt, and let the agent test against `https://api-test.e-parcel.fi` only — shipments created on production are real and billable. ## Base URLs | Environment | URL | |---|---| | Production | `https://api.e-parcel.fi` | | Test | `https://api-test.e-parcel.fi` | | Local | `http://localhost:8000` | ## Endpoints | Method | Path | Auth | Purpose | |---|---|---|---| | `POST` | `/api/v1/shipments` | Customer | Create and dispatch one shipment | | `GET` | `/api/v1/shipments` | Customer | Find shipments by your reference | | `POST` | `/api/v1/shipments/batch` | Customer | Create and dispatch up to 500 shipments | | `GET` | `/api/v1/shipments/{shipment_id}` | Customer | Look up a shipment | | `GET` | `/api/v1/shipments/{shipment_id}/label` | Customer | Download a shipment's label PDF | | `GET` | `/health` | — | Liveness | | `GET` | `/healthz` | — | Liveness | | `GET` | `/readyz` | — | Readiness | ### `POST /api/v1/shipments` **Create and dispatch one shipment** Route the parcel, call the carrier, and return the label — all in this request. The whole flow is **synchronous**: the parcel is validated and classified, a routing rule selects the carrier chain, each carrier is called in turn until one accepts, and the label is rendered from the accepting carrier's tracking number. There is nothing to poll — the response is final. **Carrier waterfall.** Carriers are attempted in the order the matching rules define. A carrier that *rejects* (will not carry this parcel) advances to the next; a carrier that *errors* is retried with exponential backoff before advancing. The first acceptance wins, and `tracking_number` is that carrier's own id — we do not mint ids. **Idempotency.** Send `Idempotency-Key` to make retries safe: a repeated key returns the original shipment instead of creating (and paying for) a second one. A 502 (carriers unavailable) is the exception: retrying it with the same key runs the carrier waterfall again on the same `shipment_id`, so one key per order is enough. A 422 rejection is final and replays as-is. Strongly recommended, since a timeout is otherwise indistinguishable from a failure. Not supported on the batch endpoint. **Delivery tiers.** `delivery_method` is a downgrade chain — `HOME` allows home, locker or pickup; `LOCKER` allows locker or pickup; `PICKUP` is exact; omitting it allows any tier. A tier with no active routing rule yields 422. **Auth:** `Authorization: Bearer ` **Parameters** | Name | In | Required | Description | |---|---|---|---| | `Idempotency-Key` | header | no | Optional client-generated key making retries safe: repeating a key returns the original shipment instead of creating a second one. After a 502 the same key tries the carriers again on that shipment. Strongly recommended, since a timeout is otherwise indistinguishable from a failure. Up to 128 characters; a longer key is a 422. Not supported on the batch endpoint. | **Request body** — `ShipmentRequest` | Field | Type | Required | Description | |---|---|---|---| | `customer_reference` | string | no | Your own order/reference id. Passed through to the carrier and printed on the label, so use it to reconcile shipments later. _(max len 128)_ Example: `ORDER-1001` | | `recipient_name` | string | **yes** | Full name of the person receiving the parcel. _(max len 256)_ Example: `Anna Aalto` | | `recipient_email` | string | no | Recipient email. Delivery tiers that need contact details require both email and phone — currently every tier does, so send both or the parcel is rejected with 400. Example: `anna@example.com` | | `recipient_phone` | string | no | Recipient phone in international format. See `recipient_email`. _(max len 32)_ Example: `+358401234567` | | `address_line1` | string | **yes** | Street address of the destination. _(max len 256)_ Example: `Mannerheimintie 1` | | `address_line2` | string | no | Apartment, stairwell, or c/o line. Optional. _(max len 256)_ Example: `A 12` | | `city` | string | **yes** | Destination city. _(max len 128)_ Example: `Helsinki` | | `zipcode` | string | **yes** | Destination postal code. Drives carrier coverage and sortation. _(max len 32)_ Example: `00100` | | `country` | string | **yes** | ISO-3166-1 alpha-2 destination country, upper-cased automatically. Only `FI` is supported; anything else is rejected with 422. Example: `FI` | | `sender_name` | string | **yes** | Merchant name, printed as the label's FROM party. _(max len 256)_ Example: `E-Parcel Oy` | | `sender_email` | string | no | Merchant contact email. Optional. Example: `merchant@example.com` | | `sender_phone` | string | no | Merchant contact phone. Optional. _(max len 32)_ Example: `+358401111111` | | `sender_address_line1` | string | **yes** | Merchant street address (the return address). _(max len 256)_ Example: `Testikatu 5` | | `sender_address_line2` | string | no | Second merchant address line. Optional. _(max len 256)_ | | `sender_city` | string | **yes** | Merchant city. _(max len 128)_ Example: `Turku` | | `sender_zipcode` | string | **yes** | Merchant postal code. _(max len 32)_ Example: `20100` | | `sender_country` | string | **yes** | ISO-3166-1 alpha-2 sender country, upper-cased automatically. Any country is accepted; outside `FI` the label and carrier show the sender name at E-Parcel's Helsinki PO box instead of this address. Example: `FI` | | `weight_g` | integer | **yes** | Gross weight in **grams** (max 50,000). Matched against each rule's cap: the heaviest tier (parcel locker) takes up to 25,000 g, so a heavier parcel finds no route (422). _(≤ 50000.0, > 0.0)_ Example: `1000` | | `length_mm` | integer | **yes** | A side in **millimetres** (max 2,000). Send the three sides in any order — the parcel is matched against each rule's box in any orientation. _(≤ 2000.0, > 0.0)_ Example: `200` | | `width_mm` | integer | **yes** | A side in **millimetres** (max 2,000). _(≤ 2000.0, > 0.0)_ Example: `150` | | `height_mm` | integer | **yes** | A side in **millimetres** (max 2,000). _(≤ 2000.0, > 0.0)_ Example: `100` | | `delivery_method` | `HOME` \| `HOME_MAILBOX` \| `HOME_DOOR` \| `LOCKER` \| `PICKUP` | no | Requested delivery tier, interpreted as a **downgrade chain**. `HOME` is an umbrella: either home variant (mailbox or to-the-door) plus locker/pickup fallback. `HOME_MAILBOX` / `HOME_DOOR` pin the home variant — they never cross to the other one — but still fall back to locker/pickup. `LOCKER` allows locker or pickup; `PICKUP` is exact. Omit to allow any tier. A tier with no active routing rule yields 422. Example: `HOME` | **Responses** | Status | Meaning | |---|---| | `200` | Successful Response | | `400` | The parcel was rejected before routing — a delivery tier that requires contact details the request did not supply. | | `401` | Missing, malformed, or unrecognised API key. | | `422` | The parcel cannot be shipped. Either no routing rule matched its weight/dimensions/tier, or every eligible carrier cleanly rejected it. FastAPI also returns 422 for request-shape errors, whose body is a list of field errors instead. | | `502` | Every eligible carrier errored (timeout, transport failure, or an unexpected response) and the inline retries were exhausted. Unlike a 422 this is not a verdict on the parcel — retrying later may succeed. |
Error examples **401 — No Authorization header** ```json { "detail": "missing or malformed Authorization header" } ``` **401 — Key not recognised** ```json { "detail": "invalid API key" } ``` **400 — Tier requires email and phone** ```json { "detail": "this parcel's routing tier requires recipient_email, recipient_phone", "field": "recipient_contact", "missing": [ "recipient_email", "recipient_phone" ] } ``` **422 — No matching routing rule (e.g. PICKUP with no rule seeded)** ```json { "detail": "no routing rule matched this parcel" } ``` **422 — Carriers tried and all rejected** ```json { "detail": "all carriers rejected the shipment", "shipment_id": 42, "state": "UNROUTABLE" } ``` **422 — Request body failed validation** ```json { "detail": [ { "type": "missing", "loc": [ "body", "weight_g" ], "msg": "Field required" } ] } ``` **422 — Destination country other than FI** ```json { "detail": [ { "type": "literal_error", "loc": [ "body", "country" ], "msg": "Input should be 'FI'" } ] } ``` **502 — All carriers erroring** ```json { "detail": "all carriers unavailable", "shipment_id": 43, "state": "FAILED" } ```
### `GET /api/v1/shipments` **Find shipments by your reference** Find your shipments by the `customer_reference` you supplied. Use it when you no longer have the `shipment_id`, for example to check whether a create whose response was lost actually went through. Only your own shipments are searched. No match is `200` with an empty list, not `404`. `customer_reference` is not unique on our side, so the result is a list, newest first. Each item has the same fields as `GET /api/v1/shipments/{shipment_id}`. **Auth:** `Authorization: Bearer ` **Parameters** | Name | In | Required | Description | |---|---|---|---| | `customer_reference` | query | **yes** | Your own reference, as sent at creation. Exact match. | | `limit` | query | no | Maximum rows, newest first. | **Responses** | Status | Meaning | |---|---| | `200` | Successful Response | | `401` | Missing, malformed, or unrecognised API key. | | `422` | Validation Error |
Error examples **401 — No Authorization header** ```json { "detail": "missing or malformed Authorization header" } ``` **401 — Key not recognised** ```json { "detail": "invalid API key" } ```
### `POST /api/v1/shipments/batch` **Create and dispatch up to 500 shipments** Dispatch many shipments in one request, with per-item results. **A batch never fails wholesale.** Unlike the single-shipment endpoint — where an unroutable parcel is a 422 and a validation failure a 400 — a batch returns `200` and reports each parcel's outcome in `shipments[]`, including items that failed before dispatch (missing contact details, no routing rule). Failed items are persisted as `UNROUTABLE` with a real `shipment_id`. Request-shape errors — including a `country` other than `FI` on any item — still reject the whole batch with 422. Always inspect every item: check `state` is `ROUTED`, and read `error` when it is not. Shipments are grouped by carrier so each carrier is called once per group (bulk where the carrier supports it, otherwise a bounded fan-out). **Two limits to respect.** The batch is capped at 500 items, and — unlike a single shipment — batch dispatch has no server-side deadline, so a very large batch can outrun the HTTP request timeout. Prefer batches you have measured. `Idempotency-Key` is not supported here: a request carrying it is rejected with 400 and nothing is created. Use the single-shipment endpoint when safe retries matter. **Auth:** `Authorization: Bearer ` **Parameters** | Name | In | Required | Description | |---|---|---|---| | `Idempotency-Key` | header | no | Not supported on this endpoint: sending it is a 400 and nothing is created. Use the single-shipment endpoint when safe retries matter. | **Request body** — `ShipmentBatchRequest` | Field | Type | Required | Description | |---|---|---|---| | `shipments` | array | **yes** | Between 1 and 500 parcels. Results come back in the same order. | **Responses** | Status | Meaning | |---|---| | `200` | Successful Response | | `400` | The request carried an `Idempotency-Key`, which the batch endpoint does not support. Nothing was created. | | `401` | Missing, malformed, or unrecognised API key. | | `422` | Validation Error |
Error examples **401 — No Authorization header** ```json { "detail": "missing or malformed Authorization header" } ``` **401 — Key not recognised** ```json { "detail": "invalid API key" } ``` **400 — Idempotency-Key sent to the batch endpoint** ```json { "detail": "Idempotency-Key is only supported for single-shipment requests", "field": "idempotency_key" } ```
### `GET /api/v1/shipments/{shipment_id}` **Look up a shipment** Fetch a shipment by the id returned at creation. Scoped to the authenticated customer — another customer's id returns 404 rather than revealing that it exists. Because dispatch is synchronous, this is for later reference, not polling: the state is already final when the create call returns. A shipment that never reached a carrier has no `final_carrier_tracking_id`. Returns the parcel as shipped — weight, dimensions and the resolved `delivery_type` — so you can reconcile without keeping your own copy. The label PDF is not inlined here; download it from `GET /api/v1/shipments/{shipment_id}/label`. **Auth:** `Authorization: Bearer ` **Parameters** | Name | In | Required | Description | |---|---|---|---| | `shipment_id` | path | **yes** | Id returned when the shipment was created. | **Responses** | Status | Meaning | |---|---| | `200` | Successful Response | | `401` | Missing, malformed, or unrecognised API key. | | `404` | No shipment with that id belongs to the authenticated customer. | | `422` | Validation Error |
Error examples **401 — No Authorization header** ```json { "detail": "missing or malformed Authorization header" } ``` **401 — Key not recognised** ```json { "detail": "invalid API key" } ``` **404 — Unknown id** ```json { "detail": "shipment not found" } ```
### `GET /api/v1/shipments/{shipment_id}/label` **Download a shipment's label PDF** Fetch the stored label again, as a PDF download. The create call returns `label_pdf_base64` once. This serves the same bytes afterwards, so losing that response is recoverable without re-shipping the parcel (which would create — and charge for — a second consignment). Only shipments that reached `ROUTED` have a label: the PDF is rendered at the moment a carrier accepts, using the tracking number it returned. **Auth:** `Authorization: Bearer ` **Parameters** | Name | In | Required | Description | |---|---|---|---| | `shipment_id` | path | **yes** | Id returned when the shipment was created. | **Responses** | Status | Meaning | |---|---| | `200` | The label PDF, as a file download. | | `401` | Missing, malformed, or unrecognised API key. | | `404` | No such shipment for this customer, or the shipment has no label because it never reached a carrier. | | `422` | Validation Error |
Error examples **401 — No Authorization header** ```json { "detail": "missing or malformed Authorization header" } ``` **401 — Key not recognised** ```json { "detail": "invalid API key" } ```
### `GET /health` **Liveness** Report that the process is running. Touches no dependencies. Use this to tell "the app is down" apart from "a dependency is down" — pair it with `/readyz`, which also checks the database. Prefer `/health`: the `/healthz` alias is unreachable behind some managed platforms, which answer that exact path themselves. **Auth:** none **Responses** | Status | Meaning | |---|---| | `200` | Successful Response | ### `GET /healthz` **Liveness** Report that the process is running. Touches no dependencies. Use this to tell "the app is down" apart from "a dependency is down" — pair it with `/readyz`, which also checks the database. Prefer `/health`: the `/healthz` alias is unreachable behind some managed platforms, which answer that exact path themselves. **Auth:** none **Responses** | Status | Meaning | |---|---| | `200` | Successful Response | ### `GET /readyz` **Readiness** Report that the service can serve traffic: process up **and** database reachable. Returns 503 when the database cannot be reached. This is the probe to point uptime monitoring at, since the app cannot do useful work without Postgres. **Auth:** none **Responses** | Status | Meaning | |---|---| | `200` | Successful Response | ## Schemas ### `CarrierFailureDetail` Every carrier was tried and none accepted. The shipment row is persisted in its terminal state, so the id is still returned. | Field | Type | Required | Description | |---|---|---|---| | `detail` | string | **yes** | Human-readable explanation of the failure. | | `shipment_id` | integer | **yes** | The persisted shipment, kept for reference. | | `state` | string | **yes** | Terminal state: `UNROUTABLE` or `FAILED`. | ### `ErrorDetail` Standard error body. Some errors add fields — see each response below. | Field | Type | Required | Description | |---|---|---|---| | `detail` | string | **yes** | Human-readable explanation of the failure. | ### `RoutingRuleInfo` The routing rule that decided a shipment — the necessary fields only. `name` is what distinguishes letterbox from door delivery on shipments created before the tier split (their stored `delivery_type` is `HOME`). | Field | Type | Required | Description | |---|---|---|---| | `id` | integer | **yes** | Id of the routing rule that decided this shipment (stable across renames). | | `name` | string | **yes** | Rule name, e.g. "Small package POSTI". | ### `ShipmentBatchRequest` A batch of parcels dispatched in one request. | Field | Type | Required | Description | |---|---|---|---| | `shipments` | array | **yes** | Between 1 and 500 parcels. Results come back in the same order. | ### `ShipmentBatchResponse` Per-item results, in the order submitted. | Field | Type | Required | Description | |---|---|---|---| | `shipments` | array | **yes** | One result per submitted parcel, same order. Inspect each item's `state` and `error` — a batch returns 200 even when items failed. | ### `ShipmentRequest` One parcel to route and dispatch. Carries both parties: the **recipient** (`recipient_*`, `address_*`, `city`, `zipcode`, `country`) and the **merchant sender** (`sender_*`). The sender block is printed as the FROM address on the label and forwarded to the carrier, so it should be the merchant's real return address. Only Finland is served: `country` must be `FI`. A sender outside Finland is accepted and stored as sent, but the label and the carrier registration show the sender name at E-Parcel's Helsinki PO box (PL 5200, 00002 Helsinki) instead of the foreign address. Weight and dimensions drive routing: they are matched against each rule's caps, and a parcel exceeding every rule's limits is unroutable. | Field | Type | Required | Description | |---|---|---|---| | `customer_reference` | string | no | Your own order/reference id. Passed through to the carrier and printed on the label, so use it to reconcile shipments later. _(max len 128)_ Example: `ORDER-1001` | | `recipient_name` | string | **yes** | Full name of the person receiving the parcel. _(max len 256)_ Example: `Anna Aalto` | | `recipient_email` | string | no | Recipient email. Delivery tiers that need contact details require both email and phone — currently every tier does, so send both or the parcel is rejected with 400. Example: `anna@example.com` | | `recipient_phone` | string | no | Recipient phone in international format. See `recipient_email`. _(max len 32)_ Example: `+358401234567` | | `address_line1` | string | **yes** | Street address of the destination. _(max len 256)_ Example: `Mannerheimintie 1` | | `address_line2` | string | no | Apartment, stairwell, or c/o line. Optional. _(max len 256)_ Example: `A 12` | | `city` | string | **yes** | Destination city. _(max len 128)_ Example: `Helsinki` | | `zipcode` | string | **yes** | Destination postal code. Drives carrier coverage and sortation. _(max len 32)_ Example: `00100` | | `country` | string | **yes** | ISO-3166-1 alpha-2 destination country, upper-cased automatically. Only `FI` is supported; anything else is rejected with 422. Example: `FI` | | `sender_name` | string | **yes** | Merchant name, printed as the label's FROM party. _(max len 256)_ Example: `E-Parcel Oy` | | `sender_email` | string | no | Merchant contact email. Optional. Example: `merchant@example.com` | | `sender_phone` | string | no | Merchant contact phone. Optional. _(max len 32)_ Example: `+358401111111` | | `sender_address_line1` | string | **yes** | Merchant street address (the return address). _(max len 256)_ Example: `Testikatu 5` | | `sender_address_line2` | string | no | Second merchant address line. Optional. _(max len 256)_ | | `sender_city` | string | **yes** | Merchant city. _(max len 128)_ Example: `Turku` | | `sender_zipcode` | string | **yes** | Merchant postal code. _(max len 32)_ Example: `20100` | | `sender_country` | string | **yes** | ISO-3166-1 alpha-2 sender country, upper-cased automatically. Any country is accepted; outside `FI` the label and carrier show the sender name at E-Parcel's Helsinki PO box instead of this address. Example: `FI` | | `weight_g` | integer | **yes** | Gross weight in **grams** (max 50,000). Matched against each rule's cap: the heaviest tier (parcel locker) takes up to 25,000 g, so a heavier parcel finds no route (422). _(≤ 50000.0, > 0.0)_ Example: `1000` | | `length_mm` | integer | **yes** | A side in **millimetres** (max 2,000). Send the three sides in any order — the parcel is matched against each rule's box in any orientation. _(≤ 2000.0, > 0.0)_ Example: `200` | | `width_mm` | integer | **yes** | A side in **millimetres** (max 2,000). _(≤ 2000.0, > 0.0)_ Example: `150` | | `height_mm` | integer | **yes** | A side in **millimetres** (max 2,000). _(≤ 2000.0, > 0.0)_ Example: `100` | | `delivery_method` | `HOME` \| `HOME_MAILBOX` \| `HOME_DOOR` \| `LOCKER` \| `PICKUP` | no | Requested delivery tier, interpreted as a **downgrade chain**. `HOME` is an umbrella: either home variant (mailbox or to-the-door) plus locker/pickup fallback. `HOME_MAILBOX` / `HOME_DOOR` pin the home variant — they never cross to the other one — but still fall back to locker/pickup. `LOCKER` allows locker or pickup; `PICKUP` is exact. Omit to allow any tier. A tier with no active routing rule yields 422. Example: `HOME` | ### `ShipmentResponse` One shipment's fully-resolved result. On success `state == "ROUTED"` with the carrier's returned `tracking_number` + the rendered label. In a batch, a failed item carries `state` (UNROUTABLE / FAILED) + `error` instead (a single-shipment failure is a 422/502, not this). | Field | Type | Required | Description | |---|---|---|---| | `shipment_id` | integer | **yes** | Database id of the shipment. Use it with `GET /api/v1/shipments/{id}`. Example: `1` | | `state` | string | **yes** | Terminal state: `ROUTED` (accepted by a carrier), `UNROUTABLE` (no rule matched, or every carrier rejected it), or `FAILED` (carriers errored). `ROUTING` is transient and should not be observed here. Example: `ROUTED` | | `carrier` | string | no | Code of the carrier that accepted the parcel. Example: `POSTI` | | `carrier_number` | integer | no | That carrier's fixed number, printed on the label. Example: `2` | | `tracking_number` | string | no | The **carrier's own** tracking id — we do not mint ids. This is the barcode on the label. Absent unless `state` is `ROUTED`. Example: `JJFI67863998958478726` | | `label_pdf_base64` | string | no | Base64-encoded PDF label, rendered only on acceptance. Inlined only here — re-download it later from `GET /api/v1/shipments/{shipment_id}/label`. | | `error` | string | no | Why this parcel was not shipped. Populated for batch items that failed. | ### `ShipmentSearchResponse` Your shipments that carry the requested `customer_reference`. | Field | Type | Required | Description | |---|---|---|---| | `shipments` | array | **yes** | Matching shipments, newest first. Empty when none match. `customer_reference` is not unique, so there can be more than one. | ### `ShipmentStatusResponse` Stored record of a shipment, including the parcel as we shipped it. Download the label separately from `GET /api/v1/shipments/{id}/label`. | Field | Type | Required | Description | |---|---|---|---| | `shipment_id` | integer | **yes** | Database id of the shipment. | | `customer_reference` | string | **yes** | Your own reference, as supplied at creation. | | `state` | string | **yes** | `ROUTED`, `UNROUTABLE`, or `FAILED`. | | `final_carrier` | string | **yes** | Carrier that accepted it, if any. | | `final_carrier_tracking_id` | string | **yes** | The accepting carrier's tracking number. Null if never accepted. | | `weight_g` | integer | **yes** | Gross weight in grams, as shipped. | | `length_mm` | integer | **yes** | Length in millimetres, as shipped. | | `width_mm` | integer | **yes** | Width in millimetres, as shipped. | | `height_mm` | integer | **yes** | Height in millimetres, as shipped. | | `requested_delivery_method` | string | **yes** | The tier you asked for (`HOME`/`LOCKER`/`PICKUP`), or null if you left it open. | | `delivery_type` | string | **yes** | The tier actually used: `HOME_MAILBOX` (letterbox), `HOME_DOOR` (to the door), `LOCKER` or `PICKUP`. May be a downgrade of the requested method. Shipments created before the tier split report `HOME` — use `routing_rule.name` to tell the variant. | | `routing_rule` | object | **yes** | The rule that matched this parcel. Null when nothing matched (UNROUTABLE before any rule applied). | | `created_at` | string | **yes** | When the shipment was submitted (UTC). | | `routed_at` | string | **yes** | When a carrier accepted it (UTC). Null if it never was. | | `scan_status` | string | no | Warehouse status once the parcel has been scanned at the E-Parcel hub: `SCANNED_OK`, `SCANNED_NEW_LABEL`, `SCANNED_NEW_SERVICE`, `SCANNED_REJECTED` (replaced by a new shipment after corrected measurements), `SCANNED_RETURNED`, `SCANNED_DESTRUCTED`. `null` until the parcel arrives. | | `scanned_at` | string | no | First warehouse scan — when the parcel arrived (UTC). | ### `ValidationErrorDetail` A rejected value, with the offending field named. | Field | Type | Required | Description | |---|---|---|---| | `detail` | string | **yes** | Human-readable explanation of the failure. | | `field` | string | no | The input field at fault. | | `missing` | array | no | Set when a delivery tier requires contact details. |