1. Architecture
Two patterns work for keeping an external website in sync with BoaterOS inventory: push (webhooks) and pull (polling). We recommend webhooks as the primary path, with polling as a warm standby. Polling alone gets you median freshness of half your polling interval — usually minutes. Webhooks are p50 sub-second.
The belt-and-suspenders answer: subscribe to webhooks, and run a nightly full reconciliation that diffs your local inventory against GET /v1/hulls. We'll cover all three.
2. Registering a receiver
Your endpoint must respond 200 OK within 10 seconds — any 2xx counts. Don't do work in the request: 200 fast, then process async. We retry on any non-2xx with exponential backoff for 72 hours before dead-lettering.
curl https://api.boater.os/v1/webhooks \ -H "Authorization: Bearer $BOATEROS_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://yourdealer.com/api/boateros/webhook", "events": ["hull.*"], "version": "2026-04-01" }'
The response contains a signing_secret. Store it. Every webhook is signed in the Boateros-Signature header as t=<unix>,v1=<hex> — same scheme as Stripe. Reject anything older than 5 minutes.
3. The six hull events
4. Idempotency
Every event carries an id (UUID v7) and a monotonic sequence per hull_id. Store both. If you've seen the id before, 200 and drop it. If the sequence you receive is lower than the one you last applied for that hull, 200 and drop it — it's an out-of-order replay.
This shows up when we retry after a 500: you may get the same event twice, and you may get it interleaved with a newer one. The sequence number resolves both cases.
5. Sample Next.js receiver
import { NextRequest, NextResponse } from "next/server"; import { verify, queue, db } from "@/lib"; export async function POST(req: NextRequest) { const raw = await req.text(); const sig = req.headers.get("boateros-signature") ?? ""; if (!verify(raw, sig, process.env.BOATEROS_WEBHOOK_SECRET!)) { return new NextResponse("bad sig", { status: 401 }); } const evt = JSON.parse(raw); // Idempotency: drop if we've seen this id or a newer sequence const seen = await db.events.upsert(evt.id, evt.data.id, evt.sequence); if (seen.stale) return new NextResponse("ok"); await queue.publish("hull.sync", evt); // process async return new NextResponse("ok"); // 200 in <50ms }
6. Handling photos
Photos are delivered as CDN URLs, not binary. Don't re-host unless you have a reason — our CDN is cheaper and faster than yours. If you must mirror, handle hull.photos_updated specifically and fan out a transcode job per URL. URLs are immutable; a photo edit produces a new URL and a new event.
7. Polling fallback
If webhooks are down (your side or ours), cut over to polling with the updated_since parameter. It uses server-side update timestamps, not your clock.
curl "https://api.boater.os/v1/hulls?updated_since=2026-04-22T10:00:00Z&limit=200" \ -H "Authorization: Bearer $BOATEROS_KEY"
Paginate with the next cursor in the response envelope. 5 req/sec is the safe rate; our rate-limit headers tell you if you can go faster.
8. Daily reconciliation
Run a full pull once a day at 4am local. Compare it to your local mirror by id. Any hull you have but we don't → archive locally. Any hull we have but you don't → fetch and index. Any hull with a different updated_at → refetch. You'll find at most a handful per quarter; the reconciliation is there for the day you do.