Testing¶
How to prove your integration is correct before real money flows: a test account, a self-test script that calls your wallet exactly like 19flip does, the integration test and report run by 19flip, end-to-end checks with real games, and the connection test. Work through the sections in order; the go-live checklist at the end is what 19flip will ask you to confirm.
1. Get a test account¶
Ask 19flip for a test operator account. It has its own api_key, secret_key and callback_url, works exactly like a production account, and is excluded from invoices and business reports. Point its callback_url at your staging wallet.
2. Check your signature code¶
Before anything else, run your verification function against the test vectors in Authentication. If they do not pass, no callback will.
3. Run the wallet self-test¶
The script below plays the role of 19flip against your wallet: it serializes and signs bodies exactly as 19flip does and sends them to your five endpoints. It needs Python 3.10+ and requests.
Set a real test player with some balance (at least 10 units) in your wallet first.
#!/usr/bin/env python3
"""Self-test for a 19flip wallet. Usage:
CALLBACK_URL=https://casino.example.com/api/providers/19flip \
API_KEY=... SECRET_KEY=... PLAYER_ID=... CURRENCY=USD python3 wallet_selftest.py
"""
import hashlib
import json
import os
import uuid
from concurrent.futures import ThreadPoolExecutor
import requests
URL = os.environ["CALLBACK_URL"].rstrip("/")
API_KEY = os.environ["API_KEY"]
SECRET = os.environ["SECRET_KEY"]
PLAYER = os.environ["PLAYER_ID"]
CUR = os.environ.get("CURRENCY", "USD")
SESSION = str(uuid.uuid4())
def call(path, body, secret=SECRET):
raw = json.dumps(body) # same serialization as 19flip: ", " and ": " separators
sig = hashlib.sha256((raw + secret).encode("utf-8")).hexdigest()
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {API_KEY}",
"X-API-Key": API_KEY,
"X-Signature": sig,
}
r = requests.post(f"{URL}/{path}", data=raw.encode("utf-8"), headers=headers, timeout=10)
try:
return r.status_code, r.json()
except ValueError:
return r.status_code, r.text
def money(amount, round_id, key):
return {"player_id": PLAYER, "amount": amount, "currency": CUR, "round_id": round_id,
"session_id": SESSION, "idempotency_key": key, "game_id": "block-boost", "transaction_id": None}
def rollback(amount, round_id, original, key):
return {"player_id": PLAYER, "amount": amount, "currency": CUR, "round_id": round_id,
"session_id": SESSION, "original_transaction_id": original, "idempotency_key": key,
"reason": "self-test"}
def check(name, cond, detail=""):
print(("PASS " if cond else "FAIL ") + name + (f" {detail}" if not cond else ""))
def tx_ok(resp):
return isinstance(resp, dict) and resp.get("status") == "SUCCESS" and {
"transaction_id", "balance_before", "balance_after"} <= resp.keys()
k = lambda: f"selftest-{uuid.uuid4()}"
rnd = 900000 + uuid.uuid4().int % 99999
# Balance
s, b = call("balance", {"player_id": PLAYER, "currency": CUR, "session_id": SESSION})
check("balance: 200 flat JSON", s == 200 and isinstance(b, dict) and "balance" in b and "data" not in b, (s, b))
start = b["balance"]
# Healthcheck
s, b = call("balance", {"player_id": "healthcheck", "currency": CUR, "session_id": "healthcheck"})
check("healthcheck: 200", s == 200, (s, b))
# Bad signature
s, b = call("balance", {"player_id": PLAYER, "currency": CUR, "session_id": SESSION}, secret="wrong")
check("bad signature: 401", s == 401, (s, b))
# Debit + idempotent repeat
key = k()
s1, d1 = call("bet/debit", money(1.0, rnd, key))
s2, d2 = call("bet/debit", money(1.0, rnd, key))
check("debit: 200 SUCCESS", s1 == 200 and tx_ok(d1), (s1, d1))
check("debit repeat: identical answer", (s1, d1) == (s2, d2), (d1, d2))
s, b = call("balance", {"player_id": PLAYER, "currency": CUR, "session_id": SESSION})
check("debit applied once", round(start - b["balance"], 2) == 1.0, (start, b))
# Concurrent duplicates
key_c = k()
with ThreadPoolExecutor(5) as pool:
results = list(pool.map(lambda _: call("bet/debit", money(1.0, rnd, key_c)), range(5)))
ok = [r for r in results if r[0] == 200]
check("concurrent duplicates: one movement", len({json.dumps(r[1], sort_keys=True) for r in ok}) == 1, results)
s, b2 = call("balance", {"player_id": PLAYER, "currency": CUR, "session_id": SESSION})
check("concurrent duplicates: debited once", round(b["balance"] - b2["balance"], 2) == 1.0, (b, b2))
# Win credit + repeat
wkey = k()
s1, w1 = call("win/credit", money(2.5, rnd, wkey))
s2, w2 = call("win/credit", money(2.5, rnd, wkey))
check("win: 200 SUCCESS", s1 == 200 and tx_ok(w1), (s1, w1))
check("win repeat: identical answer", (s1, w1) == (s2, w2), (w1, w2))
# Bet rollback by your transaction_id, and its repeat
s, r1 = call("bet/rollback", rollback(1.0, rnd, d1["transaction_id"], k()))
check("bet rollback by transaction_id: 200 SUCCESS", s == 200 and tx_ok(r1), (s, r1))
check("bet rollback refunds", round(r1["balance_after"] - r1["balance_before"], 2) == 1.0, r1)
s, r2 = call("bet/rollback", rollback(1.0, rnd, d1["transaction_id"], k()))
check("second rollback of same bet moves no money",
s == 200 and r2.get("balance_after") == r2.get("balance_before") or r2 == r1, (s, r2))
# Bet rollback by the original idempotency_key
s, r3 = call("bet/rollback", rollback(1.0, rnd, key_c, k()))
check("bet rollback by idempotency_key: 200 SUCCESS", s == 200 and tx_ok(r3), (s, r3))
# Rollback of a bet you never saw
s, r4 = call("bet/rollback", rollback(1.0, rnd, f"never-sent-{uuid.uuid4()}", k()))
check("rollback of unknown bet: 200, no money moved",
s == 200 and r4.get("balance_after") == r4.get("balance_before"), (s, r4))
# Win rollback
s, r5 = call("win/rollback", rollback(2.5, rnd, w1["transaction_id"], k()))
check("win rollback: 200 SUCCESS", s == 200 and tx_ok(r5), (s, r5))
# Insufficient funds
s, f = call("bet/debit", money(99999999.0, rnd, k()))
check("insufficient funds: 402", s == 402, (s, f))
Expected result: every line prints PASS. The two checks about rollbacks of unknown or already rolled-back bets test the recommended behaviour from Wallet callbacks; if you chose a different policy, agree it with 19flip.
4. Integration test by 19flip¶
New in 2.7.0
This section is new.
19flip runs the same kind of checks as the self-test from its own servers, against your test account, and sends you a report (HTML or PDF). The report lists every check with what we expected, what your wallet did and how to fix it, with links to the relevant sections of these docs. The HTML version also contains each request body and your response.
What to give 19flip:
| Item | Why |
|---|---|
A test player_id and currency |
Money calls are made on this player. |
A balance of at least 3 × the test bet (the default bet is 1.00, wins are 2.00) |
Otherwise the money checks are skipped. |
Optionally, a session_id from launch-game for this player |
Needed only if your wallet accepts only session ids it issued. Otherwise 19flip sends a new random UUID. |
What happens on your wallet, in this order: /balance; the connection test (session_id "healthcheck"); /balance with a wrong X-Signature (expects 401); a debit and its repeat; five simultaneous copies of another debit; a win and its repeat; a rollback of the first debit by your transaction_id and a second rollback of it under a new key; a rollback of the concurrent debit by its idempotency_key; a rollback of a bet you never received; a rollback of the win; a debit far above the balance (expects 402); a final /balance.
Every movement is rolled back, so the player's balance ends where it started (the report checks this). All idempotency_key values of a run start with itest-, so you can find them in your ledger. Answers slower than 3 seconds are reported as a warning.
A run is Ready when every critical check passes. Warnings (healthcheck, rollback of an unknown bet, final balance, response time) are recommendations. Incomplete means nothing failed but some critical checks could not run, for example because the balance was too low.
5. End-to-end with real games¶
With your test account:
| Check | How | Expected |
|---|---|---|
| Launch works | launch-game for each game you will offer, mode LIVE |
200 with launch_url; the game opens |
| Balance shown | Open the game | The game shows the same balance as your wallet |
| Bets | Play a few rounds | One /bet/debit per paid action; your balance and the game's balance agree after every step |
| Wins | Win a round | /win/credit; balances agree |
| Insufficient funds | Set the player's balance below the minimum bet and try to bet | Your wallet answers 402; the game shows "not enough funds"; no money moves |
| Demo | Block Boost / Mister Twister with mode DEMO and demo_balance 1000 |
The game plays with 1000; no callbacks reach your wallet |
| Demo refused | Bingo Bongo with mode DEMO |
502 EXTERNAL_SERVICE_ERROR from launch-game |
| Late wins | Bingo Bongo: buy cards, close the tab before the draw ends | The win arrives after the player left; your wallet credits it |
| Errors | Launch with a wrong Api-Key, a disallowed currency, an unknown game_id |
401 AUTH_ERROR, 400 REQUEST_ERROR, 400 REQUEST_ERROR |
| Reconciliation | Compare your ledger with the list of session_id / idempotency_key values you received |
Every key appears once, with one money movement |
6. Connection test by 19flip¶
Ask 19flip to run Test connection on your account. It sends one signed /balance with "session_id": "healthcheck" (default "player_id": "healthcheck", "currency": "USD"), waits up to 5 seconds, and makes one attempt. It passes on any 2xx; 19flip sees your HTTP status, latency and response body. Details: Wallet callbacks.
Go-live checklist¶
- [ ] Signature verified against raw body bytes; test vectors pass.
- [ ] Both
Authorization: BearerandX-API-Keyaccepted; key compared in constant time. - [ ] Plan for key rotation: can accept two
api_keyvalues and twosecret_keyvalues at once. - [ ] All five endpoints answer with flat JSON and HTTP
200on success. - [ ]
idempotency_keyhas a UNIQUE constraint; repeats return the stored answer; concurrent duplicates move money once. - [ ] Insufficient funds answered with
402; no other4xxmessage containsbalanceorinsufficient. - [ ] Valid wins are never refused, whatever the age of the session.
- [ ] Rollbacks find the original by your
transaction_idor by itsidempotency_key; a bet is refunded at most once. - [ ] p99 latency of your wallet well below 10 seconds.
- [ ]
healthcheckbalance call answered with200. - [ ] Your production egress IPs given to 19flip if you want an IP allowlist.
- [ ] Monitoring alerts on
401from signature failures and on5xxfrom your wallet.