Appearance
POST /api/tables
Since: 2026-04-19
The single authoritative table-creation endpoint. Browser-facing; evm-bank validates the settings blob via Zod, writes the tables row with status: 'active', and returns. Replaces the prior lazy-create path that used to live inside POST /api/deposit and inside game-server's upsertRoom helper.
Request
POST /api/tables
Authorization: Bearer <JWT>
Content-Type: application/json
{
"tableId": "ABC123",
"gameType": "poker",
"settings": {
"smallBlind": 1,
"bigBlind": 2,
"minBuyIn": 50,
"maxBuyIn": 200,
"maxPlayers": 6,
"rakePercent": 5,
"rakeCap": 4,
"rakeMinPot": 0,
"rakeRequiresFlop": true,
"turnTimeSec": 30,
"turnTimerEnabled": true
}
}Auth: JWT bearer. Verified against the same JWKS game-server uses. Authorization: Bearer <token>; invalid / missing token → 401.
Body fields:
| Field | Type | Notes |
|---|---|---|
tableId | string | The short-string identifier. The bytes32 hash for on-chain calls is computed inline at contract-call time (keccak256(toBytes(tableId))) and is never persisted. |
gameType | string | "poker" or "arena". The Zod dispatcher in @numero/types/table-settings routes to the right schema. |
settings | object | Per-game-type settings. Validated by pokerTableSettingsSchema or arenaTableSettingsSchema. Extra fields are stripped; missing required fields reject. |
Settings schemas
Poker (gameType: "poker")
Defined in packages/types/src/table-settings.ts as pokerTableSettingsSchema.
| Field | Type | Range / constraint |
|---|---|---|
smallBlind | int | >= 0 |
bigBlind | int | > 0, > smallBlind |
ante? | int | >= 0, optional |
minBuyIn | int | > 0 |
maxBuyIn | int | > 0, >= minBuyIn |
maxPlayers | int | 2 <= n <= 9 |
rakePercent | number | 0 <= n <= 100 |
rakeCap | int | >= 0 |
rakeMinPot | int | >= 0 |
rakeRequiresFlop | boolean | industry "no flop, no drop" |
turnTimeSec | int | > 0 |
turnTimerEnabled | boolean | false disables auto-fold |
hostedOnly? | boolean | true = only hosted agents can sit |
showHands? | boolean | true = all hole cards visible to spectators (requires hostedOnly) |
deckSeed? | string | test mode only (ENABLE_TEST_AUTH) — deterministic deck |
Arena (gameType: "arena")
Defined as arenaTableSettingsSchema.
| Field | Type | Range / constraint |
|---|---|---|
minBuyIn | int | > 0 |
maxBuyIn | int | > 0, >= minBuyIn |
maxPlayers | int | 2 <= n <= 8 |
rakePercent | number | 0 <= n <= 100 |
rakeCap | int | >= 0 |
turnTimeSec | int | > 0 |
turnTimerEnabled | boolean | |
scoreTarget | int | > 0 |
Known gap: arena's Zod schema does not currently declare
deckSeedbut arena test scenarios rely on it.
Responses
200 — Created
json
{
"tableId": "ABC123",
"gameType": "poker",
"status": "active"
}The tables row is now durably stored with settings JSONB = the parsed blob. Subsequent POST /api/deposit and WebSocket-upgrade calls for this tableId succeed.
400 — Validation failed
json
{
"error": "validation_failed",
"issues": [
{ "path": ["maxPlayers"], "message": "Number must be less than or equal to 9", "code": "too_big" },
{ "path": ["maxBuyIn"], "message": "maxBuyIn must be at least minBuyIn", "code": "custom" }
]
}Also 400 missing_table_id or 400 missing_game_type when the top-level fields are absent.
409 — Conflict (replay)
json
{ "error": "table_exists" }Returned on a duplicate tableId. The insert uses ON CONFLICT DO NOTHING + .returning() — if the row already exists, the second caller sees zero returned rows and the endpoint responds with 409.
401 / 500
Standard auth + internal-error responses.
OTel
Each call emits an admin.tables.create span with attributes:
| Attribute | Example | Meaning |
|---|---|---|
table.id | "ABC123" | the tableId from the body |
table.game_type | "poker" | |
settings.max_players | 6 | from the parsed settings |
auth.user_id | UUID from the JWT sub | |
result | "created" | "validation_failed" | "replay" | terminal outcome |
Why this exists
Previously, tables were created lazily by the first POST /api/deposit that referenced them. That path defaulted missing settings silently and created rows with status: 'pending' that never got validated. Multiple classes of bug traced back to this: mismatched settings between UI state and DB state, 9-seat heads-up tables, silent default-fall-through on deckSeed, and test scenarios that looked correct on paper but ran with server-default settings.
POST /api/tables inverts this: the UI (or admin tooling) explicitly asks for a table, validation runs at request time, and every tables row is known to have been through the Zod pipeline. Downstream code (the WS upgrade's TableManager.loadOrCreate, game-server room constructors) reads the validated JSONB directly without re-parsing.