Wallet API
Endpoints de callback que o operador deve implementar para gestão de saldo em tempo real
Visão Geral
No modelo seamless wallet, o operador mantém o saldo do jogador no próprio sistema. Durante o jogo, o FourPlay Mines chama os endpoints do operador para consultar e atualizar o saldo em tempo real.
Você deve implementar quatro endpoints POST no seu servidor:
| Endpoint | Quando chamado |
|---|---|
POST {callbackUrl}/wallet/balance | Antes de cada rodada — consulta o saldo |
POST {callbackUrl}/wallet/debit | Quando o jogador confirma uma aposta |
POST {callbackUrl}/wallet/credit | Quando o jogador faz cashout ou ganha |
POST {callbackUrl}/wallet/rollback | Desfaz um debit anterior em caso de erro |
O callbackUrl é a URL base do seu servidor que você informou à FourPlay ao se registrar como operador.
Autenticação dos callbacks: todos os callbacks chegam com os headers X-Operator-Id, X-Timestamp e X-Signature. Valide a assinatura HMAC antes de processar. Veja Authentication → Validando Callbacks.
Tempo de resposta
Seus callbacks devem responder em menos de 5 segundos. Respostas mais lentas são tratadas como timeout e podem acionar um rollback automático.
POST /wallet/balance
Consultado pelo Mines antes de cada rodada para verificar o saldo atual do jogador.
Request do FourPlay
Headers: X-Operator-Id, X-Timestamp, X-Signature
{
"externalPlayerId": "player_123"
}| Campo | Tipo | Descrição |
|---|---|---|
externalPlayerId | string | ID do jogador que você definiu ao criar sessão |
Response esperada (200 OK)
{
"balance": 50000
}| Campo | Tipo | Descrição |
|---|---|---|
balance | number | Saldo atual do jogador em centavos (ex: 50000 = R$500,00) |
Exemplo de Implementação
// Express
app.post('/wallet/balance', validateHmac, async (req, res) => {
const { externalPlayerId } = req.body;
const player = await db.players.findOne({ externalId: externalPlayerId });
if (!player) return res.status(404).json({ message: 'Player not found' });
res.json({ balance: player.balanceCents });
});<?php
// /wallet/balance
$rawBody = file_get_contents('php://input');
if (!validateHmacCallback(getallheaders(), $rawBody)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
$body = json_decode($rawBody, true);
$player = getPlayerByExternalId($body['externalPlayerId']);
if (!$player) {
http_response_code(404);
echo json_encode(['message' => 'Player not found']);
exit;
}
header('Content-Type: application/json');
echo json_encode(['balance' => $player['balance_cents']]);# Flask
@app.route('/wallet/balance', methods=['POST'])
def wallet_balance():
raw_body = request.get_data()
if not validate_callback(dict(request.headers), raw_body):
return jsonify({'error': 'Invalid signature'}), 401
data = request.get_json()
player = db.get_player_by_external_id(data['externalPlayerId'])
if not player:
return jsonify({'message': 'Player not found'}), 404
return jsonify({'balance': player['balance_cents']})app.MapPost("/wallet/balance", async (HttpContext ctx) =>
{
var rawBody = await new StreamReader(ctx.Request.Body).ReadToEndAsync();
if (!CallbackValidator.Validate(ctx.Request, rawBody))
return Results.Unauthorized();
var data = JsonSerializer.Deserialize<JsonElement>(rawBody);
var id = data.GetProperty("externalPlayerId").GetString()!;
var player = await db.GetPlayerAsync(id);
if (player is null) return Results.NotFound(new { message = "Player not found" });
return Results.Ok(new { balance = player.BalanceCents });
});POST /wallet/debit
Chamado quando o jogador confirma uma aposta. Você deve debitar o valor do saldo e retornar o novo saldo e um ID de transação.
Request do FourPlay
{
"externalPlayerId": "player_123",
"amount": 1000,
"gameId": "game_abc123xyz",
"type": "DEBIT"
}| Campo | Tipo | Descrição |
|---|---|---|
externalPlayerId | string | ID do jogador |
amount | number | Valor a debitar em centavos |
gameId | string | ID único da rodada — use como chave de idempotência |
type | string | Sempre "DEBIT" |
Response esperada (200 OK)
{
"balance": 49000,
"transactionId": "tx_sua_referencia_interna"
}| Campo | Tipo | Descrição |
|---|---|---|
balance | number | Saldo do jogador após o debit (em centavos) |
transactionId | string | Referência da transação no seu sistema (para auditoria) |
Erros possíveis
| HTTP | Body | Quando usar |
|---|---|---|
| 422 | { "message": "Insufficient balance" } | Saldo insuficiente |
| 404 | { "message": "Player not found" } | Jogador não existe |
Idempotência obrigatória
Se receber o mesmo gameId novamente (retry), retorne 200 com os dados da transação original sem processar o debit duas vezes. Nunca retorne erro em caso de gameId duplicado.
Exemplo de Implementação
app.post('/wallet/debit', validateHmac, async (req, res) => {
const { externalPlayerId, amount, gameId, type } = req.body;
// Idempotência: retornar resultado existente para mesmo gameId
const existing = await db.transactions.findOne({ gameId, type: 'DEBIT' });
if (existing) {
const player = await db.players.findOne({ externalId: externalPlayerId });
return res.json({ balance: player.balanceCents, transactionId: existing.id });
}
// Debitar
const player = await db.players.findOneAndUpdate(
{ externalId: externalPlayerId, balanceCents: { $gte: amount } },
{ $inc: { balanceCents: -amount } },
{ returnDocument: 'after' }
);
if (!player) return res.status(422).json({ message: 'Insufficient balance' });
const tx = await db.transactions.create({ gameId, type: 'DEBIT', amount, playerId: player._id });
res.json({ balance: player.balanceCents, transactionId: tx.id });
});<?php
// POST /wallet/debit
$rawBody = file_get_contents('php://input');
if (!validateHmacCallback(getallheaders(), $rawBody)) {
http_response_code(401); echo json_encode(['error' => 'Invalid signature']); exit;
}
$body = json_decode($rawBody, true);
$gameId = $body['gameId'];
$amount = (int)$body['amount'];
$playerId = $body['externalPlayerId'];
// Idempotência
$existing = getTransactionByGameId($gameId, 'DEBIT');
if ($existing) {
$player = getPlayerByExternalId($playerId);
echo json_encode(['balance' => $player['balance_cents'], 'transactionId' => $existing['id']]);
exit;
}
// Tentar debitar (atomicamente)
$result = debitPlayer($playerId, $amount);
if (!$result) {
http_response_code(422);
echo json_encode(['message' => 'Insufficient balance']); exit;
}
$txId = createTransaction($gameId, 'DEBIT', $amount, $playerId);
header('Content-Type: application/json');
echo json_encode(['balance' => $result['new_balance_cents'], 'transactionId' => $txId]);@app.route('/wallet/debit', methods=['POST'])
def wallet_debit():
raw_body = request.get_data()
if not validate_callback(dict(request.headers), raw_body):
return jsonify({'error': 'Invalid signature'}), 401
data = request.get_json()
game_id = data['gameId']
amount = int(data['amount'])
# Idempotência
existing = db.get_transaction(game_id, 'DEBIT')
if existing:
player = db.get_player(data['externalPlayerId'])
return jsonify({'balance': player['balance_cents'], 'transactionId': existing['id']})
# Debitar
result = db.debit_player(data['externalPlayerId'], amount)
if not result:
return jsonify({'message': 'Insufficient balance'}), 422
tx_id = db.create_transaction(game_id, 'DEBIT', amount)
return jsonify({'balance': result['new_balance'], 'transactionId': tx_id})app.MapPost("/wallet/debit", async (HttpContext ctx) =>
{
var rawBody = await new StreamReader(ctx.Request.Body).ReadToEndAsync();
if (!CallbackValidator.Validate(ctx.Request, rawBody))
return Results.Unauthorized();
var data = JsonSerializer.Deserialize<JsonElement>(rawBody);
var gameId = data.GetProperty("gameId").GetString()!;
var amount = data.GetProperty("amount").GetInt64();
var extId = data.GetProperty("externalPlayerId").GetString()!;
// Idempotência
var existing = await db.GetTransactionAsync(gameId, "DEBIT");
if (existing is not null)
{
var p = await db.GetPlayerAsync(extId);
return Results.Ok(new { balance = p!.BalanceCents, transactionId = existing.Id });
}
var result = await db.DebitPlayerAsync(extId, amount);
if (result is null) return Results.UnprocessableEntity(new { message = "Insufficient balance" });
var txId = await db.CreateTransactionAsync(gameId, "DEBIT", amount, extId);
return Results.Ok(new { balance = result.NewBalance, transactionId = txId });
});POST /wallet/credit
Chamado quando o jogador faz cashout (vitória). Você deve creditar o valor ao saldo.
Request do FourPlay
{
"externalPlayerId": "player_123",
"amount": 3500,
"gameId": "game_abc123xyz",
"type": "CREDIT"
}| Campo | Tipo | Descrição |
|---|---|---|
externalPlayerId | string | ID do jogador |
amount | number | Valor a creditar em centavos |
gameId | string | ID da rodada — mesma referência do debit |
type | string | Sempre "CREDIT" |
Response esperada (200 OK)
{
"balance": 52500,
"transactionId": "tx_credit_referencia"
}| Campo | Tipo | Descrição |
|---|---|---|
balance | number | Saldo do jogador após o crédito (em centavos) |
transactionId | string | Referência da transação no seu sistema |
Idempotência
Implemente idempotência usando gameId + type como chave, da mesma forma que no /debit.
Exemplo de Implementação
app.post('/wallet/credit', validateHmac, async (req, res) => {
const { externalPlayerId, amount, gameId } = req.body;
const existing = await db.transactions.findOne({ gameId, type: 'CREDIT' });
if (existing) {
const player = await db.players.findOne({ externalId: externalPlayerId });
return res.json({ balance: player.balanceCents, transactionId: existing.id });
}
const player = await db.players.findOneAndUpdate(
{ externalId: externalPlayerId },
{ $inc: { balanceCents: amount } },
{ returnDocument: 'after' }
);
const tx = await db.transactions.create({ gameId, type: 'CREDIT', amount });
res.json({ balance: player.balanceCents, transactionId: tx.id });
});<?php
// POST /wallet/credit
$rawBody = file_get_contents('php://input');
if (!validateHmacCallback(getallheaders(), $rawBody)) {
http_response_code(401); echo json_encode(['error' => 'Invalid signature']); exit;
}
$body = json_decode($rawBody, true);
$gameId = $body['gameId'];
$amount = (int)$body['amount'];
$existing = getTransactionByGameId($gameId, 'CREDIT');
if ($existing) {
$player = getPlayerByExternalId($body['externalPlayerId']);
echo json_encode(['balance' => $player['balance_cents'], 'transactionId' => $existing['id']]);
exit;
}
$result = creditPlayer($body['externalPlayerId'], $amount);
$txId = createTransaction($gameId, 'CREDIT', $amount, $body['externalPlayerId']);
header('Content-Type: application/json');
echo json_encode(['balance' => $result['new_balance_cents'], 'transactionId' => $txId]);@app.route('/wallet/credit', methods=['POST'])
def wallet_credit():
raw_body = request.get_data()
if not validate_callback(dict(request.headers), raw_body):
return jsonify({'error': 'Invalid signature'}), 401
data = request.get_json()
game_id = data['gameId']
amount = int(data['amount'])
existing = db.get_transaction(game_id, 'CREDIT')
if existing:
player = db.get_player(data['externalPlayerId'])
return jsonify({'balance': player['balance_cents'], 'transactionId': existing['id']})
result = db.credit_player(data['externalPlayerId'], amount)
tx_id = db.create_transaction(game_id, 'CREDIT', amount)
return jsonify({'balance': result['new_balance'], 'transactionId': tx_id})app.MapPost("/wallet/credit", async (HttpContext ctx) =>
{
var rawBody = await new StreamReader(ctx.Request.Body).ReadToEndAsync();
if (!CallbackValidator.Validate(ctx.Request, rawBody)) return Results.Unauthorized();
var data = JsonSerializer.Deserialize<JsonElement>(rawBody);
var gameId = data.GetProperty("gameId").GetString()!;
var amount = data.GetProperty("amount").GetInt64();
var extId = data.GetProperty("externalPlayerId").GetString()!;
var existing = await db.GetTransactionAsync(gameId, "CREDIT");
if (existing is not null)
{
var p = await db.GetPlayerAsync(extId);
return Results.Ok(new { balance = p!.BalanceCents, transactionId = existing.Id });
}
var result = await db.CreditPlayerAsync(extId, amount);
var txId = await db.CreateTransactionAsync(gameId, "CREDIT", amount, extId);
return Results.Ok(new { balance = result!.NewBalance, transactionId = txId });
});POST /wallet/rollback
Chamado quando ocorre um erro após um debit — por exemplo, timeout ao processar o resultado do jogo. Você deve reverter o debit original e retornar o saldo restaurado.
Request do FourPlay
{
"externalPlayerId": "player_123",
"amount": 1000,
"gameId": "game_abc123xyz",
"type": "ROLLBACK"
}| Campo | Tipo | Descrição |
|---|---|---|
externalPlayerId | string | ID do jogador |
amount | number | Valor a reverter (o mesmo do debit) em centavos |
gameId | string | ID da rodada — mesma referência do debit |
type | string | Sempre "ROLLBACK" |
Response esperada (200 OK)
O FourPlay não lê o body da resposta do rollback — apenas verifica que o status HTTP é 200. Retorne qualquer JSON válido ou {}.
{}Erros possíveis:
| HTTP | Body | Quando usar |
|---|---|---|
| 404 | { "message": "Transaction not found" } | Se não houver debit para o gameId recebido |
Idempotência
Se receber um rollback para um gameId que já foi revertido, retorne 200. Não retorne erro para rollbacks duplicados.
Exemplo de Implementação
app.post('/wallet/rollback', validateHmac, async (req, res) => {
const { externalPlayerId, amount, gameId } = req.body;
// Verificar se já foi revertido (idempotência)
const alreadyRolledBack = await db.transactions.findOne({ gameId, type: 'ROLLBACK' });
if (alreadyRolledBack) return res.json({});
// Verificar se existe o debit original
const debit = await db.transactions.findOne({ gameId, type: 'DEBIT' });
if (!debit) return res.status(404).json({ message: 'Transaction not found' });
// Reverter: creditar de volta
await db.players.updateOne(
{ externalId: externalPlayerId },
{ $inc: { balanceCents: amount } }
);
await db.transactions.create({ gameId, type: 'ROLLBACK', amount });
res.json({});
});<?php
// POST /wallet/rollback
$rawBody = file_get_contents('php://input');
if (!validateHmacCallback(getallheaders(), $rawBody)) {
http_response_code(401); echo json_encode(['error' => 'Invalid signature']); exit;
}
$body = json_decode($rawBody, true);
$gameId = $body['gameId'];
$amount = (int)$body['amount'];
// Idempotência: já revertido
if (getTransactionByGameId($gameId, 'ROLLBACK')) {
echo json_encode([]); exit;
}
// Debit original não encontrado
if (!getTransactionByGameId($gameId, 'DEBIT')) {
http_response_code(404);
echo json_encode(['message' => 'Transaction not found']); exit;
}
creditPlayer($body['externalPlayerId'], $amount);
createTransaction($gameId, 'ROLLBACK', $amount, $body['externalPlayerId']);
header('Content-Type: application/json');
echo json_encode([]);@app.route('/wallet/rollback', methods=['POST'])
def wallet_rollback():
raw_body = request.get_data()
if not validate_callback(dict(request.headers), raw_body):
return jsonify({'error': 'Invalid signature'}), 401
data = request.get_json()
game_id = data['gameId']
amount = int(data['amount'])
if db.get_transaction(game_id, 'ROLLBACK'):
return jsonify({})
if not db.get_transaction(game_id, 'DEBIT'):
return jsonify({'message': 'Transaction not found'}), 404
db.credit_player(data['externalPlayerId'], amount)
db.create_transaction(game_id, 'ROLLBACK', amount)
return jsonify({})app.MapPost("/wallet/rollback", async (HttpContext ctx) =>
{
var rawBody = await new StreamReader(ctx.Request.Body).ReadToEndAsync();
if (!CallbackValidator.Validate(ctx.Request, rawBody)) return Results.Unauthorized();
var data = JsonSerializer.Deserialize<JsonElement>(rawBody);
var gameId = data.GetProperty("gameId").GetString()!;
var amount = data.GetProperty("amount").GetInt64();
var extId = data.GetProperty("externalPlayerId").GetString()!;
if (await db.GetTransactionAsync(gameId, "ROLLBACK") is not null)
return Results.Ok(new { });
if (await db.GetTransactionAsync(gameId, "DEBIT") is null)
return Results.NotFound(new { message = "Transaction not found" });
await db.CreditPlayerAsync(extId, amount);
await db.CreateTransactionAsync(gameId, "ROLLBACK", amount, extId);
return Results.Ok(new { });
});Fluxo Completo de uma Rodada
Jogador confirma aposta
│
▼
POST /wallet/balance ──► { balance: 50000 }
│
▼
POST /wallet/debit ───► { balance: 49000, transactionId: "tx_001" }
│
┌───┴───┐
│ │
Cashout Erro/Timeout
│ │
▼ ▼
POST /wallet/credit POST /wallet/rollback
{ balance: 52500, {} (200 OK)
transactionId: ... }Requisitos de Implementação
| Requisito | Detalhes |
|---|---|
| Idempotência | Mesmo gameId + type deve retornar 200 sem processar duplicata |
| Atomicidade | Debit deve ser atômico — evite race conditions com transações de banco |
| Latência | Responder em menos de 5 segundos em todos os endpoints |
| HTTPS | Endpoint de callback deve usar TLS 1.2+ em produção |
| Validação HMAC | Validar assinatura em todo request antes de processar |
| Valores | Trabalhar sempre em centavos inteiros — sem ponto flutuante |