Document Library to a DMS

Goal: keep an external DMS (the one your compliance team already trusts) in sync with a vessel's OwlMar document library — certificates, manuals, surveys — without re-downloading everything on every run.

Pattern: cursor-paginated list, incremental via a persisted cursor, download each file by its signed fileUrl.

1. Sanity-check the endpoint

curl
curl -sS "https://api.owlmar.com/v1/documents/vessel/$VESSEL_ID?limit=25" \
-H "Authorization: Bearer $OWLMAR_API_KEY"
json
{
"data": [
{
"id": "7d3f8e21-...",
"documentType": "certificate",
"category": "safety",
"title": "MCA Load Line Certificate",
"fileUrl": "https://api.owlmar.com/uploads/documents/7d3f8e21-....pdf",
"fileSize": 482113,
"mimeType": "application/pdf",
"expirationDate": "2027-03-14T00:00:00.000Z",
"updatedAt": "2026-01-20T11:02:44.000Z"
}
],
"pagination": { "cursor": "eyJpZCI6Ii4uLiJ9", "limit": 25, "hasMore": false, "total": 6 }
}
Note

Document creation isn't on the public API surface — the only create route requires a multipart file upload, not a metadata-only body. This recipe is read/mirror-only. Uploads still go through the app.

2. The incremental sync script

Node — incremental mirror
const fs = require('node:fs/promises');
const path = require('node:path');
const API_BASE = 'https://api.owlmar.com/v1';
const API_KEY = process.env.OWLMAR_API_KEY;
const VESSEL_ID = process.env.OWLMAR_VESSEL_ID;
const SYNC_STATE_FILE = '.dms-sync-state.json';
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 loadSyncedIds() {
try {
const raw = await fs.readFile(SYNC_STATE_FILE, 'utf8');
return new Set(JSON.parse(raw));
} catch {
return new Set(); // first run — nothing synced yet
}
}
async function saveSyncedIds(ids) {
await fs.writeFile(SYNC_STATE_FILE, JSON.stringify([...ids]));
}
async function downloadToDms(doc) {
const res = await fetch(doc.fileUrl);
const buffer = Buffer.from(await res.arrayBuffer());
const destPath = path.join('dms-mirror', doc.category, doc.id + path.extname(doc.title));
await fs.mkdir(path.dirname(destPath), { recursive: true });
await fs.writeFile(destPath, buffer);
await pushMetadataToDms({
externalId: doc.id,
title: doc.title,
category: doc.category,
documentType: doc.documentType,
expirationDate: doc.expirationDate,
localPath: destPath,
});
}
async function syncVessel() {
const alreadySynced = await loadSyncedIds();
let cursor = null;
let syncedCount = 0;
do {
const qs = new URLSearchParams({ limit: '100' });
if (cursor) qs.set('cursor', cursor);
const { data: docs, pagination } = await fetchApi(
'/documents/vessel/' + VESSEL_ID + '?' + qs.toString()
);
for (const doc of docs) {
// Re-sync if new OR if it was updated since our last mirror.
const stateKey = doc.id + ':' + doc.updatedAt;
if (alreadySynced.has(stateKey)) continue;
await downloadToDms(doc);
alreadySynced.add(stateKey);
syncedCount += 1;
}
cursor = pagination.hasMore ? pagination.cursor : null;
} while (cursor);
await saveSyncedIds(alreadySynced);
console.log('Synced ' + syncedCount + ' document(s) to DMS.');
}
syncVessel();

Why it's built this way

  • State keyed on id:updatedAt, not just id. A document that's replaced (a renewed certificate uploaded over an existing record) gets its updatedAt bumped — keying the local sync state on the pair re-pulls it without needing a separate "modified" flag.
  • fileUrl is fetched directly, not proxied through another API call — it's already a fully-formed URL on the same shape the app itself uses to render document previews.
  • Cursor pagination, non-search path. This endpoint's ?search= variant runs a bounded hybrid query and doesn't support ?cursor= — if you extend this script to search by title, page with ?page= on that branch specifically instead.
Warning

Persist sync state somewhere durable in production (a database row, not a local JSON file on an ephemeral container) — losing it silently triggers a full re-download on the next run, which is safe but wasteful at scale.