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();
cURL — page through cursor
curl -sS "https://api.owlmar.com/v1/documents/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/documents/vessel/$VESSEL_ID?limit=100&cursor=$NEXT_CURSOR" \-H "Authorization: Bearer $OWLMAR_API_KEY"
Why it's built this way
- State keyed on
id:updatedAt, not justid. A document that's replaced (a renewed certificate uploaded over an existing record) gets itsupdatedAtbumped — keying the local sync state on the pair re-pulls it without needing a separate "modified" flag. fileUrlis 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.
