For developers

Hunt Time is a JSON API at api.hunttime.app and clients that render what it says. This page is how to build one of those clients. The API reference lists every route.

What a client is, and is not

The server computes everything that matters: a stand’s status, the time a hold expires, which window is running now, whether a slot is open to claims. A client draws those answers. It does not recompute them, because a phone that disagrees with the board is a phone that thinks it still holds a stand it lost.

Three rules follow from that, and they are not negotiable:

  • Never queue a booking or a check-in offline. Two phones queueing claims on the same stand both show green and both hunters walk in. If the server cannot be reached, the client says so and does not grant the stand.
  • Never compute a status or a deadline locally. The three states, both clocks, and the current slot all arrive in the board response. Render them.
  • Never cache a board without saying so. The API sends Cache-Control: no-store on every response. A client may show its last board offline only with a visible stamp like “as of 5:12 AM”.

Conventions

  • Base URL: https://api.hunttime.app. Every path below is relative to it.
  • Bodies are JSON except photo upload, which is multipart.
  • Who: Authorization: Bearer <token>. The token is opaque, issued at sign-in, and lives for 180 days of sliding inactivity. Store it in the platform keychain, never in preferences or a backup.
  • Which club: X-Club-Id: <club id>. An account can belong to several clubs. With one membership and no header the server uses that membership; with several and no header it answers 409 pick_club.
  • Platform: sign-in bodies take platform as ios, android, or web (anything else is filed as iOS) and an optional deviceName for the member’s session list.
  • Errors are always { "error": "<code>", "message": "<sentence>" } with a non-2xx status. The message is written for the member and is safe to show verbatim. Branch on error, display message.
  • Tenancy is 404-shaped. An id that belongs to another club answers not_found, the same as an id that does not exist. Never 403.

Signing in

There are three doors. All of them return the same shape on success: a token, the account, whether the account has no club yet, and the clubs it belongs to.

{
  "token": "…",
  "account": { "id": "…", "email": "walt@example.com", "fullName": "Walt Jennings" },
  "needsProfile": false,
  "clubs": [ { "id": "…", "name": "The Farm", "role": "owner", "displayName": "Walt J." } ]
}

Apple or Google

POST /api/v1/auth/oauth with { "provider": "apple" | "google", "identityToken": "…" }. Apple sends authorizationCode and name on first authorization only; pass both through when you have them. The provider’s signed identity token is the credential. There is no sign-in versus sign-up split: the provider’s subject either names an account or starts one. The response also carries hasPhone, a leftover field that shipped clients decode; ignore it.

Email and password

POST /api/v1/auth/email. The presence of two fields decides which flow you are in:

  • { "email", "password", "name" } creates an account (password at least eight characters, else 400 weak_password; existing email is 409 email_taken).
  • { "email", "password" } signs a member in. If the account owns any club, the server instead emails a six-digit code and answers { "twoFactor": true, "emailHint": "w…@example.com" }. Repeat the request with code added to finish.

Wrong email and wrong password get the same 401 bad_credentials. A wrong code is 401 bad_code. Too many attempts from one address is 429 rate_limited.

Forgot password

POST /api/v1/auth/reset with { "email" } answers { "sent": true } whether or not the email exists. Then { "email", "code", "newPassword" } sets the password and signs out every device on the account.

The first club

When needsProfile is true the account belongs to no club. Two calls get it into one.

  1. PATCH /api/v1/auth/session with { "fullName": "Walt Jennings", "ageAttested": true }. This gates on the token alone because there is no club yet. ageAttested records the age checkbox (13 or older, guardian permission under 18) once and never moves.
  2. Either POST /api/v1/clubs with { "name", "displayName", "lat", "lng", "timezone"? } to start a club (the caller becomes owner and the response carries the new joinCode), or POST /api/v1/clubs/join with { "code", "displayName" } or { "inviteToken", "displayName" } to join one. GET /api/v1/clubs/join?code= previews the club’s name before the member commits.

Then store the club id from the response and send it as X-Club-Id on everything that follows.

On every launch

Call GET /api/v1/auth/session with the stored token and club id before showing anything. It answers who the token is, which club it resolves to, and every club the account belongs to.

  • 401 no_token: the token is dead or revoked. Clear it and show the sign-in screen.
  • 403 no_membership: the member was removed from that club. Fall back to another club or to profile setup.
  • 409 pick_club: several clubs and no header. Send X-Club-Id.

Sign out with DELETE /api/v1/auth/session, or ?all=1 to revoke every device including this one. Both succeed even with a dead token.

The board

GET /api/v1/state?date=YYYY-MM-DD&window=am|pm returns the whole board for one slot in one round trip. The iOS app polls it every 20 seconds while the screen is on and refetches after every write. Do the same; there is no push of board state.

The response is the app state. The fields a client renders:

