tournaments-api
Tournaments API
- Base Path:
/api/v1/tournaments - Auth: required
Tournament Model
- _id: string (ObjectId)
- game: string (ObjectId)
- product: string (ObjectId)
- seller: string (User ID)
- leaderboard: string | null (ObjectId)
- status: 'OPEN' | 'IN_PROGRESS' | 'OVER' | 'UNFILLED'
- entryFee: number (points required to join; default 0)
- rules: string (plain text rules for the tournament)
- startAt: string (ISO) | null
- endedAt: string (ISO) | null
- winner: string | null (User ID)
- totalSeats: number
- expectedPoints: number
- collectedPoints: number
- numberOfPlayers: number
- expectedPlayers: number
- cancellationReason: string (optional; set when seller cancels)
- earlyTermination: object (optional)
enabled: boolean (whether early termination is allowed)thresholdPct: number (progress percent required to allow early termination; default 80)
- createdAt / updatedAt: string (ISO)
Example tournament document:
{
"_id": "66f000000000000000001001",
"game": "66f000000000000000000101",
"product": "66f000000000000000000001",
"seller": "john_doe",
"status": "IN_PROGRESS",
"entryFee": 10,
"rules": "Finish in time. No exploits.",
"totalSeats": 10,
"numberOfPlayers": 4,
"expectedPoints": 100,
"collectedPoints": 40,
"startAt": "2025-09-01T10:00:00.000Z",
"endedAt": null,
"createdAt": "2025-09-01T10:00:00.000Z",
"updatedAt": "2025-09-01T10:00:00.000Z"
}
Get Active Tournament By Product
- Method: GET
- Path:
/api/v1/tournaments?product=:productId - Auth: required
- Query:
product: string (required)
- Response 200: Tournament document (no populate)
- Errors:
- 400: { "error": "product is required" }
- 404: { "error": "Tournament not found" }
- 500: { "error": "Failed to fetch tournament" }
Join Tournament
- Method: POST
- Path:
/api/v1/tournaments/:id/join - Auth: required (no guests)
- Path Params:
id: string (tournament ObjectId)
- Request Body: none
- Behavior:
- Validates tournament exists and is joinable (
statusisOPENorIN_PROGRESS, seats available). - Checks user wallet has at least
entryFeepoints. - Debits
entryFeepoints from wallet, appends aLedgerentry of typeSPENDwith note "Tournament entry fee" (one entry per attempt). - Increments
collectedPointson every join. - Increments
numberOfPlayersonly on the first join of a user for that tournament. - Ensures a
Leaderboardentry exists for the user (score initialized to 0 on first creation) so score submissions can overwrite reliably. - Replays allowed: user may re-join after a 30 second cooldown from their last join attempt.
- Idempotent: if called multiple times for the same (user, tournament), it returns success without additional charge.
- Validates tournament exists and is joinable (
- Response 200:
{
"ok": true,
"wallet": { "availableZishPoints": 30 },
"tournament": { "_id": "66f...1001", "numberOfPlayers": 5, "collectedPoints": 50 },
"ledger": { "_id": "66f...abcd", "type": "SPEND", "amount": 10 }
}
- Errors:
- 400: { "error": "Invalid tournament id" } | { "error": "Tournament is not open for joining" } | { "error": "Wallet not found" }
- 401: { "error": "Unauthorized", "message": "Login required" }
- 402: { "error": "INSUFFICIENT_FUNDS", "required": 10, "balance": 3 }
- 404: { "error": "Tournament not found" }
- 500: { "error": "Failed to join tournament" }
Replay cooldown error example:
{ "error": "COOLDOWN_ACTIVE", "retryAfterSeconds": 12 }
**Notes**
- All IDs are strings.
- No populate on tournament responses.
- Wallet must exist beforehand; top-ups handled elsewhere.
**List Participants**
- **Method:** GET
- **Path:** `/api/v1/tournaments/:id/participants`
- **Auth:** required
- **Response 200:**
{ "data": [ { "username": "SilverFox57", "avatar": null, "score": 123 }, ... ] }
- **Errors:**
- 400: { "error": "Invalid tournament id" }
- 500: { "error": "Failed to fetch participants" }
**Submit Score (Best Score Tracking)**
- **Method:** POST
- **Path:** `/api/v1/tournaments/:id/score`
- **Auth:** required
- **Body:**
- `score`: number (required; must be positive, 0 is invalid)
- `avatar`: string (optional) — snapshot or URL for display
- **Behavior:**
- Requires a prior join (i.e., a `Ledger` SPEND entry for the tournament).
- **Best Score Logic:** Lower scores are better. The score will only be updated if the new score is lower than the previous score.
- If the previous score was 0 (invalid), any valid positive score will replace it.
- If the new score is not better than the existing score, the previous score remains intact.
- Score of 0 is considered invalid and will be rejected.
- Username is resolved from the `User` profile; avatar support pending a user field.
- **Response 200:**
{ "ok": true, "participant": { "username": "SilverFox57", "avatar": null, "score": 321 } }
- **Errors:**
- 400: { "error": "Invalid tournament id" } | { "error": "Invalid score" } | { "error": "Score of 0 is invalid" } | { "error": "Join required before submitting score" }
- 401: { "error": "Unauthorized", "message": "Login required" }
- 500: { "error": "Failed to submit score" }
**My Joined Tournaments**
- **Method:** GET
- **Path:** `/api/v1/tournaments/joined`
- **Auth:** required
- **Behavior:**
- Returns tournaments that the authenticated user has enrolled in (detected via `Ledger` SPEND entries).
- Each item includes the populated `tournament` (with `product` and `game` populated), the `product`, the `game`, the current user's own `leaderboard` entry for that (product, game) pair, and the current `user` profile.
- The `leaderboard` object only contains this user's score for that product/game; it is not the full leaderboard list.
- **Response 200:** Array of items (example):
[
{
"tournament": {
"_id": "66f000000000000000001001",
"status": "OPEN",
"entryFee": 10,
"rules": "Finish in time. No exploits.",
"startAt": "2025-09-10T10:00:00.000Z",
"endedAt": null,
"totalSeats": 10,
"numberOfPlayers": 4,
"expectedPlayers": 10,
"expectedPoints": 100,
"collectedPoints": 40,
"product": {
"_id": "66f000000000000000000001",
"name": "Wireless Controller",
"description": "Pro-grade wireless controller with haptics",
"price": 5999,
"category": "ACCESSORY",
"user": "0192059b-4d7f-7f65-b7d0-3a9ad1d5a1de",
"images": ["https://cdn.example.com/p/ctrl-1.jpg"],
"quantity": 5,
"condition": "New",
"createdAt": "2025-09-08T12:30:11.222Z",
"updatedAt": "2025-09-08T12:30:11.222Z"
},
"game": {
"_id": "66f000000000000000000101",
"name": "Space Dash",
"description": "Dodge and sprint in space!",
"tabcode": "spacedash",
"thumbnail": "https://cdn.example.com/games/spacedash.png",
"status": "PUBLISHED",
"createdAt": "2025-08-20T09:00:00.000Z",
"updatedAt": "2025-08-25T09:00:00.000Z"
},
"createdAt": "2025-09-09T10:00:00.000Z",
"updatedAt": "2025-09-09T10:00:00.000Z"
},
"product": {
"_id": "66f000000000000000000001",
"name": "Wireless Controller",
"description": "Pro-grade wireless controller with haptics",
"price": 5999,
"category": "ACCESSORY",
"user": "0192059b-4d7f-7f65-b7d0-3a9ad1d5a1de",
"images": ["https://cdn.example.com/p/ctrl-1.jpg"],
"quantity": 5,
"condition": "New",
"createdAt": "2025-09-08T12:30:11.222Z",
"updatedAt": "2025-09-08T12:30:11.222Z"
},
"game": {
"_id": "66f000000000000000000101",
"name": "Space Dash",
"description": "Dodge and sprint in space!",
"tabcode": "spacedash",
"thumbnail": "https://cdn.example.com/games/spacedash.png",
"status": "PUBLISHED",
"createdAt": "2025-08-20T09:00:00.000Z",
"updatedAt": "2025-08-25T09:00:00.000Z"
},
"leaderboard": { "_id": "66f00000000000000000lb01", "score": 123 },
"user": { "_id": "0192059b-4d7f-7f65-b7d0-3a9ad1d5a1de", "username": "SilverFox57", "avatar": null, "verified": false }
}
]
- Errors:
- 401: { "error": "Unauthorized", "message": "Login required" }
- 500: { "error": "Failed to fetch joined products" }
Notes
- Seat handling: When seats are full, new first-time join attempts return 400 and the tournament is marked
OVERwithendedAtset. When the last seat is filled on a successful join, the tournament is also markedOVER.
Cancel (Early Termination by Seller)
- Method: POST
- Path:
/api/v1/tournaments/cancel - Auth: required (seller/owner only)
- Body:
product: string (required; product id associated with the tournament)reason: string (optional; up to 500 chars)
- Behavior:
- Finds the current active (OPEN/IN_PROGRESS) tournament for the given
product. - Only the product owner can cancel.
- Requires early termination to be enabled either via the tournament config (
earlyTermination.enabled) or product terms acknowledgement (product.terms.enableEarlyTerminationAck). - Requires progress >= threshold:
(numberOfPlayers / expectedPlayers) * 100 >= earlyTermination.thresholdPct(default 80). - On success, sets
statustoOVER, stampsendedAt, setscancellationReason, and zeroestotalSeats.
- Finds the current active (OPEN/IN_PROGRESS) tournament for the given
- Response 200:
{
"ok": true,
"tournament": {
"_id": "66f...1001",
"status": "OVER",
"endedAt": "2025-09-10T12:34:56.789Z",
"cancellationReason": "terminated early at 85.0%"
}
} - Errors:
- 400: { "error": "product is required" }
- 401: { "error": "UNAUTHORIZED", "message": "Login required" }
- 403: { "error": "FORBIDDEN" } when caller is not owner OR early termination not enabled OR progress below threshold
- 404: { "error": "Product not found" } | { "error": "No active tournament for this product" }
- 500: { "error": "Failed to cancel tournament" }
Frontend guidance (cancel):
- Show the cancel action only when:
- User is the product owner, and
- Server-provided tournament data indicates early termination is enabled (if you fetch tournament details separately), and
- A client-side progress estimate meets the threshold (optional UI hint; server enforces it regardless).
- Pass a short reason to help audit. Treat non-200 responses distinctly: surface 403 with the specific message so sellers know why it’s blocked (e.g., progress not met).
Extend End Date
- Method: POST
- Path:
/api/v1/tournaments/extend - Auth: required (seller/owner only)
- Body:
product: string (required; product id)endDate: string (required; ISO date format, e.g., "2025-10-15T23:59:59.000Z")
- Behavior:
- Finds the active tournament for the product.
- Hard limit: Only one extension is allowed per tournament (checks
extensionCount< 1). - Validates the provided
endDateis in the future and after the currentendedAt(if exists). - Sets the new
endedAt, incrementsextensionCountto 1, and resets any "ending soon" flag.
- Response 200:
{
"ok": true,
"tournament": {
"_id": "66f...1001",
"endedAt": "2025-10-15T23:59:59.000Z",
"extensionCount": 1
}
} - Errors:
- 400: { "error": "product is required" } | { "error": "endDate is required" } | { "error": "Invalid date format" } | { "error": "End date must be in the future" } | { "error": "New end date must be after the current end date" }
- 401: { "error": "Login required" }
- 403: { "error": "FORBIDDEN" } (not owner) | { "error": "Extension limit reached. Only one extension allowed per tournament." }
- 404: { "error": "Product not found" } | { "error": "No active tournament for this product" }
- 500: { "error": "Failed to extend tournament" }
Frontend guidance (extend):
- Show an Extend button on the seller's tournament details page near the end date.
- Use a date picker to allow the seller to select a new end date.
- Disable the button when:
- The tournament is already
OVER/UNFILLED extensionCountis already 1 (extension already used)- The server responds with "Extension limit reached"
- The tournament is already
- Display a message showing "Extension used: 1/1" or "Extension available: 0/1" based on
extensionCount.