Sync Maintenance to an ERP

Goal: every night, pull maintenance events completed in the last 24 hours and push them into an external ERP — so your finance system's maintenance-cost ledger never drifts from what OwlMar actually recorded.

Pattern: cursor-paginated list, filtered client-side by completedDate, run on a cron.

1. Sanity-check the endpoint

curl
curl -sS "https://api.owlmar.com/v1/maintenance/vessel/$VESSEL_ID?limit=25" \
-H "Authorization: Bearer $OWLMAR_API_KEY"
json
{
"data": [
{
"id": "9c2e1a04-...",
"eventType": "routine",
"status": "completed",
"completedDate": "2026-01-25T14:22:00.000Z",
"description": "500-hour service — port main engine",
"totalCost": "1249.50",
"currency": "USD"
}
],
"pagination": { "cursor": "eyJpZCI6Ii4uLiJ9", "limit": 25, "hasMore": true, "total": 143 }
}

2. The sync script

Node — nightly sync
import Decimal from 'decimal.js';
const API_BASE = 'https://api.owlmar.com/v1';
const API_KEY = process.env.OWLMAR_API_KEY;
const VESSEL_IDS = process.env.OWLMAR_VESSEL_IDS.split(',');
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 fetchAllMaintenanceEvents(vesselId) {
const events = [];
let cursor = null;
do {
const qs = new URLSearchParams({ limit: '100' });
if (cursor) qs.set('cursor', cursor);
const { data, pagination } = await fetchApi(
'/maintenance/vessel/' + vesselId + '?' + qs.toString()
);
events.push(...data);
cursor = pagination.hasMore ? pagination.cursor : null;
} while (cursor);
return events;
}
async function syncVesselToErp(vesselId, sinceIso) {
const events = await fetchAllMaintenanceEvents(vesselId);
const completedSinceLastRun = events.filter(
(e) => e.status === 'completed' && e.completedDate && e.completedDate >= sinceIso
);
for (const event of completedSinceLastRun) {
await pushToErp({
externalId: event.id,
vesselId,
description: event.description,
// totalCost is a Decimal-as-string — parse with a decimal library, never Number()
totalCost: event.totalCost ? new Decimal(event.totalCost).toFixed(2) : null,
currency: event.currency,
completedAt: event.completedDate,
});
}
return completedSinceLastRun.length;
}
async function main() {
const sinceIso = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
let total = 0;
for (const vesselId of VESSEL_IDS) {
total += await syncVesselToErp(vesselId, sinceIso);
}
console.log('Synced ' + total + ' completed maintenance events to ERP.');
}
main();

Why it's built this way

  • Cursor, not offset. ?page= on a growing table can skip or repeat rows if events are created between page fetches during the sync window. ?cursor= doesn't have that failure mode — see Pagination.
  • Filter client-side by completedDate. There's no server-side completedSince= filter on this endpoint today — pulling the full result set and filtering locally is the correct approach at Enterprise's typical per-vessel maintenance volume (hundreds to low thousands of events, not millions).
  • Decimal costs parsed with a decimal library, not Number() — see Response Envelope for why.
  • 429 handling inline — a nightly batch job is exactly the kind of caller that can safely wait out a Retry-After and continue, rather than failing the whole run.
Note

For a write-back direction (posting corrections from your ERP into OwlMar), use PATCH /v1/maintenance/:id with an Idempotency-Key — see Idempotency before wiring that up.