Auth/session contracts
- Native auth uses Clerk after a session exists.
- Protected feature calls require authenticated user context and must handle session expiry gracefully.
-
Mobile OAuth can return a one-time 128-char hex
MobileSessiontoken. Prefer the URL fragment (#token=) over the query string. -
Native stashes the token in module memory (not MMKV), then:
POST /api/mobile/exchangewith{ token }(orAuthorization: Bearer <token>). No Clerk session required.- Receive
{ ticket, expiresAt }. signIn.create({ strategy: 'ticket', ticket })thensetActive({ session }).
- A second exchange of the same token fails. If exchange returns 404/401, leave the user on login.
- After a Clerk session exists, login can also use Google, Apple, email/password, and MFA. Those are Clerk strategies, not extra API routes.
-
GET /api/user/check-username?username=is the public handle-availability check.
Quest and reward API contracts
- Quest progression is backend-authoritative for eligibility and completion.
- Daily quiz sessions use
/api/v1/engage/quests/{questId}/session,/session/{sessionId}/submit, and/session/{sessionId}/forfeit. - Arena check-in is
POST /api/v1/engage/quests/arena-check-in(once per team per UTC day). - Reward claim/redeem behavior must use server responses as truth for user-facing state.
- Pass-related gating (global/event) is evaluated server-side and reflected in client rendering.
Fan Rep contracts
GET /api/v1/engage/rep/breakdownreturns Fan/Club/Player/Direct totals plusclubRepByTeam.dsportsDirectRepsumsrepType = Directrows.GET /api/v1/engage/rep/ledgeris keyset-paginated.sourceisfanAmp|club|playerAmp|dsportsDirect.POST /api/v1/engage/rep/earnaccepts onlyreaction,message,pollVote, andshare. Amount andrepTypeare server-authoritative. Direct kinds return403.- Do not call
/earnfor arena check-in, daily-quest completion, or admin Direct grants. - Direct grants use
POST /api/admin/users/{userId}/rep(isAdminonly). Native Coins UI for this is follow-up.
Team and league API contracts
- Team lists and team metadata come from
/api/teams. Use?slug=for a single team, or publicGET /api/teams/{id}when you have an id or slug (id is tried first). Archived teams return404. GET /api/teams/searchacceptsq,sport,league,cursor,limit, andsort(rank|name|fans).- Sports chips come from public
GET /api/config/sports. On500, fall back to a local cache. Do not hardcode a second catalog. - Team
sportandleagueInfo.sportare never null. Unknown values are"other". POST /api/teams/{id}/followis social only (TeamFollow).POST/DELETE /api/teams/{id}/joinis leaderboard membership only. Both are idempotent.- HQ stars are bookmarks, not follows.
GET /api/me/starred-teamslists them.PUTandDELETE /api/me/starred-teams/{teamId}star and unstar. OptionalPUTbody{ slug?, name?, logo? }stores a display snapshot. A new star past 50 clubs returns409 STARRED_TEAMS_LIMIT.POST /api/me/starred-teamsis the one-time additive merge (skippedandrejected, not a hard failure). GET /api/user/teamsreturns{ joinedTeamIds, followedTeamIds }.GET /api/teams/{id}/rosteris public and returns{ players }for the formation builder. Read optionalphotoandavailabilityon each player.availabilityis omitted when the player is available.- League metadata and branding come from
/api/leagues. GET /api/leagues/{leagueId}/standingsis public HQ standings.leagueIdis the Prisma id or slug. The response is{ rows, tiebreakRules, tables }.rowsis one team each.tableslists every remaining scope. Emptyrowsmean the league cannot be scored honestly.- Call
GET /api/teams/{id}/head-to-head?opponentId=for the public series. You get{ record, meetings }for the five most recent decided meetings. OmitopponentId, or send the same id as{id}, and you get400. - Synced canonical data changes can propagate asynchronously; client should tolerate short-lived staleness.
Binder PIN contract
- Binder PIN is not the wallet PIN and not
POST /api/v1/user/verify-pin. POST /api/binders/{id}/verify-pinreturns{ granted, token, expiresIn: 300 }.- Send
tokenasx-binder-pin-tokenonGET /api/binders/{id}. - Missing or expired proof on a PIN binder returns
403 PIN_REQUIREDwith metadata only (no shelves).
Locker room authoring
teamIdonPOST /api/locker-roomis the about-tag.authorTeamId/postAsTeamIdis who the post is authored as. Unauthorized callers get403.lineupon create is persisted and echoed. Fetch players fromGET /api/teams/{id}/rosterinstead of fabricating names.- Composer drafts use
GET/PUT/DELETE /api/locker-room/posts/draft. POST /api/locker-room/{id}/likereturns{ liked, likeCount }and does not replace/react.- Comment create may send
mentionedUserIds. Responses includementions[]. Edit must sendmentionedUserIds: []to clear mentions; omitting the field leaves existing mentions in place. - For mention typeahead, call
GET /api/user/search?q=...&scope=mention&limit=6. ReadprofileUrlfor the avatar.handleis a non-empty string on that scope. - Tapping
@handleopens that fan’s profile.
Marketplace contracts
- Prefer
GET /api/v1/marketplace/me/listings.GET /api/v1/marketplace/listings/mineis a native alias for the same handler. Create listings withx-listing-pin-token. - Listing a
processingpack returns409 PACK_NOT_LISTABLE. GET /api/v1/cards/{id}/provenanceis public ownership / sale / mint history.- Treat
GET /api/user/packsstatus as the per-purchase source of truth.
Games catalog contract
GET /api/games/catalog?teamId=...(or?teamSlug=...) is public and returns the team’s enabled mini-games grouped by cadence:{ daily: Game[], weekly: Game[] }.- Each game entry carries
id,key,title,description,icon, andbadge;keyis the stable slug the client maps to a playable game route (for exampledaily-spin). - The catalog is admin-managed and team-selected server-side; the client must render only the games returned and must not assume a fixed set.
- Responses are cacheable (
Cache-Control: public, max-age=60, stale-while-revalidate=300); short-lived staleness after a team changes its selection is expected. - Unknown teams return
404; a missingteamId/teamSlugreturns400. - Native also calls
GET /api/v1/engage/games/catalogand joins the two lists. Engine session verbs, HUD rules, and update-required behavior live in Game Engine. Do not ignore unknown engine keys. locker-singularitydoes not use session verbs. Read and mutate/api/v1/engage/games/locker-singularity/ledger. SendX-Client-Version. Mutations need a UUIDidempotencyKeythe client already holds. Send tap counts, never buzz values. Boost claim stays410 BOOST_DISABLED.
Game Center contracts
GET /api/events/scheduleaccepts repeatableteamIdquery params.GET /api/events/realtimeis public Broadcast discovery. Event detail includes arealtimepointer without the anon key.- Use the public Game Center reads under
/api/events/{gameId}for detail, box score, player-stats, plays, lineup, top-performer, and win-probability. Top-performer sides includeplayerKey(same key space asLineupSlot.playerKey) andplayerId(linked roster id, or null). - Chat mute is
POST/DELETE /api/events/{gameId}/chat/mutewith eitheruserIdorthread: true, not both. - Ratings submit is
POST /api/events/{gameId}/ratings. Do not also call/api/v1/engage/rep/earn. - Call
POST/DELETE /api/events/{gameId}/live-activityto register or end an iOS Live Activity. You must haveUserSettings.liveScoreUpdatesconsent first.
User mute and block
- Global mute is
GET/POST/DELETE /api/user/mutes. One-way hide; the muted user is not notified. - Global block is
GET/POST/DELETE /api/user/blocks. Stronger than mute; excluded from the blocker’s social surfaces. - POST body uses
mutedUserId/blockedUserId(orhandle). DELETE accepts the same id as a query param or in a JSON body. - Do not confuse these with Game Center thread mute (
/api/events/{gameId}/chat/mute).
Wallet contracts
- Custodial create:
POST /api/walletswith{ pin, legalTermsVersion }only. - Import/link: same route with
pin,address, andencryptedPrivateKeyorprivateKey.pinis required on every create path. - Custodial send: PIN gate, then
POST /api/wallets/{address}/sign-transaction. - Seed reveal: PIN-gated
POST /api/wallets/{address}/export. - Do not call deprecated
POST /api/wallets/private-keyfor new work. - Attestation is
GET /api/wallets/{address}/attestation. The path segment is the wallet database id, not the on-chain address. DELETE /api/walletsneeds{ address, pin }for custodial wallets. External links need{ address }only. Missing or wrong PIN returns400 VALIDATION_ERROR.
Featured collectibles
GET /api/user/featured-collectiblesis public.PUT /api/user/featured-collectiblesreplaces the authenticated user’s showcase (max 12).
User interests contract
User.interestsis astring[]of slug IDs stored on theUserrow in Postgres (via Prisma).- Allowed slugs:
hockey,basketball,football,soccer,baseball,esports,gamification,stats,fan_engagement,collectibles,gambling,crypto,blockchain,fantasy_sports,live_events,merch_fashion,streaming. - Native onboarding step 3 and the retroactive
/(onboarding)/interestsscreen require at least one interest before continue. PATCH /api/useracceptsinterests: string[]; server validates slugs and dedupes (max 20).GET /api/userreturnsinterestson the authenticated profile payload.- Retroactive gate: users with
onboardingComplete=trueand emptyinterestsare redirected to/(onboarding)/interestsuntil at least one interest is saved.
Settings and notifications
GET/PATCH /api/user/settingsis the settings SoT.privateProfilemirrorsisPrivate.- Persist
notifyTeams,notifyMilestones, andnotifyCommunityas separate booleans. Do not collapse them. - Inbox types include
GAME_ALERTandRATING.
Error handling and fallback UX
- Show user-actionable errors for purchase/open/claim/sign failures.
- Avoid silent failures for mutation endpoints.
- Use retry/backoff for transient network failures; avoid retry storms on authorization or validation errors.
