At a glance
Base URLhttps://api.fortunex.bet
Version 1 base path/api/b2b/v1
API document version1.0.0
OpenAPI3.0.3
Signature headerX-FX-Signature
Downloads
Files for your developers. They are the same documents FortuneX staff send to new operators.
The machine-readable description is operator-openapi.yaml (OpenAPI 3.0) next to this file. This guide explains how it all fits together.
1. What this API does
You place 4D bets for your players through FortuneX, and read back results, your balance and your weekly statements.
- Your bets go to the 4D supplier through FortuneX's one house account. You do not need an account with the supplier.
- FortuneX does not call your wallet for 4D. Your relationship with FortuneX is either a prepaid balance or a credit term (section 8).
- FortuneX takes no automatic fee on 4D. Every bet and every result is recorded so that you and FortuneX can match your records against the supplier's. Each bet carries your own
ref and subAccount for that. - Results are recorded, not paid. A win does not move money by itself (section 9).
- There are no webhooks. You poll for status and results.
2. Getting access
FortuneX staff create your operator account and an API key in the console. For each key you receive:
- a key ID (
fxk_...), public; - a secret (
fxs_...), shown once. FortuneX stores it encrypted and cannot show it again. If you lose it, ask for a rotation. - an IP allow-list: a key works only from the addresses you gave FortuneX. Tell FortuneX every address you call from, including fail-over servers.
You can have a separate sandbox key (section 11) for testing. A sandbox key never reaches the supplier and moves no money. Production and sandbox keys are different keys; neither works in the other's place.
Key rotation: FortuneX makes a new key and the old one keeps working for an agreed overlap (default 7 days), then stops. Staff can also revoke a key at once.
The API host is https://api.fortunex.bet (FortuneX tells you the final host). Until FortuneX switches the operator API on, every route answers 404 not_found.
3. Signing every request
Every request, GET or POST, carries four headers:
| Header | Value |
|---|
X-FX-Key | your key ID |
X-FX-Timestamp | Unix seconds now. Must be within 5 minutes of FortuneX's clock. |
X-FX-Nonce | 8 to 128 characters from A-Z a-z 0-9 _ - + = . :, new for every request. A nonce is accepted once. |
X-FX-Signature | lower-case hex of HMAC-SHA256(secret, stringToSign) |
stringToSign is six lines joined with a single newline (\n), with no trailing newline:
METHOD upper case: GET or POST
path the request path, exactly as sent, without the query
query the raw query string without the "?"; an empty line if there is none
timestamp the same value as X-FX-Timestamp
nonce the same value as X-FX-Nonce
sha256(body) lower-case hex SHA-256 of the raw body bytes; of the empty string for a GET
Rules that trip people up:
- Sign the exact bytes you send. Do not re-serialise the JSON after signing. Do not decode, re-order or normalise the path or the query.
- A path or query containing a line break is refused.
- For a request with no query the third line is empty, so the string contains two newlines in a row.
- Send the body with
Content-Type: application/json. Maximum body: 64 KB.
Worked example
Secret (an example only, never use it): fxs_example_secret_never_use_in_production
Request body (exactly these bytes, no spaces added):
{"ref":"order-1001","subAccount":"site-A-user-77","drawDate":"2026-11-04","pools":["magnum"],"lines":["1234#1"]}
method POST
path /api/b2b/v1/4d/bets
query (empty)
timestamp 1793700000
nonce 0f8e7d6c5b4a3921
sha256(body) = 86b0934218ce3411d21dff00918892f65ae64e1d2ba484d162736827178886ff
String to sign:
POST
/api/b2b/v1/4d/bets
1793700000
0f8e7d6c5b4a3921
86b0934218ce3411d21dff00918892f65ae64e1d2ba484d162736827178886ff
Signature: 5e55fd7bfac0380e225fdc9443f20be43d1e1018323b968263e21a3142981147
A GET with a query: GET /api/b2b/v1/4d/bets?drawDate=2026-11-04&limit=20, timestamp 1793700001, nonce 1a2b3c4d5e6f7081, empty body gives signature 53616fcf295cab5289249468d3c92d599592d577ed3d5f9d62293b67350780e1 with the same secret. Use these two as a test vector for your implementation. The nonces in the two vectors (16 hex characters) are fixed by hand so that the signature can be reproduced: to check your code, use the vector's timestamp and nonce instead of generating them. In real calls always generate a new nonce, as the code below does (24 hex characters).
Code
Node.js (no dependencies):
const crypto = require('crypto');
function signedFetch(base, keyId, secret, method, path, query, bodyObj) {
const body = bodyObj == null ? '' : JSON.stringify(bodyObj);
const ts = String(Math.floor(Date.now() / 1000)), nonce = crypto.randomBytes(12).toString('hex');
const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
const toSign = [method, path, query || '', ts, nonce, bodyHash].join('\n');
const sig = crypto.createHmac('sha256', secret).update(toSign).digest('hex');
return fetch(base + path + (query ? '?' + query : ''), {
method, body: method === 'GET' ? undefined : body,
headers: { 'Content-Type': 'application/json', 'X-FX-Key': keyId, 'X-FX-Timestamp': ts, 'X-FX-Nonce': nonce, 'X-FX-Signature': sig },
});
}
Python 3:
import hashlib, hmac, json, os, time, urllib.request
def signed(base, key_id, secret, method, path, query='', body_obj=None):
body = b'' if body_obj is None else json.dumps(body_obj, separators=(',', ':')).encode()
ts, nonce = str(int(time.time())), os.urandom(12).hex()
to_sign = '\n'.join([method, path, query, ts, nonce, hashlib.sha256(body).hexdigest()])
sig = hmac.new(secret.encode(), to_sign.encode(), hashlib.sha256).hexdigest()
req = urllib.request.Request(base + path + ('?' + query if query else ''), data=body or None, method=method,
headers={'Content-Type': 'application/json', 'X-FX-Key': key_id, 'X-FX-Timestamp': ts, 'X-FX-Nonce': nonce, 'X-FX-Signature': sig})
return urllib.request.urlopen(req)
4. Answers and errors
Success: { "ok": true, ... }. Failure: { "ok": false, "error": { "code": "...", "message": "..." } } with the HTTP status below. Always branch on code, not on the message text.
Authentication failures carry a deliberately generic 401 unauthorized (wrong key, bad signature, unknown key all look the same). Once a request is correctly signed you get specific codes.
| HTTP | code | Meaning |
|---|
| 401 | unauthorized | Key unknown or signature wrong. Check the string to sign. |
| 401 | key_revoked, key_expired | The key no longer works. |
| 401 | stale_timestamp | Your clock differs from FortuneX by more than 5 minutes. |
| 401 | replay | That nonce was already used. |
| 403 | ip_not_allowed | The request came from an address not on the key's allow-list. |
| 403 | operator_suspended | Your account is suspended: no new bets. Reads and cancels still work. |
| 403 | bets_disabled | Real 4D bets are switched off at FortuneX. |
| 403 | sandbox_key / sandbox_only | A sandbox key on a production call, or a production key on a sandbox call. |
| 404 | not_found | Unknown route (or the API is not switched on). |
| 404 | unknown_bet, unknown_statement | Not found for your account. |
| 400 | bad_json, bad_ref, bad_sub_account, bad_ticket, bad_date, bad_status, bad_cursor, bad_outcome, bad_win | The request is malformed. bad_ticket carries the reason in message (same wording FortuneX players see). |
| 400 | stake_too_small, stake_too_large | The ticket total is outside the limits in GET /4d/games. |
| 402 | insufficient_prepaid | Prepaid operators: the balance does not cover the stake. |
| 402 | credit_limit_exceeded | Credit-term operators: unsettled credit plus this stake would pass your limit. |
| 402 | credit_not_configured | Credit-term operators: no credit limit is set for 4D. Ask FortuneX. |
| 409 | idempotency_mismatch | That ref was already used for a different bet. |
| 409 | game_unavailable | A pool you chose is switched off or not open for real bets. |
| 409 | capacity | FortuneX is at its overall limit for open stakes. Try later. |
| 409 | cannot_cancel, cancel_pending | See section 7. |
| 409 | not_placed, already_settled | Sandbox result calls only. |
| 409 | use_4d_api | You used the generic wager call for a 4D game. |
| 405 | method_not_allowed | Only GET and POST are used. |
| 429 | rate_limited, operator_limit | Slow down. See section 12. |
| 503 | maintenance, busy | Try again shortly. |
| 500 | internal_error | Our fault. Retry with the same ref (section 5). |
5. Idempotency and retries
ref is your idempotency key for a bet: 1 to 100 characters from A-Z a-z 0-9 : _ . -. It is unique per operator (production and sandbox each have their own). You may send the same value in an Idempotency-Key header instead; if both are present they must be equal.
- Send the same
ref with the same ticket again: you get 200 and the same bet, nothing is charged twice. A first placement returns 201. - Send the same
ref with a different ticket (draw date, pools, lines or subAccount): **409 idempotency_mismatch**. - If a placement times out or answers 5xx, **retry with the same
ref**. Never make up a new ref for the same player action; that would place a second bet. - Each retry needs a fresh nonce and timestamp (it is a new signed request).
- 4xx answers other than 429 will not change by retrying; fix the request.
6. Placing a bet
First read GET /4d/games for the pools that are open, the next draw dates with their closing times, and the limits.
POST /4d/bets
{ "ref": "order-1001", "subAccount": "site-A-user-77", "drawDate": "2026-11-04", "pools": ["magnum", "damacai"], "lines": ["1234#1", "*2345#1"] }
subAccount: required. Your own reference for the player or site behind the bet (1 to 100 characters). It is stored with the bet and returned in every view so FortuneX can match your sales to the supplier's record per sub-account.drawDate: YYYY-MM-DD. Bets for a draw close at 18:59 Malaysian time on the draw day. A date more than 30 days ahead is refused.pools: one or more of magnum, damacai, toto, singapore, sabah, sarawak, gd (Grand Dragon), nine (9Lotto). sandakan is not active with the supplier. Use GET /4d/games for what is open now.lines: 1 to 50 lines of the same text format as FortuneX's own bet slip. The ticket cost is each line's stake multiplied by the numbers it expands to and by the number of pools. Examples:
| Line | Meaning | Cost per pool |
|---|
1234#1 | number 1234, Big, RM 1 | RM 1.00 |
1234#1#1 | Big RM 1 and Small RM 1 | RM 2.00 |
1234#1#1#1 | Big, Small and A, RM 1 each | RM 3.00 |
123#1#1 | 3D: C RM 1 and A RM 1 | RM 2.00 |
1234**#1#1#1#1#1 | RM 1 on each prize position A1 to A5 | RM 5.00 |
*2345#1 | box: all 24 arrangements, Big RM 1 each | RM 24.00 |
800020#1 | 6D, RM 1 | RM 1.00 |
The smallest stake on one number is RM 0.10 (1234#0.1). The smallest ticket and the largest ticket are in GET /4d/games (limits). 5D and 6D tickets follow FortuneX's own switch for them and are refused when it is off. When they are on, a 5D line is accepted only with the pool toto, and a 6D line only with toto, gd or nine; a ticket that puts such a line together with other pools is refused with 400 bad_ticket ("Untick the other pools"). Random ("R") lines must be drawn by you before you place the ticket.
The server works out the cost itself. You cannot send a price.
Response 201:
{ "ok": true, "created": true, "bet": { "ref": "order-1001", "subAccount": "site-A-user-77", "sandbox": false, "funding": "prepaid", "drawDate": "2026-11-04",
"pools": ["magnum"], "lines": ["1234#1"], "stakeSen": 100, "refundedSen": 0, "status": "pending", "statusText": "Waiting for approval", "reason": null,
"result": { "settled": false, "winSen": null, "wins": null, "settledAt": null }, "canCancel": true, "cancelState": null, "cancelNote": null, "createdAt": 1793700000123, "orders": [] } }
Bet lifecycle
pending ──approved by FortuneX (or automatically)──▶ sending ──supplier answers──▶ accepted | partial | refused | check
│ │
├─ rejected (a reason is given) ─▶ rejected (stake returned) ├─ cancelled (you cancelled: stake returned)
└─ you cancel ─▶ cancelled (stake returned) └─ settled: the result is recorded (win or loss)
status | Meaning | Stake |
|---|
pending | Waiting for FortuneX to send it to the supplier. | Held |
sending | Being sent. | Held |
accepted | Placed with the supplier. orders[] shows the supplier references. | Held |
partial | Some numbers were sold out; the rejected part has been returned (refundedSen). | Part returned |
refused | The supplier did not accept it. | Returned |
rejected | FortuneX did not approve it (the reason is shown) or betting closed before it was approved. | Returned |
cancelled | Cancelled. | Returned |
check | An order has an unknown outcome and a person at FortuneX is checking it with the supplier. | Held until resolved |
Whether a bet waits for a person to approve it or is sent at once is a FortuneX setting; poll GET /4d/bets/{ref} until status is no longer pending or sending.
orders[] has one entry per pool: providerRef (the reference FortuneX used for this order with the supplier), betNo and receiptNo (the supplier's numbers, once accepted), and the accepted/rejected amounts. Use these when you reconcile with FortuneX or the supplier.
Reading bets
GET /4d/bets/{ref} one bet.GET /4d/bets?drawDate=&status=&subAccount=&limit=&before= your bets, newest first. limit up to 200 (default 50). If next is not null, pass it as before for the next page.
7. Cancelling
POST /4d/bets/{ref}/cancel (empty body or {}). The rules are the same as for FortuneX's own players:
- A bet still
pending: cancelled at once, the full stake returned. - A placed bet (
accepted or partial) before the draw is about to close (until 2 minutes before 18:59 on the draw day): a cancel request. The answer shows cancelState: "requested". FortuneX approves it (or it is automatic), the supplier cancels each order, and only then does the status become cancelled and the stake return. If the supplier refuses, cancelState becomes failed or declined with a cancelNote and the bet stays placed. Poll the bet. - After the draw is about to close, or once a result is recorded:
409 cannot_cancel. - Cancelling twice:
409 cannot_cancel (or cancel_pending while a request waits).
8. Prepaid and credit-term accounts
FortuneX sets, for you and for each kind of game (4D, sports, slots, live casino), whether the game is prepaid or on credit term. Only 4D is available through this API today. GET /balance and GET /4d/games (funding) show what applies to you.
Prepaid
- Your prepaid balance is kept by FortuneX. Staff add to it when you pay in (they record the bank reference).
- When you place a bet the stake is deducted at once. If the balance is short the bet is refused with
402 insufficient_prepaid and nothing is recorded. Concurrent bets can never take the balance below zero. - A stake returned (cancel, refusal, sold-out part) goes back to the balance exactly once.
- A win is not paid to your balance automatically. Once a week FortuneX credits your profit by hand, using the recorded sales and results as the reference.
Credit term
- Nothing is deducted when you bet. The sale is recorded against your credit exposure: sales minus refunds that have not yet been settled by a paid statement.
- You have a credit limit per game kind. A bet is refused with
402 credit_limit_exceeded if your exposure plus the new stake would exceed it. A game on credit with no limit (or 0) refuses every bet (credit_not_configured). - Every Monday FortuneX issues a weekly statement for the finished week (Monday 00:00 to Monday 00:00, Malaysian time): total sales, refunds, results recorded as wins, the net and who owes whom, due 4 days after the period (the number of days can change). Statements are final once issued. Payment is recorded by FortuneX staff when the money has moved; that frees your credit room again.
GET /statements lists your statements; GET /statements/{number} shows one. They are read only.
GET /balance:
{ "ok": true, "balance": { "sandbox": false, "currency": "MYR", "prepaidBalanceSen": 250000,
"games": [ { "category": "4d", "mode": "credit", "creditLimitSen": 1000000, "exposureSen": 420000, "availableSen": 580000 },
{ "category": "sports", "mode": "prepaid", "creditLimitSen": null, "exposureSen": null, "availableSen": null } ], "asOf": 1793700000123 } }
9. Results
Results are recorded automatically after the draw, from the supplier's official result, using the same rules as FortuneX's own players (the payout table FortuneX publishes; for 6D a number that fits several prizes is paid its highest prize only). Some cases wait for a person (a 6D first prize the supplier does not publish, a very large win, an order that needs checking); the bet then stays unsettled until it is entered.
GET /4d/bets/{ref}/result: state is pending (not placed with the supplier yet, or being checked), open (placed, draw not closed), awaiting_result (closed, not recorded yet), settled (see winSen and wins[] with the prize and number) or void (refunded or never placed).GET /4d/results/{YYYY-MM-DD}: the draw's official numbers per pool (null if not published yet), the 6D first prize where known, your bets of that draw date with their stake and winSen, and totals.
A win does not move any money. It is a record. Prepaid accounts are credited by hand weekly; credit-term accounts settle the net on the weekly statement. winSen is the winning amount for that bet worked out from FortuneX's payout table.
10. Reconciliation
Every bet has your ref, your subAccount, FortuneX's providerRef per order and the supplier's betNo/receiptNo. For a day or a draw date use GET /4d/bets?drawDate= or GET /4d/results/{date}. Statement CSVs (from FortuneX staff) list every sale, refund and result with time, ref and sub-account.
11. Sandbox
A sandbox key behaves like a production key but with simulated bets:
- Same signing, same endpoints under
/4d/*, GET /balance and GET /health, with a "sandbox": true marker. All other production routes answer 403 sandbox_key. - A sandbox bet is checked with the same ticket rules, limits and open pools as a production bet, then accepted immediately. It is never sent to the supplier and it touches no balance, no credit and no statement.
- Sandbox refs are separate from production refs.
- Simulate a provider refusal: use a
subAccount that starts with sbx-refuse. The bet becomes refused with the stake "returned" (refundedSen). - Simulate an empty prepaid balance: a
subAccount that starts with sbx-nofunds answers 402 insufficient_prepaid. - Simulate the result:
POST /sandbox/4d/bets/{ref}/result with { "outcome": "win", "winSen": 350000 } (default winSen is ten times the stake) or { "outcome": "loss" }. Only for an accepted sandbox bet, once; then GET .../result shows state: "settled" and simulated: true. - Sandbox cancel is instant.
- A production key cannot call
/sandbox/* (403 sandbox_only).
12. Limits
- Per IP address: 600 requests a minute (answer
429 rate_limited, header Retry-After). - Per key: the limit set on your key (default 600 requests a minute).
- Per operator: at most 600 new bets a minute (
429 operator_limit). Production and sandbox bets are counted separately: sandbox bets never use up the production limit. - Body at most 64 KB.
- Staff may limit the global open stake across all bets (
409 capacity).
On 429 wait for Retry-After seconds and retry the same request with a fresh nonce.
13. Security checklist
- Keep the secret in a secret store, never in browser or mobile code. Sign requests from your server only.
- Call only from the IP addresses on your allow-list; tell FortuneX before they change.
- Use HTTPS only. Verify the server certificate.
- Make nonces unpredictable and never reuse them; keep your clock correct (NTP).
- Treat
ref as sensitive to duplication: derive it from your own order id so a retry reuses it. - Log request ids and
refs, never the secret or signatures.
14. What this API does not do (yet)
Webhooks (poll instead), sports, slots and live-casino wagers, and money movement of any kind: payments, top-ups and profit credits are recorded by FortuneX staff outside the API.
Endpoint reference
The endpoint reference below is generated from the OpenAPI file and is in English.
Account
GET /api/b2b/v1/health
Signed ping
Responses
200 OK
Response shape (example values)
{
"ok": true,
"time": 1793700000123,
"operatorId": 1,
"keyId": "fxk_example",
"sandbox": true
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
"ok": false,
"error": {
"code": "insufficient_prepaid",
"message": "The balance does not cover this stake."
}
}
GET /api/b2b/v1/balance
Your prepaid balance and credit position
Responses
200 OK
Response shape (example values)
{
"ok": true,
"balance": {
"sandbox": true,
"currency": "MYR",
"prepaidBalanceSen": 1793700000123,
"games": [
{
"category": "4d",
"mode": "prepaid",
"creditLimitSen": 1793700000123,
"exposureSen": 1793700000123,
"availableSen": 1793700000123
}
],
"asOf": 1793700000123,
"note": "string"
}
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
"ok": false,
"error": {
"code": "insufficient_prepaid",
"message": "The balance does not cover this stake."
}
}
4D bets
GET /api/b2b/v1/4d/games
Pools, draw dates, minimum stakes and limits
Responses
200 OK
{
"ok": true,
"sandbox": true,
"enabled": true,
"funding": "prepaid",
"currency": "string",
"games": [
{
"id": "magnum",
"name": "string",
"open": true,
"reason": "string",
"supports5d6d": true
}
],
"drawDates": [
{
"drawDate": "2026-11-04",
"day": "string",
"closesAt": "2026-11-04T10:59:00Z",
"pools": [
"string"
],
"special": true
}
],
"limits": {
"minTicketSen": 100,
"maxTicketSen": 1793700000123,
"minStakePerNumberSen": 100,
"maxLines": 1,
"closesAt": "string",
"cancelUntil": "string"
}
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
"ok": false,
"error": {
"code": "insufficient_prepaid",
"message": "The balance does not cover this stake."
}
}
GET /api/b2b/v1/4d/bets
List your bets, newest first
Parameters
| Name | In | Required | Type | Description |
|---|
drawDate | query | no | string (date) | |
status | query | no | pending | sending | accepted | partial | refused | rejected | cancelled | check | |
subAccount | query | no | string | max length 100 |
limit | query | no | integer | min 1, max 200, default 50 |
before | query | no | integer | Pass next from the previous page. |
Responses
200 OK
Response shape (example values)
{
"ok": true,
"bets": [
{
"ref": "string",
"subAccount": "string",
"sandbox": true,
"funding": "prepaid",
"drawDate": "2026-11-04",
"pools": [
"string"
],
"lines": [
"string"
],
"stakeSen": 100,
"refundedSen": 100,
"status": "pending",
"statusText": "string",
"reason": "string",
"result": {
"settled": true,
"winSen": 1793700000123,
"wins": [
{}
],
"settledAt": 1793700000123
},
"canCancel": true,
"cancelState": "requested",
"cancelNote": "string",
"createdAt": 1793700000123,
"orders": [
{
"pool": "string",
"status": "sending",
"stakeSen": 100,
"acceptedSen": 1793700000123,
"rejectedSen": 1793700000123,
"providerRef": "string",
"betNo": 1,
"receiptNo": 1
}
]
}
],
"next": 1
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
"ok": false,
"error": {
"code": "insufficient_prepaid",
"message": "The balance does not cover this stake."
}
}
POST /api/b2b/v1/4d/bets
Place a 4D bet
Prepaid: the stake is deducted at once (402 insufficient_prepaid if short). Credit term: the sale is recorded against your credit (402 credit_limit_exceeded / credit_not_configured). Sandbox key: the bet is simulated (see the Sandbox tag).
Parameters
| Name | In | Required | Type | Description |
|---|
Idempotency-Key | header | no | string | max length 100 |
{
"ref": "order-1001",
"subAccount": "site-A-user-77",
"drawDate": "2026-11-04",
"pools": [
"magnum"
],
"lines": [
"1234#1"
]
}
Responses
200 The same ref was already placed with this ticket (nothing charged again)
{
"ok": true,
"created": true,
"bet": {
"ref": "string",
"subAccount": "string",
"sandbox": true,
"funding": "prepaid",
"drawDate": "2026-11-04",
"pools": [
"string"
],
"lines": [
"string"
],
"stakeSen": 100,
"refundedSen": 100,
"status": "pending",
"statusText": "string",
"reason": "string",
"result": {
"settled": true,
"winSen": 1793700000123,
"wins": [
{}
],
"settledAt": 1793700000123
},
"canCancel": true,
"cancelState": "requested",
"cancelNote": "string",
"createdAt": 1793700000123,
"orders": [
{
"pool": "string",
"status": "sending",
"stakeSen": 100,
"acceptedSen": 1793700000123,
"rejectedSen": 1793700000123,
"providerRef": "string",
"betNo": 1,
"receiptNo": 1
}
]
}
}
201 Placed
{
"ok": true,
"created": true,
"bet": {
"ref": "string",
"subAccount": "string",
"sandbox": true,
"funding": "prepaid",
"drawDate": "2026-11-04",
"pools": [
"string"
],
"lines": [
"string"
],
"stakeSen": 100,
"refundedSen": 100,
"status": "pending",
"statusText": "string",
"reason": "string",
"result": {
"settled": true,
"winSen": 1793700000123,
"wins": [
{}
],
"settledAt": 1793700000123
},
"canCancel": true,
"cancelState": "requested",
"cancelNote": "string",
"createdAt": 1793700000123,
"orders": [
{
"pool": "string",
"status": "sending",
"stakeSen": 100,
"acceptedSen": 1793700000123,
"rejectedSen": 1793700000123,
"providerRef": "string",
"betNo": 1,
"receiptNo": 1
}
]
}
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
"ok": false,
"error": {
"code": "insufficient_prepaid",
"message": "The balance does not cover this stake."
}
}
GET /api/b2b/v1/4d/bets/{ref}
One bet with its orders
Parameters
| Name | In | Required | Type | Description |
|---|
ref | path | yes | string | Your bet reference. pattern ^[A-Za-z0-9:_.-]{1,100}$ |
Responses
200 OK
{
"ok": true,
"created": true,
"bet": {
"ref": "string",
"subAccount": "string",
"sandbox": true,
"funding": "prepaid",
"drawDate": "2026-11-04",
"pools": [
"string"
],
"lines": [
"string"
],
"stakeSen": 100,
"refundedSen": 100,
"status": "pending",
"statusText": "string",
"reason": "string",
"result": {
"settled": true,
"winSen": 1793700000123,
"wins": [
{}
],
"settledAt": 1793700000123
},
"canCancel": true,
"cancelState": "requested",
"cancelNote": "string",
"createdAt": 1793700000123,
"orders": [
{
"pool": "string",
"status": "sending",
"stakeSen": 100,
"acceptedSen": 1793700000123,
"rejectedSen": 1793700000123,
"providerRef": "string",
"betNo": 1,
"receiptNo": 1
}
]
}
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
"ok": false,
"error": {
"code": "insufficient_prepaid",
"message": "The balance does not cover this stake."
}
}
POST /api/b2b/v1/4d/bets/{ref}/cancel
Cancel a bet
Parameters
| Name | In | Required | Type | Description |
|---|
ref | path | yes | string | Your bet reference. pattern ^[A-Za-z0-9:_.-]{1,100}$ |
Request body (optional)
Responses
200 The bet after the cancel (or the request)
{
"ok": true,
"created": true,
"bet": {
"ref": "string",
"subAccount": "string",
"sandbox": true,
"funding": "prepaid",
"drawDate": "2026-11-04",
"pools": [
"string"
],
"lines": [
"string"
],
"stakeSen": 100,
"refundedSen": 100,
"status": "pending",
"statusText": "string",
"reason": "string",
"result": {
"settled": true,
"winSen": 1793700000123,
"wins": [
{}
],
"settledAt": 1793700000123
},
"canCancel": true,
"cancelState": "requested",
"cancelNote": "string",
"createdAt": 1793700000123,
"orders": [
{
"pool": "string",
"status": "sending",
"stakeSen": 100,
"acceptedSen": 1793700000123,
"rejectedSen": 1793700000123,
"providerRef": "string",
"betNo": 1,
"receiptNo": 1
}
]
}
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
"ok": false,
"error": {
"code": "insufficient_prepaid",
"message": "The balance does not cover this stake."
}
}
Results
GET /api/b2b/v1/4d/bets/{ref}/result
The result of one bet
Parameters
| Name | In | Required | Type | Description |
|---|
ref | path | yes | string | Your bet reference. pattern ^[A-Za-z0-9:_.-]{1,100}$ |
Responses
200 OK
Response shape (example values)
{
"ok": true,
"result": {
"ref": "string",
"drawDate": "2026-11-04",
"state": "pending",
"closed": true,
"status": "pending",
"stakeSen": 100,
"refundedSen": 100,
"settled": true,
"winSen": 1793700000123,
"wins": [
{}
],
"settledAt": 1793700000123,
"simulated": true
}
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
"ok": false,
"error": {
"code": "insufficient_prepaid",
"message": "The balance does not cover this stake."
}
}
GET /api/b2b/v1/4d/results/{drawDate}
The official numbers of a draw and your bets of that date
Parameters
| Name | In | Required | Type | Description |
|---|
drawDate | path | yes | string (date) | |
Responses
200 OK
{
"ok": true,
"drawDate": "2026-11-04",
"closed": true,
"pools": {
"example": {
"name": "string",
"first": "string",
"second": "string",
"third": "string",
"special": [
"string"
],
"consolation": [
"string"
],
"source": "string"
}
},
"sixFirst": {
"example": "string"
},
"bets": [
{
"ref": "string",
"subAccount": "string",
"status": "pending",
"stakeSen": 100,
"refundedSen": 100,
"settled": true,
"winSen": 1793700000123
}
],
"totals": {
"bets": 1,
"stakeSen": 100,
"refundedSen": 100,
"settled": 1,
"awaiting": 1,
"winSen": 100
}
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
"ok": false,
"error": {
"code": "insufficient_prepaid",
"message": "The balance does not cover this stake."
}
}
Statements
GET /api/b2b/v1/statements
Your weekly credit statements (production keys, read only)
Responses
200 OK
Response shape (example values)
{
"ok": true,
"statements": [
{
"number": "FXS-acme-20261026",
"periodStart": 1793700000123,
"periodEnd": 1793700000123,
"periodLabel": "2026-10-26 to 2026-11-01",
"salesSen": 100,
"refundsSen": 100,
"winsSen": 100,
"netSen": 1793700000123,
"saleCount": 1,
"refundCount": 1,
"resultCount": 1,
"winCount": 1,
"byCategory": {
"example": {
"salesSen": 100,
"refundsSen": 100,
"winsSen": 100,
"netSen": 1793700000123
}
},
"owes": "operator",
"amountSen": 100,
"dueAt": 1793700000123,
"status": "issued",
"overdue": true,
"payment": {
"direction": "received",
"amountSen": 100,
"reference": "string",
"recordedAt": 1793700000123
},
"issuedAt": 1793700000123
}
]
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
"ok": false,
"error": {
"code": "insufficient_prepaid",
"message": "The balance does not cover this stake."
}
}
GET /api/b2b/v1/statements/{number}
One statement
Parameters
| Name | In | Required | Type | Description |
|---|
number | path | yes | string | The statement number (for example FXS-acme-20261026) or its id. |
Responses
200 OK
Response shape (example values)
{
"ok": true,
"statement": {
"number": "FXS-acme-20261026",
"periodStart": 1793700000123,
"periodEnd": 1793700000123,
"periodLabel": "2026-10-26 to 2026-11-01",
"salesSen": 100,
"refundsSen": 100,
"winsSen": 100,
"netSen": 1793700000123,
"saleCount": 1,
"refundCount": 1,
"resultCount": 1,
"winCount": 1,
"byCategory": {
"example": {
"salesSen": 100,
"refundsSen": 100,
"winsSen": 100,
"netSen": 1793700000123
}
},
"owes": "operator",
"amountSen": 100,
"dueAt": 1793700000123,
"status": "issued",
"overdue": true,
"payment": {
"direction": "received",
"amountSen": 100,
"reference": "string",
"recordedAt": 1793700000123
},
"issuedAt": 1793700000123
}
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
"ok": false,
"error": {
"code": "insufficient_prepaid",
"message": "The balance does not cover this stake."
}
}
Sandbox
POST /api/b2b/v1/sandbox/4d/bets/{ref}/result
Simulate the result of a sandbox bet (sandbox key only)
Parameters
| Name | In | Required | Type | Description |
|---|
ref | path | yes | string | Your bet reference. pattern ^[A-Za-z0-9:_.-]{1,100}$ |
Request body
{
"outcome": "win",
"winSen": 350000
}
Responses
200 The bet, now settled
{
"ok": true,
"created": true,
"bet": {
"ref": "string",
"subAccount": "string",
"sandbox": true,
"funding": "prepaid",
"drawDate": "2026-11-04",
"pools": [
"string"
],
"lines": [
"string"
],
"stakeSen": 100,
"refundedSen": 100,
"status": "pending",
"statusText": "string",
"reason": "string",
"result": {
"settled": true,
"winSen": 1793700000123,
"wins": [
{}
],
"settledAt": 1793700000123
},
"canCancel": true,
"cancelState": "requested",
"cancelNote": "string",
"createdAt": 1793700000123,
"orders": [
{
"pool": "string",
"status": "sending",
"stakeSen": 100,
"acceptedSen": 1793700000123,
"rejectedSen": 1793700000123,
"providerRef": "string",
"betNo": 1,
"receiptNo": 1
}
]
}
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
"ok": false,
"error": {
"code": "insufficient_prepaid",
"message": "The balance does not cover this stake."
}
}
Data types
Sen
Whole sen (RM 1.00 = 100).
integer (int64)
BetStatus
pending | sending | accepted | partial | refused | rejected | cancelled | check
PlaceBet
| Field | Type | Description |
|---|
ref required | string | Your idempotency key and reference. |
subAccount required | string | Your own reference for the player or site behind the bet. Stored and returned for matching. |
drawDate required | string (date) | Bets close 18:59 Malaysian time on the draw day. |
pools required | array of magnum | damacai | toto | singapore | sabah | sarawak | gd | nine | Pools to bet on (each pool costs the ticket total once). A 5D line is accepted only with toto; a 6D line only with toto, gd or nine (otherwise 400 bad_ticket). |
lines required | array of string | Bet slip lines in the FortuneX text format. Smallest stake on one number is RM 0.10. |
Order
| Field | Type | Description |
|---|
pool | string | |
status | sending | accepted | refused | uncertain | cancelled | |
stakeSen | integer (sen) | Whole sen (RM 1.00 = 100). |
acceptedSen | integer (int64), may be null | Whole sen (RM 1.00 = 100). |
rejectedSen | integer (int64), may be null | Whole sen (RM 1.00 = 100). |
providerRef | string | The reference FortuneX used for this order with the supplier. |
betNo | integer, may be null | The supplier's bet number, once accepted. |
receiptNo | integer, may be null | |
Bet
| Field | Type | Description |
|---|
ref | string | |
subAccount | string | |
sandbox | boolean | |
funding | prepaid | credit | sandbox | |
drawDate | string (date) | |
pools | array of string | |
lines | array of string | |
stakeSen | integer (sen) | Whole sen (RM 1.00 = 100). |
refundedSen | integer (sen) | Whole sen (RM 1.00 = 100). |
status | pending | sending | accepted | partial | refused | rejected | cancelled | check | |
statusText | string | |
reason | string, may be null | |
result | object | |
canCancel | boolean | |
cancelState | requested | cancelling | declined | failed, may be null | |
cancelNote | string, may be null | |
createdAt | integer (int64) | |
orders | array of Order | |
BetEnvelope
| Field | Type | Description |
|---|
ok | boolean | |
created | boolean | Only on placement. |
bet | Bet Bet | |
BetResult
| Field | Type | Description |
|---|
ref | string | |
drawDate | string (date) | |
state | pending | open | awaiting_result | settled | void | |
closed | boolean | |
status | pending | sending | accepted | partial | refused | rejected | cancelled | check | |
stakeSen | integer (sen) | Whole sen (RM 1.00 = 100). |
refundedSen | integer (sen) | Whole sen (RM 1.00 = 100). |
settled | boolean | |
winSen | integer (int64), may be null | Whole sen (RM 1.00 = 100). |
wins | array of object, may be null | |
settledAt | integer (int64), may be null | |
simulated | boolean | True for a sandbox bet. |
Balance
| Field | Type | Description |
|---|
sandbox | boolean | |
currency | string | |
prepaidBalanceSen | integer (int64), may be null | Whole sen (RM 1.00 = 100). |
games | array of object | |
asOf | integer (int64) | |
note | string | |
GamesInfo
| Field | Type | Description |
|---|
ok | boolean | |
sandbox | boolean | |
enabled | boolean | False when real 4D bets are off or FortuneX is in maintenance. |
funding | prepaid | credit | sandbox | |
currency | string | |
games | array of object | |
drawDates | array of object | |
limits | object | |
DrawResults
| Field | Type | Description |
|---|
ok | boolean | |
drawDate | string (date) | |
closed | boolean | |
pools | object | Official numbers per pool id, or null when not published yet. |
sixFirst | object | The 6D first prize for gd / nine where known. |
bets | array of object | |
totals | object | |
Statement
Final once issued. net = sales - refunds - wins. Positive net: you owe FortuneX; negative: FortuneX owes you.
| Field | Type | Description |
|---|
number | string | |
periodStart | integer (int64) | |
periodEnd | integer (int64) | |
periodLabel | string | |
salesSen | integer (sen) | Whole sen (RM 1.00 = 100). |
refundsSen | integer (sen) | Whole sen (RM 1.00 = 100). |
winsSen | integer (sen) | Whole sen (RM 1.00 = 100). |
netSen | integer (int64) | |
saleCount | integer | |
refundCount | integer | |
resultCount | integer | |
winCount | integer | |
byCategory | object | Sales, refunds, wins and net per game category (today only 4d). |
owes | operator | fortunex | nobody | |
amountSen | integer (sen) | Whole sen (RM 1.00 = 100). |
dueAt | integer (int64) | |
status | issued | paid | |
overdue | boolean | |
payment | object, may be null | |
issuedAt | integer (int64) | |
Versioning
This document describes version 1.0.0 of the operator API. Its base path is versioned (/v1); the OpenAPI file carries the document version, and this page is rebuilt from it whenever the documents change.
Back to top