Recipes
A small client with pagination and 429 backoff, and runnable snippets for common jobs.
Every recipe imports the client below. Save it as cubic.mts or cubic.py. Both read the key from CUBIC_API_KEY.
- TypeScript: no dependencies, Node 18+. Run with
npx tsx <file>.mtsorbun <file>.mts. - Python:
pip install requests, Python 3.9+. Run withpython <file>.py.
TypeScript types: npx openapi-typescript https://developers.gocubic.io/openapi.json -o cubic-api.d.ts.
Client
Follows cursors, waits out 429 using Retry-After, retries 5xx with backoff, and raises everything else with the API's code and hint.
// cubic.mts
const BASE_URL = 'https://api.gocubic.io/v1';
const API_KEY = process.env.CUBIC_API_KEY;
if (!API_KEY) throw new Error('Set the CUBIC_API_KEY environment variable');
export interface MilestoneDate {
date: string;
time: string | null;
actual: boolean;
}
export interface Place {
name: string | null;
code: string | null;
countryCode: string | null;
}
export interface Milestone {
type: string;
place: Place | null;
arrival: MilestoneDate | null;
departure: MilestoneDate | null;
completed: boolean;
}
export interface Shipment {
reference: string;
status: string;
serviceType: string;
tags: string[];
portOfDischarge: Place | null;
milestones: Milestone[];
currentMilestoneIndex: number;
updatedAt: string;
[field: string]: unknown;
}
export type Query = Record<string, string | number | string[] | undefined>;
export class CubicApiError extends Error {
problem: { status: number; code: string; detail: string; hint: string; parameter?: string };
constructor(problem: CubicApiError['problem']) {
super(`${problem.status} ${problem.code}: ${problem.detail} Hint: ${problem.hint}`);
this.problem = problem;
}
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function cubicGet<T>(path: string, query: Query = {}): Promise<T> {
const url = new URL(BASE_URL + path);
for (const [key, value] of Object.entries(query)) {
if (value === undefined) continue;
for (const item of Array.isArray(value) ? value : [value]) url.searchParams.append(key, String(item));
}
for (let attempt = 0; ; attempt++) {
const response = await fetch(url, { headers: { Authorization: `Bearer ${API_KEY}` } });
if (response.ok) return (await response.json()) as T;
const retryable = response.status === 429 || response.status >= 500;
if (retryable && attempt < 5) {
const retryAfter = Number(response.headers.get('Retry-After'));
await sleep((retryAfter > 0 ? retryAfter : 2 ** attempt) * 1000);
continue;
}
const problem = await response
.json()
.catch(() => ({ status: response.status, code: 'unknown', detail: response.statusText, hint: '' }));
throw new CubicApiError(problem);
}
}
export async function* listShipments(query: Query = {}): AsyncGenerator<Shipment> {
let cursor: string | undefined;
do {
const page = await cubicGet<{ data: Shipment[]; nextCursor: string | null }>('/shipments', {
limit: 100,
...query,
cursor,
});
yield* page.data;
cursor = page.nextCursor ?? undefined;
} while (cursor);
}
export const findMilestone = (shipment: Shipment, type: string) => shipment.milestones.find((milestone) => milestone.type === type) ?? null;# cubic.py
import os
import time
import requests
BASE_URL = "https://api.gocubic.io/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['CUBIC_API_KEY']}"
class CubicApiError(Exception):
def __init__(self, problem):
super().__init__(
f"{problem.get('status')} {problem.get('code')}: {problem.get('detail')} Hint: {problem.get('hint')}"
)
self.problem = problem
def cubic_get(path, params=None):
for attempt in range(6):
response = session.get(BASE_URL + path, params=params, timeout=30)
if response.ok:
return response.json()
retryable = response.status_code == 429 or response.status_code >= 500
if retryable and attempt < 5:
retry_after = response.headers.get("Retry-After")
time.sleep(int(retry_after) if retry_after else 2**attempt)
continue
try:
problem = response.json()
except ValueError:
problem = {"status": response.status_code, "code": "unknown", "detail": response.reason, "hint": ""}
raise CubicApiError(problem)
def list_shipments(**filters):
"""Yield every shipment matching the filters, following cursors. A list value repeats the parameter."""
params = {"limit": 100, **filters}
while True:
page = cubic_get("/shipments", params)
yield from page["data"]
if page["nextCursor"] is None:
return
params["cursor"] = page["nextCursor"]
def find_milestone(shipment, milestone_type):
return next((milestone for milestone in shipment["milestones"] if milestone["type"] == milestone_type), None)Verify the connection
import { cubicGet } from './cubic.mts';
const me = await cubicGet<{ account: { name: string }; apiKey: { name: string; prefix: string } }>('/me');
console.log(`Connected to "${me.account.name}" using key "${me.apiKey.name}" (${me.apiKey.prefix}…)`);from cubic import cubic_get
me = cubic_get("/me")
print(f"Connected to \"{me['account']['name']}\" using key \"{me['apiKey']['name']}\" ({me['apiKey']['prefix']}…)")List active shipments with ETA
The ETA is a milestone's arrival while actual is false. Either milestone, or its arrival, can be missing. currentMilestoneIndex says where the shipment is now.
import { findMilestone, listShipments, type MilestoneDate } from './cubic.mts';
const describe = (arrival: MilestoneDate | null | undefined) =>
arrival ? `${arrival.date} (${arrival.actual ? 'actual' : 'estimated'})` : 'not known yet';
for await (const shipment of listShipments({ status: 'Active' })) {
const port = findMilestone(shipment, 'DestinationPort');
const delivery = findMilestone(shipment, 'Delivery');
console.log(
`${shipment.reference} ${shipment.serviceType} ` +
`current milestone ${shipment.milestones[shipment.currentMilestoneIndex].type} ` +
`port of discharge ${shipment.portOfDischarge?.name ?? '—'}: ${describe(port?.arrival)} ` +
`delivery: ${describe(delivery?.arrival)}`,
);
}from cubic import find_milestone, list_shipments
def describe(milestone):
arrival = milestone and milestone["arrival"]
if not arrival:
return "not known yet"
return f"{arrival['date']} ({'actual' if arrival['actual'] else 'estimated'})"
for shipment in list_shipments(status="Active"):
port = find_milestone(shipment, "DestinationPort")
delivery = find_milestone(shipment, "Delivery")
port_name = (shipment["portOfDischarge"] or {}).get("name") or "—"
print(
f"{shipment['reference']} {shipment['serviceType']} "
f"current milestone {shipment['milestones'][shipment['currentMilestoneIndex']]['type']} "
f"port of discharge {port_name}: {describe(port)} delivery: {describe(delivery)}"
)Find a shipment by PO, container, master bill or FBA ID
tag is an exact match on the customer's own tags (PO numbers). search is free text across the reference, tags, master bill / air waybill numbers, container numbers, and Amazon FBA and reference IDs.
import { listShipments } from './cubic.mts';
const [kind, value] = process.argv.slice(2);
if (!value || (kind !== 'tag' && kind !== 'search')) {
throw new Error('Usage: find.mts tag PO-8841 | find.mts search CSNU1234567');
}
let found = 0;
for await (const shipment of listShipments({ [kind]: value })) {
found++;
console.log(shipment.reference, shipment.status, shipment.tags.join(','));
}
if (found === 0) console.log(`No shipment matches ${kind}=${value}`);import sys
from cubic import list_shipments
if len(sys.argv) != 3 or sys.argv[1] not in ("tag", "search"):
sys.exit("Usage: find.py tag PO-8841 | find.py search CSNU1234567")
kind, value = sys.argv[1:]
matches = list(list_shipments(**{kind: value}))
for shipment in matches:
print(shipment["reference"], shipment["status"], ",".join(shipment["tags"]))
if not matches:
print(f"No shipment matches {kind}={value}")Poll for changes
Store the greatest updatedAt you have seen and pass it back as updatedSince. It is inclusive, so the boundary shipment comes back again: upsert by reference. The first run reads everything.
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { listShipments, type Shipment } from './cubic.mts';
const STATE_FILE = 'cubic-sync-state.json';
const POLL_INTERVAL_MS = 5 * 60 * 1000;
const handleChange = (shipment: Shipment) => {
console.log(`${shipment.reference} is ${shipment.status} (updated ${shipment.updatedAt})`);
};
let updatedSince: string | undefined = existsSync(STATE_FILE)
? JSON.parse(readFileSync(STATE_FILE, 'utf8')).updatedSince
: undefined;
for (;;) {
let newest: string | undefined;
for await (const shipment of listShipments({ updatedSince })) {
newest ??= shipment.updatedAt;
handleChange(shipment);
}
if (newest) {
updatedSince = newest;
writeFileSync(STATE_FILE, JSON.stringify({ updatedSince }));
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}import json
import os
import time
from cubic import list_shipments
STATE_FILE = "cubic-sync-state.json"
POLL_INTERVAL_SECONDS = 5 * 60
def handle_change(shipment):
print(f"{shipment['reference']} is {shipment['status']} (updated {shipment['updatedAt']})")
updated_since = None
if os.path.exists(STATE_FILE):
with open(STATE_FILE) as state_file:
updated_since = json.load(state_file)["updatedSince"]
while True:
filters = {"updatedSince": updated_since} if updated_since else {}
newest = None
for shipment in list_shipments(**filters):
newest = newest or shipment["updatedAt"]
handle_change(shipment)
if newest:
updated_since = newest
with open(STATE_FILE, "w") as state_file:
json.dump({"updatedSince": updated_since}, state_file)
time.sleep(POLL_INTERVAL_SECONDS)What is arriving this week
Milestone dates compare correctly as text. This uses the Delivery milestone; use DestinationPort for arrivals at the port of discharge.
import { findMilestone, listShipments } from './cubic.mts';
const isoDate = (date: Date) => date.toLocaleDateString('en-CA');
const today = new Date();
const from = isoDate(today);
const to = isoDate(new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7));
const arriving = [];
for await (const shipment of listShipments({ status: 'Active' })) {
const arrival = findMilestone(shipment, 'Delivery')?.arrival;
if (arrival && arrival.date >= from && arrival.date < to) arriving.push({ shipment, arrival });
}
arriving.sort((a, b) => a.arrival.date.localeCompare(b.arrival.date));
for (const { shipment, arrival } of arriving) {
console.log(`${arrival.date}${arrival.actual ? '' : ' (estimated)'} ${shipment.reference} ${shipment.tags.join(',')}`);
}from datetime import date, timedelta
from cubic import find_milestone, list_shipments
start = date.today().isoformat()
end = (date.today() + timedelta(days=7)).isoformat()
arriving = []
for shipment in list_shipments(status="Active"):
delivery = find_milestone(shipment, "Delivery")
arrival = delivery and delivery["arrival"]
if arrival and start <= arrival["date"] < end:
arriving.append((arrival, shipment))
for arrival, shipment in sorted(arriving, key=lambda pair: pair[0]["date"]):
suffix = "" if arrival["actual"] else " (estimated)"
print(f"{arrival['date']}{suffix} {shipment['reference']} {','.join(shipment['tags'])}")Export to a spreadsheet
One row per shipment in shipments.csv. null becomes an empty cell.
import { writeFileSync } from 'node:fs';
import { findMilestone, listShipments, type Shipment } from './cubic.mts';
const COLUMNS: [string, (shipment: Shipment) => unknown][] = [
['reference', (s) => s.reference],
['status', (s) => s.status],
['serviceType', (s) => s.serviceType],
['tags', (s) => s.tags.join(' ')],
['portOfDischarge', (s) => s.portOfDischarge?.name],
['portArrival', (s) => findMilestone(s, 'DestinationPort')?.arrival?.date],
['portArrivalIsActual', (s) => findMilestone(s, 'DestinationPort')?.arrival?.actual],
['deliveryArrival', (s) => findMilestone(s, 'Delivery')?.arrival?.date],
['deliveryArrivalIsActual', (s) => findMilestone(s, 'Delivery')?.arrival?.actual],
['updatedAt', (s) => s.updatedAt],
];
const cell = (value: unknown) => {
const text = value === null || value === undefined ? '' : String(value);
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
};
const lines = [COLUMNS.map(([header]) => header).join(',')];
for await (const shipment of listShipments()) {
lines.push(COLUMNS.map(([, read]) => cell(read(shipment))).join(','));
}
writeFileSync('shipments.csv', lines.join('\n') + '\n');
console.log(`Wrote ${lines.length - 1} shipments to shipments.csv`);import csv
from cubic import find_milestone, list_shipments
def arrival(shipment, milestone_type, field):
milestone = find_milestone(shipment, milestone_type)
return milestone["arrival"][field] if milestone and milestone["arrival"] else None
COLUMNS = [
("reference", lambda s: s["reference"]),
("status", lambda s: s["status"]),
("serviceType", lambda s: s["serviceType"]),
("tags", lambda s: " ".join(s["tags"])),
("portOfDischarge", lambda s: (s["portOfDischarge"] or {}).get("name")),
("portArrival", lambda s: arrival(s, "DestinationPort", "date")),
("portArrivalIsActual", lambda s: arrival(s, "DestinationPort", "actual")),
("deliveryArrival", lambda s: arrival(s, "Delivery", "date")),
("deliveryArrivalIsActual", lambda s: arrival(s, "Delivery", "actual")),
("updatedAt", lambda s: s["updatedAt"]),
]
count = 0
with open("shipments.csv", "w", newline="") as csv_file:
writer = csv.writer(csv_file)
writer.writerow([header for header, _ in COLUMNS])
for shipment in list_shipments():
writer.writerow([read(shipment) for _, read in COLUMNS])
count += 1
print(f"Wrote {count} shipments to shipments.csv")Sync to a database
Incremental sync into SQLite: an upsert keyed on reference, resuming from the greatest updated_at stored. Run it on a schedule.
// Requires Node 22.13+ for node:sqlite.
import { DatabaseSync } from 'node:sqlite';
import { findMilestone, listShipments } from './cubic.mts';
const db = new DatabaseSync('cubic.db');
db.exec(`CREATE TABLE IF NOT EXISTS shipments (
reference TEXT PRIMARY KEY,
status TEXT NOT NULL,
service_type TEXT NOT NULL,
delivery_date TEXT,
delivery_is_actual INTEGER,
updated_at TEXT NOT NULL,
json TEXT NOT NULL
)`);
const upsert = db.prepare(`INSERT INTO shipments VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(reference) DO UPDATE SET
status = excluded.status,
service_type = excluded.service_type,
delivery_date = excluded.delivery_date,
delivery_is_actual = excluded.delivery_is_actual,
updated_at = excluded.updated_at,
json = excluded.json`);
const last = db.prepare('SELECT MAX(updated_at) AS value FROM shipments').get() as { value: string | null };
// One transaction: the list is newest first, so a partial run would move MAX(updated_at) past rows it never stored.
let count = 0;
db.exec('BEGIN');
try {
for await (const shipment of listShipments({ updatedSince: last.value ?? undefined })) {
const arrival = findMilestone(shipment, 'Delivery')?.arrival ?? null;
upsert.run(
shipment.reference,
shipment.status,
shipment.serviceType,
arrival ? arrival.date : null,
arrival ? Number(arrival.actual) : null,
shipment.updatedAt,
JSON.stringify(shipment),
);
count++;
}
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
console.log(`Upserted ${count} shipments`);import json
import sqlite3
from cubic import find_milestone, list_shipments
db = sqlite3.connect("cubic.db")
db.execute(
"""CREATE TABLE IF NOT EXISTS shipments (
reference TEXT PRIMARY KEY,
status TEXT NOT NULL,
service_type TEXT NOT NULL,
delivery_date TEXT,
delivery_is_actual INTEGER,
updated_at TEXT NOT NULL,
json TEXT NOT NULL
)"""
)
last = db.execute("SELECT MAX(updated_at) FROM shipments").fetchone()[0]
filters = {"updatedSince": last} if last else {}
count = 0
for shipment in list_shipments(**filters):
delivery = find_milestone(shipment, "Delivery")
arrival = delivery and delivery["arrival"]
db.execute(
"""INSERT INTO shipments VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(reference) DO UPDATE SET
status = excluded.status,
service_type = excluded.service_type,
delivery_date = excluded.delivery_date,
delivery_is_actual = excluded.delivery_is_actual,
updated_at = excluded.updated_at,
json = excluded.json""",
(
shipment["reference"],
shipment["status"],
shipment["serviceType"],
arrival["date"] if arrival else None,
int(arrival["actual"]) if arrival else None,
shipment["updatedAt"],
json.dumps(shipment),
),
)
count += 1
db.commit()
print(f"Upserted {count} shipments")Handle 429 with backoff
The client already does this: on 429 it waits Retry-After seconds; on 5xx it backs off 1, 2, 4, 8, 16 seconds; after five retries it raises. A standalone retry loop is on Rate limits.