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

text
{ "data": T | T[], "pagination"?: { "cursor": string|null, "limit": number, "hasMore": boolean, "total": number }, "meta"?: {...} }

A single-record response:

json
{ "data": { "id": "3f1c9e2a-...", "vesselName": "Sea Wolf", "make": "Sunseeker" } }

A list response, with pagination:

json
{
"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

text
{ "error": string, "code": string, "field"?: string, "meta"?: object }
json
{ "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).

json
// Wrong — brittle, breaks the moment the message wording changes
if (err.error === "Rate limit exceeded") { retry(); }
// Right — stable across releases
if (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_LIMITED carries meta.retryAfterMs, and VALIDATION_ERROR carries meta.errors (an array of schema violations) when a request doesn't match the published OpenAPI spec.
json
{ "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:

json
{ "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:

node
// Wrong — floating-point arithmetic on money
const 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);
python
# Right — Python's Decimal, not float()
from decimal import Decimal
total = Decimal(expense["amount"]) + Decimal(other_expense["amount"])
Warning

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.