Response Envelope
Every /v1/* response — success or failure, single record or list — follows one of two
shapes. Parse against the shape, not against the specific endpoint, and your client code stays
correct as new endpoints ship.
Success envelope
{ "data": T | T[], "pagination"?: { "cursor": string|null, "limit": number, "hasMore": boolean, "total": number }, "meta"?: {...} }A single-record response:
{ "data": { "id": "3f1c9e2a-...", "vesselName": "Sea Wolf", "make": "Sunseeker" } }
A list response, with pagination:
{"data": [ { "id": "...", "description": "500-hour service" } ],"pagination": { "cursor": "eyJpZCI6Ii4uLiJ9", "limit": 25, "hasMore": true, "total": 143 }}
pagination is only present on list endpoints, and only some of those support true cursor
chaining today — see Pagination for exactly which. meta carries
endpoint-specific extras that don't fit the common shape (an expenses list's totalAmount, a
fleet summary's aggregate totals) — treat it as free-form, keyed by endpoint.
Error envelope
{ "error": string, "code": string, "field"?: string, "meta"?: object }{ "error": "This API key is not scoped to this vessel", "code": "VESSEL_OUT_OF_SCOPE" }
Why code, not error
Switch on code. Never parse error. The error string is for a human reading logs — its
wording can change without notice and isn't part of the API contract. code is a stable,
documented, machine-readable identifier. Every code your integration might encounter is in this
site's error catalog (coming shortly after this page in the sidebar — the same registry the app
itself uses internally).
// Wrong — brittle, breaks the moment the message wording changesif (err.error === "Rate limit exceeded") { retry(); }// Right — stable across releasesif (err.code === "RATE_LIMITED") { retry(); }
Some errors carry extra context:
field— present on validation errors, names the specific request field that failed.meta— present on a few codes with structured detail, e.g.RATE_LIMITEDcarriesmeta.retryAfterMs, andVALIDATION_ERRORcarriesmeta.errors(an array of schema violations) when a request doesn't match the published OpenAPI spec.
{ "error": "Rate limit exceeded", "code": "RATE_LIMITED", "meta": { "retryAfterMs": 1234 } }
Decimal-as-string serialization
Money and other Decimal-typed fields (amount, laborCost, totalCost, tip, and similar)
are serialized as strings, not JSON numbers:
{ "amount": "1249.50", "currency": "USD" }
This is deliberate, not an oversight — these values are stored as Postgres Decimal columns
specifically to avoid floating-point rounding error, and a JS Number cannot represent
arbitrary-precision decimals losslessly. Parse these fields with your language's
decimal/BigDecimal type before doing arithmetic on them:
// Wrong — floating-point arithmetic on moneyconst total = Number(expense.amount) + Number(otherExpense.amount);// Right — use a decimal library (example: decimal.js)import Decimal from 'decimal.js';const total = new Decimal(expense.amount).plus(otherExpense.amount);
# Right — Python's Decimal, not float()from decimal import Decimaltotal = Decimal(expense["amount"]) + Decimal(other_expense["amount"])
Number(expense.amount) will usually "work" in casual testing and then silently lose cents
at scale. Use a decimal type from day one — retrofitting it after a reconciliation report is
off by a few dollars is a worse afternoon.
