Skip to main content

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.

AttemptWait before it
11 s
22 s
33 s
43 s
53 s
6 and after5 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_codeWhat happenedStake debitedWhat to do
validation_failedThe market, the event, or the outcome was not open, or the price moved too farNoRefetch 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_blockedYour wallet reports betting disabled, or the bettor is unknown to itNoTerminal. Show "betting unavailable, contact support". Do not resubmit
insufficient_fundsYour wallet refused the debitNoShow the stake and the currency. Refresh the balance. Offer a deposit path
bet_limit_reachedThe bettor passed an operator-side bet limitNoShow your own limit message. Do not resubmit the same stake
bet_rejected_otherAny other terminal refusal from your walletUnknownShow a general "bet could not be placed". Refresh the balance before allowing another attempt. Do not retry automatically
wallet_rolled_backThe stake was debited, a later step failed, and the platform started a reversalYesShow "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
nullYour wallet was unreachable, so the debit outcome is unconfirmedUnknownShow "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-TypeShapeFields
application/problem+jsonRFC 9457 Problem Detailstype, title, status, detail
application/jsonFlat error objecterror
text/plainThe raw error textnone

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.