Skip to content

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.

PieceWhat it is
REST APIJSON over HTTPS, Bearer authentication, one error envelope
SDK@logistics/sdk — typed resources, retries, idempotency
WebhooksHMAC-signed outbound events with a replay guard

Every response uses the same envelope, which the SDK unwraps for you:

envelope
{
  "success": true,
  "data": { "...": "payload" }
}

Errors are equally consistent, which is what lets clients branch on a code rather than parse a message:

error
{
  "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.

ConceptMeaning
OrganizationThe tenant root. Everything is scoped to it; cross-tenant reads 404.
LocationA depot, warehouse, customer, port or hub, with optional coordinates and a delivery window.
SkuA canonical product, plus every external identifier it is known by.
StockMovementAn immutable change to stock. Quantities are never overwritten.
InventoryLevelA derived snapshot per SKU and location. The ledger remains the truth.
Shipment / ShipmentLegA consignment and its journey segments. Per-leg is what makes ETAs honest.
Vehicle / PositionPingA tracked asset and normalised telemetry from any source.
SyncConnector / SyncEventA 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
Authorization: Bearer lk_live_xxxxxxxxxxxxxxxxxxxx

The 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

bash
npm install @logistics/sdk
typescript
import { 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] });
ResourceCovers
client.shipmentsCreate, read, update, cancel, timeline, ETA, proof of delivery
client.trackingPublic consignee lookup with no authentication
client.vehiclesLive list, historical tracks, geofences, routes, live stream
client.geofencesZone definitions
client.inventoryLevels, movements, cycle counts, reconciliation, reorder signals
client.syncConnectors, health, backfill, replay, mapping dry-run
client.matchingResolve and merge entities across systems

Shipments & tracking

POST/v1/shipments
typescript
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.

typescript
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:

typescript
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.

GET/v1/shipments/{id}/eta
response
{
  "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:

AspectBehaviour
Per legEach leg is estimated with its own mode and speed, then chained. One end-to-end guess hides which leg slipped.
Traffic-awareRoad legs use the Google Distance Matrix API with live traffic when a key is configured, and a distance model otherwise.
ConfidenceShortens with distance and compounds down a chain of hops, so a five-leg sea journey reads as genuinely uncertain.
WindowWidens as confidence falls. Low confidence looks uncertain rather than deceptively precise.
RecalculatedRecomputed 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.

POST/v1/positions
typescript
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:

typescript
// 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.

typescript
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.

POST/v1/inventory/movements
typescript
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.

typescript
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:

typescript
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.

typescript
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.

typescript
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:

typescript
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);
PropertyWhy it matters
Idempotent eventsEvery event carries a key, so at-least-once delivery cannot double-apply.
Ordered ledgerEvery applied change is recorded, so you can reconstruct what happened and when.
Health first-classLag, backlog, error rate and staleness are queryable, not buried in a log.
Declarative mappingField 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.

typescript
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.

ConfidenceMeaning
exactSeveral identifiers agree on one entity
highA single strong identifier matched
lowAmbiguous: candidates returned, review required
noneNothing 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.

typescript
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:

typescript
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);
EventFires when
shipment.createdA shipment is created
shipment.status_changedIts status moves
shipment.eta_changedThe estimate drifts beyond a threshold
shipment.deliveredProof of delivery is recorded
vehicle.geofence_enterA vehicle enters a zone
vehicle.geofence_exitA vehicle leaves a zone
inventory.low_stockStock reaches its reorder point
sync.connector_degradedA 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.

typescript
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);
  }
}
CodeStatusMeaning
validation400 / 422The request was malformed or failed validation
auth401 / 403Missing, invalid or inactive API key
not_found404No such resource, or not in your organization
conflict409The write conflicts with current state
rate_limited429Slow down; honour Retry-After
timeoutThe request exceeded the client timeout
networkThe request never completed

The SDK handles the tedious parts of reliability for you:

BehaviourDetail
RetriesOn 408, 425, 429, 5xx and network errors, with full-jitter exponential backoff.
Retry-AfterHonoured when the server sends it.
IdempotencyEvery write sends an Idempotency-Key, so a retry cannot double-apply.
Fail fast400, 401, 403, 404 and 409 are not retried.
TimeoutsThirty seconds per attempt by default.

Endpoint reference

Shipments

GET/v1/shipments
POST/v1/shipments
GET/v1/shipments/{id}
PATCH/v1/shipments/{id}
POST/v1/shipments/{id}/cancel
GET/v1/shipments/{id}/timeline
GET/v1/shipments/{id}/eta
POST/v1/shipments/{id}/pod
GET/v1/tracking/{reference}

Fleet

GET/v1/vehicles
GET/v1/vehicles/{id}
GET/v1/vehicles/{id}/positions
POST/v1/positions
GET/v1/routes/{shipment_id}

Geofences

GET/v1/geofences
POST/v1/geofences
DELETE/v1/geofences/{id}

Inventory

GET/v1/inventory
GET/v1/inventory/{sku_id}/{location_id}
POST/v1/inventory/movements
GET/v1/inventory/movement-history
POST/v1/inventory/cycle-count
GET/v1/inventory/reconciliation
GET/v1/inventory/reorder-signals

Sync

GET/v1/sync/connectors
POST/v1/sync/connectors
GET/v1/sync/connectors/{id}/health
POST/v1/sync/connectors/{id}/backfill
POST/v1/sync/replay/{event_id}
GET/v1/sync/dead-letters
POST/v1/sync/dry-run

Matching & reference

POST/v1/matching/resolve
GET/v1/locations
POST/v1/locations
GET/v1/skus
POST/v1/skus

Ready to build? Start from the Logistics product page, or read the other products.