This reference is built from the repo's docs/api-contract.md, which a CI drift guard checks against the live route surface — every route below exists in code, and every code route appears below.

API contract

Regenerated in Sprint 31 by walking packages/api/src/app.ts route by route (the Sprint 1 original was frozen at the MVP cut). The frontend-agnostic HTTP surface for the widget, the operator admin, the customer portal, and ops. Hono + Zod-validated. Money is integer cents. Every route below carries a ### \METHOD /path`heading;bun run check:api-docs` fails if this file and the code disagree in either direction.

Conventions

  • Base: /v1 (one liveness probe also lives at the bare app root). JSON in/out (Content-Type: application/json) except .ics (text/calendar), the manifest CSV (text/csv), resource downloads (stored content-type), and the Stripe webhook (raw body).
  • Auth tiers:
    • pk (publishable key): X-Publishable-Key: pk_… header scopes the request to one operator. Read catalog/availability, create holds/bookings/carts/waitlist entries, and the customer magic-link pair. No secrets exposed; payer PII never rides this tier.
    • operator (session): POST /v1/operator/login mints a revocable server-side session. Browsers get it as an httpOnly bk_op_session cookie; API consumers send the returned token as Authorization: Bearer <token>. Bearer wins when both are present. Legacy stateless HMAC tokens are accepted only outside production (allowLegacyTokens).
    • customer (session): same model, minted by the magic-link exchange; cookie bk_cust_session or Bearer.
    • none (capability): a few routes are deliberately unauthenticated — the unguessable id in the URL is the credential (booking .ics, resource download) or the route must answer before keys exist (health).
    • CRON_SECRET: the internal tick. Constant-time Bearer compare; the route is not mounted at all when CRON_SECRET is unset (fail closed → 404).
    • Stripe signature: the webhook is verified by Stripe-Signature (HMAC), not a key.
  • CSRF: cookie-authed state-changing requests get an Origin check (same-origin or configured allowedOrigins; violation → 403 csrf_rejected). Bearer and pk requests are never CSRF-checked.
  • Rate limiting: credential endpoints (login, password-reset/*, magic-link) run a PG-backed fixed-window limiter when configured → 429 rate_limited.
  • IDs are opaque strings. Timestamps ISO-8601 UTC. Money integer *_cents, currency always "usd" today.
  • CORS: only the operator’s configured widget origins; empty list = same-origin only.

Error model (uniform)

All errors return:

{ "error": { "code": "string_enum", "message": "human readable", "details": { } } }
HTTPcode exampleswhen
400bad_request, invalid_webhook_signature, not_configuredmalformed body / bad signature / webhook secret missing
401unauthorizedmissing/invalid auth (any tier), bad credentials, spent one-shot token
403forbidden, csrf_rejectedunknown pk; wrong cron secret; cookie CSRF violation
404not_foundunknown id, or an id scoped to a different operator/customer
409slot_unavailable, hold_expired, already_converted, invalid_transitioncapacity conflicts, dead holds, illegal status transitions
422validation_errorZod/domain failures — details carries field info where available
429rate_limitedfixed-window limiter tripped
500internalunexpected

Idempotent endpoints (webhook, tip) never surface 500 on replay — they no-op with 200/201.


Health & ops

GET /health

Bare-app liveness (no /v1 prefix, no DB touch). 200 { "ok": true }.

GET /v1/health

Liveness + DB readiness for load balancers / deploy smoke. No auth, no secrets in the body. 200 { "ok": true, "version": "<short sha|dev>", "uptimeSeconds": 123, "db": "ok" }; 503 with "db": "down" when the DB round-trip fails.

POST /v1/internal/tick

GET /v1/internal/tick

Auth: CRON_SECRET (Authorization: Bearer <secret>, constant-time compare; wrong → 401). Not mounted when CRON_SECRET is unset (→ 404). One idempotent pass over all time-driven work — due-message dispatch, abandoned-cart sweep, waitlist-offer expiry. Each job is isolated (a throw becomes { "error": "…" } without skipping the others). Both verbs behave identically (GET exists because Vercel Cron invokes with GET). 200:

{ "dispatch": { "sent": 2, "failed": 0, "retried": 0 },
  "carts": { "enqueued": 1, "dispatched": 1 },
  "waitlist": { "expired": 0, "reoffered": 0, "dispatched": 0 } }

Public widget endpoints (pk)

All routes in this section require X-Publishable-Key (401 missing → 403 unknown).

GET /v1/trip-types

List active trip types for the keyed operator. Query: ?boatId= (optional). 200:

{ "tripTypes": [ {
  "id": "tt_…", "name": "4-hr Inshore", "durationMinutes": 240,
  "capacity": 6, "basePriceCents": 60000, "currency": "usd",
  "deposit": { "kind": "percent", "value": 25 },
  "bookingMode": "private", "requiresLicense": false, "requiresWaiver": false,
  "season": { "start": null, "end": null }
} ] }

GET /v1/trip-types/:id

200: a single trip type (same shape). 404 unknown / wrong operator.

GET /v1/add-ons

The operator’s active add-ons for the widget’s add-on step. 200: { "addOns": [ { "id": "ao_…", "name": "Rod rental", "priceCents": 1500, "kind": "gear", "currency": "usd" } ] }

GET /v1/availability

Open slots for a trip type in a date range. Query (required): tripTypeId, from, to (ISO dates; invalid → 422, to − from > 60 days → 422). 200:

{ "slots": [ {
  "id": "slot_…", "tripTypeId": "tt_…", "startsAt": "2026-06-10T11:00:00Z",
  "endsAt": "2026-06-10T15:00:00Z", "capacity": 6, "remaining": 6,
  "bookingMode": "private", "status": "open"
} ] }

remaining = capacity − booked − active-held (expired holds excluded). For private, remaining is the full capacity when untouched, else 0.

GET /v1/slots/:id/weather

Weather/tide snapshot for the slot’s date + operator location (advisory, read-only; NOAA-backed when live, mock by default). 404 unknown slot. 200:

{ "date": "2026-06-10",
  "tides": [ { "type": "high", "time": "2026-06-10T04:12:00Z", "heightFt": 5.2 } ],
  "marine": { "summary": "SW winds 8-12 kt, seas 1-2 ft", "windKt": 10, "waveFt": 1.5 },
  "source": "mock" }

marine may carry additive optionals (wavePeriodS, waveDirDeg, swellFt, sstF) and is null when the marine leg fails; tides degrades to [].

POST /v1/holds

Reserve capacity for checkout. Atomic under SELECT … FOR UPDATE; stale holds on the slot are reaped first. Body: { "slotId": "slot_…", "seats": 2 } (seats defaults 1; private mode holds the full boat regardless). 201: { "id": "hold_…", "slotId": "slot_…", "seats": 2, "expiresAt": "…", "status": "active" } 409 slot_unavailable (insufficient capacity / closed slot), 404 unknown slot.

POST /v1/bookings

Convert a hold + customer details into a booking and a deposit PaymentIntent. Body:

{ "holdId": "hold_…", "partySize": 2,
  "customer": { "name": "Sam Angler", "email": "sam@x.com", "phone": "+15551234567" },
  "notes": "first-timer", "licenseAck": true,
  "waiver": { "signerName": "Sam Angler", "signatureText": "Sam Angler" },
  "addOns": [ { "addOnId": "ao_…", "qty": 2 } ],
  "promoCode": "REEL10", "giftCardCode": "GC-…", "applyStoreCredit": true,
  "cartId": "cart_…" }

All fields after customer optional. Promo/gift-card/store-credit apply inside the booking transaction (promo row locked, gift balance decremented under lock — can’t over-redeem); cartId marks the cart converted; a waiver is required (422) when the trip type has requiresWaiver. 201:

{ "booking": {
    "id": "bk_…", "status": "pending_payment", "partySize": 2,
    "totalCents": 60000, "depositCents": 15000, "balanceCents": 45000,
    "discountCents": 0, "currency": "usd"
  },
  "payment": { "kind": "deposit", "amountCents": 15000,
    "stripeClientSecret": "pi_…_secret_…", "paymentIntentId": "pi_…" } }

$0-deposit special case: full-coverage credit confirms the booking inline — status arrives "confirmed" and payment has amountCents: 0 with null secret/intent (the widget skips the payment step). 409 hold_expired / already_converted; 422 on bad data.

GET /v1/bookings/:id

Status poll for the widget after payment (until the webhook confirms). Scoped to the key’s operator (404 otherwise). 200: { "id": "bk_…", "status": "confirmed", "depositPaid": true, "totalCents": 60000, "depositCents": 15000, "balanceCents": 45000, "balanceMethod": "unpaid", "tipCents": 0, "addOns": [ { "name": "Rod rental", "qty": 2, "unitPriceCents": 1500 } ] }

POST /v1/bookings/:id/tip

Crew gratuity — idempotent: one gratuity per booking; a replay returns the existing tip with alreadyExisted: true and no second charge. Body: { "tipCents": 2000 } (integer ≥ 1 → else 422). 201: { "tipCents": 2000, "payment": { "kind": "gratuity", "amountCents": 2000, "stripeClientSecret": "…", "paymentIntentId": "pi_…", "alreadyExisted": false } }

GET /v1/promo/validate

Query: code, tripTypeId (required), amountCents (optional, for the computed discount). 200: { "valid": true, "code": "REEL10", "kind": "percent", "value": 10, "discountCents": 6000 } or { "valid": false, "reason": "code has expired" }.

POST /v1/gift-cards/purchase

Buy a gift card: creates a pending card + purchase PaymentIntent; the webhook activates it on payment. Body: { "amountCents": 5000, "purchaserEmail": "a@x.com", "recipientEmail": null }. 201: { "giftCard": { "id": "gc_…", "code": "GC-…", "balanceCents": 5000, "status": "pending" }, "payment": { "paymentIntentId": "pi_…", "clientSecret": "…" } }

GET /v1/gift-cards/:code

Balance/validity lookup. 200: { "valid": true, "code": "GC-…", "balanceCents": 5000, "status": "active" } or { "valid": false, "reason": "gift card has no balance remaining" }.

GET /v1/store-credit/balance

Query: email (required). Active, unexpired credit for that email under the keyed operator. 200: { "balanceCents": 15000 }.

POST /v1/waitlist

Join the waitlist for a trip type (optionally a specific slot). Body: { "tripTypeId": "tt_…", "slotId": null, "seats": 2, "customer": { "name", "email", "phone" } }. 201: { "id": "wl_…", "status": "pending" }. 404 unknown trip type. Offers fire automatically when capacity frees (cancel / reopen / reschedule-away).

POST /v1/carts

Abandoned-cart capture — the widget upserts at the details step. Body: { "tripTypeId": "tt_…", "slotId": null, "partySize": 2, "contactEmail": "a@x.com", "contactPhone": null, "addOns": {} } (tripTypeId + contactEmail required). 201: { "id": "cart_…", "status": "active" }.

PATCH /v1/carts/:id

Update selections (any subset of the create fields). 200: { "id": "cart_…", "status": "active" }. 404 unknown/wrong-operator cart.

GET /v1/carts/:id

Resume rehydration. Marks an active cart recovered when a recovery nudge had been sent. 200: { "id", "tripTypeId", "slotId", "partySize", "contactEmail", "contactPhone", "addOns", "status" }

GET /v1/waiver-template

The operator’s active waiver template (shown before signing). 200: { "template": { "id": "wv_…", "title": "…", "bodyText": "…" } }template is null when none is active.

POST /v1/customer/magic-link

Request a portal sign-in link (pk-scoped — the portal embeds pk like the widget; also rate-limited). Body: { "email": "sam@x.com" }. Always 200 { "ok": true } regardless of hit (no account enumeration); a real customer gets an email whose link carries a 15-minute single-use exchange token. Dev/demo (exposeMagicLink) adds devLink.

POST /v1/customer/magic-link/exchange

Swap the one-shot token for a customer session, exactly once. Body: { "token": "mlk_…" }. 200: { "ok": true, "token": "<session id>", "customer": { "id", "name", "email" } } (+ sets the bk_cust_session cookie). 401 invalid/already-used token.


Public capability URLs (no auth)

GET /v1/bookings/:id/ics

200 text/calendar: VCALENDAR/VEVENT (UID = booking id, DTSTART/DTEND from the slot, SUMMARY = trip + operator). The unguessable booking id is the credential. 404 unknown.

GET /v1/resources/:id/download

Streams a stored “What you should know” file with its stored content-type, content-disposition: attachment (sanitized filename), x-content-type-options: nosniff. Active file-kind resources only — unknown, inactive, or link-kind ids → 404. Operator info content only; never per-customer data.


Stripe webhook

POST /v1/webhooks/stripe

Raw body; Stripe-Signature verified (400 invalid_webhook_signature on failure → no state change; 400 not_configured when no webhook secret is configured). Idempotent via the webhook_event PK — a fully-processed replay short-circuits to { "received": true, "duplicate": true }. Handled events:

  • payment_intent.succeeded (deposit) → payment.succeeded, booking → confirmed (amount/currency checked — underpayment never confirms), then calendar + comms side effects (best-effort; failure never un-confirms).
  • payment_intent.succeeded (balance) → balance collected (split shares settle when all paid; card-on-file charge settles → balanceMethod: "card").
  • payment_intent.succeeded (gift_card) → gift card pending → active.
  • payment_intent.payment_failedpayment.failed (off-session balance charge becomes retryable; booking stays pending_payment for a deposit).
  • setup_intent.succeeded → persist the card-on-file payment method (idempotent). Always 200 once signature-valid, even on replay.

Operator auth & sessions

POST /v1/operator/login

Rate-limited. Body: { "email": "…", "password": "…" }200 { "token": "<session id>", "operator": { "id": "op_…", "name": "…" } } + httpOnly bk_op_session cookie. 401 bad credentials. The session is revocable server-side.

POST /v1/operator/logout

Auth: operator. Revokes the current session (no-op for a legacy stateless Bearer) and clears the cookie. 200 { "ok": true }. Idempotent.

POST /v1/operator/sessions/revoke-all

Auth: operator. Logs out every device. 200 { "ok": true }.

POST /v1/operator/password-reset/request

Rate-limited, no auth. Body: { "email": "…" }. Always 200 { "ok": true } (no account enumeration); a real operator gets an email with a 30-minute one-shot reset link. Dev/demo (exposeMagicLink) adds devLink.

POST /v1/operator/password-reset/confirm

Rate-limited, no auth — the one-shot token is the credential. Body: { "token": "pwr_…", "password": "min 8 chars" }. Sets the new Argon2id hash and revokes all sessions. 200 { "ok": true }; 401 invalid/already-used token.


Operator endpoints

All routes below require operator auth (Authorization: Bearer <session> or the session cookie); unknown/wrong-operator ids → 404 (or 403 where noted).

Bookings & money

GET /v1/operator/bookings

The manifest list. Query: ?from=&to=&status= (optional). 200:

{ "bookings": [ {
  "id": "bk_…", "tripType": "4-hr Inshore", "startsAt": "2026-06-10T11:00:00Z",
  "customer": { "name": "Sam Angler", "phone": "+15551234567" },
  "partySize": 2, "status": "confirmed",
  "depositCents": 15000, "balanceCents": 45000, "balanceMethod": "unpaid", "tipCents": 0
} ] }

GET /v1/operator/bookings/:id

Full admin detail. 200: booking core (status, party/money fields, licenseAck, rescheduleOffered, cancellationReason, notes), tripName, bookingMode, startsAt/endsAt, customer { name, email, phone }, addOns[], waiver { signerName, signedAt } | null, depositStatus, depositPaid, balanceCharge: "none" | "pending" | "failed" | "card" (off-session charge state), and autoRefund (true when a real Stripe key auto-processes refund-on-cancel; false = keyless mark-for-manual).

POST /v1/operator/bookings/:id/cancel

Body (optional): { "reason": "…", "resolution": "refund" | "store_credit" } (default refund). Legal transition only (completed/cancelled source → 409 invalid_transition); releases slot capacity (private = boat capacity, shared = party size); refund flags the deposit refunded (real key → post-commit Stripe refund; keyless → mark-for-manual, no provider call), store_credit mints credit for the actually collected deposit. Frees capacity → may auto-offer the waitlist. Wrong operator → 403. 200: { "id", "status": "cancelled", "slotId", "releasedSeats": 2, "deposit": "refunded" | "store_credit", "storeCreditCents": 0, "cancellationReason", "waitlistOffered": false, "refundId": null }

POST /v1/operator/bookings/:id/complete

Mark a confirmed booking completed → fires the post-trip review request. 409 invalid_transition from any other status; wrong operator → 403. 200: { "id": "bk_…", "status": "completed" }

POST /v1/operator/bookings/:id/charge-balance

Charge the outstanding balance to the customer’s saved card, off-session. Guards: no balance (422), already on card (422), charge in flight or collected (422), no saved card (422). Real Stripe returns "pending" and settles via webhook; the keyless mock settles inline. 200: { "bookingId", "chargedCents": 45000, "status": "succeeded" | "pending", "method": { "id", "brand", "last4", "expMonth", "expYear", "isDefault" } }

GET /v1/operator/bookings/:id/split

Split status (payer emails/amounts — operator + customer tokens only, never pk). 200: { "bookingId", "shares": [ { "email", "amountCents", "status": "pending" | "paid" } ], "paidCount": 1, "totalCount": 2, "allPaid": false }

POST /v1/bookings/:id/reschedule

Auth: operator (despite the public-looking path — anglers use the customer route). Moves a confirmed/pending_payment booking to a new open, same-trip-type slot atomically (both slots locked in stable order; no re-charge). Body: { "newSlotId": "slot_…" }. 409 slot_unavailable (closed/full) / invalid_transition; 422 same-slot or different trip type; freed capacity may auto-offer the waitlist. 200: { "id", "operatorId", "slotId": "<new>", "status", "rescheduleOffered": false, "waitlistOffered": false }

GET /v1/operator/summary

Dashboard stats. Query: ?from=&to= (optional). 200: { "upcomingCount", "confirmedCount", "pendingCount", "completedCount", "cancelledCount", "depositsCollectedCents", "balanceOutstandingCents", "currency": "usd" }depositsCollectedCents = succeeded deposit payments; balanceOutstandingCents = confirmed-booking balances minus already-collected balance payments.

GET /v1/operator/manifest/export

Coast Guard manifest CSV for an operator-local day. Query: date (required, YYYY-MM-DD in the operator’s timezone), boatId (optional). 200 text/csv download (trip, boat, start, customer, party size, phone, status + souls-on-board total); confirmed + completed bookings only.

Catalog & schedule

POST /v1/trip-types

Create a trip type. Body: { boatId, name, durationMinutes, capacity, basePriceCents, deposit: { kind: "percent"|"fixed", value }, bookingMode: "private"|"shared", seasonStart?, seasonEnd? }. 201: the created trip type (public shape above). 404 unknown boat; 422 on validation (capacity > boat capacity, deposit > price, float money, …).

PATCH /v1/trip-types/:id

Edit (any subset incl. requiresLicense, requiresWaiver, active). Re-validates the merged definition (→ 422). active: false deactivates — drops from the widget, keeps history. Capacity/mode edits do not rewrite existing slots. 200: updated trip type.

GET /v1/operator/boats

200: { "boats": [ { "id": "boat_…", "name": "Reel Time", "capacity": 6 } ] }

POST /v1/operator/slots

Generate slots from a recurrence rule. Body: { "tripTypeId": "tt_…", "rule": { "startDate": "2026-06-01", "endDate": "2026-06-30", "daysOfWeek": [0,6], "times": ["06:00","13:00"] } }. Duplicates (same boat + start) are skipped silently. 201: { "created": 12 }

GET /v1/operator/slots

ALL slots (open + closed) with raw counts — the schedule screen. Query: ?tripTypeId=&from=&to=&status= (optional). 200: { "slots": [ { "id", "tripTypeId", "tripTypeName", "startsAt", "endsAt", "capacity", "bookedCount", "heldCount", "status", "bookingMode" } ] }

PATCH /v1/operator/slots/:id

Body: { "status": "closed" | "open" }. Close (weather/maintenance) → marks active bookings reschedule_offered + sends reschedule_offer messages → { "id", "status": "closed", "affected": ["bk_…"] }. Reopen → may auto-offer the waitlist → { "id", "status": "open", "waitlistOffered": false }.

POST /v1/operator/add-ons

Body: { "name": "Rod rental", "priceCents": 1500, "kind": "gear" } (kind optional). 201: { "id", "name", "priceCents", "kind", "active": true, … } shape minus active on create — concretely { id, name, priceCents, kind, currency }. 422 negative/float price.

PATCH /v1/operator/add-ons/:id

Body: any of { name, priceCents, kind, active }. Price edits don’t rewrite booked unitPriceCents snapshots; active: false drops it from the public list. 200: { "id", "name", "priceCents", "kind", "active", "currency" }

Profile

GET /v1/operator/me

200: { "id", "name", "email", "timezone", "latitude", "longitude", "tideStationId", "calendarConnected", "meetingPoint", "whatToBring", "safetyNotes" }

PATCH /v1/operator/me

Body: any of { name, timezone, latitude, longitude, tideStationId, meetingPoint, whatToBring, safetyNotes }. Invalid IANA timezone, out-of-range coords, or a non-alphanumeric tide station id → 422. 200: the updated profile.

Waivers

GET /v1/operator/waivers

200: { "templates": [ { "id", "title", "bodyText", "active" } ] } (newest first).

POST /v1/operator/waivers

Body: { "title", "bodyText" } (both required → 422). 201: the template.

PATCH /v1/operator/waivers/:id

Body: any of { title, bodyText, active }. 200: the updated template.

Trip resources (“What you should know” pack)

GET /v1/operator/resources

200: { "resources": [ { "id", "tripTypeId", "title", "kind": "link"|"file", "url", "description", "sortOrder", "active", "mimeType", "sizeBytes", "originalFilename" } ] } (sorted by sortOrder, then created).

POST /v1/operator/resources

Create a link resource. Body: { "title", "url", "description?", "tripTypeId?", "sortOrder?" }. URL must be absolute http(s) → else 422; unknown/wrong-operator tripTypeId422. 201: the resource.

POST /v1/operator/resources/upload

Create a file resource (multipart). Fields: file (required), title? (defaults to filename), description?, tripTypeId?, sortOrder?. 422 on missing file, type outside the allowlist (pdf/png/jpeg/webp/txt/md/docx), or > 10 MB (body limit). Bytes land in the FileStorageProvider (local disk; key = resource id). 201: the resource.

PATCH /v1/operator/resources/:id

Body: any of { title, url, description, tripTypeId, sortOrder, active }. url only on link kind (422 otherwise). 200: the updated resource.

Promo codes

GET /v1/operator/promo-codes

200: { "promoCodes": [ { "id", "code", "kind": "percent"|"fixed", "value", "maxUses", "usedCount", "startsAt", "endsAt", "appliesToTripTypeId", "active" } ] }

POST /v1/operator/promo-codes

Body: { "code", "kind", "value", "maxUses?", "startsAt?", "endsAt?", "appliesToTripTypeId?" }. Percent value 0–100 (422), duplicate code per operator → 422. 201: the promo.

PATCH /v1/operator/promo-codes/:id

Body: any of { code, value, maxUses, startsAt, endsAt, appliesToTripTypeId, active }. 200: the updated promo.

Gift cards

GET /v1/operator/gift-cards

200: { "giftCards": [ { "id", "code", "initialCents", "balanceCents", "status": "pending"|"active"|"void", "purchaserEmail", "recipientEmail", "expiresAt", "redemptionCount" } ] }

POST /v1/operator/gift-cards

Operator-issued card (comp/manual sale) — active immediately, no payment. Body: { "amountCents": 5000, "recipientEmail?": "…" }. 201: the gift card.

Waitlist, carts, CRM

GET /v1/operator/waitlist

Query: ?status= (optional: pending/offered/claimed/expired). 200: { "waitlist": [ { "id", "tripName", "slotStartsAt", "customer": { name, email, phone }, "requestedSeats", "status", "offerExpiresAt", "createdAt" } ] }

GET /v1/operator/carts

200: { "carts": [ { "id", "tripName", "partySize", "contactEmail", "status": "active"|"converted"|"recovered", "recoverySent", "lastSeenAt", "createdAt" } ] }

POST /v1/operator/carts/sweep

Manual abandoned-cart recovery sweep (the always-on cron is the tick). Query: ?staleMinutes= (optional override). Exactly-once per cart (locked re-check). 200: { "enqueued": 1, "dispatched": 1 }

GET /v1/operator/customers

Email-aggregated CRM list (paid bookings only). Query: ?search= (name/email ilike). 200: { "customers": [ { "email", "name", "bookingCount", "totalSpentCents", "lastBookingAt", "repeat", "tier": "regular"|"silver"|"gold" } ] }

GET /v1/operator/customers/:email

One customer: history + notes + the same aggregate. 404 when neither history nor notes exist. 200: { "email", "name", "bookingCount", "totalSpentCents", "repeat", "tier", "bookings": [ { "id", "tripName", "startsAt", "status", "totalCents" } ], "notes": [ { "id", "note", "createdAt" } ] }

POST /v1/operator/customers/:email/notes

Body: { "note": "…" } (non-empty → 422). 201: { "id", "note", "createdAt" }


Customer portal endpoints

All routes below require customer auth (magic-link session — cookie or Bearer); a booking that isn’t the signed-in angler’s reports 404 (no cross-customer leak).

POST /v1/customer/logout

Revoke the session + clear the cookie. 200 { "ok": true }. Idempotent.

GET /v1/customer/me

200: { "id", "name", "email", "phone" }

GET /v1/customer/store-credit

200: { "balanceCents": 15000 } (active, unexpired credit under the booking operator).

GET /v1/customer/payment-methods

200: { "methods": [ { "id", "brand", "last4", "expMonth", "expYear", "isDefault" } ] }

POST /v1/customer/payment-methods

Save a card on file (SetupIntent). Body (mock/demo only): { "brand?", "last4?", "expMonth?", "expYear?" }. Keyless mock persists inline → 201 { "method": { … }, "pending": false, "setupIntentClientSecret": "…" }; real Stripe returns { "method": null, "pending": true, "setupIntentClientSecret": "…" } and the setup_intent.succeeded webhook persists the method.

GET /v1/customer/bookings

200: { "bookings": [ { "id", "tripName", "startsAt", "endsAt", "partySize", "status", "totalCents", "balanceCents", "rescheduleOffered" } ] } (soonest first).

GET /v1/customer/bookings/:id

Full detail incl. “know before you go” + the resources pack. 200: booking core fields (status, money fields, depositPaid, tipCents, rescheduleOffered, cancellationReason), tripName, bookingMode, startsAt/endsAt, operatorName, meetingPoint, whatToBring, safetyNotes, addOns[], and resources: [ { "id", "title", "kind", "description", "url" } ] (file kind carries the root-relative capability download path).

GET /v1/customer/bookings/:id/reschedule-options

Open future same-trip-type slots that fit the party (next 60 days, excludes the current slot). 200: { "slots": [ <availability slot shape> ] }

POST /v1/customer/bookings/:id/reschedule

Self-service reschedule — same semantics as the operator route (atomic, no re-charge). Body: { "newSlotId": "slot_…" }. 200: { "id", "operatorId", "slotId", "status", "rescheduleOffered": false, "waitlistOffered": false }

POST /v1/customer/bookings/:id/cancel

Self-service cancel — always the refund resolution (store credit is an operator concession). Body: { "reason?": "…" }. 409 invalid_transition from terminal states. 200: same shape as the operator cancel.

POST /v1/customer/bookings/:id/split

Split the outstanding balance among friends: one PaymentIntent + share per payer. Confirmed bookings only; shares must sum to what’s still owed; one split per booking; all guards re-checked under lock. Body: { "shares": [ { "email": "b@x.com", "amountCents": 22500 } ] }. 201: { "bookingId", "balanceCents", "shares": [ { "email", "amountCents", "payLink", "status": "pending" } ] }

GET /v1/customer/bookings/:id/split

Same split-status shape as the operator route, scoped to the angler’s own booking.

GET /v1/customer/bookings/:id/ics

200 text/calendar for the angler’s own booking; 404 if it isn’t theirs.

GET /v1/customer/bookings/:id/weather

Weather/tide snapshot for the angler’s own booking (token-scoped; same shape and provider path as GET /v1/slots/:id/weather). Feeds the portal “Know before you go” tide curve. 404 if the booking isn’t theirs. 200: same body as the slot weather route.


Ocean / fish-intel endpoints (Phase 9, Sprint 36)

Habitat-suitability data for the captain dashboard (spec: docs/fish-intel-spec.md §6). The shared grids are platform-global — identical for every operator; only fish-count submissions are per-tenant. Public reads ride the pk; species ∈ pbt|yft|dorado|yt (anything else → 422). Reads serve the day’s persisted platform build first and compute live from the providers on a miss (reads never persist).

GET /v1/ocean/conditions

Auth: pk. Query: ?date=YYYY-MM-DD (default today). The raw ocean snapshot + the trip-day marine go/no-go. 200: { "date", "asOf", "source": "mock"|"noaa", "bbox", "resolutionDeg", "sst": [[…]], "currents": [[{u,v}]], "chl": [[…]]|null, "marine": { "summary", "windKt", "waveFt", … }|null, "feasibility": { "tier": "green"|"caution"|"red", "windKt", "waveFt" }, "layers": { "sst", "sstBreaks", "currents", "chl", "fish": { "status": "ok"|"unavailable"|"off", "source", "asOf" } } } — grids are dense row-major arrays (row 0 = south); asOf is the satellite field time (the recency chip); the marine SST is forecast-model data, never the satellite grid. layers is per-layer provenance for the dashboard freshness chips — status separates a healthy-but-empty layer (ok) from an unavailable source (unavailable) from a flag-gated layer (off, e.g. chl by default); sstBreaks shares SST’s provenance (derived, not fetched). On the keyless mock: sst/currents/fish ok from mock, chl off.

GET /v1/ocean/forecast/:species

Auth: pk. Query: ?date= (default today). Per-species score grid + why-breakdowns. 200: { "date", "species", "source", "asOf", "gridStats": { min, mean, max, threshold }, "grid": [ { "cellId", "lat", "lon", "score", "confidence", "contributions": [ { "key", "label", "rawSubScore", "weight", "weighted" } ], … } ], "bestBets": [ <zone> ] } 422 unknown species or bad date.

GET /v1/ocean/best-bets

Auth: pk. Query: ?date=&species= (species optional → all four). Ranked zones. 200: { "date", "zones": [ { "species", "centroid": {lat,lon}, "peakScore", "meanScore", "cellCount", "runNmi", "bearingDeg", "nearestBank", "feasibilityTier", "topContributions" } ] } (sorted by peakScore desc; bearings display WITH the coarseness disclaimer).

GET /v1/ocean/fish-counts

Auth: pk. Query: ?date=&species= (both optional). The normalized catch feed — global scraped/mock rows plus this operator’s own submissions (never another tenant’s). 200: { "date", "counts": [ { "id", "operatorId", "date", "boat", "landing", "anglers", "tripType", "durationDays", "species": [{name,kept,released?,topSizeLb?}], "area", "lat", "lon", "areaConfidence": "explicit"|"inferred"|"unknown", "source" } ] }

POST /v1/ocean/fish-counts

Auth: operator. Report a catch (or a sighted kelp paddy as a pseudo-report). Body: { "date", "boat", "landing", "anglers?", "tripType", "durationDays?", "species": [{ "name", "kept", "released?", "topSizeLb?" }], "area?", "lat?", "lon?" }. operatorId comes from the token; source is always "operator". Coords resolve server-side from the named area (gazetteer) when lat/lon are omitted; lat/lon come as a pair (422 otherwise). 201: the stored report.

POST /v1/ocean/forecast/run

Auth: operator (dev-only manual trigger — production runs the same build inside the scheduled tick; mirrors the carts/sweep precedent). Body (optional): { "date?" }. Fetches the snapshot, refreshes the day’s global count feed, scores all four species, and replaces the day’s platform run (unique(date) upsert semantics — idempotent, no conflict code). 202: { "runId", "date", "status": "ok"|"partial"|"failed", "species": ["pbt","yft","dorado","yt"] }


Flow trace (the E2E path)

  1. GET /v1/trip-types → pick tt_….
  2. GET /v1/availability?tripTypeId&from&to → pick slot_… (optionally GET /v1/slots/:id/weather for the tide/marine panel).
  3. POST /v1/holds {slotId, seats}hold_… (capacity reserved under lock).
  4. POST /v1/bookings {holdId, partySize, customer, …}bk_… pending_payment + deposit clientSecret (or inline-confirmed for a fully-credited $0 deposit).
  5. Widget confirms the PaymentIntent with Stripe.js (sandbox).
  6. Stripe → POST /v1/webhooks/stripe payment_intent.succeededbk_… confirmed → comms + calendar + .ics.
  7. GET /v1/operator/bookings (operator session) → the booking appears in the manifest.

Not yet on this surface (roadmap)

  • Public reviews surface (review_request messages fire, but there’s no /v1/reviews).
  • Operator self-signup (beta is single hand-provisioned operator).