API reference

Every route the Hunt Time backend serves, its gate, its body, its response, and its errors. Read For developers first for the conventions and the flows.

Conventions

  • Base URL https://api.hunttime.app. JSON bodies unless noted. Every response carries Cache-Control: no-store.
  • Every failure is { "error": "<code>", "message": "<sentence>" } plus occasional extras such as conflictWith.
  • An id belonging to another club answers not_found, the same as an id that does not exist.

Gates

GateMeans
noneReachable without credentials. Every such route is rate-limited by IP or verified another way.
tokenA valid bearer token. No club is resolved.
tenantA bearer token plus an active membership in the club named by X-Club-Id (or the account's only club).
ownerA tenant whose membership role is owner.
secretA shared secret or provider signature. Not for clients: cron, metrics, and the billing providers calling in.

Sign-in and account

The three doors return one shape on success: token, account, needsProfile, and clubs[]. Session and account DELETE skip the tenant gate on purpose, so a removed member can still sign out.

RouteGateBodyResponseErrors
POST/​api/​v1/​auth/​oauthnone{ provider: "apple" | "google", identityToken, authorizationCode?, name?, platform?, deviceName? }{ token, account{id, email, fullName}, hasPhone, needsProfile, clubs[] }bad_request 400 · 401 when the provider token fails verification · misconfigured 503 · rate_limited 429
POST/​api/​v1/​auth/​emailnone{ email, password } to sign in · + code to finish an owner's two-step · + name to sign upMember or sign-up: { token, account, needsProfile, clubs[] }. Owner step one: { twoFactor: true, emailHint }bad_request 400 · bad_credentials 401 · bad_code 401 · weak_password 400 · email_taken 409 · rate_limited 429 · send_failed 502
POST/​api/​v1/​auth/​resetnone{ email } to send a code · { email, code, newPassword } to set the password{ sent: true } regardless of whether the email exists · { ok: true } and every device signed outbad_request 400 · weak_password 400 · bad_code 401 · rate_limited 429
GET/​api/​v1/​auth/​sessiontenantnone{ account, club{id, name, role}, displayName, clubs[] }no_token 401 · no_membership 403 · pick_club 409
PATCH/​api/​v1/​auth/​sessiontoken{ fullName, ageAttested? }{ account }no_token 401 · bad_request 400
DELETE/​api/​v1/​auth/​sessiontoken?all=1 to revoke every device{ ok: true, devicesSignedOut? }. An unknown token is still a successful sign-outnone
DELETE/​api/​v1/​auth/​accounttokennone{ ok: true }. Anonymises the account, destroys every device and push token, deactivates memberships. History keeps display namesno_token 401

Clubs and membership

RouteGateBodyResponseErrors
POST/​api/​v1/​clubstoken{ name, displayName, lat, lng, timezone? }{ club{id, name, slug, joinCode} }. The caller becomes ownerbad_request 400 · plan_limit 409 · retry 409
PATCH/​api/​v1/​clubsownerAny of name, timezone, lat, lng, bookAheadDays, maxOpenReservations, checkinGraceMinutes, graceMinutes, autoCheckoutEnabled, autoCheckoutRadiusM, amWindowRule, pmWindowRule, bookingOpensDay, bookingOpensTime{ ok: true, changed[] }. Existing reservations keep their clocksbad_request 400 per field
GET/​api/​v1/​clubs/​join?code=tokennone{ club{id, name} }. A name to confirm before joiningno_such_club 404 · rate_limited 429
POST/​api/​v1/​clubs/​jointoken{ code, displayName } or { inviteToken, displayName }{ club{id, name} }. Rejoining an active membership succeedsno_such_club 404 · bad_invite 404 · removed 403 · plan_limit 409 · name_taken 409 · rate_limited 429
GET/​api/​v1/​club/​rostertenantnone{ members[{membershipId, displayName, role, joinedAt, seasonReservations, lastOutAt}] }. Active members, never a phone numbernone
GET/​api/​v1/​club/​membersownernone{ members[], plan{name, current, limit, atLimit} }. Includes deactivated membersnone
PATCH/​api/​v1/​club/​membersowner{ membershipId, role?, isActive? }{ member, releasedReservations }. Deactivation releases their holds as admin_clearcannot_deactivate_self 409 · last_owner 409 · not_found 404
POST/​api/​v1/​club/​join-codeownernone{ joinCode, rotatedAt }retry 409
GET/​api/​v1/​club/​invitesownernone{ invites[{id, label, expiresAt, maxUses, uses, createdAt}] }. Tokens are never returned herenone
POST/​api/​v1/​club/​invitesowner{ label?, maxUses? (1 to 500, null = unlimited), expiresInDays? (1 to 365, null = never) }{ invite, token, url }. The token appears exactly oncebad_request 400
DELETE/​api/​v1/​club/​invitesowner{ inviteId }{ revoked: true }. Instant and irreversiblenot_found 404

The board and reservations

Booking and check-in take a caller-generated clientRequestId, reused across retries; a duplicate replays the original with replayed: true. Check-out, cancel, and clear are idempotent by construction.

RouteGateBodyResponseErrors
GET/​api/​v1/​state?date=YYYY-MM-DD&window=am|pmtenantnoneThe app state for the viewed slot: serverTime, club, today, sun, isNight, slots[], viewing, me, fields[]. Sweeps lapsed reservations firstno_token 401 · no_membership 403 · pick_club 409
GET/​api/​v1/​reservationstenantnone{ hunts[] }. The member's own released reservations, newest first, up to 100none
POST/​api/​v1/​reservationstenant{ standId, huntDate, windowKey, guestCount? (0 to 20), guestNames?, clientRequestId }{ replayed, reservation{id, standId, standName, huntDate, windowKey, dayLabel, holdUntil, expiresAt} }not_found 404 · stand_inactive 409 · slot_passed 409 · too_far_ahead 409 · not_open_yet 409 · cap_reached 409 · slot_taken 409 with conflictWith{standId, standName, displayName, holdUntil}
DELETE/​api/​v1/​reservationstenant{ reservationId }{ ok: true, alreadyReleased }. Cancels your own bookingchecked_in 409 · not_found 404
POST/​api/​v1/​checkintenant{ reservationId, … } to arrive on a booking · { standId, … } for a walk-up · plus guestCount?, guestNames?, clientRequestId{ replayed, reservation{id, standId, standName, checkedInAt, expiresAt} }not_found 404 · slot_passed 409 · already_checked_in 409 · no_current_slot 409 · stand_inactive 409 · slot_taken 409
POST/​api/​v1/​checkouttenant{ reservationId, reason?: "manual" | "departed" }{ ok: true, alreadyReleased, standName, releaseReason }not_checked_in 409 · not_found 404
POST/​api/​v1/​reservations/​clearowner{ reservationId }{ ok: true, alreadyReleased, standName?, displayName? }. Recorded as admin_clear and attributednot_found 404

Fields, stands, photos

RouteGateBodyResponseErrors
GET/​api/​v1/​fieldstenantnone{ fields[{id, name, notes, sortOrder, standCount}] }. Active onlynone
POST/​api/​v1/​fieldsowner{ name, notes?, lat?, lng?, boundary? (3 to 64 [lng, lat] corners) }{ field }bad_request 400 · name_taken 409
PATCH/​api/​v1/​fieldsowner{ id, name?, notes? }{ field }not_found 404 · name_taken 409
DELETE/​api/​v1/​fieldsowner{ id }{ ok: true }. Stands without history are deleted, with history retiredfield_in_use 409 · not_found 404
GET/​api/​v1/​standsownernone{ stands[] } including retired. Members read stands through /statenone
POST/​api/​v1/​standsowner{ fieldId, name, standType?, lat, lng, notes? }{ stand, planWarning }not_found 404 · plan_limit 409 · name_taken 409
PATCH/​api/​v1/​standsowner{ id, fieldId?, name?, standType?, lat?, lng?, notes?, isActive? }. Merged onto the row{ stand }not_found 404 · name_taken 409
DELETE/​api/​v1/​standsowner{ id }{ ok: true, deleted, message? }. Deleted only if never booked; retired otherwisestand_in_use 409 · not_found 404
GET/​api/​v1/​photos?standId=|fieldId=tenantnone{ photos[{id, url, caption, width, height, createdAt}] }. url is a signed URL, stable for the daybad_request 400
GET/​api/​v1/​photostenantnoneThe camp gallery: { photos[] } with field, stand, species, member, likeCount, commentCount, likedByMe, isCover. Newest first, up to 500none
POST/​api/​v1/​photostenantmultipart/form-data: file (8 MB max; JPEG, PNG, HEIC, WebP), exactly one of standId / fieldId / harvestId, caption?{ photo }. A harvest photo only on a harvest the member loggedstorage_unavailable 503 · bad_request 400 · not_found 404
DELETE/​api/​v1/​photosowner{ photoId }{ deleted: true }. Row and stored object togethernot_found 404
POST/​api/​v1/​photos/​likestenant{ photoId }{ liked, likeCount }. A togglenot_found 404
GET/​api/​v1/​photos/​comments?photoId=tenantnone{ comments[{id, memberName, body, createdAt}] }not_found 404
POST/​api/​v1/​photos/​commentstenant{ photoId, body }{ comment }. Notifies the photo's ownerbad_request 400 · not_found 404
POST/​api/​v1/​photos/​covertenant{ photoId }{ ok: true }. Fronts the picture's field in the gallerynot_found 404
POST/​api/​v1/​photos/​reporttenant{ photoId } or { commentId }, plus reason? (200 characters){ ok: true }. Pushes the owners and writes an audit line; removes nothing. The owner's delete is the enforcementbad_request 400 · not_found 404

Harvests, conditions, safety

RouteGateBodyResponseErrors
GET/​api/​v1/​harveststenantnone{ harvests[{id, species, notes, huntDate, loggedAt, memberName, standName, photoUrl}] }. Newest first, up to 200none
POST/​api/​v1/​harveststenant{ species, huntDate?, standId?, notes? }{ harvest }. Photos attach through /photos with harvestIdbad_request 400
GET/​api/​v1/​conditionstenantnone{ wind{dir8, speedMph}, legalLight{start, end, isLegalNow}, fetchedAt }. National Weather Service, cached 15 minutesunavailable 503
POST/​api/​v1/​sostenant{ note? }{ ok: true } or { ok: true, repeated: true } within one minute of the last. Pushes the member's name and stand to every membernone

Dues

RouteGateBodyResponseErrors
GET/​api/​v1/​club/​dues?season=tenantnone{ seasons[], current } with who has paid what. Members see it toonot_found 404
POST/​api/​v1/​club/​duesowner{ label, amount, dueOn? } to open a season · { seasonId, membershipId, amount, method?, note?, paidOn? } to record a payment{ season } or { payment }bad_request 400 · not_found 404
PATCH/​api/​v1/​club/​duesowner{ seasonId, label?, amount?, dueOn?, closed? }{ ok: true }bad_request 400 · not_found 404
DELETE/​api/​v1/​club/​duesowner{ paymentId }{ ok: true }. Mistakes are deleted, not reversedbad_request 400 · not_found 404

Push

RouteGateBodyResponseErrors
POST/​api/​v1/​pushtoken{ token, platform?, environment? }. An APNs device token, hex{ registered: true }. Upserts by tokenno_token 401 · bad_request 400
PATCH/​api/​v1/​pushtenant{ claims?, noShow? }{ claims, noShow }. Per membershipbad_request 400
DELETE/​api/​v1/​pushtoken{ token }{ ok: true }. Stop pushing without signing outno_token 401 · bad_request 400

Billing

Clubs pay through Apple in-app purchase. RevenueCat completes the purchase in the app and reports it by webhook; this route is the second witness and the restore path. Web checkout is wired but off.

RouteGateBodyResponseErrors
POST/​api/​v1/​billing/​appleowner{ signedTransaction }. The StoreKit 2 JWS{ plan, status, periodEnd }. Verified to Apple's pinned root before anything is writtenbilling_unavailable 503 · bad_transaction 400 · unknown_product 400 · already_claimed 409
POST/​api/​v1/​billing/​checkoutnone{ code, plan }{ url, clubName }. Answers 503 until web checkout opensbilling_unavailable 503 · bad_request 400 · no_such_club 404 · already_active 409 · rate_limited 429

Providers, cron, health

Not for clients. Listed so the surface is complete.

RouteGateBodyResponseErrors
POST/​api/​webhooks/​applesecretApp Store Server Notification V2. Envelope and nested transaction both verified to Apple's root{ ok: true } with duplicate / unmatched / ignored variantsbilling_unavailable 503 · bad_signature 400 · bad_payload 400
POST/​api/​webhooks/​revenuecatsecretRevenueCat event. Shared-secret Authorization header{ ok: true } with duplicate / unmatched / test variantsbilling_unavailable 503 · bad_signature 401 · bad_payload 400
POST/​api/​webhooks/​stripesecretStripe event. HMAC over the raw body{ ok: true } (duplicate: true on replay)billing_unavailable 503 · bad_signature 400 · bad_payload 400
GET/​api/​cron/​expiresecretAuthorization: Bearer CRON_SECRET{ ok: true, released, noShows, reservations[] }. The hourly backstop sweepunauthorized 401
GET/​api/​metricssecretAuthorization: Bearer METRICS_TOKENPrometheus text: fleet-wide gauges, nothing tenant-scopedunauthorized 401
GET/​api/​healthnonenone200 with a small JSON bodynone

Where this comes from

This page is a rendition. The route handlers in the backend are the contract, and the backend repository’s own API document is updated with them. If a route here disagrees with the server, the server is right and this page is behind.