Webhooks

Note

Delivery is live. Configure your endpoints in Settings → Webhooks — create a subscription, copy the signing secret shown once at creation, and start receiving real deliveries against the contract below.

Event naming convention

Every event type follows <entity>.<action>.v<n> — the version is embedded in the string itself, not a separate header or field:

text
maintenance.event.created.v1
maintenance.event.completed.v1
equipment.updated.v1
expense.created.v1
expense.deleted.v1

This means a breaking payload change ships as a new event type (maintenance.event.created.v2) rather than mutating v1 under existing subscribers — you only ever receive a version you explicitly subscribed to. There's no in-place schema migration to coordinate on your end.

Initial event set

The initial registry, one entry per entity your API key can already read via /v1/*:

EventFires when
maintenance.event.created.v1A maintenance event is created
maintenance.event.completed.v1A maintenance event's workflow reaches its completed state
equipment.created.v1New equipment is added to a vessel
equipment.updated.v1Equipment fields change
expense.created.v1A new expense is logged
expense.deleted.v1An expense is deleted
inventory.item.low_stock.v1An inventory item crosses its reorder threshold
document.uploaded.v1A new document is added to a vessel's library
charter.booking.created.v1A new charter booking is created

This list grows as the registry does — new entries are additive (a new event type you don't subscribe to has zero effect on your integration) and will be listed in the changelog as they ship.

Payload shape

Every delivery is a JSON body with a consistent envelope around the event-specific payload:

json
{
"id": "evt_9c2e1a04-...",
"type": "maintenance.event.created.v1",
"createdAt": "2026-09-01T09:03:11.000Z",
"vesselId": "3f1c9e2a-...",
"data": {
"id": "9c2e1a04-...",
"vesselId": "3f1c9e2a-...",
"equipmentId": "8b4d0f11-...",
"eventType": "routine",
"description": "500-hour service — port main engine",
"scheduledDate": "2026-09-01T09:00:00.000Z"
}
}

data mirrors the corresponding REST resource's shape from the resource's own /v1/* endpoint (e.g. maintenance.event.created.v1's data is a MaintenanceEvent) — if you already parse that resource elsewhere in your integration, the same parsing code applies here.

Note

The event registry owns the payload schema, not the code that emits the event — every maintenance.event.created.v1 delivery, regardless of which app flow triggered it, is validated against the same schema before being queued for delivery. A malformed payload fails loudly at emit time rather than reaching your endpoint.

Subscribing

Go to Settings → Webhooks and click New Webhook. Paste your endpoint URL (must be https://), select which vessels it should cover and which event types you want to receive, then save. A webhook subscription is scoped the same two ways an API key is: an orgScope (which vessels) and an events list (which event types).

Your signing secret displays exactly once, immediately after you save — copy it into your receiver's configuration before closing the dialog. OwlMar never stores or displays the raw secret again; if you lose it, rotate it (Settings → Webhooks → your subscription → Generate new secret) rather than trying to recover the original.

HMAC signing scheme

Every delivery carries a signature header so you can verify it actually came from OwlMar and wasn't replayed or forged:

text
X-OwlMar-Signature: t=1723276800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

Verification, conceptually:

Node
import crypto from 'node:crypto';
function verifyWebhookSignature(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((kv) => kv.split('='))
);
const timestamp = Number(parts.t);
const nowSeconds = Math.floor(Date.now() / 1000);
if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) {
return false; // reject stale/replayed deliveries outside the tolerance window
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

The timestamp is signed as part of the payload (not just carried as a header) specifically so a captured request can't be replayed indefinitely — reject anything outside a reasonable tolerance window (a few minutes is typical).

Danger

Always verify the signature before trusting a delivery's contents, and always compare digests with a constant-time comparison (crypto.timingSafeEqual or your language's equivalent) — never a plain ===/==, which leaks timing information an attacker can use to forge a valid signature byte-by-byte.

Retry schedule

A delivery that doesn't get a 2xx response is retried on a fixed backoff schedule, then dead-lettered:

text
1 minute → 5 minutes → 30 minutes → 2 hours → 12 hours → 24 hours → dead-lettered

Six attempts total, reaching dead-letter status within 24 hours of the first attempt. This is intentionally shorter than some webhook providers' multi-day retry tails — an endpoint that's still failing 24 hours in almost always needs a human to look at it (a rotated auth secret, a crashed receiver, a changed URL), and a longer tail just delays you finding out.

Dead-letter queue

Dead-lettered deliveries are visible in Settings → Webhooks with the full attempt history (status code and error per attempt) and a manual Retry button that re-queues the delivery from the top of the same schedule — not a separate replay mechanism.

Getting started

  • Register your endpoint URL and auth boundary in Settings → Webhooks (it should accept unauthenticated POST requests and rely on signature verification, not a bearer token, for authenticity).
  • Implement signature verification against the scheme above before you rely on any delivery's contents.
  • Use the Send test event button on your subscription to fire a real sample payload synchronously, without waiting for a live trigger, so you can confirm your receiver and secret are wired correctly.

New event types are announced in the changelog as they ship.