Logistics SaaS Tracking & Carrier Integration

The first version of shipment tracking always treats a tracking number as a string in a table that users refresh by hand. It works for one carrier in one country, and falls apart the moment a second carrier shows up with a different status vocabulary, a different auth scheme, and an XML response where the first one returned JSON. Real logistics SaaS tracking is the work of making four carriers' worth of inconsistency look like one calm, real-time map.
The architecture that holds: one unified shipment schema, a carrier adapter per provider, webhooks instead of polling, and public tracking links that can't be enumerated. Get those right and you stream live waypoints to a customer's map without exhausting an API quota or leaking your shipment list. Here are the seven rules.

Rule 1: One Unified, Polymorphic Schema
Don't build a table per carrier. Route every shipment into one normalized model — a shipment row plus timestamped waypoints:
1CREATE TABLE shipments (
2 id BIGSERIAL PRIMARY KEY,
3 tenant_id UUID NOT NULL,
4 shipment_uuid UUID DEFAULT gen_random_uuid() UNIQUE,
5 tracking_number VARCHAR(100) NOT NULL,
6 carrier_code VARCHAR(30) NOT NULL CHECK (carrier_code IN ('FEDEX','UPS','DHL','USPS')),
7 status VARCHAR(50) NOT NULL DEFAULT 'MANIFEST', -- IN_TRANSIT, DELIVERED, EXCEPTION...
8 estimated_delivery_at TIMESTAMPTZ,
9 created_at TIMESTAMPTZ DEFAULT NOW()
10);
11
12CREATE TABLE shipment_waypoints (
13 id BIGSERIAL PRIMARY KEY,
14 shipment_id BIGINT REFERENCES shipments(id) ON DELETE CASCADE,
15 latitude DECIMAL(9, 6),
16 longitude DECIMAL(9, 6),
17 location_description VARCHAR(255),
18 occurred_at TIMESTAMPTZ NOT NULL
19);
20
21CREATE INDEX idx_shipments_tracking ON shipments (tenant_id, carrier_code, tracking_number);
22CREATE INDEX idx_waypoints_time ON shipment_waypoints (shipment_id, occurred_at DESC);A new carrier is a value in carrier_code and an adapter — never a schema migration.
Rule 2: Tame the Carrier Zoo With Adapters
Every provider has its own paradigm: clean REST here, verbose XML there, a different auth scheme on each. Isolate that with the adapter (strategy) pattern — one internal shape, one adapter per carrier:
1// src/logistics/carrier-adapter.interface.ts
2export interface UnifiedTrackingPayload {
3 status: 'MANIFEST' | 'TRANSIT' | 'OUT_FOR_DELIVERY' | 'DELIVERED' | 'EXCEPTION';
4 latitude?: number;
5 longitude?: number;
6 description: string;
7 occurredAt: Date;
8}
9
10export interface CarrierAdapter {
11 // Each carrier maps its raw response into the unified shape.
12 parse(raw: unknown): UnifiedTrackingPayload;
13}Your application only ever sees UnifiedTrackingPayload. When a carrier changes its format — and they do — you edit one adapter, not your whole pipeline.

Rule 3: Webhooks Over Polling
Polling thousands of open shipments on a loop is how you earn a 429 and a throttled API key — and it's stale between polls anyway. Push instead of pull. Aggregators like EasyPost or Shippo give you one webhook stream across every carrier; verify the signature, queue the event, return 200 immediately:
1// src/logistics/tracking-webhook.controller.ts
2import { Controller, Post, Body, HttpCode, HttpStatus, UseGuards } from '@nestjs/common';
3import { InjectQueue } from '@nestjs/bullmq';
4import { Queue } from 'bullmq';
5import { CarrierSignatureGuard } from './carrier-signature.guard';
6
7@Controller('webhooks/shipping')
8export class TrackingWebhookController {
9 constructor(@InjectQueue('carrier-ingestion-queue') private readonly queue: Queue) {}
10
11 @Post('update')
12 @UseGuards(CarrierSignatureGuard) // HMAC-SHA256 over the raw body + provider secret
13 @HttpCode(HttpStatus.OK)
14 async update(@Body() payload: unknown) {
15 await this.queue.add('normalize-status', { payload });
16 return { received: true };
17 }
18}The worker runs the adapter and updates the tables — the same ack-fast, process-async discipline behind any reliable receiver (job-queue comparison). Treat each carrier as a flaky upstream with timeouts and backoff, the standard third-party API reliability patterns.
Rule 4: Don't Build Route Optimization From Scratch
Multi-stop fleet routing is the traveling-salesperson problem, and you do not want to hand-code it. Offload ordering to a dedicated engine — the open-source VROOM or a hosted Routes API — and spend your time on the product, not on NP-hard math.
Rule 5: Render Routes as GeoJSON
For the map, group a shipment's waypoints into a single GeoJSON LineString. One feature draws a continuous route vector, which client map libraries render smoothly instead of choking on hundreds of individual points.

