Skip to content
APIv1
Menu

Recipes

A small client with pagination and 429 backoff, and runnable snippets for common jobs.

View as markdown

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>.mts or bun <file>.mts.
  • Python: pip install requests, Python 3.9+. Run with python <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.

TypeScript
// 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;
Python
# 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

TypeScript
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}…)`);
Python
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.

TypeScript
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)}`,
  );
}
Python
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.

TypeScript
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}`);
Python
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.

TypeScript
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));
}
Python
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.

TypeScript
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(',')}`);
}
Python
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.

TypeScript
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`);
Python
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.

TypeScript
// 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`);
Python
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.