Fleet Expense Reconciliation
Goal: produce a single reconciliation report across every vessel your key can see, instead of exporting a CSV per vessel from the app and stitching them together by hand.
Pattern: cursor-paginated walk per vessel, aggregated client-side, cross-checked against the fleet summary endpoint.
1. Sanity-check the endpoint
curl -sS "https://api.owlmar.com/v1/fleet/revenue-summary?year=2026" \-H "Authorization: Bearer $OWLMAR_API_KEY"
{"data": {"year": 2026,"vessels": [{ "vesselId": "3f1c9e2a-...", "vesselName": "Sea Wolf", "revenue": 420000, "expenses": 186400, "fees": 42000, "net": 191600 }],"totals": { "revenue": 420000, "expenses": 186400, "fees": 42000, "net": 191600 }}}
GET /v1/fleet/revenue-summary has no vesselId parameter — it's scoped to your key's entire
accessible fleet automatically and gives you the USD-normalized top-line numbers in one call.
The recipe below goes one level deeper: a per-category expense breakdown per vessel, which the
summary endpoint doesn't provide.
2. The reconciliation script
import Decimal from 'decimal.js';const API_BASE = 'https://api.owlmar.com/v1';const API_KEY = process.env.OWLMAR_API_KEY;async function fetchApi(path) {const res = await fetch(API_BASE + path, {headers: { Authorization: 'Bearer ' + API_KEY },});if (res.status === 429) {const retryAfterMs = Number(res.headers.get('Retry-After') || 1) * 1000;await new Promise((resolve) => setTimeout(resolve, retryAfterMs));return fetchApi(path);}const body = await res.json();if (!res.ok) throw new Error(body.code + ': ' + body.error);return body;}async function fetchAllExpenses(vesselId) {const expenses = [];let cursor = null;do {const qs = new URLSearchParams({ limit: '100' });if (cursor) qs.set('cursor', cursor);const { data, pagination } = await fetchApi('/expenses/vessel/' + vesselId + '?' + qs.toString());expenses.push(...data);cursor = pagination.hasMore ? pagination.cursor : null;} while (cursor);return expenses;}function reconcileVessel(vesselName, expenses) {const byCategory = new Map();for (const expense of expenses) {// amount is Decimal-as-string — never Number() on moneyconst amount = new Decimal(expense.amount);const running = byCategory.get(expense.category) || new Decimal(0);byCategory.set(expense.category, running.plus(amount));}console.log('\n' + vesselName + ' — ' + expenses.length + ' expense(s)');for (const [category, total] of byCategory) {console.log(' ' + category.padEnd(15) + total.toFixed(2));}}async function main() {const { data: vessels } = await fetchApi('/vessels');for (const vessel of vessels) {if (vessel.isLocked) continue; // skip downgrade-locked vessels — not billable scopeconst expenses = await fetchAllExpenses(vessel.id);reconcileVessel(vessel.vesselName, expenses);}}main();
curl -sS "https://api.owlmar.com/v1/expenses/vessel/$VESSEL_ID?limit=100" \-H "Authorization: Bearer $OWLMAR_API_KEY"# Take .pagination.cursor from the response above and pass it back:curl -sS "https://api.owlmar.com/v1/expenses/vessel/$VESSEL_ID?limit=100&cursor=$NEXT_CURSOR" \-H "Authorization: Bearer $OWLMAR_API_KEY"
Why it's built this way
GET /v1/vesselsfirst, then per-vessel expense walks. This endpoint is deliberately offset-only/bare-array (see Pagination's known-gaps table) because itsisLockedannotation needs the full owned-vessel set — that's exactly why it's used here as a starting point rather than paginated: it's a small, bounded list (your fleet), not a growing log.isLockedvessels are skipped. A vessel beyond your plan's vessel limit is annotatedisLocked: truerather than omitted entirely — reconciling its expenses would double-count against a vessel that isn't actually active on your billable scope.Decimalaccumulation, not floating-point. Summing dozens of expense amounts withNumberaddition compounds rounding error across a report;decimal.jsdoesn't.- Cross-check against
/v1/fleet/revenue-summary. The summary endpoint'stotals.expensesfor a given year should match the sum of every category total this script prints for that vessel in that year — a good automated sanity check to add once this is running on a schedule.
For a fleet large enough that per-vessel sequential walks are slow, fan the per-vessel
fetchAllExpenses calls out with bounded concurrency (e.g. 4-6 at a time) — stay well under
the 60 requests/second burst limit from Rate Limits.
