@lacspace/courier
v1.1.0
Published
Courier / last-mile delivery toolkit — canonical delivery state machine, tracking-number validation + carrier detection (UPS/FedEx/USPS/DHL), tracking-event timelines, business-day ETA/SLA, tracking-URL builder, Pathao (Nepal) adapter, and inbound webhook
Maintainers
Readme
@lacspace/courier
Courier / last-mile delivery toolkit — a canonical delivery state machine, a Pathao (Nepal) adapter, and inbound webhook verification + status normalization. Over global fetch and Web Crypto.
Stop clicking Confirmed → Pickup → Transit → Delivered by hand. This is the courier layer for a multi-vendor shop: one canonical delivery state machine, a Pathao "Aladdin" Merchant API v1 adapter (token auto-refresh, order creation, price/city/zone/area lookups), and inbound webhooks — verify the shared-secret / HMAC signature and normalize any carrier event into your own status vocabulary. Zero dependencies, isomorphic, fully typed.
- 🚦 State machine — one
DeliveryStatusvocabulary + guardedtransition()that refuses illegal jumps and marks terminal states - 🔎 Tracking numbers —
detectCarrier/isValidTrackingNumberguess the carrier (UPS/FedEx/USPS/DHL + regional) and verify check digits - 🧭 Timelines & ETA —
summarizeTimeline(derived status + delivered + on-time) and business-dayestimateDelivery/evaluateSla - 🔗 Tracking URLs —
trackingUrl(carrier, tn)builds the public track page for any supported carrier - 🇳🇵 Pathao adapter —
issueToken(cached + auto-refresh),createOrder,priceCalculation,cities/zones/areas - 📡 Inbound webhooks —
verifyWebhookSignature(HMAC-SHA256, timing-safe) +parsePathaoWebhook/normalizePathaoStatus - 🔌 Adapter-shaped — code against
CourierAdapter; swap or add carriers without touching your order flow - ⚡ Isomorphic — Node 18+, edge runtimes & browsers · global
fetch+ Web Crypto only · 📦 ESM + CJS · zero deps
New in 1.1.0 — all additive & backward compatible: tracking-number validation + carrier detection (with UPS/USPS/DHL/S10 check digits), a generic carrier-status normalizer into the same
DeliveryStatusvocabulary, a tracking-event timeline model (summarizeTimeline), a tracking-URL builder (trackingUrl), and business-day ETA / SLA maths (estimateDelivery,evaluateSla). Every one is pure — no network, no new deps.
Install
npm install @lacspace/courier # or pnpm add / yarn add / bun addThe delivery state machine
import { transition, canTransition, isTerminal } from "@lacspace/courier";
const order = { id: "A1", status: "confirmed" as const };
const next = transition(order, "picked_up"); // → new object, status "picked_up"
canTransition("pending", "delivered"); // false — can't skip the chain
isTerminal("delivered"); // true
transition({ status: "delivered" as const }, "returned");
// throws CourierError { code: "illegal_transition" }transition() never mutates — it returns a shallow copy with the new status. The allowed moves live in DELIVERY_TRANSITIONS.
Pathao adapter
import { createPathaoAdapter, PATHAO_SANDBOX_BASE_URL } from "@lacspace/courier";
const pathao = createPathaoAdapter({
clientId: process.env.PATHAO_CLIENT_ID!,
clientSecret: process.env.PATHAO_CLIENT_SECRET!,
username: process.env.PATHAO_USERNAME!,
password: process.env.PATHAO_PASSWORD!,
storeId: Number(process.env.PATHAO_STORE_ID),
// baseUrl defaults to production api-hermes.pathao.com;
// use PATHAO_SANDBOX_BASE_URL for the courier-api-sandbox host.
});
const shipment = await pathao.createOrder({
recipientName: "Ram Thapa",
recipientPhone: "9800000000",
recipientAddress: "Baneshwor, Kathmandu",
cityId: 1, zoneId: 2, areaId: 3,
amountToCollect: 1500, // COD; 0 for prepaid
itemQuantity: 1,
itemWeight: 0.5, // kg
description: "T-shirt",
merchantOrderId: "SHOP-42",
});
// shipment.trackingId === Pathao consignment_id, status "confirmed"The token is issued lazily, cached, and auto-refreshed a minute before it expires. priceCalculation(), cities(), zones(cityId) and areas(zoneId) are also exposed.
Note: Pathao has no clean public track-by-consignment endpoint in v1.
track()throwsCourierError { code: "unsupported" }on purpose — Pathao reports status via webhooks (below).
Inbound webhooks
import {
verifyPathaoWebhook,
verifyWebhookSignature,
parsePathaoWebhook,
PATHAO_WEBHOOK_ACK_HEADER,
transition,
} from "@lacspace/courier";
// In your webhook route (raw body string in hand):
if (!verifyPathaoWebhook({ headerSecret: req.header("X-PATHAO-Signature"), expectedSecret: SECRET })) {
return res.status(401).end();
}
const evt = parsePathaoWebhook(rawBody); // { event, status, consignmentId, merchantOrderId, raw }
const order = await db.orders.findByConsignment(evt.consignmentId);
await db.orders.save(transition(order, evt.status)); // guarded advance
// Pathao expects a 202 that echoes the integration secret back:
res.setHeader(PATHAO_WEBHOOK_ACK_HEADER, SECRET).status(202).end();For carriers that sign the body (rather than a shared header), use the generic HMAC verifier:
const ok = await verifyWebhookSignature(rawBody, signatureHeader, secret); // HMAC-SHA256 hex, timing-safeTracking numbers, timelines & ETA (new in 1.1.0)
import {
detectCarrier, isValidTrackingNumber, trackingUrl,
normalizeTrackingStatus, summarizeTimeline, estimateDelivery, evaluateSla,
} from "@lacspace/courier";
// 1. Validate a tracking number + guess the carrier (check digits verified)
detectCarrier("1Z12345E0205271688");
// { carrier: "ups", valid: true, candidates: [{ carrier: "ups", checkDigitValid: true }], ... }
isValidTrackingNumber("1234567891", "dhl"); // true — DHL air-waybill mod-7
// 2. Normalize any carrier status string/code into a canonical DeliveryStatus
normalizeTrackingStatus("Out for delivery"); // "out_for_delivery"
normalizeTrackingStatus("DL"); // "delivered" (FedEx scan code)
// 3. Roll unordered events into a summary (derived status, delivered, on-time)
const s = summarizeTimeline({
events: [
{ status: "confirmed", timestamp: "2026-01-01T10:00:00Z" },
{ status: "delivered", timestamp: "2026-01-03T14:00:00Z" },
],
promisedBy: "2026-01-04T00:00:00Z",
});
s.currentStatus; // "delivered" · s.isDelivered → true · s.onTime → true
// 4. Public tracking URL
trackingUrl("fedex", "123456789012"); // https://www.fedex.com/fedextrack/?trknbr=...
// 5. Business-day ETA (skips weekends + holidays) + on-time / late SLA
const eta = estimateDelivery({ shipDate: "2026-01-08", transitDays: 3, holidays: ["2026-01-12"] });
evaluateSla({ due: eta, deliveredAt: "2026-01-15" }); // { onTime: false, late: true, ... }Everything above is pure — no network calls — and reuses the package's canonical DeliveryStatus. The now clock is injectable on summarizeTimeline and evaluateSla for deterministic tests.
API
| Export | Description |
| --- | --- |
| DeliveryStatus | pending \| confirmed \| picked_up \| in_transit \| out_for_delivery \| delivered \| returned \| cancelled \| failed \| on_hold |
| DELIVERY_TRANSITIONS | Record<DeliveryStatus, DeliveryStatus[]> — allowed forward moves |
| canTransition(from, to) / isTerminal(s) | state-machine guards |
| transition(order, to) | new object with updated status; throws on illegal move |
| CourierError | Error with optional code / status |
| CourierAdapter / CourierShipment / CreateOrderInput | carrier-agnostic contract |
| createPathaoAdapter(config) | Pathao adapter + issueToken / priceCalculation / cities / zones / areas |
| PATHAO_PROD_BASE_URL / PATHAO_SANDBOX_BASE_URL | Pathao hosts |
| verifyWebhookSignature(payload, signature, secret) | HMAC-SHA256 hex, timing-safe |
| verifyPathaoWebhook({ headerSecret, expectedSecret }) | timing-safe shared-secret compare |
| parsePathaoWebhook(body) / normalizePathaoStatus(event) | event → canonical status |
| PATHAO_STATUS_MAP / PATHAO_WEBHOOK_ACK_HEADER | Pathao event map + ack header name |
| Carrier | ups \| fedex \| usps \| dhl \| canada_post \| royal_mail \| australia_post \| pathao |
| detectCarrier(tn) | { input, normalized, valid, carrier?, candidates } — detect + verify check digits |
| isValidTrackingNumber(tn, carrier?) | true if the number is valid (optionally for a given carrier) |
| trackingUrl(carrier, tn) / CARRIER_TRACKING_URLS | public tracking URL builder + templates |
| normalizeTrackingStatus(raw) / FEDEX_STATUS_MAP | carrier status string/code → DeliveryStatus |
| TrackingEvent / TrackingTimeline / TimelineSummary | tracking-event model |
| summarizeTimeline(timeline, { now? }) / sortTrackingEvents(events) | derived status, delivered, on-time |
| estimateDelivery({ shipDate, transitDays, weekend?, holidays? }) | business-day ETA |
| addBusinessDays / businessDaysBetween / isBusinessDay / DEFAULT_WEEKEND | business-day helpers |
| evaluateSla({ due, deliveredAt?, now? }) | { onTime, late, delivered, deltaMs } |
All crypto uses Web Crypto (globalThis.crypto.subtle) — never hand-rolled. timingSafeEqual is exported too.
Licensing
This package is free under the Lacspace Free Licence — permissive freedoms. Use it in personal and commercial projects at no cost; just keep the notice.
Not every Lacspace package is free. We also offer Commercial (paid), Client-specific, and Private (proprietary) packages under separate terms. See the full Lacspace Licence Centre.
The Lacspace Developer Platform
@lacspace/courier is part of 80+ zero-dependency, isomorphic TypeScript packages. Explore the ecosystem:
- 🗂️ All packages — https://developer.lacspace.com/packages
- 🧭 Developer handbook — https://developer.lacspace.com/handbook
- 🧪 Live playground — https://developer.lacspace.com/playground
- 🖥️ Finished app templates — https://templates.lacspace.com
- 🚀 Scaffold a full app —
npm create lacspace-app@latest
Free under the Lacspace Free Licence — a permissive, free-to-use licence.
