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 carriesCache-Control: no-store. - Every failure is
{ "error": "<code>", "message": "<sentence>" }plus occasional extras such asconflictWith. - An id belonging to another club answers
not_found, the same as an id that does not exist.
Gates
| Gate | Means |
|---|---|
none | Reachable without credentials. Every such route is rate-limited by IP or verified another way. |
token | A valid bearer token. No club is resolved. |
tenant | A bearer token plus an active membership in the club named by X-Club-Id (or the account's only club). |
owner | A tenant whose membership role is owner. |
secret | A 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.
| Route | Gate | Body | Response | Errors |
|---|---|---|---|---|
POST/api/v1/auth/oauth | none | { 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/email | none | { email, password } to sign in · + code to finish an owner's two-step · + name to sign up | Member 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/reset | none | { 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 out | bad_request 400 · weak_password 400 · bad_code 401 · rate_limited 429 |
GET/api/v1/auth/session | tenant | none | { account, club{id, name, role}, displayName, clubs[] } | no_token 401 · no_membership 403 · pick_club 409 |
PATCH/api/v1/auth/session | token | { fullName, ageAttested? } | { account } | no_token 401 · bad_request 400 |
DELETE/api/v1/auth/session | token | ?all=1 to revoke every device | { ok: true, devicesSignedOut? }. An unknown token is still a successful sign-out | none |
DELETE/api/v1/auth/account | token | none | { ok: true }. Anonymises the account, destroys every device and push token, deactivates memberships. History keeps display names | no_token 401 |
Clubs and membership
| Route | Gate | Body | Response | Errors |
|---|---|---|---|---|
POST/api/v1/clubs | token | { name, displayName, lat, lng, timezone? } | { club{id, name, slug, joinCode} }. The caller becomes owner | bad_request 400 · plan_limit 409 · retry 409 |
PATCH/api/v1/clubs | owner | Any of name, timezone, lat, lng, bookAheadDays, maxOpenReservations, checkinGraceMinutes, graceMinutes, autoCheckoutEnabled, autoCheckoutRadiusM, amWindowRule, pmWindowRule, bookingOpensDay, bookingOpensTime | { ok: true, changed[] }. Existing reservations keep their clocks | bad_request 400 per field |
GET/api/v1/clubs/join?code= | token | none | { club{id, name} }. A name to confirm before joining | no_such_club 404 · rate_limited 429 |
POST/api/v1/clubs/join | token | { code, displayName } or { inviteToken, displayName } | { club{id, name} }. Rejoining an active membership succeeds | no_such_club 404 · bad_invite 404 · removed 403 · plan_limit 409 · name_taken 409 · rate_limited 429 |
GET/api/v1/club/roster | tenant | none | { members[{membershipId, displayName, role, joinedAt, seasonReservations, lastOutAt}] }. Active members, never a phone number | none |
GET/api/v1/club/members | owner | none | { members[], plan{name, current, limit, atLimit} }. Includes deactivated members | none |
PATCH/api/v1/club/members | owner | { membershipId, role?, isActive? } | { member, releasedReservations }. Deactivation releases their holds as admin_clear | cannot_deactivate_self 409 · last_owner 409 · not_found 404 |
POST/api/v1/club/join-code | owner | none | { joinCode, rotatedAt } | retry 409 |
GET/api/v1/club/invites | owner | none | { invites[{id, label, expiresAt, maxUses, uses, createdAt}] }. Tokens are never returned here | none |
POST/api/v1/club/invites | owner | { label?, maxUses? (1 to 500, null = unlimited), expiresInDays? (1 to 365, null = never) } | { invite, token, url }. The token appears exactly once | bad_request 400 |
DELETE/api/v1/club/invites | owner | { inviteId } | { revoked: true }. Instant and irreversible | not_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.
| Route | Gate | Body | Response | Errors |
|---|---|---|---|---|
GET/api/v1/state?date=YYYY-MM-DD&window=am|pm | tenant | none | The app state for the viewed slot: serverTime, club, today, sun, isNight, slots[], viewing, me, fields[]. Sweeps lapsed reservations first | no_token 401 · no_membership 403 · pick_club 409 |
GET/api/v1/reservations | tenant | none | { hunts[] }. The member's own released reservations, newest first, up to 100 | none |
POST/api/v1/reservations | tenant | { 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/reservations | tenant | { reservationId } | { ok: true, alreadyReleased }. Cancels your own booking | checked_in 409 · not_found 404 |
POST/api/v1/checkin | tenant | { 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/checkout | tenant | { reservationId, reason?: "manual" | "departed" } | { ok: true, alreadyReleased, standName, releaseReason } | not_checked_in 409 · not_found 404 |
POST/api/v1/reservations/clear | owner | { reservationId } | { ok: true, alreadyReleased, standName?, displayName? }. Recorded as admin_clear and attributed | not_found 404 |
Fields, stands, photos
| Route | Gate | Body | Response | Errors |
|---|---|---|---|---|
GET/api/v1/fields | tenant | none | { fields[{id, name, notes, sortOrder, standCount}] }. Active only | none |
POST/api/v1/fields | owner | { name, notes?, lat?, lng?, boundary? (3 to 64 [lng, lat] corners) } | { field } | bad_request 400 · name_taken 409 |
PATCH/api/v1/fields | owner | { id, name?, notes? } | { field } | not_found 404 · name_taken 409 |
DELETE/api/v1/fields | owner | { id } | { ok: true }. Stands without history are deleted, with history retired | field_in_use 409 · not_found 404 |
GET/api/v1/stands | owner | none | { stands[] } including retired. Members read stands through /state | none |
POST/api/v1/stands | owner | { fieldId, name, standType?, lat, lng, notes? } | { stand, planWarning } | not_found 404 · plan_limit 409 · name_taken 409 |
PATCH/api/v1/stands | owner | { id, fieldId?, name?, standType?, lat?, lng?, notes?, isActive? }. Merged onto the row | { stand } | not_found 404 · name_taken 409 |
DELETE/api/v1/stands | owner | { id } | { ok: true, deleted, message? }. Deleted only if never booked; retired otherwise | stand_in_use 409 · not_found 404 |
GET/api/v1/photos?standId=|fieldId= | tenant | none | { photos[{id, url, caption, width, height, createdAt}] }. url is a signed URL, stable for the day | bad_request 400 |
GET/api/v1/photos | tenant | none | The camp gallery: { photos[] } with field, stand, species, member, likeCount, commentCount, likedByMe, isCover. Newest first, up to 500 | none |
POST/api/v1/photos | tenant | multipart/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 logged | storage_unavailable 503 · bad_request 400 · not_found 404 |
DELETE/api/v1/photos | owner | { photoId } | { deleted: true }. Row and stored object together | not_found 404 |
POST/api/v1/photos/likes | tenant | { photoId } | { liked, likeCount }. A toggle | not_found 404 |
GET/api/v1/photos/comments?photoId= | tenant | none | { comments[{id, memberName, body, createdAt}] } | not_found 404 |
POST/api/v1/photos/comments | tenant | { photoId, body } | { comment }. Notifies the photo's owner | bad_request 400 · not_found 404 |
POST/api/v1/photos/cover | tenant | { photoId } | { ok: true }. Fronts the picture's field in the gallery | not_found 404 |
POST/api/v1/photos/report | tenant | { 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 enforcement | bad_request 400 · not_found 404 |
Harvests, conditions, safety
| Route | Gate | Body | Response | Errors |
|---|---|---|---|---|
GET/api/v1/harvests | tenant | none | { harvests[{id, species, notes, huntDate, loggedAt, memberName, standName, photoUrl}] }. Newest first, up to 200 | none |
POST/api/v1/harvests | tenant | { species, huntDate?, standId?, notes? } | { harvest }. Photos attach through /photos with harvestId | bad_request 400 |
GET/api/v1/conditions | tenant | none | { wind{dir8, speedMph}, legalLight{start, end, isLegalNow}, fetchedAt }. National Weather Service, cached 15 minutes | unavailable 503 |
POST/api/v1/sos | tenant | { note? } | { ok: true } or { ok: true, repeated: true } within one minute of the last. Pushes the member's name and stand to every member | none |
Dues
| Route | Gate | Body | Response | Errors |
|---|---|---|---|---|
GET/api/v1/club/dues?season= | tenant | none | { seasons[], current } with who has paid what. Members see it too | not_found 404 |
POST/api/v1/club/dues | owner | { 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/dues | owner | { seasonId, label?, amount?, dueOn?, closed? } | { ok: true } | bad_request 400 · not_found 404 |
DELETE/api/v1/club/dues | owner | { paymentId } | { ok: true }. Mistakes are deleted, not reversed | bad_request 400 · not_found 404 |
Push
| Route | Gate | Body | Response | Errors |
|---|---|---|---|---|
POST/api/v1/push | token | { token, platform?, environment? }. An APNs device token, hex | { registered: true }. Upserts by token | no_token 401 · bad_request 400 |
PATCH/api/v1/push | tenant | { claims?, noShow? } | { claims, noShow }. Per membership | bad_request 400 |
DELETE/api/v1/push | token | { token } | { ok: true }. Stop pushing without signing out | no_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.
| Route | Gate | Body | Response | Errors |
|---|---|---|---|---|
POST/api/v1/billing/apple | owner | { signedTransaction }. The StoreKit 2 JWS | { plan, status, periodEnd }. Verified to Apple's pinned root before anything is written | billing_unavailable 503 · bad_transaction 400 · unknown_product 400 · already_claimed 409 |
POST/api/v1/billing/checkout | none | { code, plan } | { url, clubName }. Answers 503 until web checkout opens | billing_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.
| Route | Gate | Body | Response | Errors |
|---|---|---|---|---|
POST/api/webhooks/apple | secret | App Store Server Notification V2. Envelope and nested transaction both verified to Apple's root | { ok: true } with duplicate / unmatched / ignored variants | billing_unavailable 503 · bad_signature 400 · bad_payload 400 |
POST/api/webhooks/revenuecat | secret | RevenueCat event. Shared-secret Authorization header | { ok: true } with duplicate / unmatched / test variants | billing_unavailable 503 · bad_signature 401 · bad_payload 400 |
POST/api/webhooks/stripe | secret | Stripe event. HMAC over the raw body | { ok: true } (duplicate: true on replay) | billing_unavailable 503 · bad_signature 400 · bad_payload 400 |
GET/api/cron/expire | secret | Authorization: Bearer CRON_SECRET | { ok: true, released, noShows, reservations[] }. The hourly backstop sweep | unauthorized 401 |
GET/api/metrics | secret | Authorization: Bearer METRICS_TOKEN | Prometheus text: fleet-wide gauges, nothing tenant-scoped | unauthorized 401 |
GET/api/health | none | none | 200 with a small JSON body | none |
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.