Integrate with a coding agent
Paste this prompt into Claude Code, Cursor, Codex or a similar agent that works in your wallet backend. It contains the full callback spec and tells the agent how to run this tester.
# Task: integrate this codebase with the Jackpot Studio RGS
Jackpot Studio is a remote game server (RGS) for casino games. To offer its games, this backend must expose five signed HTTP "wallet callback" endpoints that the RGS calls to authenticate players and to debit, credit and roll back bets. Implement them in this codebase and verify them with the Jackpot Studio Integration Tester.
The full specification is below. If you can fetch URLs, the same content is at https://staging-integration.jackpot.studio/llms-full.txt and the OpenAPI schema is at https://staging-integration.jackpot.studio/openapi.yaml.
## How to work
1. Study the codebase before you write code. Find:
- the web framework and how routes, middleware and request validation are written;
- the player, account, balance or ledger model, and how money is stored (decimal or minor units);
- how database transactions and row locks are done;
- existing test setup.
Reuse these patterns. Do not add a second ledger if one exists: debits, credits and rollbacks must go through the existing balance logic so that the player's balance stays correct everywhere.
2. If something you need is not clear from the code (for example which table holds the balance, or which currency field to use), ask me before you guess.
3. Implement, in this order:
1. HMAC verification middleware on the raw request body, for all five routes.
2. A transactions table (or reuse an existing one) with a unique key on the RGS `transactionId`.
3. The five endpoints, with idempotency and the rollback rules.
4. Add automated tests for: signature accept/reject, stale timestamp, insufficient funds, idempotent replay, duplicate `transactionId` with different parameters, rollback of an unknown debit followed by the late debit, and concurrent identical debits.
5. Read the HMAC secret from configuration (for example `JACKPOT_HMAC_SECRET`). Never commit a secret.
6. When the code is deployed to a reachable URL, run the Integration Tester (JSON API below) and fix every `fail`. Then tell me the result. If you cannot reach the tester, give me the exact steps to run it.
## Wallet callback contract
The Jackpot Studio RGS (remote game server) holds no player money. It calls five HTTP endpoints on the operator's backend ("wallet callbacks") to authenticate players and move money. The operator implements these endpoints under one base URL (the "callback URL"):
| Endpoint | Purpose |
|---|---|
| `POST {base}/callback/authenticate` | Verify the player on game launch and return the balance |
| `POST {base}/callback/balance` | Return the current balance |
| `POST {base}/callback/debit` | Take a bet from the balance |
| `POST {base}/callback/credit` | Pay a win into the balance (amount `"0"` for a loss) |
| `POST {base}/callback/rollback` | Reverse an earlier debit |
Machine-readable schema: https://staging-integration.jackpot.studio/openapi.yaml
### Transport rules
- All calls are `POST` with `Content-Type: application/json` and a compact JSON body. Rejecting other content types is optional hardening.
- Answer every callback you process, success or error, with HTTP `200` and a JSON body that has a `status` field. The RGS treats any other HTTP status as a failed call, whatever the body says:
- a debit answered that way is rolled back, and the player sees a generic error instead of, for example, "insufficient funds";
- a credit or rollback answered that way is retried until it gets a `200`.
- Exception: a request that fails signature verification may be refused either way. Recommended: HTTP `401` with body `{"requestId":"<echo if known>","status":"ERROR_INVALID_SIGNATURE"}`.
- Never return HTTP `5xx` because of bad input (very long strings, unknown fields, SQL/HTML fragments, huge numbers, Unicode). Validate first and return an error status.
- Answer in less than 10 seconds. After a timeout the RGS rolls a debit back, and retries a credit or rollback.
- Ignore unknown JSON fields and unknown headers. The RGS can add fields at any time. The RGS also sends an `X-Operator-ID` header with your operator UUID; you may check it, but it is not signed.
- Always echo `requestId` from the request in the response.
- Amounts and balances are **decimal strings** (`"1.50"`), never JSON numbers. Parse them with a decimal or integer-minor-unit type. Never use binary floating point for money.
- Never put request values (for example `playerId`) into the response body or into error messages without escaping. A `playerId` of `<script>alert(1)</script>` must not appear raw in the response.
- Use parameterized SQL for every query.
### Request signing (HMAC-SHA256)
Every callback carries two headers:
| Header | Value |
|---|---|
| `X-Timestamp` | Unix time in seconds, as a decimal string |
| `X-HMAC-SHA256` | Lowercase hex HMAC-SHA256 of the message below, keyed with the shared HMAC secret |
```
message = X-Timestamp + path + body
```
- `path` is the literal endpoint path, for example `/callback/debit`. It does **not** include any path prefix of the operator's base URL. If the base URL is `https://api.example.com/rgs`, the request goes to `/rgs/callback/debit`, but the signed path is still `/callback/debit`.
- `body` is the raw request body bytes, exactly as received. The RGS sends compact JSON (no whitespace outside strings).
- Do not parse and re-serialize the body before you verify it. Key order, Unicode escapes (`<`) and number format can change, and then the signature does not match.
- Capture the raw body before any JSON body parser consumes it (for example `express.raw`/`verify` callback in Node, `request.get_data()` in Flask, `await request.body()` in FastAPI, `io.ReadAll(r.Body)` in Go).
- Refuse the request (recommended: HTTP `401`, `ERROR_INVALID_SIGNATURE`) when any of these is true:
- A header is missing.
- `X-Timestamp` is not an integer, or `abs(now - X-Timestamp) > 30` seconds.
- The signature does not match. Compare with a constant-time function (`hmac.Equal`, `crypto.timingSafeEqual`, `hmac.compare_digest`).
- Verify the signature on **all five** endpoints, `authenticate` and `balance` included.
- Read the secret from configuration (environment variable or secret store). Never hard-code or commit it.
Reference implementation (Go):
```go
func verify(r *http.Request, body []byte, secret, path string) bool {
ts := r.Header.Get("X-Timestamp")
sig := r.Header.Get("X-HMAC-SHA256")
t, err := strconv.ParseInt(ts, 10, 64)
if err != nil || sig == "" {
return false
}
if d := time.Now().Unix() - t; d > 30 || d < -30 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts))
mac.Write([]byte(path)) // "/callback/debit", not r.URL.Path if you mount under a prefix
mac.Write(body) // raw bytes
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(strings.ToLower(sig)))
}
```
Reference implementation (Node.js):
```js
const crypto = require("node:crypto");
function verify(req, rawBody /* Buffer */, secret, path /* "/callback/debit" */) {
const ts = req.get("X-Timestamp");
const sig = req.get("X-HMAC-SHA256") || "";
if (!/^\d+$/.test(ts || "") || Math.abs(Date.now() / 1000 - Number(ts)) > 30) return false;
const expected = crypto.createHmac("sha256", secret)
.update(ts).update(path).update(rawBody).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(sig.toLowerCase());
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```
### Status codes
Put one of these values in the response `status` field:
| Status | Use it when |
|---|---|
| `OK` | The operation succeeded (or was already applied with the same parameters) |
| `ERROR_NOT_ENOUGH_MONEY` | The balance is less than the debit amount |
| `ERROR_PLAYER_DISABLED` | The player account is locked, disabled or self-excluded |
| `ERROR_INVALID_SESSION` | The player is unknown |
| `ERROR_SESSION_EXPIRED` | The session expired (never use it on credit or rollback) |
| `ERROR_INVALID_GAME` | The game is unknown or disabled |
| `ERROR_WRONG_CURRENCY` | The currency does not match the player's wallet |
| `ERROR_DUPLICATE_TRANSACTION` | The `transactionId` was already used with **different** parameters |
| `ERROR_TRANSACTION_DOES_NOT_EXIST` | A rollback targets a debit you never processed. The RGS accepts this or `OK` (see rollback) |
| `ERROR_INVALID_SIGNATURE` | The HMAC check failed (recommended with HTTP `401`) |
| `ERROR_WRONG_SYNTAX` | The JSON is malformed, a required field is missing, or a value is invalid (for example a negative amount) |
| `ERROR_UNKNOWN` | Any other error. On a debit, the RGS treats it as ambiguous and sends a rollback; every other non-OK debit status is a final rejection with no rollback |
### Endpoints
#### `POST /callback/authenticate`
Request: `{"requestId":"…","playerId":"…","currency":"USD","gameCode":"dice-alpha"}`
Response: `{"requestId":"…","status":"OK","balance":"100.00","accountCurrency":"USD"}`
- Optional response fields: `accountCurrency`, `name`, `countryCode` (ISO 3166-1 alpha-2), `languageCode` (ISO 639-1), `birthDate` (`YYYY-MM-DD`), `registrationDate` (`YYYY-MM-DD`), `address`, `excluded` (boolean; `true` refuses the launch for a self-excluded player), `excludedUntil` (`YYYY-MM-DD`).
- If you send `accountCurrency`, it must equal the request `currency`.
- `balance` must equal what `/callback/balance` returns for the same player.
- Unknown or empty `playerId`: return a non-OK status (`ERROR_INVALID_SESSION`).
- The RGS calls authenticate once when the player launches a game, before any bet. `gameCode` is the full game code (slug plus RTP suffix, for example `dice-alpha` or `blackjack-leo`) and is forwarded unchanged on later debits. If you keep a game session per player, open it here.
- If the game is not enabled for this operator, return `ERROR_INVALID_GAME`.
#### `POST /callback/balance`
Request: `{"requestId":"…","playerId":"…"}`
Response: `{"requestId":"…","status":"OK","balance":"100.00"}`
- Unknown or empty `playerId`: return a non-OK status.
#### `POST /callback/debit`
Request: `{"requestId":"…","playerId":"…","transactionId":"…","roundId":"…","gameCode":"dice-alpha","amount":"1.50","metadata":"{}"}`
Response: `{"requestId":"…","status":"OK","balance":"98.50"}` (balance **after** the debit). Optional `customUserError`: a message the game shows to the player when you reject the debit.
- `amount` must be greater than 0. Reject negative amounts with `ERROR_WRONG_SYNTAX`. Rejecting `"0"` is recommended (the tester gives a warning if you accept it).
- `amount` larger than the balance: return HTTP `200` with `ERROR_NOT_ENOUGH_MONEY` and do not change the balance. Only this answer lets the RGS tell the player that the balance is too low.
- Amounts with more decimal places than the currency allows, or values beyond your numeric range (for example `"99999999999999999999.99"`), must not cause a 5xx. Return an error status.
- If a rollback for this `transactionId` arrived earlier (see rollback), do not take the money: return a non-OK status and leave the balance unchanged. (Alternatively, take it and reverse it at once; the balance must end unchanged.)
#### `POST /callback/credit`
Request: `{"requestId":"…","playerId":"…","transactionId":"…","roundId":"…","roundClosed":true,"gameId":"dice-alpha","amount":"3.00","metadata":"{}"}`
Response: `{"requestId":"…","status":"OK","balance":"101.50"}` (balance **after** the credit).
- Note the field name is `gameId` here (it is `gameCode` on debit).
- `amount` can be `"0"` (a loss). Accept it, return `OK` and the current balance.
- Reject negative amounts with `ERROR_WRONG_SYNTAX`.
- A round can have more than one debit and more than one credit (blackjack sends an extra debit for each double, split or insurance). Only the call with `roundClosed: true` ends the round.
- A credit must succeed even if the player's session expired or the player was disabled after the bet. Never return `ERROR_SESSION_EXPIRED` for a credit.
#### `POST /callback/rollback`
Request: `{"requestId":"…","playerId":"…","transactionId":"…","reverseTransactionId":"…","roundId":"…","roundClosed":true,"gameId":"dice-alpha","metadata":"{}"}`
Response: `{"requestId":"…","status":"OK","balance":"100.00"}` (balance **after** the rollback).
- `transactionId` is the ID of the rollback itself. `reverseTransactionId` is the `transactionId` of the debit to reverse.
- If the debit exists: give its amount back to the player, mark the debit as reversed, return `OK`.
- If the debit was already reversed: do not give the money back again. Return `OK`.
- If the debit is unknown: return HTTP `200` with `OK` (or `ERROR_TRANSACTION_DOES_NOT_EXIST`) and the unchanged balance, **and** store a marker that `reverseTransactionId` is cancelled. The RGS sends a rollback when a debit call timed out, so the debit can arrive later. A later debit with that `transactionId` must not take money.
- A rollback must succeed even if the session expired. The RGS retries a rollback until it gets HTTP `200` with `OK` or `ERROR_TRANSACTION_DOES_NOT_EXIST`; any other answer keeps it retrying.
### Idempotency and concurrency
The RGS retries a credit or rollback with the same `transactionId` after a timeout or a non-200 answer. It does not resend a failed debit; it rolls the debit back instead. Still treat every `transactionId` as an idempotency key.
- Store every processed `transactionId` with its operation type and parameters (`playerId`, `roundId`, `amount`, `reverseTransactionId`) and the response you sent.
- Same `transactionId`, same parameters: return `OK` with the stored response. Do not apply the operation again.
- Same `transactionId`, different parameters: return `ERROR_DUPLICATE_TRANSACTION` (recommended) or the original result. Never move money again.
- `requestId` is unique per HTTP request (retries get a new `requestId`). Do not use it for idempotency.
- Do the idempotency check, the balance change and the transaction record in **one database transaction**. Lock the player's balance row (`SELECT … FOR UPDATE`) or use an atomic conditional update, and put a unique constraint on `transactionId`. The tester sends 5 identical debits at the same time and expects exactly one deduction. It also sends a debit and its rollback at the same time.
- The endpoints must handle at least 20 concurrent requests for the same player without errors.
## Integration tester
The Jackpot Studio Integration Tester (https://staging-integration.jackpot.studio) plays the role of the RGS. It sends signed callbacks to the operator's wallet and checks the answers. A human can use the web form. An agent can use the JSON API below.
### Before a run
- The wallet must be reachable from the public internet over HTTPS. A local dev server needs a tunnel (for example `cloudflared tunnel --url http://localhost:3000` or `ngrok http 3000`).
- The tester calls the wallet only from these IP addresses. If the wallet or its firewall uses an IP allowlist, add all of them:
- `18.158.57.148`
- `63.188.86.202`
- Create a test player in the wallet with a balance of at least `100000.00` in the run currency (default `USD`). The tests use amounts of `50`–`300` and leave small net changes. Reset the balance between runs if needed.
- Enable game `dice-alpha` for the test player. Enable `blackjack-leo` too if the operator offers blackjack; otherwise the multi-debit round test is skipped.
- The tester authenticates the player once per run, before the first bet, like a game launch.
- Use a staging HMAC secret, never a production secret.
### JSON API
Start a run. `operatorId` (sent as `X-Operator-ID`) and `currency` (default `USD`) are optional. An empty or missing `testIds` and `categories` runs all tests:
```bash
curl -sS -X POST https://staging-integration.jackpot.studio/api/v1/runs \
-H 'Content-Type: application/json' \
-d '{"baseUrl":"https://wallet.example.com","hmacSecret":"…","playerId":"test-player-1","currency":"USD","categories":["Happy Path"]}'
```
```json
{"id":"3f9c1a2b","status":"running","statusUrl":"https://staging-integration.jackpot.studio/api/v1/runs/3f9c1a2b","reportUrl":"https://staging-integration.jackpot.studio/report/3f9c1a2b"}
```
Get the result. `wait` (seconds, max 50) blocks until the run completes or the time ends:
```bash
curl -sS 'https://staging-integration.jackpot.studio/api/v1/runs/3f9c1a2b?wait=50'
```
```json
{
"id": "3f9c1a2b",
"status": "complete",
"summary": {"total": 7, "passed": 6, "failed": 1, "warnings": 0, "skipped": 0, "durationMs": 2310},
"results": [
{
"id": 2, "name": "Debit reduces balance", "category": "Happy Path", "status": "fail", "durationMs": 310,
"error": "",
"assertions": [{"label": "Balance reduced by debit amount", "pass": false, "expected": "900", "got": "1000"}],
"calls": [{"method": "POST", "url": "https://wallet.example.com/callback/debit", "status": 200, "durationMs": 120,
"request": "{…}", "response": "{…}", "error": ""}]
}
]
}
```
- `status` of the run: `running` or `complete`. Repeat the GET while it is `running`.
- `status` of a test: `pass`, `fail`, `warning` (passes, with an `advisory` suggestion) or `skip`.
- `calls` holds the raw HTTP exchange of each test. Use it to see what the wallet returned.
- `reportUrl` is a printable HTML report for humans. It opens only after the run is `complete`.
- `GET https://staging-integration.jackpot.studio/api/v1/tests` lists all tests with ID, name and category.
- Runs are kept in memory only. They disappear when the tester restarts.
### Test catalog
**Happy Path**
- 1: Balance query
- 2: Debit reduces balance
- 3: Credit increases balance
- 4: Full round
- 5: Rollback restores balance
- 6: Multi-action round
- 7: Zero-win round
- 39: Authenticate successful
- 40: Authenticate returns balance
**Idempotency**
- 8: Idempotent debit replay
- 9: Idempotent credit replay
- 10: Idempotent rollback replay
- 43: Duplicate debit different params
- 44: Duplicate credit different params
- 45: Duplicate rollback different params
**Error Handling**
- 11: Insufficient funds
- 12: Rollback unknown tx
- 42: Authenticate invalid player
**HMAC Security**
- 15: Wrong HMAC signature
- 16: Mismatched body HMAC
- 17: Tampered body
- 29: Replay attack (expired timestamp)
- 41: Authenticate HMAC required
**Attack Vectors**
- 18: Negative debit amount
- 19: Negative credit amount
- 20: Zero debit amount
- 21: Overflow amount
- 22: SQL injection in playerId
- 23: XSS in fields
- 24: Missing required fields
- 25: Extra unknown fields
- 26: Wrong Content-Type
- 27: Concurrent duplicates
**Edge Cases**
- 30: Very long strings
- 31: Unicode/emoji
- 32: Decimal precision
- 33: Empty playerId
- 34: Rapid requests
**Debit/Rollback Ordering**
- 35: Rollback before debit
- 37: Concurrent debit and rollback
- 38: Late debit after confirmed rollback
### What the tests check
A "refusal" is either a non-200 HTTP status or HTTP `200` with a non-OK `status`. The tester accepts both where the RGS does. The rules in the contract above are the recommended behavior; a `warning` result means the wallet works with the RGS but misses that recommendation.
- **Happy Path**: balance, authenticate, debit, credit, zero-amount credit, rollback and a two-debit blackjack round give the exact expected balances. Authenticate balance equals balance endpoint. The blackjack round is skipped if authenticate for `blackjack-leo` is refused.
- **Idempotency**: an identical replay of a debit or credit does not move money again. A replayed rollback must settle (HTTP `200` with `OK` or `ERROR_TRANSACTION_DOES_NOT_EXIST`) and refund only once. A reused `transactionId` with a different amount or target may be refused or answered with the original result, but must not move money again.
- **Error Handling**: a debit of balance + 1 is refused (`warning` unless it is HTTP `200` with `ERROR_NOT_ENOUGH_MONEY`). A rollback of an unknown debit settles. An unknown player on authenticate is refused.
- **HMAC Security**: a wrong signature, a signature of a different body, and a tampered body are each refused. A fresh, valid request gets `200`. A timestamp 60 seconds old should be refused (`warning` if accepted).
- **Attack Vectors**: negative debit/credit amounts are refused. A zero debit should be refused (`warning`). Huge amounts, SQL injection, and XSS strings in `playerId` do not cause a 5xx, and `<script>` is not reflected. A debit without `requestId` and `amount` is refused. Unknown fields are ignored. `Content-Type: text/plain` must not cause a 5xx (`warning` if it is processed). Five identical concurrent debits deduct once.
- **Edge Cases**: a 10,000-character `playerId`, Unicode in fields, and amounts `"0.001"` and `"999999999.99"` do not cause a 5xx. An empty `playerId` is refused. 20 concurrent balance requests all return `OK`.
- **Debit/Rollback Ordering**: a rollback before its debit settles and does not change the balance. The late debit then must not reduce the balance. A concurrent debit and rollback leave a consistent balance.