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
- Authentication
- Rate limits
- Error envelope
- Endpoints
- The Hand envelope
- Card encoding
- Action kinds
- Position enum
- Web replayer URLs
- Universal Link / Deep link
- Quickstart curl
- 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_idmust equalauth()->id(set when the user "claims" the hand into their/my-handslibrary)
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.
winnersmay be{}. Setincomplete: trueand computepotWonfrom 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[].positionset matches the canonical seating for thattableSize(see Position enum). Set equality, not ordering.- Hero's seat (when
heroCardspresent and the seat listsholeCards) must haveholeCards === heroCards. - Board lengths per street:
preflopempty,flop6 chars,turn2,river2. - Action
kind ∈ {b, r, 3b, 4b, 5b}requiresamount.kind ∈ {f, x, c}forbidsamount. seats[].player.realNameisprohibited— 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).