Betting: Odds, Placement and Settlement
This page covers what a bettor is shown before they commit, how a bet is placed, how your client learns whether it was accepted, and what happens when the market resolves.
The payout formula, the entry odds rule, and the bet history rule depend on each other, so read them together.
Every endpoint on this page needs the platform JWT.
Show the payout before the bettor commits
Payout is stake × odds, and it is gross. It includes the stake.
Before the bet exists there is no position, so compute the payout from the Outcome's
display_odds. After placement, read potential_payout from the response, or compute it from
entry_odds. Both give the same number.
You MUST round the payout for display to the number of decimal places the bettor's currency uses, which is that currency's subunit count: 2 for USD and EUR, 0 for JPY, 3 for KWD. Do not assume 2 for every fiat currency. Settlement rounds the amount it pays the same way, so any other rule shows the bettor a number they will not be paid.
potential_payout arrives with no fixed decimal scale and can run to 16 decimal places, so
round it before you display it.
Re-read the event before you submit
You MUST re-GET the event immediately before submitting a bet.
The real-time channel carries odds, not availability. A bet slip that has been open for a while can hold a price and an availability that no longer apply.
Place a bet
Idempotency-Key is required. Generate one UUID v4 per bet the bettor intends to place, and hold
it for the life of that attempt.
curl -X POST https://<your predictions host>/api/v1/bettor/bets \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890" \
-d '{
"market_id": "def67890-e89b-41d4-a716-446655440002",
"outcome_id": "abc12345-e89b-41d4-a716-446655440001",
"stake": "10.00"
}'
The response is 202 Accepted, not a placed bet.
{
"id": "9f8e7d6c-...",
"status": "PENDING",
"message": "Bet accepted for processing",
"entry_odds": "1.85",
"potential_payout": "18.50",
"display_probability": "54.1",
"outcome_index": 0
}
Do not tell the bettor their bet succeeded. The 202 means the platform accepted the request
for processing. The bet is placed only when the position reaches CONFIRMED.
Poll until the bet resolves
You MUST poll GET /api/v1/bettor/bets/{id} after the 202. Bet status is read by polling.
Use this schedule.
| Attempt | Wait before it |
|---|---|
| 1 | 1 s |
| 2 | 2 s |
| 3 | 3 s |
| 4 | 3 s |
| 5 | 3 s |
| 6 and after | 5 s |
Stop at 60 s.
You MUST compare status case-insensitively. GET /bettor/bets/{id} returns it in
lowercase and POST /bettor/bets returns it in uppercase.
const SCHEDULE_MS = [1000, 2000, 3000, 3000, 3000]
const STEADY_MS = 5000
const CEILING_MS = 60_000
type Terminal = 'CONFIRMED' | 'REJECTED'
async function waitForPlacement(betId: string, token: string): Promise<Terminal | 'TIMEOUT'> {
const startedAt = Date.now()
for (let attempt = 0; Date.now() - startedAt < CEILING_MS; attempt++) {
const wait = SCHEDULE_MS[attempt] ?? STEADY_MS
await new Promise((resolve) => setTimeout(resolve, wait))
const response = await fetch(`${BASE_URL}/bettor/bets/${betId}?locale=en`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) continue
const position = await response.json()
const status = String(position.status).toUpperCase()
if (status === 'CONFIRMED' || status === 'REJECTED') return status
}
return 'TIMEOUT'
}
Handle the 60-second ceiling
A bet does not always reach a terminal status inside the poll window. Treat the ceiling as a normal outcome, not an error.
At 60 s still pending, tell the bettor the bet is still processing and to check their bet history. Do not tell them it failed. Do not tell them it was placed.
You MUST NOT offer a retry from this state. A retry means a new Idempotency-Key. If the
original bet later confirms, the bettor is staked twice on the same outcome.
Treat a repeated idempotency key as a replay
Sending the same Idempotency-Key again returns the original 202 verbatim. It does not create a
second bet, and it does not return a conflict.
So write replay-tolerant code, not conflict handling.
This matters on failure. On any 5xx, network error, or unparseable body from
POST /api/v1/bettor/bets, do not resubmit with a new key. Either replay the same
Idempotency-Key, or list the bettor's recent bets and reconcile. A new key creates a second bet.
Set your client timeout above 3 s. The platform answers 500 if a request runs longer than that, and the bet may still be created, so a shorter client timeout reports a failure for a bet that was accepted.
Two lifecycles, not one status list
Placement resolution and bet outcome are separate tracks. A bet that is CONFIRMED has not been
won or lost yet.
Placement resolution runs from PENDING to CONFIRMED or REJECTED. It resolves in
seconds, and it is what the poll loop waits for.
Bet outcome runs from CONFIRMED to SETTLED or VOIDED. It resolves when the real-world
event resolves, which can take months. No loop waits for it. Refetch on app load, or when the
bettor opens their bet history.
SETTLED means the market resolved and the platform paid the position. A winning position pays
stake × entry_odds and carries payout_amount and settled_at. A losing position settles at
zero. VOIDED means the market was cancelled and the stake was returned.
Handle a rejection
A rejected position carries rejection_code. Bind your message and your localization to that
code.
rejection_code | What happened | Stake debited | What to do |
|---|---|---|---|
validation_failed | The market, the event, or the outcome was not open, or the price moved too far | No | Refetch the event. If it is closed, drop the selection. If the price moved, show the new price and ask the bettor to confirm again |
bettor_blocked | Your wallet reports betting disabled, or the bettor is unknown to it | No | Terminal. Show "betting unavailable, contact support". Do not resubmit |
insufficient_funds | Your wallet refused the debit | No | Show the stake and the currency. Refresh the balance. Offer a deposit path |
bet_limit_reached | The bettor passed an operator-side bet limit | No | Show your own limit message. Do not resubmit the same stake |
bet_rejected_other | Any other terminal refusal from your wallet | Unknown | Show a general "bet could not be placed". Refresh the balance before allowing another attempt. Do not retry automatically |
wallet_rolled_back | The stake was debited, a later step failed, and the platform started a reversal | Yes | Show "bet failed, your stake is being returned". Do not state that it arrived. Refresh the balance, and offer another attempt only once it shows the stake back |
null | Your wallet was unreachable, so the debit outcome is unconfirmed | Unknown | Show "temporarily unavailable, your balance is being reconciled". Refresh the balance before allowing another attempt |
You MUST handle rejection_code: null as its own case, not as a parse error. The code is
null when the operator wallet could not be reached.
You MUST refresh the balance from GET /api/v1/auth/me after wallet_rolled_back, after
bet_rejected_other, and after a null code, before you let the bettor try again.
A reversal is scheduled, not immediate. The balance is what confirms it completed, so never tell
a bettor their stake was returned on the strength of rejection_code alone.
You MUST NOT render rejection_reason. It is an internal diagnostic string, never display
copy. Build what the bettor sees from rejection_code.
Branch on the HTTP status, not on the error body
Error bodies come in three shapes. Sniff Content-Type before you parse.
Content-Type | Shape | Fields |
|---|---|---|
application/problem+json | RFC 9457 Problem Details | type, title, status, detail |
application/json | Flat error object | error |
text/plain | The raw error text | none |
Some responses carry no body and no Content-Type at all. A 401 is the common one.
The text/plain responses are the ones you hit most while building: a missing or non-UUID
Idempotency-Key, and a non-UUID id in the path.
Treat the body as opaque diagnostic text. Decide what to show the bettor from the HTTP status and
from rejection_code, never from an error body schema.
Read bet history
curl "https://<your predictions host>/api/v1/bettor/bets?status=open&limit=20&locale=en" \
-H "Authorization: Bearer <token>"
status accepts the stored values pending, confirmed, rejected, and settled, plus two
virtual ones: open for pending and confirmed together, and cancelled as an alias for
rejected. The filter is case-insensitive. Page it with cursor and limit, the same way as the
event list.
You MUST render entry_odds on every placed bet, labeled as the price the bettor got.
Settlement pays stake × entry_odds, so this is the number that decides what they are paid.
Each bet has market_image_url and event_image_url. The empty and absent values mean the same
as in the event read. Select the image with the order in
Render event and market images.
You MUST NOT show a market's current odds anywhere in bet history. There is no cash out, so the current price is a number the bettor cannot act on. Showing it beside a placed bet suggests the payout moved with it, which it does not.
Read the portfolio summary
curl https://<your predictions host>/api/v1/bettor/portfolio \
-H "Authorization: Bearer <token>"
{
"total_stakes": "150.00",
"total_positions": 12
}
total_stakes sums the stakes of confirmed positions. total_positions counts positions in
every status. For individual positions, use GET /api/v1/bettor/bets.