Logistics API & SDK
Track shipments and vehicles, keep inventory you can explain, and bridge systems that were never designed to talk to each other. A REST API plus a typed, dependency-free TypeScript SDK.
Overview
The Logistics API is a REST API returning JSON. Every endpoint is namespaced under /v1. The SDK wraps it so you rarely touch HTTP directly.
| Piece | What it is |
|---|---|
| REST API | JSON over HTTPS, Bearer authentication, one error envelope |
| SDK | @logistics/sdk — typed resources, retries, idempotency |
| Webhooks | HMAC-signed outbound events with a replay guard |
Every response uses the same envelope, which the SDK unwraps for you:
{
"success": true,
"data": { "...": "payload" }
}Errors are equally consistent, which is what lets clients branch on a code rather than parse a message:
{
"success": false,
"error": "Folder not found",
"code": "not_found",
"status": 404
}Core concepts
There is one canonical model. Adapters translate vendor payloads into it, and nothing downstream ever sees a vendor-specific field name. That is the whole point: five systems with five shapes become one shape.
| Concept | Meaning |
|---|---|
| Organization | The tenant root. Everything is scoped to it; cross-tenant reads 404. |
| Location | A depot, warehouse, customer, port or hub, with optional coordinates and a delivery window. |
| Sku | A canonical product, plus every external identifier it is known by. |
| StockMovement | An immutable change to stock. Quantities are never overwritten. |
| InventoryLevel | A derived snapshot per SKU and location. The ledger remains the truth. |
| Shipment / ShipmentLeg | A consignment and its journey segments. Per-leg is what makes ETAs honest. |
| Vehicle / PositionPing | A tracked asset and normalised telemetry from any source. |
| SyncConnector / SyncEvent | A link to an external system and the idempotent change ledger. |
Authentication
Send a Bearer API key on every request. Keys are shown once at creation; only a hash is stored server-side.
Authorization: Bearer lk_live_xxxxxxxxxxxxxxxxxxxxThe public tracking endpoint is the one exception: a consignee can check a shipment by reference without any credentials, and it deliberately exposes only what a consignee needs.
SDK quickstart
npm install @logistics/sdkimport { Logistics } from '@logistics/sdk';
const client = new Logistics({
baseUrl: 'https://api.example.com',
apiKey: process.env.LOGISTICS_API_KEY,
});
// Everything hangs off the client, grouped by resource.
const shipment = await client.shipments.get('SHP-12345');
const eta = await client.shipments.eta('SHP-12345');
const vehicles = await client.vehicles.list({ bbox: [3.1, 6.4, 3.5, 6.7] });| Resource | Covers |
|---|---|
client.shipments | Create, read, update, cancel, timeline, ETA, proof of delivery |
client.tracking | Public consignee lookup with no authentication |
client.vehicles | Live list, historical tracks, geofences, routes, live stream |
client.geofences | Zone definitions |
client.inventory | Levels, movements, cycle counts, reconciliation, reorder signals |
client.sync | Connectors, health, backfill, replay, mapping dry-run |
client.matching | Resolve and merge entities across systems |
Shipments & tracking
const shipment = await client.shipments.create({
reference: 'SHP-12345',
originId: 'loc_lagos',
destinationId: 'loc_abuja',
carrierId: 'dhl',
serviceLevel: 'express',
});
// Status history
const timeline = await client.shipments.timeline(shipment.id);
// Move it along
await client.shipments.update(shipment.id, { status: 'in_transit' });Attach proof of delivery once it lands. This is the artefact that settles a dispute, so it carries a timestamp and coordinates alongside the photo and signature.
await client.shipments.submitPod(shipment.id, {
deliveredAt: new Date().toISOString(),
point: { lat: 9.0765, lng: 7.3986 },
receivedBy: 'A. Bello',
photoUrls: ['https://cdn.example.com/pod/123.jpg'],
note: 'Left with security at gate 2',
});Public tracking needs no key, and returns only consignee-safe fields:
const status = await client.tracking.get('SHP-12345');
// { reference, status, origin, destination, estimatedAt, confidence }ETAs
An ETA is always a window with a confidence score, never a single precise timestamp. A confident-looking wrong time is worse than an honest range, and customers forgive the range far more readily.
{
"eta": "2026-09-19T14:00:00Z",
"window": { "earliest": "2026-09-19T12:00:00Z", "latest": "2026-09-19T18:00:00Z" },
"confidence": 0.82,
"legs": [
{ "sequence": 1, "mode": "road", "eta": "...", "confidence": 0.79, "durationMinutes": 412 }
],
"recalculated": true,
"computedAt": "2026-09-18T08:00:00Z"
}How the numbers are produced, and why you can trust them:
| Aspect | Behaviour |
|---|---|
| Per leg | Each leg is estimated with its own mode and speed, then chained. One end-to-end guess hides which leg slipped. |
| Traffic-aware | Road legs use the Google Distance Matrix API with live traffic when a key is configured, and a distance model otherwise. |
| Confidence | Shortens with distance and compounds down a chain of hops, so a five-leg sea journey reads as genuinely uncertain. |
| Window | Widens as confidence falls. Low confidence looks uncertain rather than deceptively precise. |
| Recalculated | Recomputed on demand and after position updates, not once at dispatch. |
Fleet & telemetry
Telemetry arrives in whatever shape your source uses and is normalised into one PositionPing. GPS hardware, a driver phone, an OBD dongle and a carrier API all land in the same place, so you are not locked to a device vendor.
await fetch('/v1/positions', {
method: 'POST',
headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' },
body: JSON.stringify({
vehicleId: 'veh_1',
point: { lat: 6.5244, lng: 3.3792 },
speedKph: 48,
source: 'gps_hardware',
}),
});Query vehicles for a map viewport, and stream live positions with one API:
// Snapshot inside a bounding box (minLng,minLat,maxLng,maxLat)
const inView = await client.vehicles.list({ bbox: [3.1, 6.4, 3.5, 6.7] });
// Live stream — polls or uses a socket behind one interface
const stop = client.vehicles.subscribe(
(ping) => updateMarker(ping.vehicleId, ping.point),
{ vehicleIds: ['veh_1'], intervalMs: 5000, onError: console.error },
);
// later
stop();Geofences & routes
Geofences turn raw positions into events worth acting on: arrival, departure and deviation without the driver having to press anything.
await client.geofences.create({
name: 'Lagos Depot',
polygon: [[3.35, 6.45], [3.40, 6.45], [3.40, 6.50], [3.35, 6.50]], // [lng, lat]
triggers: ['enter', 'exit'],
});
// Planned versus actually-travelled polyline for a shipment
const route = await client.vehicles.route('shipment_id');
// { planned: [...], actual: [...], maxDeviationM }Inventory
Stock is an append-only ledger, not an overwritten number. You always keep the history, so “why does the count say 47?” has an answer, and a retried write cannot double-count.
await client.inventory.recordMovement({
skuId: 'sku_9',
locationId: 'wh_lagos',
delta: -3, // signed: negative is outbound
uom: 'each',
reason: 'picked',
refType: 'order',
refId: 'ORD-778',
});
// Current levels, including the derived qtyAvailable
const levels = await client.inventory.list({ locationId: 'wh_lagos' });Cycle counts reconcile counted against expected. Small variances adjust automatically; large ones are flagged for a human, because silently fixing a big discrepancy would hide the problem causing it.
const result = await client.inventory.cycleCount({
locationId: 'wh_lagos',
counts: [{ skuId: 'sku_9', countedQty: 44 }],
autoAdjustWithinPct: 2, // beyond this, flag instead of adjust
});
// { adjusted: [...], flagged: [{ skuId, expected, counted, variance, variancePct }] }Reconciliation compares system against ledger, so drift is visible before it becomes a complaint:
const recon = await client.inventory.reconciliation({ locationId: 'wh_lagos' });
// [{ skuId, locationId, systemQty, ledgerQty, variance, lastCountedAt }]
const signals = await client.inventory.reorderSignals();
// [{ skuId, qtyAvailable, reorderPoint, suggestedOrderQty, daysOfCover }]Cross-system sync
This is the part that bridges systems never designed to talk. Three ideas carry it: idempotency so a retry is harmless, visibility so silence is never mistaken for success, and replay so a fixed mapping can be re-run without duplicating data.
const conn = await client.sync.createConnector({
name: 'Shopify Store',
type: 'shopify',
direction: 'inbound',
});
// The single most useful number: how far behind is it?
const health = await client.sync.health(conn.id);
// { status, lastSyncAt, lagSeconds, backlog, errorRate, stale }Test a field mapping against real payloads before it goes anywhere near production. Nothing is written in a dry run.
const preview = await client.sync.dryRun({
mapping: { 'line_items.0.sku': 'internalCode', 'line_items.0.quantity': 'qty' },
sample: [shopifyOrder],
});
// { mapped: [...], unmapped: ['...'], errors: [...] }Re-run a date range after fixing a mapping, and replay anything that dead-lettered:
await client.sync.backfill(conn.id, { from: '2026-09-01', to: '2026-09-18', dryRun: true });
const dead = await client.sync.deadLetters(conn.id);
await client.sync.replay(dead[0].id);| Property | Why it matters |
|---|---|
| Idempotent events | Every event carries a key, so at-least-once delivery cannot double-apply. |
| Ordered ledger | Every applied change is recorded, so you can reconstruct what happened and when. |
| Health first-class | Lag, backlog, error rate and staleness are queryable, not buried in a log. |
| Declarative mapping | Field maps are data. Changing one is a row edit, not a deploy. |
Entity matching
The same physical product is often a different identifier in every system. Matching resolves an external identifier to a canonical entity, and returns a confidence band rather than a silent guess.
const match = await client.matching.resolve({
entityType: 'sku',
identifiers: [{ type: 'sku', value: 'SHOP-9912', sourceSystem: 'shopify' }],
});
// { confidence, score, match, candidates, needsReview }When two records tie, the API returns the candidates and sets needsReview. Deciding that automatically would be how duplicate data spreads.
| Confidence | Meaning |
|---|---|
exact | Several identifiers agree on one entity |
high | A single strong identifier matched |
low | Ambiguous: candidates returned, review required |
none | Nothing matched |
Webhooks
Events are signed with an HMAC over the raw body. Verify against the raw bytes, never a re-serialised object, or the signature will not match.
import { verifyWebhookAsync } from '@logistics/sdk/webhooks';
app.post('/webhooks/logistics', async (req, res) => {
try {
const event = await verifyWebhookAsync({
payload: req.rawBody, // raw string or Uint8Array
signatureHeader: req.headers['x-signature'],
secret: process.env.LOGISTICS_WEBHOOK_SECRET,
});
console.log(event.type, event.data);
res.sendStatus(200);
} catch {
res.sendStatus(400); // bad signature or stale timestamp
}
});Or route them with the built-in dispatcher:
const client = new Logistics({ apiKey, webhookSecret: 'whsec_...' });
client.webhooks.on('shipment.eta_changed', (e) => notifyCustomer(e.data));
client.webhooks.on('vehicle.geofence_enter', (e) => markArrived(e.data));
client.webhooks.handle(req.headers['x-signature'], req.rawBody);| Event | Fires when |
|---|---|
shipment.created | A shipment is created |
shipment.status_changed | Its status moves |
shipment.eta_changed | The estimate drifts beyond a threshold |
shipment.delivered | Proof of delivery is recorded |
vehicle.geofence_enter | A vehicle enters a zone |
vehicle.geofence_exit | A vehicle leaves a zone |
inventory.low_stock | Stock reaches its reorder point |
sync.connector_degraded | A connector goes stale or starts failing |
Verification is constant-time and includes a replay guard (default five minutes).
Errors & reliability
Every failure is a typed LogisticsError with a code and an HTTP status. Branch on those, never on message text.
import { LogisticsError } from '@logistics/sdk';
try {
await client.shipments.get('missing');
} catch (e) {
if (e instanceof LogisticsError) {
if (e.isNotFound) { /* 404 */ }
if (e.isRateLimited) { /* 429, back off */ }
console.log(e.code, e.status, e.retryable);
}
}| Code | Status | Meaning |
|---|---|---|
validation | 400 / 422 | The request was malformed or failed validation |
auth | 401 / 403 | Missing, invalid or inactive API key |
not_found | 404 | No such resource, or not in your organization |
conflict | 409 | The write conflicts with current state |
rate_limited | 429 | Slow down; honour Retry-After |
timeout | — | The request exceeded the client timeout |
network | — | The request never completed |
The SDK handles the tedious parts of reliability for you:
| Behaviour | Detail |
|---|---|
| Retries | On 408, 425, 429, 5xx and network errors, with full-jitter exponential backoff. |
| Retry-After | Honoured when the server sends it. |
| Idempotency | Every write sends an Idempotency-Key, so a retry cannot double-apply. |
| Fail fast | 400, 401, 403, 404 and 409 are not retried. |
| Timeouts | Thirty seconds per attempt by default. |
Endpoint reference
Shipments
Fleet
Geofences
Inventory
Sync
Matching & reference
Ready to build? Start from the Logistics product page, or read the other products.