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
All responses carry an X-Request-Id header (a UUID generated per request, or the
caller-supplied value if X-Request-Id was sent, useful for correlating logs across the
API, the widget, and admin).
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.
GET /v1/operator/system-status
Auth: operator session. Richer diagnostics for the admin console: DB round-trip latency, uptime, build version, and provider mode. No secrets in the body. 200:
{
"db": { "ok": true, "latencyMs": 2 },
"version": "abc1234",
"uptimeSeconds": 3601,
"providers": {
"payment": "mock",
"messaging": "mock",
"calendar": "mock"
}
}
payment is "live" / "test" / "mock" based on the active Stripe publishable key prefix;
messaging is "mock" / "live" based on the active provider class.
GET /v1/operator/email-preview
Auth: operator session. Returns a rendered HTML preview of any email template, populated
with sample data (and the operator’s real business name). Useful for reviewing templates without
sending a real email. Query param: type (required), one of the MESSAGE_TYPE enum values.
200 Content-Type: text/html, the rendered HTML email.
GET /v1/widget-info
Public. Returns the current widget bundle version and its SHA-384 Subresource Integrity hash (for SRI-hardened embeds). No auth required.
200 { "version": "0.0.0", "sriHash": "sha384-<base64>" | null }.
sriHash is null when the widget bundle has not been built (development) or is not co-located with the API process. The hash changes with every widget rebuild, so operators should update SRI-locked embed snippets after widget upgrades.
GET /v1/config
Public client config (no auth, no secrets). 200 { "signupEnabled": false }, whether
operator self-signup is open (SIGNUP_ENABLED=true). The admin reads this once on load to decide
whether to show the “Create an account” link. The server-side 404 gate on the signup routes is the
real boundary; this flag is purely UX.
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).
Query: ?jobs= selects the beat (Phase 146 — the two-beat freshness contract):
- omitted / anything unrecognized →
all, the full daily set (build + gap sweep + late-postings count re-check + prunes). Fired by the0 13 * * *cron. counts→ the evening beat: dock-count ingestion for the current Pacific day and nothing else. Fired by the30 4 * * *cron (= 21:30 PDT / 20:30 PST — Vercel cron schedules are UTC, and the DST drift is accepted: the morning beat re-checks the same date).frequent→ the light sub-daily set (reminders/expiries/holds/limiter). Owner-gated; no cron fires it today.
200:
{ "dispatch": { "sent": 2, "failed": 0, "retried": 0 },
"carts": { "enqueued": 1, "dispatched": 1 },
"waitlist": { "expired": 0, "reoffered": 0, "dispatched": 0 },
"countsIngest": { "ingested": [ { "date": "2026-07-26", "source": "sandiegofishreports",
"outcome": "ok", "parsedRows": 22, "write": "replaced",
"alert": "skipped", "snapshotFailed": false } ] },
"rawSnapshotPrune": { "swept": 0 } }
POST /v1/internal/counts-backfill
Auth: CRON_SECRET (same constant-time Bearer compare as the tick; wrong → 401). Not
mounted when CRON_SECRET is unset (→ 404). POST only — a GET would let a link preview start a
scrape run. Owner/agent-triggered, not a cron: the historical dock-count walk (Phase 147) is an
operational task, deliberately absent from vercel.json so it can never delay a daily beat.
Walks boats.php?date= from newest to oldest over the requested range, writing parsed fish_count
rows only (no raw snapshots). Bounded hard at this route — 3 fetches and 50s per call, whatever the
caller asks for — so a full backfill is many calls, or one run of bun run backfill:counts. Progress
is a durable per-date ledger, so calling again always resumes and never re-fetches a settled date.
Politeness is enforced in Postgres, not per process: one walk claim per source (a concurrent call
returns started: false and fetches nothing), one fetch claim shared with the daily count beats
(so a beat and a backfill can never be in flight against the source at once — the loser defers), and
a ≥10s floor measured from the last completed fetch by any caller.
Query: ?from=YYYY-MM-DD ?to=YYYY-MM-DD (both clamped: never older than 2018-01-01, never
newer than 2 days behind the current Pacific day), ?maxFetches=N (capped at 3).
200:
{ "started": true, "source": "sandiegofishreports", "from": "2018-01-01", "to": "2026-07-24",
"attempted": 3, "ok": 2, "empty": 1, "unparseable": 0, "failed": 0, "rows": 41,
"skipped": 12, "remaining": 3110, "stopped": "max_fetches",
"maxFetches": 3, "maxDurationMs": 50000, "spacingMs": 10000,
"seasons": [ { "season": "2026", "dates": 15, "rows": 320, "ok": 12, "empty": 3,
"unparseable": 0, "failed": 0, "datasetDates": 60, "datasetRows": 1420 } ] }
stopped is complete | complete_with_failures | max_fetches | deadline |
consecutive_failures | lease_lost | fetch_claim_held; everything but complete means “call
again”, and remaining says how much is still owed. complete_with_failures is a range walked to
its end that still owes dates whose fetch failed (retryable) — distinguished from complete so a
caller never has to cross-check two fields to learn whether a range is done. Each season carries two halves:
the ledger counts (what the backfill settled, where unparseable records a page we could not read
rather than a day with no reports) and datasetDates/datasetRows (what this source’s rows
actually add up to, including dates the live beats wrote).
Public widget endpoints (pk)
All routes in this section require X-Publishable-Key (401 missing → 403 unknown).
GET /v1/payment-config
Whether real Stripe is active + the publishable key the widget needs to mount Stripe Elements for the
deposit step. enabled is false under the keyless mock or with no publishable key; the widget then
stays reserve-only (no card step) and publishableKey/mode are null. The publishable key is public
by design (pk_test_…/pk_live_…); no secret is returned.
200:
{ "enabled": true, "publishableKey": "pk_test_…", "mode": "test" }
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 },
"ratingCount": 12, "ratingAverage": 4.8
} ] }
ratingCount is the number of published reviews; ratingAverage is their 1-dp mean (null
when ratingCount is 0). Powers the widget social-proof chip (Phase 13).
GET /v1/trip-types/:id
200: a single trip type (same shape, incl. ratingCount / ratingAverage). 404 unknown / wrong operator.
GET /v1/trip-types/:id/reviews
Public published reviews + aggregate for a trip type (Phase 13). Newest first; pk-scoped. 404 if the trip type isn’t the keyed operator’s. 200:
{ "ratingCount": 12, "ratingAverage": 4.8, "reviews": [ {
"id": "rev_…", "rating": 5, "comment": "Wide-open bite by 7am.",
"authorName": "Marisol R.", "operatorResponse": null,
"operatorRespondedAt": null, "createdAt": "2026-06-21T21:30:49.830Z"
} ] }
authorName is the reviewer’s first name + last initial (privacy-preserving).
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/operator-policy
The operator’s cancellation/deposit policy text, shown in the widget review step (Sprint 60).
Minimal projection, no PII; the operator is resolved from the publishable key.
200: { "cancellationPolicy": "72h notice for a full refund.", "depositPolicy": "25% deposit, balance at the dock.", "showConditions": false, "refundTiers": null, "paymentMode": "deposit" }
(policy text fields are null when the operator hasn’t set them).
paymentMode (S76, Stripe Connect charge routing): "deposit" ⇒ the widget collects an online card
deposit; "reserve_only" ⇒ the operator can’t take online deposits yet (Connect not charge-ready), so
the widget skips the card step and the angler reserves + settles at the dock. Derived from the
operator’s Connect status; never exposes account ids.
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/holds/:id/extend
Refresh an active hold’s TTL (S-Trust1) so a slow checkout doesn’t silently lose the slot.
Row-locked and idempotent: expiresAt is always recomputed as now + 10min, never stacked on
top of the previous expiry, and no new hold row is ever created.
200: { "id": "hold_…", "slotId": "slot_…", "seats": 2, "expiresAt": "…", "status": "active" }
409 hold_expired (hold already expired or already converted/released), 404 unknown hold.
POST /v1/bookings
Convert a hold + customer details into a booking and a deposit PaymentIntent. Send an
Idempotency-Key header (8-128 chars) for retry/double-submit safety; clients that cannot set
custom headers may send the same value as idempotencyKey in the JSON body.
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_…", "idempotencyKey": "checkout-attempt-…", "source": "hosted" }
All fields after customer optional. source accepts only the literal "hosted" (set by the
hosted booking page’s widget mount, S108-S3) — any other value is a validation error, and there is
no way to claim "operator" through this public route. Analytics-grade attribution only; it never
affects pricing, capacity, or auth. Promo/gift-card/store-credit apply inside the booking
transaction (promo row locked, gift balance decremented under lock, so it can’t over-redeem);
cartId marks the cart converted; a waiver is required (422) when the trip type has
requiresWaiver. A replay with the same operator + idempotency key + identical request returns the
original booking response without converting the hold or spending credits again. Reusing the key with a
different request returns 422 validation_error.
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).
Reserve-only special case (S76): when the operator can’t take online deposits yet (Connect not
charge-ready, GET /v1/operator-policy reports paymentMode: "reserve_only"), the booking confirms
inline with no PaymentIntent; status arrives "confirmed" and payment has the real
amountCents (the deposit owed) but null stripeClientSecret/paymentIntentId. The angler pays the
deposit + balance at the dock. A charge-ready Connect operator’s deposit is otherwise created on their
connected account (direct charge, $0 platform fee) and settles via the Connect webhook.
409 hold_expired / already_converted; 422 on bad data.
POST /v1/bookings/quote
Server-authoritative price preview (W1). Read-only + idempotent; computes money via the same
@booking/core assembly and the same dynamic-pricing-rule path as POST /v1/bookings, so the review
screen quotes exactly what the booking will charge. Redeems no promo, spends no gift card / store
credit, locks nothing, writes nothing. Rate-limited 30/min.
Body:
{ "tripTypeId": "tt_…", "slotId": "slot_…", "partySize": 2,
"addOns": [ { "addOnId": "ao_…", "qty": 2 } ],
"promoCode": "REEL10", "giftCardCode": "GC-…",
"useStoreCredit": true, "contactEmail": "sam@x.com" }
All fields after partySize optional; contactEmail is only needed to resolve store credit. An
invalid/expired promo or empty gift card is not an error; the quote returns without that discount
plus a messages[] entry (promo_invalid / gift_card_invalid). Unknown trip/slot → 404; party
size over capacity / unknown add-on → 422.
200:
{ "currency": "usd",
"adjustedBaseCents": 30000, "dynamicPricingApplied": true,
"tripSubtotalCents": 60000,
"addOns": [ { "addOnId": "ao_…", "qty": 2, "unitPriceCents": 1500, "lineCents": 3000 } ],
"addOnsSubtotalCents": 3000,
"discounts": { "promo": { "code": "REEL10", "amountCents": 6000 } },
"totalCents": 57000, "depositCents": 15000, "balanceCents": 42000,
"paymentMode": "deposit", "messages": [] }
adjustedBaseCents is the per-seat (shared) or whole-boat (private) base after dynamic rules;
dynamicPricingApplied is true when a rule moved it off the persisted price. paymentMode: "reserve_only" means nothing is charged online (settled at the dock). The quoted
depositCents/balanceCents equal what POST /v1/bookings produces for the same inputs.
POST /v1/widget-perf
Opt-in, zero-PII field perf telemetry (S-Metrics1); the widget only calls this when the operator’s
embed sets perfTelemetry/data-perf-telemetry. Log-only sink (no DB table): the handler writes one
widget_perf log line and returns. Rate-limited 60/min.
Body: { "step": "review", "metric": "lcp", "valueMs": 1200 }; step is one of the widget’s
Step values, metric is "lcp" | "inp" | "step_transition". No email/name/booking id/customer id
accepted or logged; operatorId is resolved server-side from the publishable key, never trusted from
the body.
200: { "ok": true }
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.
POST /v1/customer/signup
Optional password account (Sprint 51). pk-scoped, rate-limited. Gated by SIGNUP_ENABLED → 404
when off (magic-link above is never gated). Body: { "email": "…", "password": "min 8 chars", "name": "…", "phone"?: "…" }. Creates or links an unverified password account for
the email (a booked-but-passwordless angler is linked; a missing email is created) and emails a 24h
one-shot verification link (/portal/#/verify?xt=…). No session is issued (verify first).
201 { "ok": true, "devLink"? }; 409 email_taken when the email already has a password
(never 500).
POST /v1/customer/signup/verify
pk-scoped, rate-limited. Gated by SIGNUP_ENABLED → 404 when off. Body: { "token": "cvt_…" } (the xt value). Atomically consumes the verification one-shot (single-use), flips
emailVerified, and mints a customer session. 200 { "ok": true, "token": "<session id>", "customer": { "id", "name", "email" } } + bk_cust_session cookie; 401
invalid/already-used/expired token.
POST /v1/customer/signup/resend
pk-scoped, rate-limited. Gated by SIGNUP_ENABLED → 404 when off. Body: { "email": "…" }.
Re-issues a verification link for an existing unverified password account. Always 200
{ "ok": true, "devLink"? } (no enumeration; a missing/already-verified/passwordless email no-ops).
POST /v1/customer/login
Password login for a customer (pk-scoped, rate-limited; not gated by SIGNUP_ENABLED, since existing
accounts must keep signing in). Magic-link stays the ungated default alongside this. Body:
{ "email": "…", "password": "…", "rememberMe"?: boolean } → 200 { "token": "<session id>", "customer": { "id", "name", "email" } } + bk_cust_session cookie. 401 bad credentials (incl. a
magic-link-only angler with no password, no enumeration). 403 email_unverified when the
password is correct but the email is unverified. rememberMe: true → 30-day session vs the 12h default.
POST /v1/customer/password-reset/request
pk-scoped, rate-limited. Body: { "email": "…" }. Always 200 { "ok": true } (no
enumeration); a customer with a password gets an email with a 30-minute one-shot reset link
(/portal/#/reset?token=…). A magic-link-only (passwordless) email silently no-ops. Dev/demo
(exposeMagicLink) adds devLink.
POST /v1/customer/password-reset/confirm
pk-scoped, rate-limited; the one-shot token is the credential. Body: { "token": "cpr_…", "password": "min 8 chars" }. Sets the new hash and revokes all customer sessions. 200
{ "ok": true }; 401 invalid/already-used token (incl. a non-customer audience 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/calendar-feed/:token
Read-only iCal subscription feed (S112) for one operator’s schedule — meant for Google/
Apple/Outlook “subscribe by URL”, not a browser visit. No publishable key, no session; the
124-bit token IS the credential. Rate-limited 30/min per IP. 200 text/calendar; charset=utf-8, content-disposition: inline; filename="baited-schedule.ics",
cache-control: private, max-age=3600: a multi-VEVENT VCALENDAR, one event per booked
slot (UID: {slotId}@baited, so the event updates in place across refetches — never one
event per booking row) within a fixed window (30 days back → 365 days forward, not
configurable). SUMMARY: {tripType} — {N anglers} ({M parties}). LOCATION: the
operator’s meeting point (omitted when unset). DESCRIPTION: one line per party — name,
phone, party size, balance due, plus a deposit pending marker for pending_payment
bookings. Included booking statuses: everything except cancelled and no_show (i.e.
held, pending_payment, confirmed, completed) — an empty open slot with zero active
bookings never appears; a slot dropping to zero active bookings (last one cancelled) drops
out of the calendar on the next client refresh. PII surface, deliberate and
operator-scoped only: party name, phone, party size, balance due — no email, no payment
ids, never another operator’s data (tenancy is enforced by the same trip-type join used by
every other operator query). All text fields are RFC-5545-escaped and line-folded, so a
hostile customer name can never inject calendar structure. 404 (identical body for
unknown token, a rotated/superseded token, or an operator with no feed configured — no
oracle) { "error": { "code": "not_found" } }. 429 rate_limited past the cap.
POST /v1/pay/:token/intent
Pay-by-link deposit (Phase 121 S2) — token-authenticated, no publishable key, no CORS widening.
Mints or resumes the ONE PaymentIntent for the link’s current generation (decision 4): resumes the
stored intent (retrieved on its connected-account snapshot) or creates one keyed on the generation
so a rotated link mints a genuinely new intent; concurrent first opens share the same intent. All
amounts come from the server — the request body is ignored. Rate-limited 20/min per IP.
Cache-Control: no-store, Referrer-Policy: no-referrer. 200 (real mode): { "status": "requires_payment", "clientSecret", "publishableKey", "connectedAccountId", "amountCents", "balanceCents" } — the page inits Stripe.js on connectedAccountId. 200 (keyless mock): settles
through the shared confirmation seam → { "status": "paid", "amountCents", "balanceCents" }. 200
(processing): the provider says the intent already succeeded but the signed webhook hasn’t
confirmed yet — no client secret. 409 already_paid: a paid token never returns a client secret.
404: unknown / expired / rotated / revoked / cancelled / malformed tokens — one identical body.
409 conflict: the stored intent is terminally cancelled and needs an operator rotation.
GET /v1/pay/:token/status
Pay-by-link poll (Phase 121 S2). Same token auth + no-store/no-referrer headers. 200: { "status": "awaiting_payment" | "processing" | "paid", "depositCents", "balanceCents" } — the page
polls after confirmPayment until the signed webhook lands. 404: the same uniform dead-token
body as the intent route.
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.
GET /pay/:token
Hosted pay-by-link page (Phase 121 S3) — an HTML route mounted at the app root (not /v1;
vercel.json rewrites /pay/* to the API). Distinct from the /v1/pay/:token/* JSON routes above,
which this page’s inline script drives. Server-renders EITHER the token-authorized payment shell
(operator brand + trip name/date/time + party size + deposit due + balance after; a Stripe Payment
Element the inline script mounts in light DOM, initialized on the intent route’s connectedAccountId,
elements.submit() → confirmPayment({redirect:"if_required"}) → poll GET /v1/pay/:token/status
until the signed webhook confirms) OR the read-only paid receipt (until TTL). Keyless mock mode
settles inline via the intent route and renders the identical receipt with no Stripe.js request. NO
customer email/phone, operator id, booking id, or the raw token is ever rendered in a human-readable
place — the token rides only in the same-origin fetch URLs. Every operator string is HTML-escaped;
brandAccent/logoUrl re-validated at render. A per-response CSP nonce gates the single inline
<style>/<script>; script-src also allows js.stripe.com (+ its frames/api), nothing else.
404 (byte-identical body + STATIC nonce-free CSP, requested token not echoed) for unknown /
expired / rotated / revoked / cancelled / malformed / non-deposit-link tokens alike. Headers on 200,
receipt, and 404: Referrer-Policy: no-referrer, Cache-Control: no-store, X-Frame-Options: DENY,
X-Content-Type-Options: nosniff, X-Robots-Tag: noindex, nofollow.
GET /book/:slug
Hosted per-operator booking page (S108) — the repo’s first HTML route, mounted at the app
root (not /v1; vercel.json rewrites /book/* to the API). Server-side slug→operator
resolution, then a server-rendered shell: OG unfurl head (<title> “Book a trip — {name}”,
og:title/og:description/og:site_name/og:image static Baited image, theme-color from
the operator’s validated accent, index,follow + self-canonical), a branded skeleton visible
before JS, and an eager same-origin widget.global.js boot mounting on
[data-booking-widget] with the operator’s publishable pk_<operatorId> (public by design).
--bw-brand is set from the validated brandAccent; --bw-scheme: auto. Every
operator-controlled string is HTML-escaped; brandAccent/logoUrl are re-validated at render
(strict color forms / https-only) and dropped when invalid — never interpolated raw.
404 (designed HTML, same headers, no operator-id oracle, requested slug not echoed) for
unknown, unclaimed, reserved, or malformed slugs alike. Headers on both statuses:
Content-Security-Policy: frame-ancestors 'none', X-Frame-Options: DENY,
X-Content-Type-Options: nosniff, Cache-Control: no-store.
GET /book/:slug/og.svg
Per-operator dynamic OG-unfurl image (S118-S2) referenced by og:image on the hosted page
above. Same slug-resolution as GET /book/:slug: an unknown, unclaimed, reserved, or
malformed slug degrades to the generic fallback card body (below) rather than a 404 — a
crawler doesn’t retry og:image on failure, so a plain card beats a broken image in the
unfurl. Renders the operator’s validated brandAccent/logoUrl (re-validated at render,
dropped when invalid) over an SVG boat-silhouette background. Content-Type: image/svg+xml,
Cache-Control tuned for social-crawler caching.
GET /book/og-fallback.svg
The generic (no-operator-branding) OG card, served both as the direct fallback image and
inline whenever /book/:slug/og.svg can’t resolve an operator. Same headers as above.
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.
POST /v1/webhooks/stripe/connect
Stripe Connect webhook (S75): a DISTINCT endpoint from /webhooks/stripe because Connect
events arrive with their own signing secret (STRIPE_CONNECT_WEBHOOK_SECRET). Raw body;
Stripe-Signature verified (400 invalid_webhook_signature on failure → no state change;
400 not_configured when the connect secret/stripe client is absent, fail-closed). Idempotent
via the webhook_event PK. Handled events:
account.updated→ persistcharges_enabled/payouts_enabled/details_submittedonto the operator resolved solely by the uniqueconnect_account_id(an unknown account id writes nothing, still 200).payment_intent.succeeded/payment_intent.payment_failed(S76) → a deposit collected on the operator’s connected account is a direct charge, so its events arrive HERE (signed with the Connect secret), not on/webhooks/stripe. Routed through the same booking-settle path: the payment resolves by globally-uniquestripePaymentIntentId, so confirm + post-confirm side effects run identically to the platform endpoint. Other Connect event types are acknowledged with no state change. Always 200 once signature-valid.
Operator auth & sessions
POST /v1/operator/login
Rate-limited. Body: { "email": "…", "password": "…", "rememberMe"?: boolean } → 200
{ "token": "<session id>", "operator": { "id": "op_…", "name": "…" } } + httpOnly
bk_op_session cookie. 401 bad credentials. 403 email_unverified when the password is
correct but the operator’s email is not yet verified (self-signup, Sprint 49); no session is
minted. rememberMe: true issues a 30-day session instead of the 12h default. 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.
POST /v1/operator/signup
Rate-limited, no auth. Gated by SIGNUP_ENABLED → 404 when off. Body:
{ "firstName": "trimmed 1–80", "lastName": "trimmed 1–80", "businessName": "…", "email": "…", "password": "min 8 chars", "phone": "trimmed 7–32" }
(all required since S82; first/last/phone are persisted on the operator row; a missing/blank value
→ 400, like an invalid email). Creates an unverified operator and emails a 24h one-shot
verification link (/admin/#/verify?xt=…). No session is issued (verify first). 201
{ "ok": true, "devLink"? } (devLink only under exposeMagicLink); 409 email_taken
(duplicate email, caught unique-violation, never 500).
POST /v1/operator/signup/verify
Rate-limited, no auth; the one-shot token is the credential. Gated by SIGNUP_ENABLED → 404
when off. Body: { "token": "ovt_…" } (the xt value). Atomically consumes the
verification one-shot (single-use), flips emailVerified to true, and mints a 12h operator
session. 200 { "ok": true, "token": "<session id>", "operator": { "id", "name", "email" } }
- httpOnly
bk_op_sessioncookie; 401 invalid/already-used/expired token.
POST /v1/operator/signup/resend
Rate-limited, no auth. Gated by SIGNUP_ENABLED → 404 when off. Body: { "email": "…" }.
Re-issues a verification link for an existing unverified operator. Always 200 { "ok": true, "devLink"? } (no account enumeration; a missing/already-verified email silently no-ops).
GET /v1/operator/oauth/google/start
Google OAuth (S83), no auth, rate-limited. Gated by GOOGLE_OAUTH_ENABLED → 404 when off;
intent=signup additionally requires SIGNUP_ENABLED (404 when signup closed), while intent=login
works even when signup is closed. Query: intent=login|signup (default login), optional
returnTo (allowlisted to /, /onboarding, /settings, /connect). Generates a high-entropy
state+nonce, stores sha256 of each + intent/returnTo server-side in operator_oauth_state,
and 302s to Google’s authorization endpoint (scope=openid email profile, identity-only, no
refresh tokens). Dormant until S84 (keyless mock provider until client id/secret are set).
GET /v1/operator/oauth/google/callback
Google’s redirect target (S83), no auth, rate-limited, GOOGLE_OAUTH_ENABLED-gated. Query (from
Google): code, state, optional error. Consumes the single-use state row, exchanges the
code, validates the ID token (jose/JWKS: iss/aud/exp/signature) and the nonce. Then: known
operator_identity(google, sub) → log in; else verified-email auto-link to a verified existing
operator (the §7 predicate); else intent=signup → write an operator_oauth_signup_draft, set the
httpOnly bk_oauth_finish cookie, and route to the Finish screen (no token in the URL). Always
302: success to …/admin/#/oauth/callback?status=ok (sets the bk_op_session cookie), failures
to …/admin/#/login?oauth_error=<code> (oauth_denied, oauth_not_configured,
oauth_state_invalid, oauth_email_unverified, email_taken_unverified, oauth_identity_conflict,
oauth_not_found). Never carries tokens/PII in the URL.
POST /v1/operator/oauth/google/draft
Read-only prefill for the Finish screen (S83), rate-limited, GOOGLE_OAUTH_ENABLED-gated. No body
or query: the finish token rides the httpOnly bk_oauth_finish cookie the callback set (kept out
of URLs/logs/JS). Returns the draft’s Google display fields without consuming it. 200 { "email", "firstName", "lastName" }; 401 oauth_finish_invalid (cookie missing/expired/used);
503 oauth_not_configured. Only non-secret display data, never tokens or sub.
POST /v1/operator/oauth/google/complete
Creates a Google operator (S83), authorized by the single-use finish token in the httpOnly
bk_oauth_finish cookie (no session yet), rate-limited, GOOGLE_OAUTH_ENABLED-gated. Body: { "firstName": "1–80", "lastName": "1–80", "businessName": "min 1", "phone": "7–32" }. In ONE
transaction: consumes the draft + creates operator (emailVerified=true, passwordHash=null,
name=businessName, contact fields) + operator_identity + a session (no half-built row); clears the
finish cookie. 200 { "ok": true, "token": "<session id>", "operator": { "id", "name", "email" } }
bk_op_session; 400 validation; 401oauth_finish_invalid(cookie missing/expired/used); 409email_taken(race); 503oauth_not_configured.
POST /v1/operator/oauth/google/native
Bearer-tier Google sign-in for the native app (S115-S8), no auth, rate-limited,
GOOGLE_OAUTH_ENABLED-gated (404 when off). The app runs the OAuth code flow itself
(ASWebAuthenticationSession → Google) and posts the result here — this is the native sibling of the
web cookie/redirect callback. Body: { "code": "min 1", "nonce": "min 16", "rememberMe"?: bool }. code is Google’s authorization code (a base64url identity payload in dev/mock); nonce is the
RAW per-attempt nonce whose sha256 the app sent to Google as the nonce param. Same fail-closed
posture as the web flow (503 oauth_not_configured when enabled but the real provider isn’t
configured and the mock isn’t allowed). Exchanges the code with the shared provider (jose/JWKS in
prod, keyless mock in dev), verifies the id_token nonce claim = sha256(nonce), then applies the §7
login predicate: known operator_identity(google, sub) → log in; else verified-email auto-link
to a verified operator → link + log in. 200 { "token": "<session id>", "operator": { "id", "name" } } (no cookie — the token is the bearer). 401 oauth_invalid (code exchange / nonce
failure), oauth_email_unverified (unverified Google email); 404 oauth_not_found (no Baited
account — native is login-only, web owns signup); 409 email_taken_unverified /
oauth_identity_conflict; 503 oauth_not_configured.
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,
"source": "widget", "settlement": null,
"paymentHandling": null, "depositLinkStatus": null,
"createdAt": "2026-06-01T17:04:11.512Z"
} ] }
source is "widget" | "operator" | "hosted" ("hosted" added S108-S3: a booking made through
the operator’s hosted booking page, GET /book/:slug); settlement
("cash_at_dock" | "paid_outside" | "comped") is non-null only for operator-logged bookings
(S106 DB check constraint). Added S115-S3b so clients can mark operator-logged rows.
paymentHandling (Phase 121, derived; owner-only) is null for widget/hosted bookings, the
settlement value for an off-platform operator booking, or "deposit_link" for a pay-by-link
booking. depositLinkStatus is "active" | "expired" | "revoked" | "paid" | null — non-null only
for a deposit_link booking. The raw capability token is never projected here (only its status).
GET /v1/operator/bookings/today
Day-of-trip roster for one operator-local calendar date. Query: ?date=YYYY-MM-DD
(optional; defaults to today in the operator timezone). Includes confirmed, completed,
and no_show trips only. 200:
{
"date": "2026-06-23",
"timezone": "America/Chicago",
"counts": { "confirmed": 2, "completed": 1, "noShow": 0 },
"bookings": [ {
"id": "bk_…", "tripType": "4-hr Inshore", "startsAt": "2026-06-23T11:00:00Z",
"endsAt": "2026-06-23T15:00:00Z",
"customer": { "name": "Sam Angler", "phone": "+15551234567" },
"partySize": 2, "status": "confirmed",
"licenseAck": true, "waiverSigned": false, "balanceDueCents": 45000
} ]
}
POST /v1/operator/bookings
Log a booking the operator took over text/DM (S106). Composes hold-create + booking-create
server-side in one request (the caller never handles a hold token), riding the single
creation path for capacity, tenancy, pricing, and idempotency.
Body: { "slotId", "tripTypeId"?, "partySize", "customer": { "name", "email"?, "phone"? }, "settlement"? , "paymentHandling"?, "sendComms"? (default true), "notes"?, "licenseAck"?, "waiver"? { "signerName", "signatureText" }, "idempotencyKey"? }.
Money handling (Phase 121). paymentHandling is "cash_at_dock" | "paid_outside" | "comped" | "deposit_link". Backward compatible with the S106 settlement-only contract: send settlement
alone (mapped to that handling), or paymentHandling; if both are present they must agree (and
deposit_link requires settlement absent), else 422 validation_error; at least one is
required (else 422).
- Off-platform settlements (
cash_at_dock/paid_outside/comped): collects no platform money, confirms inline, no Stripe PaymentIntent.cash_at_dock→ full trip price owed at the dock (balanceMethod: "cash_at_dock");paid_outside/comped→ nothing owed (total/deposit/ balance all 0). Enqueues customer confirmation/reminder unlesssendComms: false. deposit_link(pay-by-link): holds the seat, keeps the trip’s normal pricing, stayspending_payment, writes arequires_paymentdeposit row on the charge-account snapshot, persists the comms choice, and returns a re-copyable link. No PaymentIntent is created here (the hosted/pay/:tokenpage mints it — S2). Refused when the trip deposit is $0 (422), when the operator can’t take online deposits yet (Connect not charge-ready → 409conflict), or when the slot has departed (422). A refusal leaks no hold or booking.
Contact rule: at least one of email/phone is required (400, zod schema refine). tripTypeId,
if sent, must match the slot’s trip (422 validation_error). The booking is tagged
source: "operator".
Idempotency: the Idempotency-Key header (or body idempotencyKey) is honored via the
existing (operatorId, key) table — a replay returns the cached booking (and the same
re-copyable deposit link) without creating a second hold or a provider call; the same key with a
different request body → 422. A full slot → 422 slot_unavailable; a slot the operator doesn’t
own → 404 not_found.
201 (settlement): { "booking": { "id", "status": "confirmed", … }, "payment": { "kind": "deposit", "amountCents": 0, "stripeClientSecret": null, "paymentIntentId": null, "connectedAccountId": null }, "confirmation": { "manageLink", "icsLink", "meetingPoint", "whatToBring" } }.
201 (deposit_link): as above but booking.status: "pending_payment" with the real
depositCents/balanceCents, payment.amountCents = the deposit (still no client secret/intent),
plus "depositLink": { "path": "/pay/dpl_…", "expiresAt", "status": "active" }.
GET /v1/operator/bookings/:id/deposit-link
Owner-scoped (Phase 121 S2). The current pay-by-link so the booking-detail card can re-copy it and
show state. 200: { "status": "active" | "expired" | "revoked" | "paid", "path": "/pay/dpl_…" | null (present only when active), "expiresAt", "depositCents", "balanceCents" }. Not a deposit-link
booking, or a booking the operator doesn’t own → 404 not_found.
POST /v1/operator/bookings/:id/deposit-link/rotate
Owner-scoped (Phase 121 S2). Issues a fresh 14-day-capped token and cancels the current
PaymentIntent first (decision 5) so the old URL and any open checkout can no longer charge; bumps
the link generation. Cookie calls are CSRF-guarded; Bearer stays the native tier. 200: { "mode": "rotate", "status": "active", "path": "/pay/dpl_…", "expiresAt" }. A booking no longer awaiting a
deposit (paid/cancelled) or a departed trip → 409 conflict; if the deposit is paid mid-rotation
the old link is left intact and a 409 is returned (no silent replacement). Operator B → 404.
DELETE /v1/operator/bookings/:id/deposit-link
Owner-scoped (Phase 121 S2). Revoke: cancels the current PaymentIntent and leaves the token null so
the link is dead. 200: { "mode": "revoke", "status": "revoked", "path": null, "expiresAt": null }. Same 409/404 semantics as rotate.
GET /v1/operator/bookings/:id
Full admin detail. 200: booking core (status, party/money fields, licenseAck,
rescheduleOffered, cancellationReason, notes, and — S115-S3b — provenance:
source, settlement, createdAt, same semantics as the list above; plus Phase 121
paymentHandling (derived; see the list) and depositLink: { status, expiresAt } | null for a
deposit-link booking — no raw token), 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 + balance money and enqueues one store_credit_issued email when the
credit is positive. Frees capacity → may auto-offer the waitlist. Wrong operator → 403.
200: { "id", "status": "cancelled", "slotId", "releasedSeats": 2, "deposit": "refunded" | "partially_refunded" | "kept" | "store_credit", "refundPercent": 100, "refundedCents": 5000, "storeCreditCents": 0, "cancellationReason", "waitlistOffered": false, "refundId": null, "balanceRefundIds": [] }
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/no-show
Mark a confirmed booking no_show. No capacity is released, no refund/store credit is
issued, and no review request is sent. 409 invalid_transition from any other status;
wrong operator → 403. 200: { "id": "bk_…", "status": "no_show" }
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. The move always enqueues a
booking_rescheduled email+sms to the customer inside the same locked transaction (S110 —
not operator-opt-outable, unlike the portal self-serve reschedule which sends nothing new).
200: { "id", "operatorId", "slotId": "<new>", "status", "rescheduleOffered": false, "waitlistOffered": false }
GET /v1/operator/bookings/:id/reschedule-options
Auth: operator. Open future same-trip-type slots this booking could move to (next 60
days, fits the party, excludes the current slot) — the picker’s candidate list (S110).
Cross-tenant booking id → 404 not_found (existence-oracle-safe, same rule as the write).
200: { "slots": [ <availability slot shape> ] }
GET /v1/operator/slots/:id/weather
Auth: operator. Conditions chip for a reschedule candidate slot (S110-S3) — same
lookup and shape as GET /v1/slots/:id/weather, scoped to the caller’s own slots
(cross-tenant/unknown slot → 404 not_found). Advisory: a failed/empty provider leg
degrades to the same { tides: [], marine: null } shape rather than an error.
GET /v1/operator/summary
Dashboard stats. Query: ?from=&to= (optional). 200:
{ "upcomingCount", "confirmedCount", "pendingCount", "completedCount", "cancelledCount", "noShowCount", "depositsCollectedCents", "balanceOutstandingCents", "currency": "usd" }
depositsCollectedCents = succeeded deposit payments; balanceOutstandingCents =
confirmed-booking balances minus already-collected balance payments.
GET /v1/operator/analytics/revenue
Revenue/financial analytics (Phase 14). Cash-basis: every figure is money actually
collected (succeeded payments only). Query: ?from=&to= (ISO datetimes, optional;
default trailing 30 days), bucket (day|week|month, default day). 200:
{ bucket, from, to, timezone, series: [{ period: "YYYY-MM-DD", depositCents, balanceCents, tipCents, totalCents }], byTripType: [{ tripTypeId, name, collectedCents, bookings }], totals: { depositCents, balanceCents, tipCents, totalCents }, outstanding: { overdueCents, dueSoonCents, dueLaterCents, totalCents }, outside: { cashAtDock: { count, cents }, paidOutside: { count, cents }, comped: { count, cents } }, currency: "usd" }
series buckets are operator-local; outstanding is a point-in-time view of confirmed
bookings’ still-owed balance aged by trip date (overdue = trip already passed). outside
(S106-S3) is money handled OFF-platform on operator-logged bookings (confirmed+completed,
windowed by when logged) — deliberately separate from the platform-collected totals/series,
which stay $0 for these bookings (decision 6). cents is real for cashAtDock (owed at the dock);
it is 0 for paidOutside/comped because Baited never records those amounts (surfaced as counts).
GET /v1/operator/analytics/catch
Catch analytics (Phase 14) over trip_outcome. Query: ?from=&to= (YYYY-MM-DD on the trip
date, optional). 200: { tripsLogged, totalKept, totalReleased, avgKeptPerTrip, bySpecies: [{ name, kept, released }], byMonth: [{ month: "YYYY-MM", kept, released, trips }] }
species aggregated from the outcome jsonb; bySpecies sorted by total caught desc, byMonth asc.
GET /v1/operator/activity
Recent-activity feed (Phase 14): a derived union of new bookings, cancellations, tips, and sent
messages (no separate log table). Query: ?limit= (1–100, default 30). 200:
{ events: [{ id, kind: "booking_created"|"booking_cancelled"|"tip"|"message", at, title, detail, amountCents? }] }
newest-first.
GET /v1/operator/threads
Operator message inbox (Phase 16): every customer↔operator thread, newest activity first.
200: { threads: [{ id, bookingId, customerName, tripName, startsAt, lastMessageAt, lastSenderRole, lastMessagePreview, unread }] }
(unread = a customer message the operator hasn’t read since).
GET /v1/operator/threads/:id
One thread + its messages (operator-scoped); marks the operator side read. 404 if not the
operator’s. 200: { thread: { id, bookingId, lastMessageAt, lastSenderRole }, messages: [{ id, senderRole, body, createdAt }] }
POST /v1/operator/threads/:id/messages
Operator reply; email-notifies the angler. Body: { "body": "…" } (1–4000, trimmed; empty → 422).
404 if not the operator’s. 201: the updated { thread, messages }.
GET /v1/operator/reports/bookings.csv
Bookings CSV export (Phase 14). Query: ?from=&to= (YYYY-MM-DD, optional; UTC-day bounds
on the trip date), status (optional booking status). 200 text/csv download: Booking ID,
Trip, Start (operator-local), Customer, Phone, Party, Status, Source (widget|operator),
Settlement (cash_at_dock|paid_outside|comped|blank for widget), Deposit, Balance, Balance
method, Tip (money as dollar decimals). Source+Settlement (S106-S3) distinguish operator-logged
bookings — whose deposit is $0 and whose money is handled off-platform — from widget bookings.
GET /v1/operator/reports/financials.csv
Payment-level financials CSV (Phase 14): every payment row (all statuses, so refunds/failures
show), joined to its booking’s trip + customer. Query: ?from=&to= (YYYY-MM-DD on the payment
receipt date). 200 text/csv: Date, Booking ID, Trip, Customer, Email, Kind, Status, Amount.
GET /v1/operator/reports/trip-outcomes.csv
Catch-log CSV (Phase 14) over trip_outcome. Query: ?from=&to= (YYYY-MM-DD on the trip date).
200 text/csv: Date, Area, Lat, Lon, Species (flattened kept/released/top-size), Notes.
GET /v1/operator/conditions-outcomes
Conditions+outcomes export (Phase 16): each trip outcome joined to its archived
condition_observation feature vector (the captain’s labeled conditions → catch dataset).
Query: ?from=&to= (YYYY-MM-DD on the trip date). 200: { rows: [{ date, area, lat, lon, species, notes, conditionStationId, conditionDistanceNmi, sstF, windKt, waveFt, swellFt, wavePeriodS, tideHeightFt, tidePhase, moonPhase01, moonIllumination01 }] } (condition fields null when that
trip’s day/station wasn’t archived).
GET /v1/operator/reports/conditions-outcomes.csv
The same conditions+outcomes data as CSV. Query: ?from=&to=. 200 text/csv: Date, Area,
Lat, Lon, Species, Station, StationDistanceNmi, SST_F, Wind_kt, Wave_ft, Swell_ft, WavePeriod_s,
TideHeight_ft, TidePhase, MoonPhase01, MoonIllum01, Notes.
GET /v1/operator/reports/accounting.csv
QuickBooks-compatible booking-level accounting export. Auth: operator. One row per booking in
the date range, filtered by booking creation date. Query: ?from=&to= (both optional). 200
text/csv download with columns: Invoice No, Invoice Date (MM/DD/YYYY), Service Date, Customer,
Email, Service (trip type), Party Size, Total, Discount, Tip, Deposit Paid, Balance Due, Status.
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 Deal Sportfishing", "capacity": 6 } ] }
POST /v1/operator/boats
Create a boat (Phase 14 / Sprint 58). Body: { "name", "capacity" } (capacity int ≥ 1).
201: the created { id, name, capacity }. 422 on invalid capacity.
PATCH /v1/operator/boats/:id
Edit a boat’s name/capacity (any subset). Ownership-scoped. 200: the updated boat; 404 unknown/foreign boat; 422 on invalid capacity.
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, for 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 }.
GET /v1/operator/slots/bulk-close-preview
Block-a-day preview (S111). Query: ?startDate=&endDate=&tripTypeId= (endDate defaults
to startDate; range capped at 31 days). Candidates are open, future, operator-owned slots in
range — the same underlying query bulk-close/bulk-reopen execute against, so there is only
one definition of “what’s in range” for a given instant (not two implementations that could
drift). A confirm made after the preview always recomputes candidates fresh — a booking landing
or a slot departing between the two calls is expected, not a bug. Each day row also carries
slotId — the first (earliest) candidate slot of that day, a read-only representative used by
the admin UI to fetch a conditions chip (S111-S3); it is never used to select which slots close.
200:
{ "days": [ { "date": "2026-07-08", "slots": 6, "bookedSlots": 2, "anglers": 5, "slotId": "slot_…" } ], "totals": { "slots", "bookedSlots", "anglers" } }.
POST /v1/operator/slots/bulk-close
Close every candidate slot in range. Body:
{ "startDate", "endDate"?, "tripTypeId"?, "mode": "all" | "spare_booked", "reason": "weather" | "maintenance" | "day_off" }.
all closes booked slots too and offers each affected booking a reschedule with the given
reason; spare_booked leaves slots with active bookings open and untouched. One transaction
for the whole range; messages dispatch after commit. 200:
{ "closed", "sparedBooked", "anglersOffered", "affected": ["bk_…"] }. 422 on a range over
31 days or an invalid mode/reason.
POST /v1/operator/slots/bulk-reopen
Reopen every closed candidate slot in range — the inverse of bulk-close. Body:
{ "startDate", "endDate"?, "tripTypeId"? }. Offers the next waitlist entry per reopened slot.
200: { "reopened", "waitlistOffered" }.
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", "cancellationPolicy", "depositPolicy", "slug", "brandAccent", "logoUrl" }
PATCH /v1/operator/me
Body: any of { name, timezone, latitude, longitude, tideStationId, meetingPoint, whatToBring, safetyNotes, cancellationPolicy, depositPolicy, slug, brandAccent, logoUrl }. Invalid IANA timezone, out-of-range coords, or a non-alphanumeric
tide station id → 422. 200: the updated profile. cancellationPolicy/depositPolicy (Phase 14) are
free-text statements shown to anglers in the portal; not enforced.
Hosted booking page fields (S108), all nullable (null clears):
slug— the/book/<slug>handle. Lowercased+trimmed, then validated in@booking/core: pattern^[a-z0-9](?:-?[a-z0-9]){2,39}$(3–40 chars, single hyphens) and a reserved list (admin,api,book,portal,intel,demo,www,app,assets,widget,v1,settings,login,signup,help,docs,static). Reserved or malformed → 422 with the rule spelled out. Taken → 409conflictnaming the slug; the DB unique constraint is the claim-race backstop (concurrent claims → one wins, the other 409s).brandAccent— a single strict CSS color (hex /rgb()/hsl()/oklch()/oklab(), numeric components only — it reaches a style attribute onGET /book/:slug). Anything else (keywords,url(…),;-breakouts) → 422.logoUrl— absolutehttps://URL, no embedded credentials, ≤2048 chars → else 422.
GET /v1/operator/slug-availability
S108-S2: backs the Settings “claim your booking page” live-typing check. Query: slug
(required, ≤80 chars). Read-only — runs the same @booking/core validation as the PATCH, then
checks for an existing owner. A slug already owned by the calling operator reads as available.
200: { "slug", "available": boolean, "reason"?: "invalid" | "reserved" | "taken" }. Never
409s or 422s itself; the PATCH (with its unique-constraint backstop) is still the sole path that
actually claims a slug.
GET /v1/operator/onboarding-status
Profile-completion signals for the dashboard checklist + setup wizard (Sprint 50). Each
boolean is derived from data the operator already owns, with no separate onboarding state. 200:
{ "emailVerified", "hasBoat", "hasTripTypes", "hasSlots", "meetingPointSet", "hasBooking", "complete", "paymentsConnected" }
where complete is the AND of the core steps and paymentsConnected (S75) is derived from the
operator’s Stripe Connect charges_enabled (true → the operator can take online card deposits).
Stripe Connect (Express) onboarding (S75)
POST /v1/operator/connect/start
Rate-limited. Creates the operator’s Express connected account on first call (reuses the existing
one after; one account per operator), mints a Stripe-hosted onboarding Account Link, and
returns 200 { "onboardingUrl": "https://connect.stripe.com/…" }. refresh_url/return_url
point back into the admin (/connect?status=refresh|return). Keyless mock returns a deterministic
URL. Money never touches Baited; charge routing to the connected account is S76.
GET /v1/operator/connect/status
200: { "state": "not_started" | "pending" | "connected", "chargesEnabled", "payoutsEnabled", "detailsSubmitted" }
The persisted snapshot (webhook-driven via account.updated, not polled). charges_enabled →
connected; details_submitted without charges → pending; neither → not_started.
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/reviews
All of the operator’s reviews, published AND hidden, newest first (Phase 13, Sprint 54).
Operator-scoped; shows the full customer name + trip + booking ref. 200:
{ "reviews": [ { "id", "bookingId", "rating", "comment", "status": "published"|"hidden", "operatorResponse", "operatorRespondedAt", "createdAt", "tripName", "customerName" } ] }
POST /v1/operator/reviews/:id/respond
Post (or edit) the operator’s public reply to one of their reviews. Body: { response: string }
(1–2000 chars; whitespace-only → 400). 404 if the review isn’t this operator’s. 200: the
updated review.
PATCH /v1/operator/reviews/:id
Hide or re-publish one of the operator’s reviews. Body: { status: "published" | "hidden" }.
Hidden reviews drop out of the public aggregate + GET /v1/trip-types/:id/reviews. 404 if not
this operator’s. 200: the updated review.
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" }
POST /v1/feedback
Operator feedback → GitHub inbox (Phase 122). Requires operator session. Row is written to
Postgres first (source of truth) and the response is returned immediately; a GitHub Issue sync to
mattoney/baited-feedback follows best-effort (keyless mock when GITHUB_FEEDBACK_TOKEN is
unset) and never affects this response. Each submit also opportunistically retries syncing up to 5
of the oldest unsynced rows. Body: { "body": "…" (1..4000 chars), "context"?: { "route"?, "appVersion"? } } — userAgent and operatorId are server-assembled, never trusted from the
client. 201: { "id", "createdAt", "githubIssueUrl" } (githubIssueUrl null until synced).
Operator webhook endpoints
All routes require operator session. Operators register HTTPS endpoints to receive
real-time event notifications. Every delivery is HMAC-SHA256 signed (X-Baited-Signature: t=<unix_seconds>,v1=<hex>; signed string is "<t>.<body>"). Max 5 attempts with
exponential back-off. Idempotency anchor: (endpoint_id, event_type, reference_id).
GET /v1/operator/webhooks
List all webhook endpoints for the operator. 200: [ { "id", "url", "secret", "eventTypes": ["booking.confirmed"|"booking.cancelled"|"payment.succeeded"], "enabled", "description", "createdAt", "updatedAt" } ].
POST /v1/operator/webhooks
Register a new webhook endpoint. Body: { "url": "<https url>", "eventTypes": ["…"], "description"?: "…" }. At least one event type required. 201: the created row (same
shape as the list item; secret is returned once here; subsequent reads omit it).
PATCH /v1/operator/webhooks/:id
Update URL, event types, enabled flag, or description. Body: any subset of
{ "url", "eventTypes", "enabled", "description" }. 200: the updated row.
404 when the endpoint doesn’t exist or belongs to another operator.
DELETE /v1/operator/webhooks/:id
Delete the endpoint and cascade-remove all pending deliveries. 200: { "ok": true }.
404 when the endpoint doesn’t exist or belongs to another operator.
POST /v1/operator/webhooks/:id/rotate-secret
Rotate the HMAC signing secret. The old secret is invalidated immediately. Update your
receiver before calling this. 200: { "id", "secret": "whs_<new>" }.
404 when the endpoint doesn’t exist or belongs to another operator.
Operator calendar subscription feed (S112)
Read-only iCal subscription feed for the operator’s own schedule — no OAuth, no Google
account provisioning. The token is a long-lived capability (icf_<24 chars>, stored raw)
that appears only in the two authed responses below, never in GET /v1/operator/me
and never logged. Rotation is overwrite semantics: issuing a new token immediately
invalidates the old one (no grace window).
GET /v1/operator/calendar-feed
Requires operator session. 200: { "feedUrl": null } when no token has been issued
yet, otherwise { "feedUrl", "webcalUrl", "googleCalendarUrl" }. feedUrl is derived from
the request’s own origin (no config to invent).
POST /v1/operator/calendar-feed/token
Requires operator session. Issues a fresh token whether or not one already exists
(“Create link” / “Reset link” in the UI are the same call). 201:
{ "feedUrl", "webcalUrl", "googleCalendarUrl" }.
Crew / staff sub-accounts
Crew members log in with their own credentials and inherit the operator’s tenant scope.
Roles: manager (full console access) | crew (read-only). Only the operator account
(not a crew member) may manage other crew members; crew PATCH/POST/DELETE return 403.
GET /v1/operator/crew
List all crew members (active and inactive) for the operator. 200: [ { "id", "operatorId", "name", "email", "role": "manager"|"crew", "active", "createdAt", "updatedAt" } ].
POST /v1/operator/crew
Create a crew member. Body: { "name", "email", "role": "manager"|"crew", "password" }.
Password must be ≥ 8 chars. 201: the created crew member (no password hash).
422 when email already exists within this operator.
PATCH /v1/operator/crew/:id
Update a crew member’s name, role, active flag, or password. Body: any subset of
{ "name", "role", "active", "password" }. 200: the updated crew member.
404 when the crew member doesn’t exist or belongs to another operator.
DELETE /v1/operator/crew/:id
Deactivate (soft-delete) a crew member: sets active: false and revokes no current
sessions (sessions expire naturally). 200: { "deactivated": true }.
404 when the crew member doesn’t exist or belongs to another operator.
POST /v1/crew/login
Authenticate a crew member. Body: { "operatorId", "email", "password" }. Sets the
bk_op_session cookie (same as operator login) so the crew member can use the admin console.
200: { "id", "name", "email", "role", "operatorId" }.
401 on bad credentials or inactive account.
POST /v1/crew/logout
Revoke the current crew session and clear the bk_op_session cookie. 200: { "ok": true }.
Season passes / memberships
Operators define pass types and sell instances to customers. Two kinds:
trip_bundle: customer gets N trips (tracked by tripsRemaining; decrements on each use, status → exhausted when 0).
period_pass: customer gets unlimited trips within a date window (validFrom/validUntil).
GET /v1/operator/pass-types
List all pass type definitions for this operator. 200: array of pass type objects.
POST /v1/operator/pass-types
Create a pass type. Body: { "name", "description"?, "kind": "trip_bundle"|"period_pass", "tripsIncluded"? (required for trip_bundle), "validDays"? (required for period_pass), "priceCents" }.
201: created pass type. 422 on missing kind-required fields.
PATCH /v1/operator/pass-types/:id
Update a pass type’s name, description, priceCents, or active status. Body: any subset of { "name", "description", "priceCents", "active" }. Kind and trip counts are immutable after creation. 200: updated pass type. 404 if not owned by this operator.
DELETE /v1/operator/pass-types/:id
Deactivate a pass type (sets active: false; existing customer passes are unaffected). 200: the updated pass type object.
GET /v1/operator/customer-passes
List customer pass instances. Optional query params: ?status=active|exhausted|expired|cancelled, ?customerId=<id>.
200: array of customer pass objects with { "id", "operatorId", "customerId", "passTypeId", "kind", "tripsRemaining", "validFrom", "validUntil", "status", "note", "createdAt", "updatedAt" }.
POST /v1/operator/customer-passes
Sell/assign a pass to a customer. Body: { "customerId", "passTypeId", "note"? }.
For period_pass, validFrom/validUntil are computed from today + validDays.
For trip_bundle, tripsRemaining is set from tripsIncluded.
201: the created customer pass. 404 if customer or pass type not found. 422 if pass type is inactive.
PATCH /v1/operator/customer-passes/:id
Update a customer pass status or note. Body: { "status"?: "active"|"exhausted"|"expired"|"cancelled", "note"? }. 200: updated pass. 404 if not owned by this operator.
POST /v1/operator/customer-passes/:id/use
Record a use against a pass. Decrements tripsRemaining (bundle) or validates date window (period).
Status transitions to exhausted when bundle hits 0, or expired if the date window has passed.
Body: { "bookingId"?, "note"? }. 200: updated customer pass. 422 on expired/exhausted/inactive pass.
Dynamic pricing rules
Operators define rules that adjust the base trip price (basePriceCents) before a booking is created.
Rules are matched against the slot date, the booking date, and the trip type. All matching rules are
applied additively in priority order. The adjusted price is clamped to a minimum of 1 cent.
GET /v1/operator/pricing-rules
List all pricing rules (active and inactive). 200: array of rule objects with
{ id, name, kind ("flat"|"percent"), adjustment (cents or integer %), appliesToAll, tripTypeId?, daysOfWeek?, validFrom?, validTo?, minDaysAdvance?, maxDaysAdvance?, priority, active, createdAt, updatedAt }.
POST /v1/operator/pricing-rules
Create a pricing rule. Body: { name, kind, adjustment, appliesToAll? (default true), tripTypeId?, daysOfWeek? ([0-6]), validFrom? (YYYY-MM-DD), validTo?, minDaysAdvance?, maxDaysAdvance?, priority? (default 0) }.
Percent adjustments are capped at -100 to +500. 201: created rule. 400 on validation error.
PATCH /v1/operator/pricing-rules/:id
Update a rule. All fields optional. active can be toggled. 200: updated rule. 404 if not owned.
DELETE /v1/operator/pricing-rules/:id
Hard-delete the rule. Affects no bookings (rules are applied at booking time only). 200: { ok: true }. 404 if not owned.
Notification preferences (P27)
Per-operator control over which optional transactional messages are sent. Only opt-outable types
can be disabled: reminder, review_request, tip_request, trip_resources, abandoned_cart.
Critical / security / financial types (confirmation, reschedule_offer, waitlist_offer,
store_credit_issued, magic_link, password_reset, operator/customer verification, new_message) are
always delivered and cannot be disabled. Enforcement is a single dispatch chokepoint: a disabled
type’s message is marked suppressed (a terminal status, distinct from sent/failed) instead of
being delivered, across both the email and SMS channels of that type.
GET /v1/operator/notification-preferences
List the full opt-out matrix. 200: array of { notificationType, enabled }, one entry per
opt-outable type, defaulting to enabled: true when the operator has set no preference.
PUT /v1/operator/notification-preferences/:type
Set whether an opt-outable type is enabled. Body: { enabled: boolean }. 200:
{ notificationType, enabled }. 422 if :type is a critical/security type or not a valid
opt-outable notification type.
Referral / affiliate program
Operators define affiliate partners with a unique code and a commission percentage. When a customer
includes affiliateCode in their booking request, the booking is linked to that affiliate (silent
no-op on unknown/inactive codes). Commission is computed as totalCents × commissionPercent / 100
and displayed in the admin summary; it is a reporting figure, not an automated payout.
GET /v1/operator/affiliates
List all affiliates. 200: array of affiliate summary objects with { "id", "name", "email", "code", "commissionPercent", "active", "notes", "bookingCount", "totalRevenueCents", "earnedCents", "createdAt", "updatedAt" }.
POST /v1/operator/affiliates
Create an affiliate. Body: { "name", "email"?, "code" (unique per operator, alphanumeric + - _), "commissionPercent"? (0–100, default 0), "notes"? }. 201: created affiliate. 422 on duplicate code.
PATCH /v1/operator/affiliates/:id
Update name, email, commissionPercent, active status, or notes. 200: updated affiliate. 404 if not owned.
DELETE /v1/operator/affiliates/:id
Deactivate (sets active: false). Linked bookings are unaffected. 200: updated affiliate object.
Group bookings
Operators can group related bookings (e.g., a corporate event or tournament) into a named group
for reporting and coordination. Bookings can be assigned/unassigned independently; deleting a group
un-links its bookings (groupId → null) without cancelling them.
GET /v1/operator/booking-groups
List all booking groups for this operator. 200: array of group summary objects with aggregate
{ "id", "operatorId", "name", "description", "contactName", "contactEmail", "contactPhone", "notes", "bookingCount", "totalCents", "createdAt", "updatedAt" }.
POST /v1/operator/booking-groups
Create a group. Body: { "name", "description"?, "contactName"?, "contactEmail"?, "contactPhone"?, "notes"? }.
201: created group. 422 on validation error.
GET /v1/operator/booking-groups/:id
Get group detail including its bookings. 200: group object + "bookings" array with
{ "id", "customerId", "status", "partySize", "totalCents", "createdAt" } per booking. 404 if not owned.
PATCH /v1/operator/booking-groups/:id
Update group fields. Body: any subset of group fields (all nullable). 200: updated group. 404 if not owned.
DELETE /v1/operator/booking-groups/:id
Delete the group. All assigned bookings have groupId set to null (not cancelled). 200: { "ok": true }. 404 if not owned.
POST /v1/operator/booking-groups/:id/bookings
Assign bookings to the group. Body: { "bookingIds": ["bk_…", …] } (1–100 IDs). Bookings not
owned by the operator are silently skipped. 200: { "ok": true }. 404 if group not owned.
DELETE /v1/operator/booking-groups/:id/bookings/:bid
Unassign a single booking from the group. 200: { "ok": true }. 404 if the booking is not in this group.
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", "hasPassword": boolean, "emailVerified": boolean }
(hasPassword/emailVerified added Sprint 51; they drive the portal’s verify banner + password-change
affordance; the raw hash is never returned).
PATCH /v1/customer/me
Edit the signed-in angler’s own profile. Auth: customer. Scoped to the caller’s own row; a
customer can never edit another (the id comes from the session). Body: { "name"?: "…", "phone"?: "…" } (both optional; name min 1, phone min 3). 200: the refreshed profile
(same shape as GET /v1/customer/me).
GET /v1/customer/store-credit
200: { "balanceCents": 15000 } (active, unexpired credit under the booking operator).
GET /v1/customer/data-export
GDPR/CCPA self-service export of everything tied to the signed-in angler. 200:
{ "profile", "bookings": [...], "payments": [{ "kind", "amountCents", "status", "createdAt" }], "reviews": [...], "storeCredits": [...], "waivers": [...], "messages": [...], "notesAbout": [...] }.
Scoped to the customer (notes are matched on the customer’s own email); payments expose no Stripe ids.
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.
DELETE /v1/customer/payment-methods/:id
Remove a saved card (Phase 15). Scoped to the angler’s identity (operator + email); not theirs /
unknown → 404. 200: { "ok": true }.
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, cancellationPolicy, depositPolicy, 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/receipt
Print-friendly HTML receipt (Phase 15) for the angler’s own booking: operator, trip, party,
money breakdown (add-ons, total, deposit, balance, tip). Cookie auth rides the same-origin open;
200 text/html, 404 if it isn’t theirs. (Print/save-to-PDF in the browser; no PDF dep.)
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.
GET /v1/customer/bookings/:id/thread
The customer↔operator message thread for the angler’s own booking (Phase 16). One thread per
booking; the GET marks the customer’s side read. Token-scoped; 404 if not theirs.
200: { "thread": { "id": "mthr_…", "bookingId": "bk_…", "lastMessageAt": "…", "lastSenderRole": "customer"|"operator" } | null, "messages": [ { "id": "tmsg_…", "senderRole": "customer"|"operator", "body": "…", "createdAt": "…" } ] }
POST /v1/customer/bookings/:id/thread/messages
Angler posts a message (creating the thread on first contact). Body: { "body": "…" }
(1–4000 chars; trimmed; empty → 422). Token-scoped; 404 if not theirs.
201: the updated { thread, messages }.
GET /v1/customer/bookings/:id/review
The angler’s own review for this booking, or null (Phase 13). Token-scoped; 404 if the
booking isn’t theirs. 200: { review: { id, bookingId, rating, comment, status, operatorResponse, operatorRespondedAt, createdAt } | null }.
POST /v1/customer/bookings/:id/review
Submit a review for the angler’s own completed booking (Phase 13). One review per booking.
Body: { rating: 1..5 (int), comment?: string }. 404 if the booking isn’t theirs; 409
invalid_transition if the trip isn’t completed yet; 422 validation_error if the rating
is out of range or the booking is already reviewed; 400 if the rating isn’t an integer.
201: the created review (same shape as the GET’s review).
GET /v1/customer/passes
List the customer’s active season passes. 200: array of customer pass objects
(same shape as the operator endpoint: kind, tripsRemaining, validFrom, validUntil, status).
Only returns status: "active" passes; exhausted/expired/cancelled passes are omitted.
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).
Trip-Day Outlook: horizon + provenance (spec: docs/forecast-mode-spec.md §A1)
/conditions, /forecast/:species, and /best-bets accept a near-future trip day. UTC
horizonDays = floor((target − today) / 1d). horizonDays <= 0 → unchanged nowcast;
1..MAX_OUTLOOK_DAYS (= 1) → outlook; > MAX_OUTLOOK_DAYS → 422 validation_error.
/conditions and /forecast/:species carry a provenance block (identical on the cached
fish_forecast-row path and the live-compute path):
"provenance": {
"mode": "nowcast" | "outlook",
"targetDate": "YYYY-MM-DD", "horizonDays": <int>,
"structure": { "source", "asOf": <iso>|null, "kind": "persisted"|"observed"|"forecast" },
"feasibility": { "source", "kind": "forecast"|"mock", "coversTargetDay": <bool> },
"gridConfidence": "low"|"med"|"high",
"engineVersion": <string>
}
Only wind/sea (feasibility) is genuinely forecast; the heatmap structure is the latest
satellite field carried forward (kind:"persisted"), and the score never moves with the
horizon; horizonDays only caps confidence (gridConfidence: med at +1, low at +2).
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, "ssh": [[…]]|null, "marine": { "summary", "windKt", "waveFt", … }|null, "buoys": { "source", "stations": [ { "stationId", "label", "status": "ok"|"unavailable", "observation": { "observedAt", "waveFt", "dominantPeriodS", "averagePeriodS", "waterTempF", "windDirDeg", "windKt" }|null, "band": "fresh"|"aging"|"stale"|null, "detail" } ] }|null, "feasibility": { "tier": "green"|"caution"|"red", "windKt", "waveFt" }, "layers": { "sst", "sstBreaks", "currents", "chl", "ssh", "fish": { "status": "ok"|"unavailable"|"off", "source", "asOf" } }, "provenance": { "mode", "targetDate", "horizonDays", "structure", "feasibility", "gridConfidence", "engineVersion" } }
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/ssh/fish ok from mock, chl off. provenance is
the Trip-Day Outlook block (above). 422 for horizonDays > MAX_OUTLOOK_DAYS.
ssh is the sea-surface height anomaly grid in METRES (Phase 151, CoastWatch
noaacwBLENDEDsshDaily, variable sla) — signed, positive = a high/anticyclonic centre. Its
layers.ssh.asOf is the SSH field’s OWN publication time, not the SST field’s, because its
staleness band is derived from its own cadence (aging >2d, stale >3d).
buoys are NDBC point checks (stations 46232/46225/46258) served BESIDE marine, never merged
into it: the modeled wave number and the measured one are shown as a comparison. Every
observation field is independently nullable (MM at the source = missing, never zero), band is
the cadence-derived age (aging >3h, stale >6h), and a station that failed is unavailable on its
own row with detail — one dead buoy is never a buoys-wide outage. null when no buoy provider
is configured.
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, gridConfidence }, "grid": [ { "cellId", "lat", "lon", "score", "confidence", "contributions": [ { "key", "label", "rawSubScore", "weight", "weighted" } ], … } ], "bestBets": [ <zone> ], "provenance": { … } }
gridStats.gridConfidence is the min cell confidence across water cells, after the horizon
cap; provenance is the Trip-Day Outlook block (above), attached identically on the cached
and live paths. 422 unknown species, bad date, or horizonDays > MAX_OUTLOOK_DAYS.
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). Accepts a +1
outlook day; 422 for horizonDays > MAX_OUTLOOK_DAYS.
GET /v1/ocean/bite-intel
Auth: pk. Query: ?date= (optional). The instrument-dock seam (Phase 142): the
operator’s day tide predictions shaped for a drawn curve, plus moon phase. 200:
{ "date", "station": { "tideStationId", "lat", "lon", "timezone"? }, "tide": {…}|null, "moon": {…}, "generatedAt" }
Shape change (Phase 155): the biteWindows field — the Phase 142 S1 CONCEPT placeholder
({ "tier": "CONCEPT", "note", … }, “no defensible composite”) — was removed from this
payload. Bite windows are now computed client-side by the intel app (COMPUTABLE tier,
packages/intel/src/lib/biteWindows.ts, Phase 154) from this payload’s tide + station;
the server never had a real model to serve. No known consumer read the field.
GET /v1/ocean/counts-seasonal
Auth: pk. Query: ?date= (optional, defaults to today UTC). Seasonal analytics over the
global dock feed (Phase 155 / Intel 10x P10): species momentum, the season-to-date curve, and
the multi-year calendar-window baseline. Every computation runs through YESTERDAY (today’s dock
counts do not exist until the boats are back). 200:
{ "date", "throughDate", "momentum": {…}, "season": {…}, "baseline": {…} }
- Scope: reads
fish_count WHERE operator_id IS NULLonly. Operator-submitted rows are excluded in SQL, so no operator’s private submissions can reach another operator’s numbers — there is no operator input to this endpoint at all. - Floors (never a quieter version of the claim).
momentum.stateisbuildingunder 14 observed days in the recent window (carryingrecentObservedDays/requiredDaysfor the “building history: N of 14 days” line) andno_comparisonwhen the prior window is short.season.stateisthis_season_onlyunder 3 qualifying prior years.baseline.stateisbuildingunder the same floor, and thenrankandbandare null — no “historically” claim renders. - Coverage honesty. Momentum counts only days whose source set matches the current one
(
coverage,excludedByCoverage); cross-year comparisons are restricted to the sources common to every compared year (coverage,droppedYears), so a widened feed cannot read as a good year.coverageState: "no_rows_this_year"marks the case where this season has nothing observed yet —droppedYearsis then empty, because no coverage verdict is available to give. - One span per year.
baseline.windowFrom/windowToare dated in this year and describe thelookbackDays-day window ending at this calendar day; prior years are measured over the SAME elapsed span, so a mid-window read never compares a half window against whole ones. baseline.rankis a RANK among observed years (“3rd of 4”), never a percentile — three or four years cannot support one.
GET /v1/ocean/bite-validation
Auth: operator. How many of this operator’s logged catches (trip_outcome, the captain’s
logbook) came from trips that fished a computed bite window (Phase 155 / Intel 10x P10). 200:
{ "state": "progress"|"ready", "counted", "required", "inside", "outside", "insidePct", "exposurePct", "withCatchTime", "catchTimeUnusable", "excluded": {…}, "datesEvaluated", "since", "through", "beyondDateBound", "maxDates", "station" }
- Scope: operator session only; the query filters
trip_outcome.operator_idAND re-checks ownership through the slot’s boat. Logbook data never crosses operators. - Floor: under 30 counted catches
stateisprogressandinsidePct/exposurePctare null — a progress counter, no rate and no verdict. Nothing in this endpoint promotes a window to “locally validated”; that label does not flip here. - What it measures: two bases, and the payload says which. A catch with a recorded
caughtAtLocal(optional, Phase 158) is judged on that INSTANT against the day’s windows; a catch without one keeps the original heuristic — the trip’s on-water span (the slot’s departure→return) compared against the windows.withCatchTimecounts the former, so a tally can never be read as more evidence than it is;catchTimeUnusablecounts times the trip’s own hours contradict, which are reported and ignored rather than trusted over the slot.exposurePct— the share of counted trip hours the windows covered — is the chance baselineinsidePctmust be read against under either basis, and the two always ship together. excludednames why a logged trip did not count:no_fish,no_span,span_too_long(over 18 h — one day’s windows do not describe a long-range trip),no_windows(no tide predictions on record for that date, so no window was computable).
GET /v1/ocean/ais-effort
Auth: pk. Query: ?date= (optional, defaults to today UTC). The fleet-effort map layer
(Phase 152 / Intel 10x P6; historical bake Phase 156 / P11): aggregated effort zones only — no
vessel identities. The payload has no MMSI, name, call sign or track field at all; the identities
are absent by type, not hidden.
200:
{ "date", "generatedAt", "tier": "HISTORICAL"|"CONCEPT", "source": "marinecadastre"|"mock"|"none", "fabricated", "zones": [ { "id", "label", "centroid": {lat,lon}, "radiusNmi", "intensity01" } ], "unavailable": null, "note", "coverageNote", "bake": { "label", "coverage", "framing", "caveat", "years", "sampledDays", "bakedOn", "ageDays", "stale" } | null, "asOf": null }
tieris HISTORICAL when the served zones come from the MarineCadastre bake, CONCEPT when the mock is serving (AIS_SOURCE=mock) or when there is nothing to serve.LIVEarrives only with a real feed (P12).fabricatedis a narrower claim, not a restatement oftier: it is true only for invented zones (the mock), soCONCEPT+fabricated: falseis the honest reading of an empty state — nothing was invented, there is simply nothing to show.bakeis non-null if and only iftierisHISTORICAL, and is required there. It is the vintage:label(“typical July effort”),coverage(“3 sampled days across 2022–2024”),framing(the “not live positions and not today’s fleet” sentence) andcaveat(what the speed + vessel-type heuristic cannot separate). A HISTORICAL tier with no vintage is not expressible — the endpoint downgrades such a provider to theno-bake-coverageempty state rather than serving the word bare, and the intel client downgrades it again at its own seam.staleis true once the bake is more than one season (92 days) pastbakedOn— PRD §4 d.5. Stale MARKS the layer; it never hides it.sourceis the provider’s own key. Fabricated zones say"mock"and never a real feed’s name;"marinecadastre"appears only on zones actually derived from those files.intensity01is a relative render weight — each zone’s share of its month’s heaviest zone — and not a count of boats. No counts are served.asOfis null by construction: neither invented zones nor a typical-month bake has a single observation time. The bake’s age lives inbake.ageDays.unavailableis non-null withzones: []when the layer has nothing to serve:{ "reason": "unconfigured" }whenAIS_SOURCEnames a provider that does not exist yet (source"none"— neither the bake nor the mock is substituted under that name), or{ "reason": "no-bake-coverage" }when the bake holds no zones for the date’s calendar month (source stays"marinecadastre"; a neighbouring month’s zones are never borrowed).- The bake is a committed asset (
packages/api/src/providers/ais-bake/) with its own manifest of request params, sampled days, filters and aggregation rules; the raw ~1.1 GB/day source CSVs never enter the repo. Zones are per calendar MONTH: every July date returns July’s zones. - Stateless: nothing is persisted and nothing is fetched, and nothing here reaches the scoring engine (PRD §6 O1 — effort zones must never move a grid score).
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" } ], "provenance": { "state", "dayBuilt", "verified", "rows", "lastFetch", "sources" } }
provenance describes the GLOBAL ingestion for the date (never narrowed by species, and
unaffected by this operator’s own submissions), so an empty counts array is always
diagnosable. state is one of:
ok— counts exist for the date.real_zero— collected, source healthy, genuinely no qualifying reports (“no reports posted” — a zero of reports, never a claim about fish).source_down— the last fetch failed (“unavailable”).parser_drift— the page rendered but no row matched the parser (“source format changed”).
An incident on the latest fetch outranks rows already held: a date whose last fetch failed
reads source_down even though its previously-ingested counts are still served (they are
never wiped on our own failure). state describes the SOURCE, counts describes the data.
never_built— nothing was collected for the date (“not collected”). Transient: the gap sweep backfills missed days, so this should fill in within a day.
dayBuilt is the forecast_run presence check (the first step of the documented diagnostic
order); lastFetch is { source, outcome, fetchedAt, parsedRows, error } or null.
verified (= lastFetch != null) says whether a recorded ingestion attempt stands behind the
state. It is false only for dates built before the ingestion record existed. A client must not
present an unverified real_zero as “we checked and nobody reported” — it is “no reports on
record”.
sources (Phase 148) is the latest fetch per count source, newest first:
[{ "source", "role", "outcome", "fetchedAt", "parsedRows", "error" }]. outcome is the
per-fetch provenance (ok | source_down | parser_drift); role is one of:
primary— prod-proven and complete for the ports it covers (sandiegofishreports). This is the sourcestateandlastFetchdescribe, so a supplementary outage can never make the day readsource_downwhile San Diego’s counts came in fine.supplementary— fills the ports the primary does not reach (sportfishingreport, SoCal-wide). Its incidents appear ONLY here.unknown— a source the region pack does not describe (the keyless mocks). Treated as non-supplementary for thestateread, which is what keeps mock-only dates behaving as before.
A day can therefore read state: "ok" with a parser_drift entry in sources — the correct
reading of “San Diego is fine, the SoCal supplement is not”. Where the two feeds both covered a
trip, the losing feed’s value is retained on the winning row and is not served in counts.
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/trip-outcomes
Auth: operator. Record what a vessel trip (availability slot) caught: the labeled
(conditions → outcome) pair at the trip grain (one outcome per slot). Body:
{ "slotId", "species": [{ "name", "kept", "released?", "topSizeLb?" }], "area?", "lat?", "lon?", "notes?", "caughtAtLocal?" }.
caughtAtLocal (Phase 158) is an OPTIONAL station-local wall clock, "HH:MM" or "HH:MM:SS"
(422 on anything else; null/omitted stores no time and clears one on a re-POST) — the trip-grain
record stays complete without it, and the bite-window counter uses it only when present.
The server validates the slot exists, belongs to the operator, and has departed (404
unknown slot, 403 another operator’s slot, 409 a trip that hasn’t sailed). The trip date
comes from the slot’s departure day (not the body). Coords resolve from the named area
(gazetteer centroid) when lat/lon are omitted; lat/lon come as a pair (422 otherwise). The
server derives the nearest archive-station condition link (conditionStationId,
conditionDate, conditionDistanceNmi, optional conditionObservationId), kept only within
~30 nmi; otherwise all link fields are null. Upserts on slotId (a re-POST replaces, no
duplicate). 201: the stored outcome.
GET /v1/ocean/trip-outcomes
Auth: operator. Query: ?from=&to= (both optional, inclusive YYYY-MM-DD range over
the trip date). Lists this operator’s trip outcomes, newest first. 200:
{ "outcomes": [ { "id", "slotId", "operatorId", "date", "species", "area", "lat", "lon", "notes", "caughtAtLocal", "conditionStationId", "conditionDate", "conditionDistanceNmi", "conditionObservationId" } ] }
(caughtAtLocal is "HH:MM:SS" station-local or null — null on every row logged without a time.)
GET /v1/ocean/favorite-banks
Auth: operator. Lists the operator’s starred banks (Sprint 62d), sorted by bank name. The
favorite stores only the gazetteer bank name + an optional note; the read joins back to the
authored gazetteer (@booking/ocean BANKS) so coords always reflect the current gazetteer. 200:
{ "favorites": [ { "id", "bankName", "note", "lat", "lon", "kind", "approxNmiFromPointLoma", "createdAt" } ] }
POST /v1/ocean/favorite-banks
Auth: operator. Star a bank. Body: { "bankName", "note?" }. bankName must match a bank
in the authored gazetteer (422 otherwise; a typo can’t persist a dangling favorite). Upserts on
(operator, bankName), so re-starring just updates the note (idempotent). 201: the stored
favorite, enriched with its gazetteer coords (same shape as a list item).
DELETE /v1/ocean/favorite-banks/:bankName
Auth: operator. Unstar a bank (:bankName is URL-encoded). 404 when the operator hasn’t
starred that bank. 200: { "ok": true }.
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).