Rule 6: Public Tracking Links That Don't Leak
Customers expect to track a parcel without an account, and that's where teams accidentally ship a BOLA vulnerability. A URL like /track/5420 is an open invitation to enumerate sequential IDs and scrape every customer's shipping address. Never expose internal integer keys — identify public shipments by a random UUID plus a signed token:
1import * as crypto from 'crypto';
2
3export function trackingUrl(shipmentUuid: string): string {
4 const sig = crypto
5 .createHmac('sha256', process.env.TRACKING_LINK_SECRET!)
6 .update(shipmentUuid)
7 .digest('hex')
8 .slice(0, 16);
9 return `https://yourlogisticsapp.com/track/${shipmentUuid}?t=${sig}`;
10}The link works only if you were handed it; a guessed UUID with the wrong signature gets nothing.
Rule 7: Rate-Shop Carriers Concurrently
To show the cheapest option across carriers, call their rate endpoints with Promise.all, not sequentially, and put a hard ~2s timeout on each so one slow carrier can't stall checkout:
1const quotes = await Promise.all(
2 carriers.map((c) =>
3 withTimeout(c.getRate(parcel), 2000).catch(() => null), // skip slow/failed carriers
4 ),
5);
6return quotes.filter(Boolean).sort((a, b) => a!.price - b!.price);Logistics SaaS Tracking: Ingestion Models Compared
| Direct polling | Direct webhooks | Consolidated API | |
|---|---|---|---|
| Latency | Hourly, stale | Real-time | Near real-time |
| Rate-limit risk | High (429s) | Low | Minimal |
| Build cost | 4+ integrations | Per-carrier handlers | One wrapper |
| Maintenance | High (schema drift) | Medium | Low (vendor absorbs it) |
Model the shipment once, adapter the carriers, receive instead of poll, and sign your public links. Do that and a logistics platform stops being four brittle integrations duct-taped together and becomes the live map a fleet manager actually trusts — while the carrier zoo stays safely behind your adapters, where it belongs.
Frequently Asked Questions
Use one unified, polymorphic schema, not a table per carrier. A shipments table holds the tracking number, a carrier_code, a normalized status, and the estimated delivery; a waypoints table holds the timestamped location events. Index by tenant and tracking number, and by shipment + time. Adding FedEx vs UPS vs DHL becomes a value in a column and an adapter, not a new set of tables.
Webhooks, ideally through a consolidation API like EasyPost or Shippo. Polling thousands of open shipments on a schedule burns your rate limits and triggers 429s, and it's always stale between polls. A consolidation layer gives you one webhook stream across all carriers; you verify the signature, queue the event, and return 200 fast. Keep polling only as a fallback for carriers without webhooks.
Use the adapter (strategy) pattern. Define one internal UnifiedTrackingPayload interface, then write a small adapter per carrier that maps its REST or XML response into that shape before anything touches your database. Your application logic only ever sees the normalized object, so a carrier changing its format means editing one adapter, not hunting through your codebase.
Never expose sequential integer IDs in the URL (/track/5420) — attackers enumerate them to scrape your shipments, a classic BOLA flaw. Identify public shipments with a random UUID, and ideally a signed token, so a link only works if you were given it. The unguessable identifier is what lets customers track without logging in while keeping everyone else out.
Call each carrier's rate endpoint concurrently with Promise.all rather than sequentially, and put a tight timeout (around 2 seconds) on each so one slow carrier can't stall your checkout. Return whatever responded in time, cheapest first. A consolidation API simplifies this further by exposing rate shopping across carriers behind a single call.
