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

API contract

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

Conventions

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

Error model (uniform)

All errors return:

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

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


Health & ops

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 the 0 13 * * * cron.
  • counts → the evening beat: dock-count ingestion for the current Pacific day and nothing else. Fired by the 30 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 card pending → active.
  • payment_intent.payment_failedpayment.failed (off-session balance charge becomes retryable; booking stays pending_payment for a deposit).
  • setup_intent.succeeded → persist the card-on-file payment method (idempotent). Always 200 once signature-valid, even on replay.

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 → persist charges_enabled / payouts_enabled / details_submitted onto the operator resolved solely by the unique connect_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-unique stripePaymentIntentId, 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_session cookie; 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; 401 oauth_finish_invalid (cookie missing/expired/used); 409 email_taken (race); 503 oauth_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 unless sendComms: false.
  • deposit_link (pay-by-link): holds the seat, keeps the trip’s normal pricing, stays pending_payment, writes a requires_payment deposit row on the charge-account snapshot, persists the comms choice, and returns a re-copyable link. No PaymentIntent is created here (the hosted /pay/:token page mints it — S2). Refused when the trip deposit is $0 (422), when the operator can’t take online deposits yet (Connect not charge-ready → 409 conflict), 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" }.

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.

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 → 409 conflict naming 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 on GET /book/:slug). Anything else (keywords, url(…), ;-breakouts) → 422.
  • logoUrl — absolute https:// 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_enabledconnected; 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 tripTypeId422. 201: the resource.

POST /v1/operator/resources/upload

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

PATCH /v1/operator/resources/:id

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

Promo codes

GET /v1/operator/promo-codes

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

POST /v1/operator/promo-codes

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

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

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

Gift cards

GET /v1/operator/gift-cards

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

POST /v1/operator/gift-cards

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

Waitlist, carts, CRM

GET /v1/operator/waitlist

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

GET /v1/operator/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_DAYS422 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 NULL only. 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.state is building under 14 observed days in the recent window (carrying recentObservedDays / requiredDays for the “building history: N of 14 days” line) and no_comparison when the prior window is short. season.state is this_season_only under 3 qualifying prior years. baseline.state is building under the same floor, and then rank and band are 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 — droppedYears is then empty, because no coverage verdict is available to give.
  • One span per year. baseline.windowFrom/windowTo are dated in this year and describe the lookbackDays-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.rank is 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_id AND re-checks ownership through the slot’s boat. Logbook data never crosses operators.
  • Floor: under 30 counted catches state is progress and insidePct / exposurePct are 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. withCatchTime counts the former, so a tally can never be read as more evidence than it is; catchTimeUnusable counts 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 baseline insidePct must be read against under either basis, and the two always ship together.
  • excluded names 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 }

  • tier is 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. LIVE arrives only with a real feed (P12). fabricated is a narrower claim, not a restatement of tier: it is true only for invented zones (the mock), so CONCEPT + fabricated: false is the honest reading of an empty state — nothing was invented, there is simply nothing to show.
  • bake is non-null if and only if tier is HISTORICAL, 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) and caveat (what the speed + vessel-type heuristic cannot separate). A HISTORICAL tier with no vintage is not expressible — the endpoint downgrades such a provider to the no-bake-coverage empty state rather than serving the word bare, and the intel client downgrades it again at its own seam.
  • stale is true once the bake is more than one season (92 days) past bakedOn — PRD §4 d.5. Stale MARKS the layer; it never hides it.
  • source is 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.
  • intensity01 is a relative render weight — each zone’s share of its month’s heaviest zone — and not a count of boats. No counts are served.
  • asOf is null by construction: neither invented zones nor a typical-month bake has a single observation time. The bake’s age lives in bake.ageDays.
  • unavailable is non-null with zones: [] when the layer has nothing to serve: { "reason": "unconfigured" } when AIS_SOURCE names 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 source state and lastFetch describe, so a supplementary outage can never make the day read source_down while 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 the state read, 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)

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

Not yet on this surface (roadmap)

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