- 1
Create a key in the dashboard → API & webhooks (Business or Enterprise plan) and pick the scopes you need.
- 2
Send requests to the base URL with the header Authorization: Bearer stw_live_…
- 3
Register a webhook endpoint for plan.completed and quote.received, then verify its signature.
Base URL
https://api.stowage.id/v1/ext
Every request uses an organisation API key in the Authorization header. Keys are shown once and stored as a SHA-256 hash — keep them on your server, never in front-end or mobile code.
Authorization: Bearer stw_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Scopes
plans:read | Read plans |
plans:write | Solve & save plans |
rfqs:read | Read RFQs & quotes |
rfqs:write | Create freight RFQs |
ports:read | Search ports (UN/LOCODE) |
vessel:read | Read plans |
vessel:write | Read plans |
Rate limits apply per key over a sliding 1-minute window; call quotas per organisation per calendar month. Exceeding the rate limit → 429 with a Retry-After header; quota used up or a plan without API → 402.
| Plan | Requests/minute per key | Calls/month |
| Free · Day Pass · Starter · Professional | Not available | Not available |
| Business | 60 | 10,000 |
| Enterprise | 600 | Unlimited |
Synchronous solves are limited to 500 units per request; larger loads use Advanced Optimisation in the Studio (413 response).
Error format
All errors share one shape; HTTP codes: 400 invalid input, 401 bad key, 402 plan/quota, 403 scope, 404 not found, 413 too large, 429 rate limit.
{
"error": {
"code": "paywall",
"message": "Monthly API call quota exhausted",
"paywall": { "moment": "feature", "quota": "api_calls", "limit": 10000, "used": 10000, "suggestedPlan": "enterprise" }
}
}
curl
curl -X POST https://api.stowage.id/v1/ext/plans/solve \
-H "Authorization: Bearer $STOWAGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "PO 4711",
"space": { "code": "40HC" },
"items": [
{ "sku": "CARTON-A", "l": 600, "w": 400, "h": 400, "kg": 12, "qty": 40 },
{ "sku": "CRATE-B", "l": 1200, "w": 800, "h": 1000, "kg": 300, "qty": 6, "stackable": false }
],
"save": true
}'
curl "https://api.stowage.id/v1/ext/ports?q=Tanjung%20Priok" \
-H "Authorization: Bearer $STOWAGE_API_KEY"
TypeScript SDK
Dependency-free TypeScript client (fetch + WebCrypto) for Node 18+, Deno, Bun and Cloudflare Workers. stowage-ext.ts
import { createExtClient } from './stowage-ext'; // /developers/stowage-ext.ts
const stowage = createExtClient({ apiKey: process.env.STOWAGE_API_KEY! });
const { result, plan } = await stowage.solve({
space: { code: '40HC' },
items: [{ sku: 'CARTON-A', l: 600, w: 400, h: 400, kg: 12, qty: 40 }],
save: true,
});
console.log(result.utilizationPct, plan?.appUrl);
const { rfq } = await stowage.createRfq({
title: 'Kopi ke Singapura', originLocode: 'IDTPP', destLocode: 'SGSIN', mode: 'LCL', incoterm: 'FOB',
readyDate: '2026-12-01', planId: plan?.id, contact: { name: 'Ops', email: 'ops@example.com' },
});
Load planning simulation. Not a substitute for official weighing, VGM, the vessel loading computer, or operator approval.
Generated from the OpenAPI 3.1 specification. openapi.json
POST /plans/solve Plans
Solve a load plan
Packs the cargo into one space synchronously with the Stowage heuristic (≤ 500 units in total). With save: true the result is stored as a plan and plan.completed is sent. Scope: plans:write.
Request body · SolveRequest
title stringspace Space · requireditems CargoItem[] · requiredoptions { minSupport, respectUnloadOrder, heavyBottom }save boolean — Store as a load plan (plans quota) and emit plan.completed. Default falseincludePlacements boolean — Return every placement. Default true
Responses
200 Packing result400 Invalid input (validation_error)401 Missing or revoked API key (api_key_required)402 Plan without the API feature, or the monthly api_calls quota is used up (paywall)403 The key lacks the scope this endpoint needs (forbidden)413 More than 500 units (too_many_units): use Advanced Optimisation in the Studio429 Rate limit for this key exceeded (rate_limited); see Retry-After
GET /plans/{id} Plans
Get a plan
Summary of a saved plan of your organisation; ?document=1 adds the full plan document (items, placements, steps). Scope: plans:read.
Parameters
id path · string · requireddocument query · "0" | "1" | "true" | "false"
Responses
200 The plan400 Invalid input (validation_error)401 Missing or revoked API key (api_key_required)402 Plan without the API feature, or the monthly api_calls quota is used up (paywall)403 The key lacks the scope this endpoint needs (forbidden)404 No such plan in your organisation429 Rate limit for this key exceeded (rate_limited); see Retry-After
POST /rfqs Freight RFQ
Create a freight RFQ
Sends a request for quotation to matched, verified vendors. Quotes arrive as quote.received webhooks. Scope: rfqs:write.
Request body · RfqRequest
title string · requiredoriginLocode string · requireddestLocode string · requiredmode "FCL" | "LCL" | "air" | "truck" · requiredincoterm "EXW" | "FCA" | "FAS" | "FOB" | "CFR" | "CIF" | "CPT" | "CIP" | "DAP" | "DPU" | "DDP" · requiredreadyDate string · required — YYYY-MM-DD, within 12 monthsplanId string — Attach a saved plan: cargo is computed from itcargo { cbm, kg, packages, containers, description }commodity stringnotes stringcontact { name, email, phone } · requiredexpiresInDays integer
Responses
201 Created400 Invalid input (validation_error)401 Missing or revoked API key (api_key_required)402 Plan without the API feature, or the monthly api_calls quota is used up (paywall)403 The key lacks the scope this endpoint needs (forbidden)404 planId not found in your organisation429 Rate limit for this key exceeded (rate_limited); see Retry-After
GET /rfqs/{id} Freight RFQ
Get an RFQ with its quotes
Status of an RFQ of your organisation and the quotes received so far (amounts in minor units). Scope: rfqs:read.
Parameters
id path · string · required
Responses
200 The RFQ400 Invalid input (validation_error)401 Missing or revoked API key (api_key_required)402 Plan without the API feature, or the monthly api_calls quota is used up (paywall)403 The key lacks the scope this endpoint needs (forbidden)404 No such RFQ in your organisation429 Rate limit for this key exceeded (rate_limited); see Retry-After
GET /ports Reference data
Search ports (UN/LOCODE)
Search by name or UN/LOCODE; without q the curated Indonesian ports are listed first. Scope: ports:read.
Parameters
q query · stringcountry query · stringseaport query · "0" | "1" | "true" | "false"limit query · string — 1–20, default 20
Responses
200 Matching ports400 Invalid input (validation_error)401 Missing or revoked API key (api_key_required)402 Plan without the API feature, or the monthly api_calls quota is used up (paywall)403 The key lacks the scope this endpoint needs (forbidden)429 Rate limit for this key exceeded (rate_limited); see Retry-After
GET /containers Reference data
Equipment catalog
ISO containers, Indonesian trucks, pallets and air ULDs with inner dimensions (mm) and payloads (kg). Codes are accepted by /plans/solve. Any valid key.
Parameters
kind query · "containers" | "trucks" | "pallets" | "ulds"
Responses
200 Catalog400 Invalid input (validation_error)401 Missing or revoked API key (api_key_required)402 Plan without the API feature, or the monthly api_calls quota is used up (paywall)403 The key lacks the scope this endpoint needs (forbidden)429 Rate limit for this key exceeded (rate_limited); see Retry-After
POST /integrations/{partner}/plans Partner integrations
Partner: create a plan in a connected organisation
For Kontainer.id and Logistikku.id. Authenticated by the integration the customer approved (consent + scopes) and an HMAC signature of the raw body — no API key. Sandbox until production partner credentials are issued. Needs integration scope plans:write.
Parameters
partner path · "kontainer_id" | "logistikku_id" · requiredstowage-integration header · string · required — Integration id (`int_…`) the customer granted youstowage-signature header · string · required — `t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>`
Request body · PartnerPlanRequest
title string · requiredexternalRef string — Your shipment / booking referencespace Space · requireditems CargoItem[] · required
Responses
201 Plan created400 Invalid input401 Unknown integration or bad signature (bad_signature)402 The organisation has no plan quota left403 Integration revoked or scope not granted409 Signature already used (replayed)
GET /integrations/{partner}/me Partner integrations
Partner: shared identity of the consenting user
Name, e-mail and organisation of the user who connected the integration — only with the profile:read scope they approved. Signature over an empty body.
Parameters
partner path · "kontainer_id" | "logistikku_id" · requiredstowage-integration header · string · required — Integration id (`int_…`) the customer granted youstowage-signature header · string · required — `t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>`
Responses
200 Identity401 Unknown integration or bad signature403 Integration revoked or scope not granted
We POST JSON to your endpoint. Reply 2xx within 10 seconds; otherwise we retry up to 7 times over ≈ 9 hours (30 s, 2 min, 10 min, 30 min, 2 h, 6 h). Every delivery is logged in the dashboard.
Events
plan.completed A plan was completed
Sent when a plan is saved through the API (save: true), by a partner integration, or when an Advanced Optimisation run finishes. Verify Stowage-Signature before trusting the body.
data: { planId, title, mode, spaceCode, version, utilizationPct, weightUtilizationPct, totalKg, itemCount, unitCount, source, appUrl }
quote.received A vendor quote arrived
Sent when a vendor submits or revises a quote on one of your RFQs.
data: { rfqId, rfqTitle, quoteId, vendorId, vendorName, amountMinor, currency, transitDays, validUntil, revised }
Verifying signatures
The Stowage-Signature header contains t=<unix seconds>,v1=<hex>. Compute HMAC-SHA256 with the endpoint secret over "<t>.<raw body>", compare it with v1 in constant time, and reject timestamps more than 5 minutes off.
Stowage-Signature: t=1767225600,v1=5f2b…c9e1
import { verifyWebhookSignature } from './stowage-ext';
// Express: app.post('/webhooks/stowage', express.raw({ type: 'application/json' }), handler)
const ok = await verifyWebhookSignature({
secret: process.env.STOWAGE_WEBHOOK_SECRET!, // whsec_…
body: req.body.toString('utf8'), // RAW body, before JSON.parse
header: req.header('Stowage-Signature'),
});
if (!ok) return res.status(400).end();
const event = JSON.parse(req.body.toString('utf8')); // { id, type, createdAt, orgId, data }
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret: string, rawBody: string, header: string, toleranceSec = 300): boolean {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=') as [string, string]));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return parts.v1?.length === expected.length && timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}
Partners send plans to organisations that granted access (consent + scopes) using the Stowage-Integration header and a Stowage-Signature over the raw body — no API key. Shared identity only with the profile:read scope. Currently in sandbox mode.
BODY='{"title":"Booking KTR-991","space":{"code":"20GP"},"items":[{"sku":"BOX","l":1000,"w":1000,"h":1000,"kg":100,"qty":4}]}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$INTEGRATION_SECRET" -hex | sed 's/^.* //')
curl -X POST https://api.stowage.id/v1/ext/integrations/kontainer_id/plans \
-H "Content-Type: application/json" \
-H "Stowage-Integration: $INTEGRATION_ID" \
-H "Stowage-Signature: t=$T,v1=$SIG" \
-d "$BODY"