ThinkGTO
← All developer docs

API reference

Hands Share API — v1

Public API for recording, sharing, and replaying poker hands at https://app.thinkgto.com. Used by the iOS Preflop+ app and intended for Android / third-party / partner integrations.

Base URL: https://app.thinkgto.com All endpoints: /api/v1/


Table of contents

  1. Authentication
  2. Rate limits
  3. Error envelope
  4. Endpoints
  5. The Hand envelope
  6. Card encoding
  7. Action kinds
  8. Position enum
  9. Web replayer URLs
  10. Universal Link / Deep link
  11. Quickstart curl
  12. Changelog

Authentication

Two paths:

ApiKey (recommended for mobile / server-to-server)

Authorization: ApiKey <plaintext-key>

Keys are issued per client. Contact [email protected] to provision one. We store only sha256(plaintext) server-side, so plaintext is shared once; clients should stash it in Keychain / encrypted prefs and offer remote config override for rotation.

When you POST with a key, the row records hand_api_key_id so we can attribute uploads back to your client.

Sanctum (browser, signed-in users)

Stateful SPA cookie auth via app.thinkgto.com. Only relevant to users who have an account; mobile/server clients won't use this path.

Anonymous POST (web recorder only)

POST /api/v1/hands accepts requests without any auth header — that path is used by anonymous browsers hitting /record. The endpoint is still rate-limited per-IP (see below). External consumers should always send an ApiKey so requests count against your client's bucket and so we can reach you if abuse needs to be addressed.


Rate limits

