Webhooks
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:
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/*:
| Event | Fires when |
|---|---|
maintenance.event.created.v1 | A maintenance event is created |
maintenance.event.completed.v1 | A maintenance event's workflow reaches its completed state |
equipment.created.v1 | New equipment is added to a vessel |
equipment.updated.v1 | Equipment fields change |
expense.created.v1 | A new expense is logged |
expense.deleted.v1 | An expense is deleted |
inventory.item.low_stock.v1 | An inventory item crosses its reorder threshold |
document.uploaded.v1 | A new document is added to a vessel's library |
charter.booking.created.v1 | A 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:
{"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.
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:
X-OwlMar-Signature: t=1723276800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Verification, conceptually:
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));}
import hashlib, hmac, timedef verify_webhook_signature(raw_body: bytes, signature_header: str, secret: str, tolerance_seconds: int = 300) -> bool:parts = dict(kv.split('=') for kv in signature_header.split(','))timestamp = int(parts['t'])if abs(time.time() - timestamp) > tolerance_seconds:return Falsesigned_payload = f"{timestamp}.".encode() + raw_bodyexpected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()return hmac.compare_digest(expected, 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).
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:
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
POSTrequests 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.
