{"openapi":"3.1.0","info":{"title":"E-Parcel Dispatcher","summary":"Synchronous parcel routing, carrier dispatch and label generation.","description":"Route parcels to the right carrier and get a printable label back — in a single\nrequest.\n\n## How it works\n\n`POST /api/v1/shipments` does everything synchronously: it validates the parcel,\nmatches it against the routing rules, calls each eligible carrier until one\naccepts, and renders the label from the accepting carrier's tracking number.\nThere is no queue and nothing to poll — when the call returns, the outcome is\nfinal.\n\nCarriers are tried in the order the routing rules define. A carrier that\n*rejects* the parcel advances to the next; one that *errors* is retried with\nbackoff before advancing. The response reports only the accepting carrier —\nwhich carriers were tried along the way is routing policy, not part of the API.\n\n## Tracking numbers and labels\n\nThe tracking number is always the **carrier's own** id — we never mint one. It\nis also the barcode on the label. `label_pdf_base64` is returned inline only at\ncreation; afterwards, download the same PDF from\n`GET /api/v1/shipments/{shipment_id}/label`.\n\n## Authentication\n\nEvery endpoint outside `/health` and `/readyz` needs a bearer token:\n\n```\nAuthorization: Bearer <api-key>\n```\n\nYour API key is issued by E-Parcel and reaches everything under `/api/v1/*`. Contact us to rotate it or to get an additional key.\n\n## Handling failures\n\n| Status | Meaning | Retry? |\n|---|---|---|\n| `400` | The parcel was rejected before routing (missing required contact details) | No — fix the request |\n| `401` | Missing or unrecognised API key | No |\n| `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 |\n| `502` | Every carrier errored and retries were exhausted | Yes, later |\n\nA `422` is a verdict on the parcel; a `502` is a verdict on the carriers.\n\nSend an `Idempotency-Key` on single-shipment creates so a retry after a timeout\nreturns the original shipment instead of creating — and paying for — a second\none. Retrying a `502` with the same key tries the carriers again on that same\nshipment.\n\n## Batches\n\n`POST /api/v1/shipments/batch` takes up to 500 parcels and **never fails\nwholesale**: it returns `200` with one result per item, so inspect each item's\n`state` and `error` individually.\n\n## Building with an AI agent\n\nIntegrating through Claude, Cursor, GitHub Copilot or another AI coding agent?\nGive it the agent docs. They add what this reference leaves implicit: the\nrules a correct integration follows, copy-ready client code in Python and\nTypeScript, and a test plan. Every environment serves them, without\nauthentication:\n\n| File | Path | Use it for |\n|---|---|---|\n| Index | `/llms.txt` | Agents that discover docs via the llms.txt convention |\n| Full docs | `/llms-full.txt` | One Markdown file: integration guide + this reference |\n| Claude skill | `/agents/e-parcel-api.zip` | Claude Code, Claude.ai, Claude Desktop |\n\n**Claude Code.** Install the skill in your project (use `~/.claude/skills`\ninstead to have it in every project):\n\n```bash\ncurl -fsSL https://api.e-parcel.fi/agents/e-parcel-api.zip -o e-parcel-api.zip\nmkdir -p .claude/skills && unzip -o e-parcel-api.zip -d .claude/skills && rm e-parcel-api.zip\n```\n\nStart a new `claude` session. It loads the skill whenever you work on the\nintegration; type `/e-parcel-api` to load it explicitly. On Windows, use\n`Expand-Archive e-parcel-api.zip -DestinationPath .claude\\skills` instead of\n`unzip`.\n\n**Claude.ai and Claude Desktop.** Download\n`https://api.e-parcel.fi/agents/e-parcel-api.zip` and upload it as a custom\nskill: Customize → Skills → \"+\" (on older layouts Settings → Capabilities →\nSkills → Upload skill). Code execution must be enabled. Or add\n`/llms-full.txt` to a Project's knowledge instead.\n\n**Other agents.** Point the agent at `https://api.e-parcel.fi/llms-full.txt`,\nor add this to the `AGENTS.md` (or equivalent rules file) in your repository:\n\n```markdown\n## E-Parcel shipping API\nBefore changing E-Parcel integration code, read\nhttps://api.e-parcel.fi/llms-full.txt and follow its golden rules.\nDevelop and test against https://api-test.e-parcel.fi only.\n```\n\nA good first prompt: *\"Integrate our order fulfilment with the E-Parcel API\nusing the e-parcel-api skill. Target the test environment; the API key is in\nthe `EPARCEL_API_KEY` environment variable.\"*\n\nKeep the API key out of the conversation: put it in an environment variable\nyour code reads, never paste it into a prompt, and let the agent test against\n`https://api-test.e-parcel.fi` only — shipments created on production are real\nand billable.","version":"0.1.0"},"paths":{"/healthz":{"get":{"tags":["health"],"summary":"Liveness","description":"Report that the process is running. Touches no dependencies.\n\nUse this to tell \"the app is down\" apart from \"a dependency is down\" — pair\nit with `/readyz`, which also checks the database.\n\nPrefer `/health`: the `/healthz` alias is unreachable behind some managed\nplatforms, which answer that exact path themselves.","operationId":"liveness_healthz_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object","title":"Response Liveness Healthz Get"}}}}}}},"/health":{"get":{"tags":["health"],"summary":"Liveness","description":"Report that the process is running. Touches no dependencies.\n\nUse this to tell \"the app is down\" apart from \"a dependency is down\" — pair\nit with `/readyz`, which also checks the database.\n\nPrefer `/health`: the `/healthz` alias is unreachable behind some managed\nplatforms, which answer that exact path themselves.","operationId":"liveness_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object","title":"Response Liveness Health Get"}}}}}}},"/readyz":{"get":{"tags":["health"],"summary":"Readiness","description":"Report that the service can serve traffic: process up **and** database\nreachable.\n\nReturns 503 when the database cannot be reached. This is the probe to point\nuptime monitoring at, since the app cannot do useful work without Postgres.","operationId":"readiness_readyz_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object","title":"Response Readiness Readyz Get"}}}}}}},"/api/v1/shipments":{"post":{"tags":["shipments"],"summary":"Create and dispatch one shipment","description":"Route the parcel, call the carrier, and return the label — all in this request.\n\nThe whole flow is **synchronous**: the parcel is validated and classified, a\nrouting rule selects the carrier chain, each carrier is called in turn until\none accepts, and the label is rendered from the accepting carrier's tracking\nnumber. There is nothing to poll — the response is final.\n\n**Carrier waterfall.** Carriers are attempted in the order the matching rules\ndefine. A carrier that *rejects* (will not carry this parcel) advances to the\nnext; a carrier that *errors* is retried with exponential backoff before\nadvancing. The first acceptance wins, and `tracking_number` is that carrier's\nown id — we do not mint ids.\n\n**Idempotency.** Send `Idempotency-Key` to make retries safe: a repeated key\nreturns the original shipment instead of creating (and paying for) a second\none. A 502 (carriers unavailable) is the exception: retrying it with the\nsame key runs the carrier waterfall again on the same `shipment_id`, so one\nkey per order is enough. A 422 rejection is final and replays as-is.\nStrongly recommended, since a timeout is otherwise indistinguishable from a\nfailure. Not supported on the batch endpoint.\n\n**Delivery tiers.** `delivery_method` is a downgrade chain — `HOME` allows\nhome, locker or pickup; `LOCKER` allows locker or pickup; `PICKUP` is exact;\nomitting it allows any tier. A tier with no active routing rule yields 422.","operationId":"create_shipment_api_v1_shipments_post","security":[{"CustomerApiKey":[]}],"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string","maxLength":128},{"type":"null"}],"description":"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.","title":"Idempotency-Key"},"description":"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."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShipmentRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShipmentResponse"}}}},"401":{"description":"Missing, malformed, or unrecognised API key.","content":{"application/json":{"examples":{"missing":{"summary":"No Authorization header","value":{"detail":"missing or malformed Authorization header"}},"invalid":{"summary":"Key not recognised","value":{"detail":"invalid API key"}}},"schema":{"$ref":"#/components/schemas/ErrorDetail"}}}},"400":{"description":"The parcel was rejected before routing — a delivery tier that requires contact details the request did not supply.","content":{"application/json":{"examples":{"contact":{"summary":"Tier requires email and phone","value":{"detail":"this parcel's routing tier requires recipient_email, recipient_phone","field":"recipient_contact","missing":["recipient_email","recipient_phone"]}}},"schema":{"$ref":"#/components/schemas/ValidationErrorDetail"}}}},"422":{"description":"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.","content":{"application/json":{"examples":{"no_rule":{"summary":"No matching routing rule (e.g. PICKUP with no rule seeded)","value":{"detail":"no routing rule matched this parcel"}},"all_rejected":{"summary":"Carriers tried and all rejected","value":{"detail":"all carriers rejected the shipment","shipment_id":42,"state":"UNROUTABLE"}},"schema_error":{"summary":"Request body failed validation","value":{"detail":[{"type":"missing","loc":["body","weight_g"],"msg":"Field required"}]}},"unsupported_country":{"summary":"Destination country other than FI","value":{"detail":[{"type":"literal_error","loc":["body","country"],"msg":"Input should be 'FI'"}]}}}}}},"502":{"description":"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.","content":{"application/json":{"examples":{"unavailable":{"summary":"All carriers erroring","value":{"detail":"all carriers unavailable","shipment_id":43,"state":"FAILED"}}},"schema":{"$ref":"#/components/schemas/CarrierFailureDetail"}}}}}},"get":{"tags":["shipments"],"summary":"Find shipments by your reference","description":"Find your shipments by the `customer_reference` you supplied.\n\nUse it when you no longer have the `shipment_id`, for example to check\nwhether a create whose response was lost actually went through. Only your\nown shipments are searched. No match is `200` with an empty list, not `404`.\n\n`customer_reference` is not unique on our side, so the result is a list,\nnewest first. Each item has the same fields as\n`GET /api/v1/shipments/{shipment_id}`.","operationId":"search_shipments_api_v1_shipments_get","security":[{"CustomerApiKey":[]}],"parameters":[{"name":"customer_reference","in":"query","required":true,"schema":{"type":"string","minLength":1,"description":"Your own reference, as sent at creation. Exact match.","title":"Customer Reference"},"description":"Your own reference, as sent at creation. Exact match."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":1,"description":"Maximum rows, newest first.","default":50,"title":"Limit"},"description":"Maximum rows, newest first."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShipmentSearchResponse"}}}},"401":{"description":"Missing, malformed, or unrecognised API key.","content":{"application/json":{"examples":{"missing":{"summary":"No Authorization header","value":{"detail":"missing or malformed Authorization header"}},"invalid":{"summary":"Key not recognised","value":{"detail":"invalid API key"}}},"schema":{"$ref":"#/components/schemas/ErrorDetail"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/shipments/batch":{"post":{"tags":["shipments"],"summary":"Create and dispatch up to 500 shipments","description":"Dispatch many shipments in one request, with per-item results.\n\n**A batch never fails wholesale.** Unlike the single-shipment endpoint —\nwhere an unroutable parcel is a 422 and a validation failure a 400 — a batch\nreturns `200` and reports each parcel's outcome in `shipments[]`, including\nitems that failed before dispatch (missing contact details, no routing\nrule). Failed items are persisted as `UNROUTABLE` with a real\n`shipment_id`. Request-shape errors — including a `country` other than `FI`\non any item — still reject the whole batch with 422. Always inspect every item: check `state` is `ROUTED`,\nand read `error` when it is not.\n\nShipments are grouped by carrier so each carrier is called once per group\n(bulk where the carrier supports it, otherwise a bounded fan-out).\n\n**Two limits to respect.** The batch is capped at 500 items, and — unlike a\nsingle shipment — batch dispatch has no server-side deadline, so a very large\nbatch can outrun the HTTP request timeout. Prefer batches you have measured.\n\n`Idempotency-Key` is not supported here: a request carrying it is rejected\nwith 400 and nothing is created. Use the single-shipment endpoint when safe\nretries matter.","operationId":"create_shipment_batch_api_v1_shipments_batch_post","security":[{"CustomerApiKey":[]}],"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Not supported on this endpoint: sending it is a 400 and nothing is created. Use the single-shipment endpoint when safe retries matter.","title":"Idempotency-Key"},"description":"Not supported on this endpoint: sending it is a 400 and nothing is created. Use the single-shipment endpoint when safe retries matter."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShipmentBatchRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShipmentBatchResponse"}}}},"401":{"description":"Missing, malformed, or unrecognised API key.","content":{"application/json":{"examples":{"missing":{"summary":"No Authorization header","value":{"detail":"missing or malformed Authorization header"}},"invalid":{"summary":"Key not recognised","value":{"detail":"invalid API key"}}},"schema":{"$ref":"#/components/schemas/ErrorDetail"}}}},"400":{"description":"The request carried an `Idempotency-Key`, which the batch endpoint does not support. Nothing was created.","content":{"application/json":{"examples":{"idempotency_key":{"summary":"Idempotency-Key sent to the batch endpoint","value":{"detail":"Idempotency-Key is only supported for single-shipment requests","field":"idempotency_key"}}},"schema":{"$ref":"#/components/schemas/ValidationErrorDetail"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/shipments/{shipment_id}":{"get":{"tags":["shipments"],"summary":"Look up a shipment","description":"Fetch a shipment by the id returned at creation.\n\nScoped to the authenticated customer — another customer's id returns 404\nrather than revealing that it exists.\n\nBecause dispatch is synchronous, this is for later reference, not polling:\nthe state is already final when the create call returns. A shipment that\nnever reached a carrier has no `final_carrier_tracking_id`.\n\nReturns the parcel as shipped — weight, dimensions and the resolved\n`delivery_type` — so you can reconcile without keeping your own copy.\n\nThe label PDF is not inlined here; download it from\n`GET /api/v1/shipments/{shipment_id}/label`.","operationId":"get_shipment_api_v1_shipments__shipment_id__get","security":[{"CustomerApiKey":[]}],"parameters":[{"name":"shipment_id","in":"path","required":true,"schema":{"type":"integer","description":"Id returned when the shipment was created.","title":"Shipment Id"},"description":"Id returned when the shipment was created."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShipmentStatusResponse"}}}},"401":{"description":"Missing, malformed, or unrecognised API key.","content":{"application/json":{"examples":{"missing":{"summary":"No Authorization header","value":{"detail":"missing or malformed Authorization header"}},"invalid":{"summary":"Key not recognised","value":{"detail":"invalid API key"}}},"schema":{"$ref":"#/components/schemas/ErrorDetail"}}}},"404":{"description":"No shipment with that id belongs to the authenticated customer.","content":{"application/json":{"examples":{"missing":{"summary":"Unknown id","value":{"detail":"shipment not found"}}},"schema":{"$ref":"#/components/schemas/ErrorDetail"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/shipments/{shipment_id}/label":{"get":{"tags":["shipments"],"summary":"Download a shipment's label PDF","description":"Fetch the stored label again, as a PDF download.\n\nThe create call returns `label_pdf_base64` once. This serves the same bytes\nafterwards, so losing that response is recoverable without re-shipping the\nparcel (which would create — and charge for — a second consignment).\n\nOnly shipments that reached `ROUTED` have a label: the PDF is rendered at\nthe moment a carrier accepts, using the tracking number it returned.","operationId":"download_label_api_v1_shipments__shipment_id__label_get","security":[{"CustomerApiKey":[]}],"parameters":[{"name":"shipment_id","in":"path","required":true,"schema":{"type":"integer","description":"Id returned when the shipment was created.","title":"Shipment Id"},"description":"Id returned when the shipment was created."}],"responses":{"200":{"description":"The label PDF, as a file download.","content":{"application/pdf":{}}},"401":{"description":"Missing, malformed, or unrecognised API key.","content":{"application/json":{"examples":{"missing":{"summary":"No Authorization header","value":{"detail":"missing or malformed Authorization header"}},"invalid":{"summary":"Key not recognised","value":{"detail":"invalid API key"}}},"schema":{"$ref":"#/components/schemas/ErrorDetail"}}}},"404":{"description":"No such shipment for this customer, or the shipment has no label because it never reached a carrier."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"CarrierFailureDetail":{"properties":{"detail":{"type":"string","title":"Detail","description":"Human-readable explanation of the failure."},"shipment_id":{"type":"integer","title":"Shipment Id","description":"The persisted shipment, kept for reference."},"state":{"type":"string","title":"State","description":"Terminal state: `UNROUTABLE` or `FAILED`."}},"type":"object","required":["detail","shipment_id","state"],"title":"CarrierFailureDetail","description":"Every carrier was tried and none accepted. The shipment row is persisted\nin its terminal state, so the id is still returned."},"ErrorDetail":{"properties":{"detail":{"type":"string","title":"Detail","description":"Human-readable explanation of the failure."}},"type":"object","required":["detail"],"title":"ErrorDetail","description":"Standard error body. Some errors add fields — see each response below."},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"RoutingRuleInfo":{"properties":{"id":{"type":"integer","title":"Id","description":"Id of the routing rule that decided this shipment (stable across renames)."},"name":{"type":"string","title":"Name","description":"Rule name, e.g. \"Small package POSTI\"."}},"type":"object","required":["id","name"],"title":"RoutingRuleInfo","description":"The routing rule that decided a shipment — the necessary fields only.\n\n`name` is what distinguishes letterbox from door delivery on shipments\ncreated before the tier split (their stored `delivery_type` is `HOME`)."},"ShipmentBatchRequest":{"properties":{"shipments":{"items":{"$ref":"#/components/schemas/ShipmentRequest"},"type":"array","maxItems":500,"minItems":1,"title":"Shipments","description":"Between 1 and 500 parcels. Results come back in the same order."}},"type":"object","required":["shipments"],"title":"ShipmentBatchRequest","description":"A batch of parcels dispatched in one request."},"ShipmentBatchResponse":{"properties":{"shipments":{"items":{"$ref":"#/components/schemas/ShipmentResponse"},"type":"array","title":"Shipments","description":"One result per submitted parcel, same order. Inspect each item's `state` and `error` — a batch returns 200 even when items failed."}},"type":"object","required":["shipments"],"title":"ShipmentBatchResponse","description":"Per-item results, in the order submitted."},"ShipmentRequest":{"properties":{"customer_reference":{"anyOf":[{"type":"string","maxLength":128},{"type":"null"}],"title":"Customer Reference","description":"Your own order/reference id. Passed through to the carrier and printed on the label, so use it to reconcile shipments later.","examples":["ORDER-1001"]},"recipient_name":{"type":"string","maxLength":256,"minLength":1,"title":"Recipient Name","description":"Full name of the person receiving the parcel.","examples":["Anna Aalto"]},"recipient_email":{"anyOf":[{"type":"string","format":"email"},{"type":"null"}],"title":"Recipient Email","description":"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.","examples":["anna@example.com"]},"recipient_phone":{"anyOf":[{"type":"string","maxLength":32},{"type":"null"}],"title":"Recipient Phone","description":"Recipient phone in international format. See `recipient_email`.","examples":["+358401234567"]},"address_line1":{"type":"string","maxLength":256,"minLength":1,"title":"Address Line1","description":"Street address of the destination.","examples":["Mannerheimintie 1"]},"address_line2":{"anyOf":[{"type":"string","maxLength":256},{"type":"null"}],"title":"Address Line2","description":"Apartment, stairwell, or c/o line. Optional.","examples":["A 12"]},"city":{"type":"string","maxLength":128,"minLength":1,"title":"City","description":"Destination city.","examples":["Helsinki"]},"zipcode":{"type":"string","maxLength":32,"minLength":1,"title":"Zipcode","description":"Destination postal code. Drives carrier coverage and sortation.","examples":["00100"]},"country":{"type":"string","const":"FI","title":"Country","description":"ISO-3166-1 alpha-2 destination country, upper-cased automatically. Only `FI` is supported; anything else is rejected with 422.","examples":["FI"]},"sender_name":{"type":"string","maxLength":256,"minLength":1,"title":"Sender Name","description":"Merchant name, printed as the label's FROM party.","examples":["E-Parcel Oy"]},"sender_email":{"anyOf":[{"type":"string","format":"email"},{"type":"null"}],"title":"Sender Email","description":"Merchant contact email. Optional.","examples":["merchant@example.com"]},"sender_phone":{"anyOf":[{"type":"string","maxLength":32},{"type":"null"}],"title":"Sender Phone","description":"Merchant contact phone. Optional.","examples":["+358401111111"]},"sender_address_line1":{"type":"string","maxLength":256,"minLength":1,"title":"Sender Address Line1","description":"Merchant street address (the return address).","examples":["Testikatu 5"]},"sender_address_line2":{"anyOf":[{"type":"string","maxLength":256},{"type":"null"}],"title":"Sender Address Line2","description":"Second merchant address line. Optional."},"sender_city":{"type":"string","maxLength":128,"minLength":1,"title":"Sender City","description":"Merchant city.","examples":["Turku"]},"sender_zipcode":{"type":"string","maxLength":32,"minLength":1,"title":"Sender Zipcode","description":"Merchant postal code.","examples":["20100"]},"sender_country":{"type":"string","pattern":"^[A-Z]{2}$","title":"Sender Country","description":"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.","examples":["FI"]},"weight_g":{"type":"integer","maximum":50000.0,"exclusiveMinimum":0.0,"title":"Weight G","description":"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).","examples":[1000]},"length_mm":{"type":"integer","maximum":2000.0,"exclusiveMinimum":0.0,"title":"Length Mm","description":"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.","examples":[200]},"width_mm":{"type":"integer","maximum":2000.0,"exclusiveMinimum":0.0,"title":"Width Mm","description":"A side in **millimetres** (max 2,000).","examples":[150]},"height_mm":{"type":"integer","maximum":2000.0,"exclusiveMinimum":0.0,"title":"Height Mm","description":"A side in **millimetres** (max 2,000).","examples":[100]},"delivery_method":{"anyOf":[{"type":"string","enum":["HOME","HOME_MAILBOX","HOME_DOOR","LOCKER","PICKUP"]},{"type":"null"}],"title":"Delivery Method","description":"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.","examples":["HOME"]}},"type":"object","required":["recipient_name","address_line1","city","zipcode","country","sender_name","sender_address_line1","sender_city","sender_zipcode","sender_country","weight_g","length_mm","width_mm","height_mm"],"title":"ShipmentRequest","description":"One parcel to route and dispatch.\n\nCarries both parties: the **recipient** (`recipient_*`, `address_*`, `city`,\n`zipcode`, `country`) and the **merchant sender** (`sender_*`). The sender\nblock is printed as the FROM address on the label and forwarded to the\ncarrier, so it should be the merchant's real return address.\n\nOnly Finland is served: `country` must be `FI`. A sender outside Finland is\naccepted and stored as sent, but the label and the carrier registration\nshow the sender name at E-Parcel's Helsinki PO box (PL 5200, 00002\nHelsinki) instead of the foreign address.\n\nWeight and dimensions drive routing: they are matched against each rule's\ncaps, and a parcel exceeding every rule's limits is unroutable."},"ShipmentResponse":{"properties":{"shipment_id":{"type":"integer","title":"Shipment Id","description":"Database id of the shipment. Use it with `GET /api/v1/shipments/{id}`.","examples":[1]},"state":{"type":"string","title":"State","description":"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.","examples":["ROUTED"]},"carrier":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Carrier","description":"Code of the carrier that accepted the parcel.","examples":["POSTI"]},"carrier_number":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Carrier Number","description":"That carrier's fixed number, printed on the label.","examples":[2]},"tracking_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tracking Number","description":"The **carrier's own** tracking id — we do not mint ids. This is the barcode on the label. Absent unless `state` is `ROUTED`.","examples":["JJFI67863998958478726"]},"label_pdf_base64":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Label Pdf Base64","description":"Base64-encoded PDF label, rendered only on acceptance. Inlined only here — re-download it later from `GET /api/v1/shipments/{shipment_id}/label`."},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error","description":"Why this parcel was not shipped. Populated for batch items that failed."}},"type":"object","required":["shipment_id","state"],"title":"ShipmentResponse","description":"One shipment's fully-resolved result.\n\nOn success `state == \"ROUTED\"` with the carrier's returned `tracking_number`\n+ the rendered label. In a batch, a failed item carries `state` (UNROUTABLE /\nFAILED) + `error` instead (a single-shipment failure is a 422/502, not this)."},"ShipmentSearchResponse":{"properties":{"shipments":{"items":{"$ref":"#/components/schemas/ShipmentStatusResponse"},"type":"array","title":"Shipments","description":"Matching shipments, newest first. Empty when none match. `customer_reference` is not unique, so there can be more than one."}},"type":"object","required":["shipments"],"title":"ShipmentSearchResponse","description":"Your shipments that carry the requested `customer_reference`."},"ShipmentStatusResponse":{"properties":{"shipment_id":{"type":"integer","title":"Shipment Id","description":"Database id of the shipment."},"customer_reference":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Customer Reference","description":"Your own reference, as supplied at creation."},"state":{"type":"string","title":"State","description":"`ROUTED`, `UNROUTABLE`, or `FAILED`."},"final_carrier":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Final Carrier","description":"Carrier that accepted it, if any."},"final_carrier_tracking_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Final Carrier Tracking Id","description":"The accepting carrier's tracking number. Null if never accepted."},"weight_g":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Weight G","description":"Gross weight in grams, as shipped."},"length_mm":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Length Mm","description":"Length in millimetres, as shipped."},"width_mm":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Width Mm","description":"Width in millimetres, as shipped."},"height_mm":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Height Mm","description":"Height in millimetres, as shipped."},"requested_delivery_method":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Requested Delivery Method","description":"The tier you asked for (`HOME`/`LOCKER`/`PICKUP`), or null if you left it open."},"delivery_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Delivery Type","description":"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":{"anyOf":[{"$ref":"#/components/schemas/RoutingRuleInfo"},{"type":"null"}],"description":"The rule that matched this parcel. Null when nothing matched (UNROUTABLE before any rule applied)."},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"When the shipment was submitted (UTC)."},"routed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Routed At","description":"When a carrier accepted it (UTC). Null if it never was."},"scan_status":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scan Status","description":"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":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Scanned At","description":"First warehouse scan — when the parcel arrived (UTC)."}},"type":"object","required":["shipment_id","customer_reference","state","final_carrier","final_carrier_tracking_id","weight_g","length_mm","width_mm","height_mm","requested_delivery_method","delivery_type","routing_rule","created_at","routed_at"],"title":"ShipmentStatusResponse","description":"Stored record of a shipment, including the parcel as we shipped it.\n\nDownload the label separately from `GET /api/v1/shipments/{id}/label`."},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"ValidationErrorDetail":{"properties":{"detail":{"type":"string","title":"Detail","description":"Human-readable explanation of the failure."},"field":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Field","description":"The input field at fault."},"missing":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Missing","description":"Set when a delivery tier requires contact details."}},"type":"object","required":["detail"],"title":"ValidationErrorDetail","description":"A rejected value, with the offending field named."}},"securitySchemes":{"CustomerApiKey":{"type":"http","description":"Customer API key: `Authorization: Bearer <key>`.","scheme":"bearer"}}},"tags":[{"name":"shipments","description":"Create, dispatch and look up shipments."},{"name":"health","description":"Unauthenticated probes. `/health` reports the process is up; `/readyz` also checks the database."}]}