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
curl -sS "https://api.owlmar.com/v1/fleet/revenue-summary?year=2026" \
-H "Authorization: Bearer $OWLMAR_API_KEY"
json
{
"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

Node — per-category breakdown
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 money
const 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 scope
const expenses = await fetchAllExpenses(vessel.id);
reconcileVessel(vessel.vesselName, expenses);
}
}
main();

Why it's built this way

  • GET /v1/vessels first, then per-vessel expense walks. This endpoint is deliberately offset-only/bare-array (see Pagination's known-gaps table) because its isLocked annotation 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.
  • isLocked vessels are skipped. A vessel beyond your plan's vessel limit is annotated isLocked: true rather than omitted entirely — reconciling its expenses would double-count against a vessel that isn't actually active on your billable scope.
  • Decimal accumulation, not floating-point. Summing dozens of expense amounts with Number addition compounds rounding error across a report; decimal.js doesn't.
  • Cross-check against /v1/fleet/revenue-summary. The summary endpoint's totals.expenses for 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.
Note

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.