FieldWhat it is
serverTimeThe server clock. Compare deadlines against this, not the device clock.
clubName, time zone, coordinates, plan, the auto check-out fence every member needs, and settings (owners only, null for members). joinCode is null for members.
todayToday's morning and evening windows with their start, end, hold and expiry times.
sunSunrise, sunset, and civil dawn and dusk in minutes since local midnight, for a night theme that can switch without a network call.
isNightWhether it is night at the club right now.
slotsEvery bookable (date, window) inside the horizon, each with isCurrent, isOpen, and opensLabel for the claims-open gate.
viewingWhich slot this board is for.
meThe viewer: display name, role, the stand they are checked into (drives the check-out banner), upcoming bookings, and how many they may hold.
fields[]Each field with its stands. A stand has status open, reserved, or taken, and a holder with displayName, partyLabel, holdUntil, expiresAt, and isMe.

Status is three states, not two. reserved means spoken for but empty. taken means a person with a rifle is at that stand. Render each as a color, a shape, and a word together; color alone fails one hunter in twelve.

Booking, check-in, check-out

Every write goes through Postgres, which is the only arbiter of who holds a stand. A client never checks then writes; it writes and reads the answer.

Idempotency

Booking and check-in take a caller-generated clientRequestId. Generate it once per tap and reuse it on every retry. A duplicate replays the original with replayed: true instead of booking twice. Flaky service in a stand is the normal condition; without this a retry double-books.

Book

POST /api/v1/reservations with { "standId", "huntDate", "windowKey", "guestCount"?, "guestNames"?, "clientRequestId" }. Up to 20 guests. The lost race is 409 slot_taken with conflictWith naming who won and until when; show it calmly, it is not an error. Other 409s: cap_reached, too_far_ahead, slot_passed, not_open_yet, stand_inactive.

Check in

POST /api/v1/checkin with either reservationId (arriving on a booking) or standId (a walk-up: the server books the current window and checks in, in one insert), plus guests and clientRequestId. More than two hours early is 409 slot_passed.

Check out and cancel

POST /api/v1/checkout with { "reservationId", "reason"?: "manual" | "departed" }; departed is what a geofence exit sends. DELETE /api/v1/reservations with { "reservationId" } cancels a booking that has not been checked into. Both are idempotent by construction: alreadyReleased: true is a success, and either may land an hour after the server already released the stand.

Errors worth mapping

Everything not listed here should surface its message as written.

CodeStatusDo this
no_token401Treat as signed out.
no_membership403Removed from this club. Pick another or go to profile setup.
pick_club409Send X-Club-Id and retry.
not_owner403Owner-only route with a member token. Hide the control.
not_found404Not yours, or gone. Refresh the board.
slot_taken409Somebody won the race. Show conflictWith calmly.
plan_limit409The club is at its plan cap. Show the message; offer the plan screen to owners.
rate_limited429Show the message and wait.
bad_credentials, bad_code, weak_password, email_taken401 / 400 / 409Sign-in form states.
no_such_club, bad_invite, removed, name_taken404 / 403 / 409Join form states.

Push

POST /api/v1/push with { "token", "platform"?, "environment"? } registers a device token. It gates on the bearer token alone, because iOS hands the app a token before any board has loaded. Ask for notification permission at the moment it makes sense, after the first booking, not at first launch. PATCH sets the per-club opt-outs claims and noShow; DELETE stops pushes without signing out.

Delivery is APNs only today. The schema accepts platform: "android", but nothing delivers to it until the server has an FCM transport. An Android client should register nothing rather than register into a void.

Photos

POST /api/v1/photos as multipart/form-data: file up to 8 MB (JPEG, PNG, HEIC, WebP), exactly one of standId, fieldId, or harvestId, and an optional caption. Photo URLs in every response are signed and stable for the calendar day, then expire; cache the image, not the URL.

Running the backend locally

With access to the hunttime-backend repository and any local Postgres:

npm install
cp .env.example .env.local        # set DATABASE_URL to a local database
npm run db:setup                  # migrate and seed two demo clubs
npm run dev                       # http://localhost:3000
npm test                          # all ten suites

With no email provider configured, the owner sign-in and reset codes print in the server log; reading them out of the log is the intended local workflow. Point a simulator at http://localhost:3000; a physical device needs your machine’s LAN address.

Before shipping a client

  1. Token in the keychain, never in preferences or a backup.
  2. X-Club-Id on every tenant call; pick_club handled.
  3. Board refetched after every write and on a timer while visible.
  4. Three states rendered as color plus shape plus word, never color alone.
  5. clientRequestId generated per tap and reused on retry for booking and check-in.
  6. No offline queue for booking or check-in. A clear message instead.
  7. slot_taken shown as information, not as a failure.
  8. Deadlines compared against serverTime, not the device clock.
  9. alreadyReleased and replayed treated as success.
  10. Sign-out and delete-account clear local state only after the server confirms.

What an outside developer can do today

Hunt Time has no developer program yet. Saying that plainly is more useful than a page that implies one.

The only credential the API issues is a bearer token minted when a person signs in with Apple, Google, or an email. An integration holds that person’s token, acts as that member, sees only their clubs, and is bound by their role. The contract it speaks is the one on the reference page, versioned under /api/v1.

These do not exist, and would have to before a public program:

  • API keys or a third-party sign-in flow, so an integration is not a person.
  • Scopes, so a key can read a board without being able to book.
  • Outbound webhooks, so a stand being claimed can reach another system.
  • Per-key rate limits and developer terms.

If you want to build something on Hunt Time, write to support@hunttime.app and say what. The order those pieces get built in will follow who asks.