New Expense to Slack
Goal: the moment a crew member logs a new expense on any vessel, post it to a Slack channel so ops can see spend as it happens instead of waiting for a monthly report.
Pattern: short-interval polling with a persisted watermark. Webhooks will replace this with a push model once delivery infrastructure ships — until then, polling on a tight interval is the correct approach, not a workaround.
1. Sanity-check the endpoint
curl
curl -sS "https://api.owlmar.com/v1/expenses/vessel/$VESSEL_ID?limit=25" \-H "Authorization: Bearer $OWLMAR_API_KEY"
json
{"data": [{"id": "4a7e2c19-...","category": "fuel","description": "Diesel bunkering — Fort Lauderdale","amount": "3480.00","currency": "USD","vendor": "World Fuel Services","date": "2026-01-28","createdAt": "2026-01-28T16:42:03.000Z"}],"pagination": { "cursor": "eyJpZCI6Ii4uLiJ9", "limit": 25, "hasMore": false, "total": 12 },"meta": { "totalAmount": "18420.75" }}
2. The polling script
Node — poll + post to Slack
const API_BASE = 'https://api.owlmar.com/v1';const API_KEY = process.env.OWLMAR_API_KEY;const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL;const VESSEL_IDS = process.env.OWLMAR_VESSEL_IDS.split(',');const POLL_INTERVAL_MS = 60 * 1000; // 1 minute — well inside the 10,000/hr limit for a handful of vessels// Persisted per-vessel watermark — swap this in-memory Map for your own// durable store (Redis, a database row) so a process restart doesn't// re-alert on everything.const lastSeenCreatedAt = new Map();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 postToSlack(expense, vesselId) {const text = ':moneybag: New expense on vessel ' + vesselId + ': *' +expense.description + '* — ' + expense.amount + ' ' + expense.currency +' (' + expense.category + ')';await fetch(SLACK_WEBHOOK_URL, {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ text }),});}async function pollVessel(vesselId) {const watermark = lastSeenCreatedAt.get(vesselId);const { data: expenses } = await fetchApi('/expenses/vessel/' + vesselId + '?limit=25');// Newest first — walk until we hit the watermark, then stop.const newExpenses = [];for (const expense of expenses) {if (watermark && expense.createdAt <= watermark) break;newExpenses.push(expense);}for (const expense of newExpenses.reverse()) {await postToSlack(expense, vesselId);}if (expenses.length > 0) {lastSeenCreatedAt.set(vesselId, expenses[0].createdAt);}}async function pollLoop() {for (const vesselId of VESSEL_IDS) {await pollVessel(vesselId);}setTimeout(pollLoop, POLL_INTERVAL_MS);}pollLoop();
cURL — one poll tick
curl -sS "https://api.owlmar.com/v1/expenses/vessel/$VESSEL_ID?limit=25" \-H "Authorization: Bearer $OWLMAR_API_KEY"
Why it's built this way
- Watermark by
createdAt, not by counting rows. Counting breaks the moment two expenses are created in the same poll interval; comparing against the newestcreatedAtyou've already processed doesn't. - 1-minute interval, not 1-second. Even across a handful of vessels this stays far under the 10,000/hour limit — see Rate Limits before tightening the interval for a larger fleet.
- This endpoint's
?search=path doesn't support cursor (bounded hybrid search), but the plain listing used here does — see the endpoint's own gap notes in Pagination if you extend this to also search by vendor/category.
Note
Once webhook delivery ships, replace the poll loop with an expense.created.v1 subscription
and drop the watermark logic entirely — the payload shape you're already parsing here
(data = an Expense) is the same shape a webhook delivery will carry.
