Client code
Minimal, dependency-light clients you can copy into your codebase and adapt
(naming, logging, persistence). Both read the base URL and API key from
EPARCEL_BASE_URL and EPARCEL_API_KEY, use a 60-second timeout, and treat
5xx and network errors as retryable with the same idempotency key.
Uses httpx.
"""Minimal E-Parcel Dispatcher client."""from __future__ import annotations
import base64import osimport timefrom 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.textIn a web request or job worker, enqueue the retries rather than sleeping inline.
Node 18+ (built-in fetch), no dependencies.
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<ShipmentResponse> { return this.request("POST", "/api/v1/shipments", shipment, { "Idempotency-Key": idempotencyKey }); }
async createBatch(shipments: ShipmentRequest[]): Promise<ShipmentResponse[]> { const body = await this.request<{ shipments: ShipmentResponse[] }>( "POST", "/api/v1/shipments/batch", { shipments }); return body.shipments; }
getShipment(shipmentId: number): Promise<Record<string, unknown>> { return this.request("GET", `/api/v1/shipments/${shipmentId}`); }
async getLabel(shipmentId: number): Promise<Buffer> { 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<T>(method: string, path: string, body?: unknown, headers: Record<string, string> = {}): Promise<T> { 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<string, string> = {}): Promise<Response> { 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<unknown> { const text = await res.text(); try { return JSON.parse(text); } catch { return text; }}A network error or AbortSignal timeout rejects with a TypeError or
TimeoutError, not an EParcelError. Treat it as “outcome unknown” and retry
with the same idempotency key.
Generating types from the schema
Section titled “Generating types from the schema”The OpenAPI document is published by every environment at /openapi.json.
To generate TypeScript types from it instead of writing them by hand:
npx openapi-typescript https://api-test.e-parcel.fi/openapi.json -o eparcel.d.ts