@classytic/carrier-bd
v0.1.0
Published
Bangladesh carrier adapters for @classytic/carrier — RedX, Pathao, Steadfast. Implements the unified CarrierAdapter port with a shared resilient HTTP client (circuit breaker, retry, timeout). Optional zero-config address→area resolution via @classytic/bd-
Readme
@classytic/carrier-bd
Bangladesh carrier adapters for @classytic/carrier. Three drop-in last-mile couriers, one contract.
| Carrier | Strengths | COD | Webhooks | Auth | |---|---|---|---|---| | RedX | Nationwide coverage, detailed tracking timeline, charge calculator. | ✅ | ✅ | Static JWT | | Pathao | Fastest in Dhaka metro, competitive intra-city pricing. | ✅ | ✅ | OAuth password grant (auto refresh) | | Steadfast | Simplest API, flat-rate pricing, 48 h payouts. | ✅ | ✅ | API key + secret |
All three implement the unified CarrierAdapter port, so you pick carriers per shipment via declarative rules — the router never branches on vendor code.
Install
npm install @classytic/carrier @classytic/carrier-bd @classytic/primitivesQuick start
import { CarrierRouter, CarrierRegistry } from '@classytic/carrier';
import { PathaoAdapter, RedXAdapter, SteadfastAdapter } from '@classytic/carrier-bd';
const router = new CarrierRouter({
registry: new CarrierRegistry([
new PathaoAdapter({
// Pathao OAuth — adapter manages token lifecycle
credentials: {
clientId: process.env.PATHAO_CLIENT_ID!,
clientSecret: process.env.PATHAO_CLIENT_SECRET!,
username: process.env.PATHAO_USERNAME!,
password: process.env.PATHAO_PASSWORD!,
},
defaultStoreCode: Number(process.env.PATHAO_DEFAULT_STORE_ID),
}),
new RedXAdapter({
apiKey: process.env.REDX_API_KEY!,
defaultPickupStoreId: Number(process.env.REDX_STORE_ID),
}),
new SteadfastAdapter({
apiKey: process.env.STEADFAST_API_KEY!,
apiSecret: process.env.STEADFAST_SECRET!,
}),
]),
rules: [
{ carrier: 'pathao', priority: 30, match: { country: 'BD', postalPrefix: '12' } },
{ carrier: 'redx', priority: 20, match: { country: 'BD' } },
{ carrier: 'steadfast', priority: 10, match: { country: 'BD' } },
],
ranking: 'priority',
});
const quotes = await router.rateShop(shipment, ctx);
const { label } = await router.purchase(shipment, ctx);Pathao auth — OAuth password grant (built-in)
The Pathao Aladdin API uses an OAuth password grant — issue an access token with client_id + client_secret + username + password, refresh before expiry. The adapter handles this automatically:
new PathaoAdapter({
credentials: { clientId, clientSecret, username, password },
// OR the legacy path: pre-issued bearer token
// apiKey: '<bearer>',
});When credentials is set, the adapter:
- Issues a token on the first authenticated call.
- Coalesces concurrent first-call token requests into one issue-token POST.
- Refreshes 5 min before expiry using
grant_type=refresh_token. - Falls back to a fresh password grant if a refresh attempt fails or returns a malformed payload.
Live API helpers also use this auth pipeline:
const cities = await pathao.listCities();
const zones = await pathao.listZones(cityId);
const areas = await pathao.listAreas(zoneId);
const stores = await pathao.listStores();Address → carrier-area resolution
Most callers don't want to pass metadata.deliveryAreaId on every shipment — the host's domain typically only knows the destination postal code and city. Wire an AreaResolver and the adapter figures out the carrier-specific id.
Static resolver — @classytic/bd-areas
import { RedXAdapter } from '@classytic/carrier-bd';
import { createBdAreasResolver } from '@classytic/carrier-bd/bd-areas-resolver';
const adapter = new RedXAdapter({
apiKey: process.env.REDX_API_KEY!,
defaultPickupStoreId: 1,
areaResolver: createBdAreasResolver(),
});@classytic/bd-areas is an optional peer dep — the core @classytic/carrier-bd doesn't import it. The resolver lives at a separate subpath so hosts that don't need it never pay the dataset cost.
Live resolver — createPathaoLiveResolver(adapter)
For Pathao specifically, the bd-areas dataset doesn't ship Pathao IDs (Pathao zones are landmark-level — they don't fit the unified Area shape). The adapter ships a live resolver that calls Pathao's own API (cached) to resolve a destination address → Pathao zone id:
import { PathaoAdapter, createPathaoLiveResolver } from '@classytic/carrier-bd';
const pathao = new PathaoAdapter({ credentials });
pathao.areaResolver = createPathaoLiveResolver(pathao);
// or pass at constructionReuses the same adapter's token cache + circuit breaker. Cache TTL defaults to 1 h, configurable via cacheTtlMs. Returns null for non-Pathao carriers so it composes safely.
Composite resolver — createCompositeResolver([static, live])
Production setup: try the cheap static resolver first, fall back to the live resolver if the dataset doesn't have the answer:
import { createBdAreasResolver } from '@classytic/carrier-bd/bd-areas-resolver';
import { createCompositeResolver, createPathaoLiveResolver } from '@classytic/carrier-bd';
const pathao = new PathaoAdapter({ credentials });
const resolver = createCompositeResolver([
createBdAreasResolver(), // free, offline, RedX-good
createPathaoLiveResolver(pathao), // network fallback for Pathao
]);
pathao.areaResolver = resolver;Errors from individual resolvers are swallowed by default — pass { failFast: true } to bubble. Pass { onError } to log without breaking the chain.
Resolution precedence
For every quoteShipment / buyLabel call:
metadata.deliveryAreaIdon the shipment — caller knows the id, use it verbatim.areaResolver.resolve(destination, carrier)— if wired.- Neither →
ProviderValidationErrornaming what to supply.
BD-specific shipment metadata
const shipment: ShipmentInput = {
origin: { /* ... */ },
destination: { name: 'Alice', phone: '+8801...', line1: 'House 22', city: 'Dhaka', country: 'BD' },
packages: [{ weightGrams: 750 }],
codAmount: { amount: 1500, currency: 'BDT' },
metadata: {
// Universal
deliveryAreaId: 100,
deliveryAreaName: 'Gulshan', // RedX needs name + id
pickupStoreId: 1, // overrides constructor default
deliveryInstructions: 'Call before delivery',
merchantInvoiceId: 'ORD-2026-00042', // defaults to shipment.reference
// Pathao-specific (hierarchical)
deliveryCityId: 1, // Dhaka
deliveryZoneId: 10, // Gulshan (overrides resolver)
},
};The BdShipmentMetadata TypeScript type documents every key.
Pathao bulk-upload CSV exporter
For merchants who don't want full API integration — Pathao's web dashboard accepts a CSV at Orders → Bulk Upload. Build it server-side or in the browser:
import {
buildPathaoBulkCsv,
defaultPathaoCsvFilename,
type PathaoCsvRow,
} from '@classytic/carrier-bd';
const rows: PathaoCsvRow[] = orders.map((o) => ({
itemType: 'parcel',
recipientName: o.deliveryAddress.recipientName,
recipientPhone: o.deliveryAddress.recipientPhone,
recipientAddress: `${o.deliveryAddress.addressLine1}, ${o.deliveryAddress.addressLine2}`,
recipientCity: o.deliveryAddress.city,
recipientZone: o.deliveryAddress.zone,
amountToCollect: o.totals.grandTotal.amount,
itemQuantity: o.items.length,
itemWeight: 0.5,
}));
const { csv, totalRows, skipped } = buildPathaoBulkCsv(rows);
// csv = RFC-4180 string. Skipped rows include row index + reason.
// `failFast: true` throws on the first invalid row.Validation rules: name ≥ 2 chars, phone 10–15 digits, address ≥ 5 chars, weight 0.5–10 kg, city + zone non-empty. Phones are always quoted so Excel doesn't reformat them as scientific notation.
Capabilities matrix
| Feature | RedX | Pathao | Steadfast |
|---|---|---|---|
| rateShopping | ❌ | ❌ | ❌ (flat rate) |
| cod | ✅ | ✅ | ✅ |
| addressVerification | ✅ | ❌ | ❌ |
| voidLabel | ✅ | ❌ (contact CS) | ❌ (mark in-app) |
| multiPackage | ❌ | ❌ | ❌ |
| international | ❌ | ❌ | ❌ |
| webhooks | ✅ | ✅ | ✅ |
Adapters without a capability are skipped automatically by the @classytic/carrier router when the shipment requires it.
Resilient HTTP out of the box
Every adapter uses a built-in HTTP client with:
- Circuit breaker per provider — opens after N consecutive failures, half-opens after a cooldown.
- Exponential backoff retry for 408 / 429 / 5xx.
- Per-request timeout via
AbortController. - Injectable
fetch/sleep/nowMsfor deterministic tests.
new RedXAdapter({
apiKey: '...',
http: { timeoutMs: 10_000, maxRetries: 2, failureThreshold: 3 },
});Webhooks
Each adapter exposes ingestWebhook(payload, headers) which normalises vendor payloads to NormalizedWebhookEvent[]:
app.post('/webhooks/:carrier', async (req, reply) => {
const adapter = registry.get(req.params.carrier);
const events = adapter.ingestWebhook(req.body, req.headers);
for (const evt of events) await yourEventBus.publish('carrier:tracking.updated', evt);
reply.send({ ok: true });
});Signature verification is intentionally host-owned — each carrier's HMAC rules differ and are often account-specific.
Tests
Three tiers per testing-infrastructure.md. Single vitest.config.ts with projects: [unit, integration, e2e].
| Command | What runs |
|---|---|
| npm test | unit + integration (default CI path) — pure mocked-fetch tests |
| npm run test:unit | adapter behaviour, OAuth lifecycle, resolvers, CSV exporter |
| npm run test:integration | adapter ↔ resolver wiring + cross-carrier contract suite |
| npm run test:e2e | env-gated live RedX + Pathao sandbox — exercises the full happy path |
Sandbox creds live in .env (gitignored). dotenv/config is auto-loaded by vitest:
PATHAO_API_URL=https://courier-api-sandbox.pathao.com
PATHAO_CLIENT_ID=... PATHAO_CLIENT_SECRET=...
[email protected] PATHAO_PASSWORD=lovePathao
REDX_API_URL=https://sandbox.redx.com.bd/v1.0.0-beta REDX_API_KEY=<jwt>The e2e suites auto-discover live cities, zones, areas, and pickup stores from each carrier's API.
Extending
Write additional BD carriers (eCourier, Paperfly, Sundarban, etc.) by implementing CarrierAdapter from @classytic/carrier. The HTTP client, status-mapping helpers, and address utilities in this package are exported for reuse:
import {
createHttpClient,
inferFromMessage,
flattenAddress,
totalWeightGrams,
type ProviderStatusMap,
} from '@classytic/carrier-bd';License
MIT
