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();

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 newest createdAt you'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.