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.tsroute 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/loginmints a revocable server-side session. Browsers get it as an httpOnlybk_op_sessioncookie; API consumers send the returnedtokenasAuthorization: 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_sessionor 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_SECRETis unset (fail closed → 404). - Stripe signature: the webhook is verified by
Stripe-Signature(HMAC), not a key.
- pk (publishable key):
- CSRF: cookie-authed state-changing requests get an Origin check (same-origin or
configured
allowedOrigins; violation → 403csrf_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 → 429rate_limited. - IDs are opaque strings. Timestamps ISO-8601 UTC. Money integer
*_cents,currencyalways"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": { } } }
| HTTP | code examples | when |
|---|---|---|
| 400 | bad_request, invalid_webhook_signature, not_configured | malformed body / bad signature / webhook secret missing |
| 401 | unauthorized | missing/invalid auth (any tier), bad credentials, spent one-shot token |
| 403 | forbidden, csrf_rejected | unknown pk; wrong cron secret; cookie CSRF violation |
| 404 | not_found | unknown id, or an id scoped to a different operator/customer |
| 409 | slot_unavailable, hold_expired, already_converted, invalid_transition | capacity conflicts, dead holds, illegal status transitions |
| 422 | validation_error | Zod/domain failures — details carries field info where available |
| 429 | rate_limited | fixed-window limiter tripped |
| 500 | internal | unexpected |
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 cardpending → active.payment_intent.payment_failed→payment.failed(off-session balance charge becomes retryable; booking stayspending_paymentfor 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
tripTypeId → 422. 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)
GET /v1/trip-types→ picktt_….GET /v1/availability?tripTypeId&from&to→ pickslot_…(optionallyGET /v1/slots/:id/weatherfor the tide/marine panel).POST /v1/holds {slotId, seats}→hold_…(capacity reserved under lock).POST /v1/bookings {holdId, partySize, customer, …}→bk_…pending_payment+ depositclientSecret(or inline-confirmedfor a fully-credited $0 deposit).- Widget confirms the PaymentIntent with Stripe.js (sandbox).
- Stripe →
POST /v1/webhooks/stripepayment_intent.succeeded→bk_…confirmed→ comms + calendar +.ics. GET /v1/operator/bookings(operator session) → the booking appears in the manifest.
Not yet on this surface (roadmap)
- Public reviews surface (
review_requestmessages fire, but there’s no/v1/reviews). - Operator self-signup (beta is single hand-provisioned operator).