Rate limits apply to the JSON API only (/api/v1/...). The public share URL (https://app.thinkgto.com/hands/{shortCode}) is the HTML replayer page and is not rate-limited — share it on Discord, Twitter, anywhere, and unlimited people from unlimited IPs can open it. The HTML response is self-contained (snapshot data embedded inline), so viewers never hit the JSON API.

Buckets on the JSON API are keyed on auth user id when present, otherwise the request IP. X-Device-Id is no longer inspected. iOS clients bucket per-IP.

Endpoint Limit Bucket Notes
https://app.thinkgto.com/hands/{shortCode} unlimited Public HTML replayer. Share freely.
GET /api/v1/hands/{shortCode} 300/hr user id OR IP JSON envelope for programmatic consumers (iOS Universal Link fetch, partner integrations).
POST /api/v1/hands 30/hr + 200/day user id OR IP Per-IP fallback covers all iOS uploads.
DELETE /api/v1/hands/{shortCode} 30/hr + 200/day user id OR IP Authenticated owners only.

When a JSON API bucket is exhausted: 429 RATE_LIMITED with a Retry-After header (seconds).

The 300/hr-per-IP read limit is generous in practice — each iOS device, partner server, or end-user browser-tab has its own IP, so viral sharing of the /hands/{code} URL never approaches the cap. If your integration genuinely needs higher per-IP read throughput (e.g. you're proxying many requests through one egress IP), ping [email protected] for a bump.


Error envelope

Every non-2xx body follows:

{
  "error": {
    "code":    "INVALID_HAND",
    "message": "actions[1].board must be 6 chars on flop",
    "field":   "actions[1].board",
    "details": {}
  }
}

field is a dot-path pointer into the request JSON. details is an open object for code-specific context.

HTTP code When
400 INVALID_HAND Schema or semantic validation failed.
400 INVALID_SHORTCODE URL slug fails ^[A-Za-z0-9]{10}$.
401 UNAUTHORIZED Authorization: ApiKey is missing or invalid on a route that required it.
403 NOT_OWNER DELETE: caller is not the authenticated owner.
404 NOT_FOUND shortCode does not resolve to any hand.
410 GONE Hand was tombstoned by moderation.
413 PAYLOAD_TOO_LARGE Request body > 50 KB.
429 RATE_LIMITED Bucket exhausted. Retry-After header set.
500 INTERNAL Unexpected. Should be rare; please report.

Endpoints

POST /api/v1/hands

Upload a single hand. Body = Hand envelope minus server-assigned fields (id, shortCode, createdAt).

Headers

Content-Type:   application/json
Authorization:  ApiKey <key>            ← optional but recommended
X-Client:       <product>/<version>     ← optional, informational only

Response 201 Created

{
  "id":         "h_8e1f9c7a",
  "shortCode":  "k7Pq2xRm9a",
  "url":        "https://app.thinkgto.com/hands/k7Pq2xRm9a",
  "createdAt":  "2026-05-30T12:34:56Z"
}

The shortCode is a server-generated 10-character base62 slug. It is opaque, statistically unique, and the canonical handle for the hand everywhere else in the API.

GET /api/v1/hands/{shortCode}

Fetch a hand by short code. No auth required. Cacheable.

Path: shortCode must match ^[A-Za-z0-9]{10}$ — otherwise 400 INVALID_SHORTCODE.

Response headers:

Cache-Control: public, max-age=86400, immutable
ETag:          "<sha256 hex of payload>"

Clients can send If-None-Match for cheap revalidation → 304 Not Modified if unchanged.

Response body = the full Hand envelope (see below) plus id, shortCode, createdAt, tags.

410 GONE if the hand was tombstoned by moderation. The status itself is canonical — the body still carries the standard error envelope.

DELETE /api/v1/hands/{shortCode}

Reserved for authenticated owners. Anonymous uploads (from iOS or the anon web recorder) cannot be deleted via this endpoint — they're permanent unless an admin tombstones them through internal moderation.

Requires:

  • Sanctum session cookie auth
  • The hand's user_id must equal auth()->id (set when the user "claims" the hand into their /my-hands library)

Response: 204 No Content on success. Subsequent GET of the same shortCode returns 410 GONE.

For takedown / DMCA / abuse on anonymous uploads, contact [email protected].


The Hand envelope

Send this in the POST body. Receive it in the GET response (plus the server-assigned fields).

{
  "gameType":     "NLHE",          // or "PLO"
  "format":       "cash",          // or "tournament"
  "tableSize":    6,               // 2..9
  "stakes":       "1/2 NL",        // free-form, ≤ 32 chars
  "ante":         "none",          // none | bba | btna | ante
  "heroPosition": "BTN",           // see Position enum below

  // Optional — observers recording someone else's hand can omit this.
  // When present, must be 4 chars (NLHE) or 8 chars (PLO).
  "heroCards":    "AhKs",

  "seats": [
    {
      "position":  "BTN",          // see Position enum
      "stackBB":   100.0,
      "holeCards": "AhKs",         // optional; same length rule as heroCards
      "player": {                  // optional villain snapshot
        "alias":      "Reg @ Table 4",
        "playerType": "Reg",       // LAG | TAG | Fish | Reg | Pro | Unknown
        "notes":      "Reads, tendencies…"
        // realName is REJECTED if present (privacy by default)
      }
    }
    // … one per active seat for the chosen tableSize
  ],

  "actions": [
    {
      "name":  "preflop",          // preflop | flop | turn | river
      "board": "",                 // NEW cards only per street, NOT cumulative
                                   // "" on preflop, 6 chars flop, 2 turn, 2 river
      "actions": [
        { "position": "BTN", "kind": "r", "amount": 2.5 },
        { "position": "BB",  "kind": "c" }
      ]
    }
    // … include only streets that were actually reached
  ],

  "showdown": [
    { "position": "BB", "cards": "QdJc" }    // optional reveals
  ],

  "potWon":   -2.5,                // hero's signed net result in BB
  "winners":  { "0": ["BB"] },     // potId → [positions]; "0" = main pot.
                                   // MAY be empty {} for an incomplete hand
                                   // whose outcome is unknown.
  "venue":    "",                  // optional, ≤ 280 chars
  "notes":    "Free-form analysis text. Markdown not parsed.",

  // Optional. true when the hand was saved before it fully resolved
  // (partial board, partial action, or no declared winner). Defaults to
  // false. Stored on hands.is_incomplete and echoed back on GET.
  "incomplete": false
}

Partial / incomplete hands

A client may save a hand before its full history is captured — the user folded mid-hand, ran out of time, or only remembers part of the board. Two mechanics:

  • Skipped board street. Emit the street with a placeholder board of the correct length so later cards keep their slot: flop"??????", turn"??", river"??". The length check accepts these; the replayer renders a face-down card. Example — flop + river, no turn:

    "actions": [
      { "name": "preflop", "board": "",       "actions": [ … ] },
      { "name": "flop",    "board": "Td7d2c", "actions": [ … ] },
      { "name": "turn",    "board": "??",     "actions": [] },
      { "name": "river",   "board": "9s",     "actions": [] }
    ]
    
  • No declared winner. winners may be {}. Set incomplete: true and compute potWon from contributions: if hero folded, potWon = -(hero's contributions); if hero is still live with no winner, potWon = 0.

Full UX + logic for clients implementing this: see docs/specs/incomplete-hands-mobile-spec.md.

Mandatory shape rules

These are checked server-side and rejected with 400 INVALID_HAND + a field dot-path:

  • tableSize ∈ [2, 9].
  • seats[].position set matches the canonical seating for that tableSize (see Position enum). Set equality, not ordering.
  • Hero's seat (when heroCards present and the seat lists holeCards) must have holeCards === heroCards.
  • Board lengths per street: preflop empty, flop 6 chars, turn 2, river 2.
  • Action kind ∈ {b, r, 3b, 4b, 5b} requires amount. kind ∈ {f, x, c} forbids amount.
  • seats[].player.realName is prohibited — server rejects the request if the field is present even with an empty value.

Card encoding

Two ASCII chars per card.

  • Ranks (uppercase, 13): A K Q J T 9 8 7 6 5 4 3 2
  • Suits (lowercase, 4): s h d c (spades, hearts, diamonds, clubs)

Concatenated, no separator. Examples: Ah, Kd, 2c.

Game Hole cards Showdown
NLHE 4 chars (AhKs) 4 chars
PLO 8 chars (AhKsQdJc) 8 chars

Board strings on actions[].board are NEW cards only: 6 chars on flop, 2 on turn, 2 on river. The replayer accumulates them on transition.


Action kinds

kind Meaning amount?
f Fold absent
x Check absent
c Call absent
b Bet (postflop first aggression) required
r Raise (preflop opens, or postflop subsequent) required
3b 3-bet required
4b 4-bet required
5b 5-bet required
ai All-in informational
post Blind / ante / straddle informational

amount is interpreted as the absolute streetContrib target (raise-to value), not a raise-by delta. Server caps to remaining stack silently when it exceeds.

post is normally not emitted by recorders — the server's state machine seeds blinds and antes from seats[].stackBB and ante.


Position enum

10 positions: UTG, UTG1, MP, MP1, LJ, HJ, CO, BTN, SB, BB.

Per-table-size seating (button-anchored: the late seats CO/HJ/LJ stay fixed and the middle is trimmed — MP → UTG+1 — as the table shrinks; UTG is always first to act):

Size Positions
2 BTN, BB
3 BTN, SB, BB
4 CO, BTN, SB, BB
5 UTG, CO, BTN, SB, BB
6 UTG, HJ, CO, BTN, SB, BB
7 UTG, LJ, HJ, CO, BTN, SB, BB
8 UTG, UTG1, LJ, HJ, CO, BTN, SB, BB
9 UTG, UTG1, MP, LJ, HJ, CO, BTN, SB, BB

Heads-up rule: no SB seat — BTN posts the small blind. Preflop action starts at BTN; postflop at BB.


Web replayer URLs

URL Purpose
https://app.thinkgto.com/hands/{shortCode} Public HTML replayer with OG + Twitter rich-preview meta. Mobile-friendly. Anyone with the link sees the hand. Not rate-limited.
https://app.thinkgto.com/open/{shortCode} Universal Link landing — see below. Not rate-limited.

This is the URL to share with end users. Drop it in a chat, paste it in an email, embed it in a Discord post — the recipient opens the page directly in their browser, no app or account required.

The replayer carries Open Graph meta so pasting the URL in Discord/Slack/Twitter renders a rich card. The image URL on the OG meta is reserved for when our OG renderer ships.

The HTML page does NOT call the JSON API to fetch the hand — the snapshot data is rendered server-side and embedded inline as a <script type="application/json"> block. That keeps the page fast and makes its viewership independent of the API rate limit.


Universal Link / Deep link

When pasting https://app.thinkgto.com/open/{shortCode} on a device with the Preflop+ iOS app installed, iOS intercepts via AASA and routes into the app's Feed scope. Without the app installed, the URL renders an HTML page with a Smart App Banner + auto-redirect to the App Store.

The deep-link route is /open/*, NOT /hands/*. The share URL itself (/hands/{code}) is always browser-friendly and never intercepted — only the explicit "Open in Preflop+" button on the replayer page hits /open/* and so triggers the app.

AASA: https://app.thinkgto.com/.well-known/apple-app-site-association.


Quickstart curl

BASE=https://app.thinkgto.com
KEY=<your-api-key>

# 1) Upload a hand
curl -sS -X POST "$BASE/api/v1/hands" \
  -H 'Content-Type: application/json' \
  -H "Authorization: ApiKey $KEY" \
  -H 'X-Client: android/myapp/1.0' \
  -d @sample-hand.json | jq .

# →
# { "id": "h_8e1f9c7a", "shortCode": "k7Pq2xRm9a",
#   "url": "https://app.thinkgto.com/hands/k7Pq2xRm9a",
#   "createdAt": "2026-05-31T12:34:56Z" }

# 2) Fetch it back
curl -sS "$BASE/api/v1/hands/k7Pq2xRm9a" | jq .

# 3) Render in a browser:
xdg-open "$BASE/hands/k7Pq2xRm9a"

Android (OkHttp)

val client = OkHttpClient.Builder().build()
val body = RequestBody.create(
    "application/json".toMediaType(),
    handJsonString
)
val req = Request.Builder()
    .url("https://app.thinkgto.com/api/v1/hands")
    .post(body)
    .header("Authorization", "ApiKey ${BuildConfig.HANDS_API_KEY}")
    .header("X-Client", "android/preflop-android/${BuildConfig.VERSION_NAME}")
    .build()

client.newCall(req).execute().use { resp ->
    if (resp.code == 201) {
        val json = JSONObject(resp.body!!.string())
        val shareUrl = json.getString("url")
        // copy to clipboard, etc.
    }
}

Python (requests)

import requests

BASE = "https://app.thinkgto.com"
KEY  = "..."

r = requests.post(
    f"{BASE}/api/v1/hands",
    headers={
        "Content-Type": "application/json",
        "Authorization": f"ApiKey {KEY}",
        "X-Client": "python/myscript/0.1",
    },
    json=hand_envelope,
    timeout=10,
)
r.raise_for_status()
share_url = r.json()["url"]

Changelog

Date Change
2026-06-07 Incomplete hands. New optional incomplete boolean; winners may now be {}; skipped board streets use "??"/"??????" placeholders. Backward compatible — existing clients sending full hands are unaffected. Client UX spec: docs/specs/incomplete-hands-mobile-spec.md.
2026-06-01 Doc fix: endpoint section headings normalised from shorthand /v1/hands to the full /api/v1/hands path so consumers reading individual sections out of context don't drop the framework's /api/ prefix. The actual route URL was always /api/v1/hands — examples already had it; only the headings were shorthand.
2026-06-01 Doc clarification: the public share URL /hands/{shortCode} is HTML and has NO rate limit. The 300/hr cap was always JSON-API-only; the table now makes that explicit.
2026-05-31 X-Device-Id no longer inspected. Rate buckets re-keyed to user id / IP. DELETE restricted to Sanctum-authenticated owners; anonymous uploads are permanent. heroCards now optional.
2026-05-30 Initial v1 release: POST/GET/DELETE on /hands/{shortCode}, public replayer, AASA file.

For amendments, ping [email protected] or open a PR against this file in the thinkgto-app repo (docs/api/hands-api-v1.md).