SkyExch Iframe
Add a complete sports exchange to your site with one iframe. Live odds, in-play markets, bookmaker and fancy markets, bet placement, settlement and full player reports all run on our side. You add a button, one server-side API call, and a way to move money.
One API call to launch
Your server calls POST /v1/sessions and gets a one-time launchUrl. No passwords, no second login.
Three wallet modes
Move money yourself (TRANSFER) or let us pull it from your wallet (AUTO, SEAMLESS).
Everything inside
Cricket, soccer, tennis and more; My Bets, P&L, account statement, activity log.
Mobile first
Built for phones; fills any width. Works in Chrome, Safari (iOS) and in-app web views.
| API base URL | https://fair247.vip/sportsframe-gw |
|---|---|
| Your iframe host | Assigned at onboarding, e.g. https://acme-ai.6xwkts.com |
| Auth | HMAC-SHA256 signed requests, server to server (details) |
| Format | JSON over HTTPS, UTF-8 |
| Currency | Agreed at onboarding (e.g. BDT), amounts with at most 2 decimals |
| SDK | /sdk/skyexch-iframe.js (no dependencies, ~4 KB) |
Live demo #
Click Demo login. This page asks our demo backend for a launchUrl, exactly as your server would with POST /v1/sessions, and mounts it with the SDK. You get a demo player with 1,000 play balance and can browse and place bets on live markets. The panel logs every event the iframe sends.
The demo player is kept in this browser and topped back up to 1,000 on each demo login (a few times a day). Demo balances are play money for evaluation only. Your production integration uses your own credentials and players.
Quick start #
- Get credentials. We send you an API key, an API secret and your iframe host (onboarding). Keep the secret on your server only.
- Add one backend endpoint on your site, e.g.
POST /api/sports/launch, that runs for the logged-in player and calls ourPOST /v1/sessions:// npm i express (Node 18+, uses the built-in fetch) const crypto = require("crypto"); const API = "https://fair247.vip/sportsframe-gw"; const KEY = process.env.SKYEXCH_API_KEY, SECRET = process.env.SKYEXCH_API_SECRET; async function skyexch(method, path, body) { const raw = body ? JSON.stringify(body) : ""; const ts = String(Math.floor(Date.now() / 1000)); const nonce = crypto.randomBytes(16).toString("hex"); const sig = crypto.createHmac("sha256", SECRET).update(`${ts}\n${nonce}\n${method}\n${path}\n${raw}`).digest("hex"); const res = await fetch(API + path, { method, body: body ? raw : undefined, headers: { "Content-Type": "application/json", "X-Operator-Key": KEY, "X-Timestamp": ts, "X-Nonce": nonce, "X-Signature": sig } }); const json = await res.json(); if (!json.ok) throw Object.assign(new Error(json.error.code), { status: res.status, error: json.error }); return json.data; } // Your route. req.user is YOUR logged-in player (never trust an id from the browser). app.post("/api/sports/launch", requireLogin, async (req, res) => { const data = await skyexch("POST", "/v1/sessions", { externalPlayerId: String(req.user.id), parentOrigin: "https://www.acme.com", displayName: req.user.name, }); res.json({ launchUrl: data.launchUrl }); });<?php const SX_API = 'https://fair247.vip/sportsframe-gw'; function skyexch($method, $path, $body = null) { $raw = $body === null ? '' : json_encode($body); $ts = (string) time(); $nonce = bin2hex(random_bytes(16)); $sig = hash_hmac('sha256', "$ts\n$nonce\n$method\n$path\n$raw", getenv('SKYEXCH_API_SECRET')); $ch = curl_init(SX_API . $path); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Operator-Key: ' . getenv('SKYEXCH_API_KEY'), "X-Timestamp: $ts", "X-Nonce: $nonce", "X-Signature: $sig"], ]); if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, $raw); // the SAME bytes you signed $json = json_decode(curl_exec($ch), true); if (empty($json['ok'])) throw new Exception($json['error']['code'] ?? 'NETWORK_ERROR'); return $json['data']; } // Your route, for the logged-in player only. $data = skyexch('POST', '/v1/sessions', [ 'externalPlayerId' => (string) $currentUser->id, 'parentOrigin' => 'https://www.acme.com', 'displayName' => $currentUser->name, ]); header('Content-Type: application/json'); echo json_encode(['launchUrl' => $data['launchUrl']]);# pip install requests import hmac, hashlib, json, os, secrets, time, requests API = "https://fair247.vip/sportsframe-gw" def skyexch(method, path, body=None): raw = "" if body is None else json.dumps(body, separators=(",", ":")) ts, nonce = str(int(time.time())), secrets.token_hex(16) sig = hmac.new(os.environ["SKYEXCH_API_SECRET"].encode(), f"{ts}\n{nonce}\n{method}\n{path}\n{raw}".encode(), hashlib.sha256).hexdigest() r = requests.request(method, API + path, data=raw.encode() if body is not None else None, timeout=10, headers={ "Content-Type": "application/json", "X-Operator-Key": os.environ["SKYEXCH_API_KEY"], "X-Timestamp": ts, "X-Nonce": nonce, "X-Signature": sig}) j = r.json() if not j.get("ok"): raise RuntimeError(j["error"]["code"]) return j["data"] # Your view, for the logged-in player only. data = skyexch("POST", "/v1/sessions", {"externalPlayerId": str(user.id), "parentOrigin": "https://www.acme.com", "displayName": user.name}) return {"launchUrl": data["launchUrl"]} - Show the iframe on your Sports page with the SDK:
<div id="sports" style="height: calc(100dvh - 56px)"></div> <script src="https://docs.6xwkts.com/sdk/skyexch-iframe.js"></script> <script> async function newLaunchUrl() { const r = await fetch("/api/sports/launch", { method: "POST", credentials: "include" }); return (await r.json()).launchUrl; } (async () => { const frame = SkyExchIframe.mount({ container: "#sports", launchUrl: await newLaunchUrl(), onSessionEnded: async () => frame.relaunch(await newLaunchUrl()), }); })(); </script> - Move money. TRANSFER mode: call
/v1/wallet/depositand/v1/wallet/withdrawfrom your "Transfer to Sports" screen. AUTO / SEAMLESS: expose the four wallet callbacks instead. - Run the test plan on staging, then go live.
How it works #
- A
launchUrlworks once and expires after 60 seconds. Create it right before you render the iframe. - Creating a new session ends the player's previous one (another tab or device). One player has one active sports session.
- The player is created on our side the first time you use their
externalPlayerId. Our id for them is<OPERATORCODE>_<EXTERNALID>(e.g.ACME_PLAYER42); it shows in the iframe header and reports. - Your API secret stays on your server. The browser only ever sees the
launchUrl.
Onboarding #
You send us
| Item | Example | Why |
|---|---|---|
| Operator code (2–8 letters/digits) | ACME | Prefix of your players' ids on our side |
| Every page origin that embeds the iframe | https://www.acme.com, https://m.acme.com | Only these can launch sessions and receive events |
| Server egress IPs (recommended) | 203.0.113.10 | API calls from other IPs are refused |
| Currency | BDT | Wallet currency |
| Wallet mode | TRANSFER / AUTO / SEAMLESS | Wallet modes |
| Callback base URL (AUTO / SEAMLESS) | https://api.acme.com/skyexch | Where we call your wallet endpoints |
| Idle timeout (AUTO, optional) | 10 minutes | When an inactive session's money goes back |
| Staging origin (optional) | https://staging.acme.com | Testing before going live |
You receive
| Item | Example |
|---|---|
| API base URL | https://fair247.vip/sportsframe-gw |
| API key | sif_… (public identifier) |
| API secret | 43 random characters. Server-side only. Shown once. |
| Callback secret (AUTO / SEAMLESS) | We sign our calls to your wallet with it. Server-side only. |
| Your iframe host | https://acme-ai.6xwkts.com |
| Operator float (TRANSFER) | Prepaid balance credited by our team |
Wallet modes #
Each player has a sports wallet with us. Bets are placed from it and settle into it. You choose at onboarding how money gets in and out:
| Mode | Who moves money | Player sees | You build |
|---|---|---|---|
| TRANSFER | You, with deposit / withdraw API calls, from your operator float | Two balances: main wallet and sports wallet | API calls + a "Transfer to Sports" screen |
| AUTO | We do. When the iframe opens we take the player's whole balance from your wallet; when they leave (you call revoke, or idle timeout) we give back everything not in an open bet. Later settlements follow automatically. | One balance | 4 wallet endpoints |
| SEAMLESS | We do, per bet. Before each bet we take what the bet can lose at most; seconds later we return what wasn't needed; winnings follow as bets settle. | One balance, always in your wallet | 4 wallet endpoints (fast: called on every bet) |
Which one? TRANSFER if your site already has wallet transfer screens. AUTO for one balance with the fewest calls to you (one debit per session). SEAMLESS if money must stay in your wallet until the moment of a bet.
Authentication #
Every /v1/* request is signed with HMAC-SHA256 using your API secret.
| Header | Value |
|---|---|
X-Operator-Key | Your API key |
X-Timestamp | Current Unix time in seconds; must be within ±300 s of our clock (keep NTP on) |
X-Nonce | 8–64 random chars [A-Za-z0-9_-], new for every request |
X-Signature | Lowercase hex HMAC-SHA256(apiSecret, canonical) |
Content-Type | application/json |
canonical is five parts joined by a newline (\n):
<X-Timestamp>
<X-Nonce>
<HTTP method, upper case>
<path + query string, starting at /v1, e.g. /v1/players/p1/statement?from=2026-10-01&to=2026-10-31>
<the exact request body bytes; empty string for GET>The path in the signature starts at /v1/…, without the /sportsframe-gw prefix of the base URL. Sign the exact body string you send: serialising the JSON twice can change the bytes and break the signature.
const crypto = require("crypto");
function signedHeaders(apiKey, apiSecret, method, path, rawBody = "") {
const ts = String(Math.floor(Date.now() / 1000));
const nonce = crypto.randomBytes(16).toString("hex");
const sig = crypto.createHmac("sha256", apiSecret)
.update(`${ts}\n${nonce}\n${method}\n${path}\n${rawBody}`).digest("hex");
return { "Content-Type": "application/json", "X-Operator-Key": apiKey,
"X-Timestamp": ts, "X-Nonce": nonce, "X-Signature": sig };
}function sx_headers($apiKey, $apiSecret, $method, $path, $rawBody = '') {
$ts = (string) time();
$nonce = bin2hex(random_bytes(16));
$sig = hash_hmac('sha256', "$ts\n$nonce\n$method\n$path\n$rawBody", $apiSecret);
return ['Content-Type: application/json', "X-Operator-Key: $apiKey",
"X-Timestamp: $ts", "X-Nonce: $nonce", "X-Signature: $sig"];
}import hmac, hashlib, secrets, time
def sx_headers(api_key, api_secret, method, path, raw_body=""):
ts, nonce = str(int(time.time())), secrets.token_hex(16)
msg = f"{ts}\n{nonce}\n{method}\n{path}\n{raw_body}".encode()
sig = hmac.new(api_secret.encode(), msg, hashlib.sha256).hexdigest()
return {"Content-Type": "application/json", "X-Operator-Key": api_key,
"X-Timestamp": ts, "X-Nonce": nonce, "X-Signature": sig}KEY=sif_xxx; SECRET=xxx; API=https://fair247.vip/sportsframe-gw
BODY='{"externalPlayerId":"player42","parentOrigin":"https://www.acme.com"}'
TS=$(date +%s); NONCE=$(openssl rand -hex 16)
SIG=$(printf '%s\n%s\n%s\n%s\n%s' "$TS" "$NONCE" POST /v1/sessions "$BODY" \
| openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -s "$API/v1/sessions" -H "Content-Type: application/json" -H "X-Operator-Key: $KEY" \
-H "X-Timestamp: $TS" -H "X-Nonce: $NONCE" -H "X-Signature: $SIG" -d "$BODY"Test vector: secret test-secret, timestamp 1700000000, nonce abcdef0123456789, GET /v1/operator, empty body → canonical "1700000000\nabcdef0123456789\nGET\n/v1/operator\n" → signature 1f577ee544ee42d975ee48b2b1d11b67a5c7344e51e1dee7feccf3a9097afaf6. Check your helper against it before the first real call.
Requests & responses #
Every response is JSON with ok:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "PLAYER_NOT_FOUND", "message": "Unknown externalPlayerId", "details": {} } }Branch on error.code, never on message.
| Field | Rule |
|---|---|
externalPlayerId | Your player id. 1–64 chars A-Z a-z 0-9 _ . @ : -. Case-sensitive. Never reuse one for a different person. |
transactionId | Your unique id for a transfer. 1–64 chars A-Z a-z 0-9 _ . : -. |
amount | Positive number, at most 2 decimals. |
parentOrigin | Origin of the page that embeds the iframe, exactly as the browser reports it: https://www.acme.com (no path, no trailing slash). Must be registered. |
POST/v1/sessions #
Launch the iframe for a player. Creates the player on first use. Call it each time the player opens Sports.
| Field | Type | |
|---|---|---|
externalPlayerId | string, required | Your player id |
parentOrigin | string, required | The page origin that embeds the iframe |
displayName | string, optional | Up to 64 chars, used when the player is first created |
startPage | string, optional | Default in-play; see start pages |
// request
{ "externalPlayerId": "player42", "parentOrigin": "https://www.acme.com", "displayName": "Ravi", "startPage": "in-play" }
// 200 response
{ "ok": true, "data": {
"launchUrl": "https://acme-ai.6xwkts.com/launch#code=Qm9...",
"expiresInSeconds": 60, "playerCreated": false, "clientId": "ACME_PLAYER42" } }Errors: PARENT_ORIGIN_NOT_ALLOWED, PLAYER_BLOCKED, PLAYER_BUSY (retry in a second).
POST/v1/sessions/revoke #
Log a player out of sports. Call it when the player logs out of your site, closes Sports, or when you block them. The open iframe stops working on its next request and sends session-ended.
{ "externalPlayerId": "player42" }
// 200
{ "ok": true, "data": { "revoked": true, "returnedToOperator": 650 } }AUTO / SEAMLESS: revoke also returns the player's withdrawable sports balance to your wallet immediately (a credit callback); returnedToOperator is that amount (always 0 in TRANSFER). Money in open bets follows as bets settle.
POST/v1/wallet/deposit #
TRANSFER mode only. Moves amount from your operator float to the player's sports wallet. Creates the player if needed. In AUTO / SEAMLESS it answers 403 WALLET_MODE_MISMATCH.
// request
{ "externalPlayerId": "player42", "amount": 500, "transactionId": "dep-7f3c2a" }
// 200 response
{ "ok": true, "data": {
"transactionId": "dep-7f3c2a", "externalPlayerId": "player42", "direction": "DEPOSIT",
"amount": 500, "status": "SUCCESS", "failCode": null, "playerBalance": 500,
"createdAt": "2026-10-31T10:15:00.000Z", "replayed": false } }playerBalance is the player's withdrawable balance afterwards. Errors: OPERATOR_INSUFFICIENT_FUNDS (422, top up your float), TRANSACTION_ID_REUSED (409). See safe transfers.
POST/v1/wallet/withdraw #
TRANSFER mode only. Same body and response as deposit, with "direction": "WITHDRAW". Moves money from the player's sports wallet back to your float. You can withdraw up to the player's withdrawable balance: money in open bets and bonus-locked money stay.
Errors: PLAYER_NOT_FOUND (404), PLAYER_INSUFFICIENT_FUNDS (422), TRANSACTION_ID_REUSED (409).
Withdraw everything: read the balance, then withdraw withdrawable. If a bet settles in between you get PLAYER_INSUFFICIENT_FUNDS and nothing moves: read again and retry with a new transactionId.
GET/v1/wallet/transactions/{transactionId} #
Status of a transfer: the same object as the deposit/withdraw response. 404 TRANSACTION_NOT_FOUND means we never received it, so it is safe to send again with the same id. In AUTO / SEAMLESS you can also look up the transactionIds we sent you in callbacks (in-, bet-, out-).
GET/v1/players/{externalPlayerId}/balance #
{ "ok": true, "data": { "externalPlayerId": "player42", "currency": "BDT",
"available": 1250, "exposure": 300, "bonusLocked": 0, "withdrawable": 1250 } }| Field | Meaning |
|---|---|
available | What the player can still bet with (the iframe header balance) |
exposure | Money held against open bets (worst-case loss) |
bonusLocked | Bonus money not yet released by wagering |
withdrawable | What /wallet/withdraw accepts now: available − bonusLocked |
GET/v1/players/{externalPlayerId}/statement #
Query: from=YYYY-MM-DD&to=YYYY-MM-DD&limit=100&offset=0. The player's balance history (deposits, withdrawals, bet settlements), newest first. Dates are IST (Asia/Kolkata), to is inclusive, limit max 500. Remember to include the query string in the signed path.
{ "ok": true, "data": { "externalPlayerId": "player42", "timezone": "Asia/Kolkata", "limit": 100, "offset": 0,
"rows": [ { "time": "2026-10-31 15:45:00", "type": "Deposit", "credit": 500, "debit": 0, "balance": 500, "remark": "Deposit" } ] } }GET/v1/operator #
Your operator float (TRANSFER). Alert yourself well before it runs out: deposits fail with OPERATOR_INSUFFICIENT_FUNDS once it does.
{ "ok": true, "data": { "operatorCode": "ACME", "currency": "BDT", "float": 248500 } }POST/v1/players #
Optional pre-creation. Sessions and deposits already create players.
{ "externalPlayerId": "player42", "displayName": "Ravi" }
// 200
{ "ok": true, "data": { "externalPlayerId": "player42", "clientId": "ACME_PLAYER42", "created": true } }Error codes #
| HTTP | code | Meaning / what to do |
|---|---|---|
| 400 | INVALID_REQUEST | A field is missing or invalid; see message |
| 401 | INVALID_SIGNATURE | Bad key or signature, clock off by more than 300 s, or a reused nonce |
| 403 | IP_NOT_ALLOWED | Call from an unregistered IP |
| 403 | OPERATOR_SUSPENDED | Your account is suspended; contact us |
| 403 | PARENT_ORIGIN_NOT_ALLOWED | parentOrigin is not registered for you |
| 403 | WALLET_MODE_MISMATCH | Deposit/withdraw called in AUTO / SEAMLESS |
| 403 | PLAYER_BLOCKED | The player is blocked on our side |
| 404 | PLAYER_NOT_FOUND | No such externalPlayerId yet |
| 404 | TRANSACTION_NOT_FOUND | We never received this transactionId |
| 409 | TRANSACTION_ID_REUSED | Same transactionId with a different player, direction or amount |
| 409 | PLAYER_BUSY | Player being created by a parallel request; retry in a second |
| 422 | OPERATOR_INSUFFICIENT_FUNDS | Float too low for this deposit |
| 422 | PLAYER_INSUFFICIENT_FUNDS | Amount is more than withdrawable |
| 422 | PLAYER_NOT_UNDER_OPERATOR | Account configuration issue; contact us |
| 429 | – | Rate limit (per IP); back off and retry |
| 500 | INTERNAL_ERROR | Safe to retry (transfers: with the same transactionId) |
A 422 transfer is final. Retrying the same transactionId returns the same 422 and moves no money. To try again, use a new id.
Embedding the iframe #
Use the SDK (recommended) or a plain iframe:
<iframe src="LAUNCH_URL" title="Sports" style="border:0;width:100%;height:100dvh"
allow="autoplay; fullscreen; encrypted-media; picture-in-picture; clipboard-write"
allowfullscreen referrerpolicy="no-referrer"></iframe>- Give the frame the full viewport height on mobile (
100dvhminus your header). The exchange scrolls inside it. - The report pages use a table layout and read best at ≥ 768 px width.
- Do not add
sandbox. If your policy requires it, use at leastallow-scripts allow-same-origin allow-forms allow-popups. - If your page has a CSP, allow framing your iframe host:
frame-src https://acme-ai.6xwkts.com. - To relaunch an iframe that is already on
/launch, change something before the#:…/launch?r=123#code=…. A URL that differs only after#does not reload. - Native apps: load your own web page that embeds the iframe in a WebView with JavaScript and DOM storage enabled.
JavaScript SDK #
Load https://docs.6xwkts.com/sdk/skyexch-iframe.js or host a copy yourself (no dependencies). It exposes window.SkyExchIframe (alias window.SportsFrame) and works as a CommonJS/ESM import.
const frame = SkyExchIframe.mount({
container: "#sports", // element or CSS selector
launchUrl, // from your backend (POST /v1/sessions)
autoHeight: false, // true: iframe grows with its content and your page scrolls
minHeight: 600, // with autoHeight
onEvent: (type, payload) => console.log(type, payload),
onSessionEnded: async (payload, type) => frame.relaunch(await newLaunchUrl()),
});
frame.navigate("my-bets"); // open a page (see Start pages)
frame.refreshBalance(); // reload the header balance (after a deposit/withdraw)
frame.logout(); // end the session in the iframe (also call /v1/sessions/revoke)
frame.relaunch(newUrl); // new launchUrl after session-ended / launch-failed
frame.destroy(); // remove the iframe and listenersRate-limit automatic relaunches (e.g. once per 30 s) so a persistent failure cannot loop. Each relaunch() needs a new launchUrl.
Events (postMessage) #
Messages from the iframe are { source: "sportsframe", type, payload }, sent only to the session's parentOrigin. The SDK checks the origin for you; without it, check event.origin === "https://acme-ai.6xwkts.com" and event.source === iframe.contentWindow.
From the iframe
type | payload | When |
|---|---|---|
ready | { path, page } | App loaded with a session |
route | { path, page } | The player moved to another page |
resize | { height } | Content height changed (used by autoHeight) |
session-ended | { reason } | Session replaced by a newer launch, revoked, expired, or logged out |
launch-failed | { code } | LAUNCH_CODE_INVALID (expired/used), NETWORK_ERROR, STORAGE_UNAVAILABLE, LAUNCH_CODE_MISSING |
navigation-blocked | { path } | The player hit a link outside sports (e.g. the home logo). You may route them to your own page. |
To the iframe
iframe.contentWindow.postMessage({ source: "sportsframe", type, payload }, iframeOrigin) (the SDK methods wrap these):
type | payload | Effect |
|---|---|---|
navigate | { page } | Opens a page |
refresh-balance | {} | Reloads the header balance |
logout | {} | Ends the session in the iframe |
Start pages #
Values for startPage and navigate:
| Page | Shows |
|---|---|
in-play | Live events (default) |
sports | All sports and upcoming events |
multi-market | Pinned markets |
account | Account menu |
my-bets | Open bets |
bet-history | Settled bets |
profit-loss | Profit & loss by market |
account-statement | Account statement |
balance-overview | Balance overview |
activity-log | Login / activity log |
my-profile | Profile |
TRANSFER: safe transfers #
transactionId is the idempotency key. Sending the same deposit or withdrawal again (same id, player, direction and amount) moves money once; repeats return the first result with "replayed": true.
Deposit (main wallet → sports)
- In your database, in one transaction: debit the player's main wallet and insert a pending transfer row with a new
transactionId. - Call
POST /v1/wallet/depositwith that id. 200→ mark the row done.422→ final failure: refund the main wallet, mark failed.- Timeout, network error or
5xx→ do not refund yet. Retry the same call, or checkGET /v1/wallet/transactions/{id}:200= it happened,404= it did not, resend. - Call
frame.refreshBalance()so the iframe header updates at once.
Withdraw (sports → main wallet)
The mirror: call us first; credit the main wallet only after a 200. On timeout, resolve with the status lookup before crediting.
// Node.js deposit with a pending row (pseudo-DB calls)
async function transferToSports(user, amount) {
const transactionId = `dep-${crypto.randomUUID()}`;
await db.tx(async (t) => {
await t.debitMainWallet(user.id, amount);
await t.insertTransfer({ transactionId, userId: user.id, amount, status: "PENDING" });
});
try {
await skyexch("POST", "/v1/wallet/deposit", { externalPlayerId: String(user.id), amount, transactionId });
await db.markTransfer(transactionId, "DONE");
} catch (e) {
if (e.status === 422) { // final: nothing moved on our side
await db.tx(async (t) => { await t.creditMainWallet(user.id, amount); await t.markTransfer(transactionId, "FAILED"); });
throw e;
}
// timeout / 5xx: leave PENDING; a job resolves it with GET /v1/wallet/transactions/{id}
throw e;
}
}AUTO / SEAMLESS: wallet callbacks #
In these modes we call you. Expose four POST endpoints under your callback base URL:
| Endpoint | We send | You do |
|---|---|---|
{base}/balance | externalPlayerId, currency | Return the player's spendable balance. No side effects. |
{base}/debit | externalPlayerId, currency, amount, transactionId, reason | Take amount from the player's wallet |
{base}/credit | externalPlayerId, currency, amount, transactionId, reason, refTransactionId | Add amount to the player's wallet |
{base}/rollback | externalPlayerId, currency, transactionId, refTransactionId, reason | Undo debit refTransactionId |
reason: launch (AUTO, iframe opened), bet (SEAMLESS, before a bet), session-end (revoke), idle (AUTO timeout), settlement (bets settled / unused bet money), refund (a debit we could not use).
Authentication of our calls
Same signing scheme as your calls to us, with your callback secret as the key, X-Operator-Key = your API key, and the path = the full path of your endpoint (e.g. /skyexch/debit). Reject bad signatures, timestamps outside ±300 s and reused nonces with HTTP 401.
Responses
200 { "ok": true, "data": { "balance": 1350.00 } } // applied; balance after the operation
4xx { "ok": false, "error": { "code": "INSUFFICIENT_FUNDS" } } // refused, NOT applied| You answer | We treat it as |
|---|---|
200 with ok: true | Applied. data.balance is required on every endpoint. |
4xx with an error.code | Definitely not applied (INSUFFICIENT_FUNDS, PLAYER_BLOCKED, TRANSACTION_ROLLED_BACK…) |
5xx, timeout (8 s), invalid body | Unknown: it may or may not have been applied |
Answer within 2 seconds (SEAMLESS: the player is waiting on a bet).
Rules your wallet must follow
- Idempotent by
transactionId. The same id again must not apply twice; return the original answer, including an earlierINSUFFICIENT_FUNDS. - Credits must always succeed (except signature errors). We retry a credit until you return
200: after 10 s, 20 s, 40 s … up to every 15 minutes, sametransactionId. - Rollback: if debit
refTransactionIdwas applied, refund it; if not, do nothing. Either way answer200, and from then on refuse that debit withTRANSACTION_ROLLED_BACKif it arrives late. A rollback has its own idempotenttransactionId. - We roll back unknown debits: a debit answered with 5xx or a timeout is rolled back, never retried, so it can't take money twice.
- Never go negative: answer
INSUFFICIENT_FUNDSinstead.
What happens when
| AUTO | SEAMLESS | |
|---|---|---|
| Iframe opens | balance, then debit of the whole balance (launch). Refused / no answer → player starts with 0 in sports. | No money moves. |
| Each bet | Nothing (money is already in sports). | balance, then debit of min(bet's max loss − money already in sports, your balance) (bet). |
| Player leaves | You call revoke, or idle timeout → credit of everything withdrawable (session-end / idle). | ~20 s after the last bet: credit of everything withdrawable (settlement). |
| Bets settle later | credits with settlement. | credits with settlement. |
Example handler (Node.js)
// Express. Every operation is ONE database transaction; walletTx.transactionId is UNIQUE.
app.post("/skyexch/:op(balance|debit|credit|rollback)", express.text({ type: "*/*" }), async (req, res) => {
if (!verifySignature(req, CALLBACK_SECRET)) return res.status(401).json({ ok: false, error: { code: "INVALID_SIGNATURE" } });
const b = JSON.parse(req.body);
const out = await db.tx(async (t) => {
if (req.params.op === "balance") return [200, { ok: true, data: { balance: await t.balance(b.externalPlayerId) } }];
const seen = await t.findWalletTx(b.transactionId); // idempotency
if (seen) return [seen.status, seen.body];
let r;
if (req.params.op === "debit") {
if (await t.isRolledBack(b.transactionId)) r = [409, { ok: false, error: { code: "TRANSACTION_ROLLED_BACK" } }];
else if (await t.balance(b.externalPlayerId) < b.amount) r = [422, { ok: false, error: { code: "INSUFFICIENT_FUNDS" } }];
else { await t.debit(b.externalPlayerId, b.amount); r = [200, { ok: true, data: { balance: await t.balance(b.externalPlayerId) } }]; }
} else if (req.params.op === "credit") {
await t.credit(b.externalPlayerId, b.amount);
r = [200, { ok: true, data: { balance: await t.balance(b.externalPlayerId) } }];
} else { // rollback
await t.refundDebitIfApplied(b.refTransactionId); // and mark it rolled back
r = [200, { ok: true, data: { balance: await t.balance(b.externalPlayerId) } }];
}
await t.saveWalletTx(b.transactionId, r[0], r[1]);
return r;
});
res.status(out[0]).json(out[1]);
});
function verifySignature(req, secret) {
const ts = req.get("X-Timestamp"), nonce = req.get("X-Nonce"), sig = req.get("X-Signature") || "";
if (req.get("X-Operator-Key") !== API_KEY || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
if (!rememberNonce(nonce)) return false; // reject reused nonces
const expected = crypto.createHmac("sha256", secret).update(`${ts}\n${nonce}\nPOST\n${req.originalUrl}\n${req.body}`).digest("hex");
return sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}Reconciliation #
- TRANSFER: daily, compare your transfers with
GET /v1/wallet/transactions/{id}or the playerstatement. Resolve anything pending for more than a few minutes with a status lookup. - AUTO / SEAMLESS: each of our
transactionIds is one movement on both sides. Prefixes:in-(AUTO launch debit),bet-(SEAMLESS debit),out-(credit back),refund-(credit of an unused debit),rb-(rollback). Compare your wallet journal with ours daily.
Security checklist #
- Keep the API secret and callback secret on your servers. If one leaks, tell us and we rotate it at once.
- Create sessions only for the player logged in to your site. Never accept
externalPlayerIdfrom the browser. launchUrlis single-use and valid for 60 s: do not log, cache or send it by email/SMS.- Call
/v1/sessions/revokewhen a player logs out of your site or you block them. - Register your server IPs so calls from anywhere else are refused.
- Check
event.originon every message if you do not use the SDK.
Test plan #
On staging (we register your staging origin and fund a test float):
- A new player opens Sports →
playerCreated: true; the iframe opens logged in on In-Play. - Deposit 100 → the header shows it after
refreshBalance(). - Same deposit again (same
transactionId) →replayed: true, float unchanged. - Withdraw more than
withdrawable→PLAYER_INSUFFICIENT_FUNDS, nothing moved. - Place a bet; check My Bets; after settlement check Profit & Loss and Account Statement.
- Open Sports in a second tab → the first gets
session-ended; your page relaunches. - Reuse an old
launchUrl→launch-failedwithLAUNCH_CODE_INVALID. - Safari (iOS) and Chrome (Android): open, bet, background the app, come back.
- Kill the network during a deposit → resolve it with
GET /v1/wallet/transactions/{id}.
AUTO / SEAMLESS (instead of 2–4 and 9):
- AUTO: open Sports with 500 in your wallet → your wallet 0, iframe header 500;
revoke→ 500 back (± results). - AUTO: leave the iframe open in a hidden tab past the idle timeout → money returns, the iframe sends
session-ended. - SEAMLESS: place a 100 bet → one
debitof at most the max loss; within ~30 s acreditof the unused part. - Answer one
debitwith HTTP 500 → you receive arollback; your balance is unchanged. - Answer one
creditwith HTTP 500 → the sametransactionIdcomes again and is applied once. - Call your endpoints with a wrong signature → you answer 401.
Go-live: production credentials, production origins registered, IP allowlist set, float funded (TRANSFER), reconciliation running.
Full example (Node.js, TRANSFER) #
A complete minimal operator: login stub, launch endpoint, transfer endpoints, and the Sports page.
// server.js — node server.js (Node 18+, npm i express)
const express = require("express");
const crypto = require("crypto");
const app = express();
app.use(express.json());
const API = "https://fair247.vip/sportsframe-gw";
const KEY = process.env.SKYEXCH_API_KEY, SECRET = process.env.SKYEXCH_API_SECRET;
const ORIGIN = process.env.PUBLIC_ORIGIN; // e.g. https://www.acme.com (registered with us)
async function skyexch(method, path, body) {
const raw = body ? JSON.stringify(body) : "";
const ts = String(Math.floor(Date.now() / 1000)), nonce = crypto.randomBytes(16).toString("hex");
const sig = crypto.createHmac("sha256", SECRET).update(`${ts}\n${nonce}\n${method}\n${path}\n${raw}`).digest("hex");
const res = await fetch(API + path, { method, body: body ? raw : undefined, signal: AbortSignal.timeout(10000),
headers: { "Content-Type": "application/json", "X-Operator-Key": KEY, "X-Timestamp": ts, "X-Nonce": nonce, "X-Signature": sig } });
const json = await res.json();
if (!json.ok) throw Object.assign(new Error(json.error.code), { status: res.status });
return json.data;
}
// Replace with your real session middleware.
const requireLogin = (req, res, next) => { req.user = { id: "player42", name: "Ravi" }; next(); };
app.post("/api/sports/launch", requireLogin, async (req, res) => {
try {
const d = await skyexch("POST", "/v1/sessions", { externalPlayerId: req.user.id, parentOrigin: ORIGIN, displayName: req.user.name });
res.json({ launchUrl: d.launchUrl });
} catch (e) { res.status(502).json({ error: e.message }); }
});
app.post("/api/sports/deposit", requireLogin, async (req, res) => {
// Debit your main wallet + insert a PENDING row first (see "TRANSFER: safe transfers").
const transactionId = `dep-${crypto.randomUUID()}`;
try { res.json(await skyexch("POST", "/v1/wallet/deposit", { externalPlayerId: req.user.id, amount: Number(req.body.amount), transactionId })); }
catch (e) { res.status(e.status || 502).json({ error: e.message }); }
});
app.post("/api/sports/withdraw", requireLogin, async (req, res) => {
const transactionId = `wd-${crypto.randomUUID()}`;
try { res.json(await skyexch("POST", "/v1/wallet/withdraw", { externalPlayerId: req.user.id, amount: Number(req.body.amount), transactionId })); }
catch (e) { res.status(e.status || 502).json({ error: e.message }); }
});
app.post("/api/logout", requireLogin, async (req, res) => {
await skyexch("POST", "/v1/sessions/revoke", { externalPlayerId: req.user.id }).catch(() => {});
res.json({ ok: true });
});
app.get("/sports", (req, res) => res.send(`<!doctype html>
<meta name="viewport" content="width=device-width, initial-scale=1">
<body style="margin:0">
<div id="sports" style="height:100dvh"></div>
<script src="https://docs.6xwkts.com/sdk/skyexch-iframe.js"></script>
<script>
const newUrl = async () => (await (await fetch("/api/sports/launch", { method: "POST" })).json()).launchUrl;
let last = 0;
(async () => {
const frame = SkyExchIframe.mount({ container: "#sports", launchUrl: await newUrl(),
onSessionEnded: async () => { if (Date.now() - last > 30000) { last = Date.now(); frame.relaunch(await newUrl()); } } });
})();
</script>`));
app.listen(8080, () => console.log("http://localhost:8080/sports"));FAQ #
Do players need a separate login?
No. Your backend vouches for the player with POST /v1/sessions.
What about third-party cookie blocking?
The iframe uses no cookies. It keeps its session in its own storage; if a browser blocks that too, it keeps it in memory and everything works until a reload, then you get session-ended and relaunch.
Can a player have money in sports and in my main wallet at once?
In TRANSFER mode, yes: the two wallets are separate until you move money. In AUTO / SEAMLESS the player sees one balance.
Where do I see a player's bets?
Inside the iframe (My Bets, Bets History, Profit & Loss). For your back office use the statement endpoint; contact us if you need a bet-level feed.
My requests fail with INVALID_SIGNATURE
Check, in order: the clock (±300 s), a new nonce per request, the signed path starts at /v1 and includes the query string, and you sign the exact body bytes you send.
Which languages are supported?
The UI is English today. lang is reserved in /v1/sessions.