FourPlay

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:

EndpointQuando chamado
POST {callbackUrl}/wallet/balanceAntes de cada rodada — consulta o saldo
POST {callbackUrl}/wallet/debitQuando o jogador confirma uma aposta
POST {callbackUrl}/wallet/creditQuando o jogador faz cashout ou ganha
POST {callbackUrl}/wallet/rollbackDesfaz 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"
}
CampoTipoDescrição
externalPlayerIdstringID do jogador que você definiu ao criar sessão

Response esperada (200 OK)

{
  "balance": 50000
}
CampoTipoDescrição
balancenumberSaldo 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"
}
CampoTipoDescrição
externalPlayerIdstringID do jogador
amountnumberValor a debitar em centavos
gameIdstringID único da rodada — use como chave de idempotência
typestringSempre "DEBIT"

Response esperada (200 OK)

{
  "balance": 49000,
  "transactionId": "tx_sua_referencia_interna"
}
CampoTipoDescrição
balancenumberSaldo do jogador após o debit (em centavos)
transactionIdstringReferência da transação no seu sistema (para auditoria)

Erros possíveis

HTTPBodyQuando 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"
}
CampoTipoDescrição
externalPlayerIdstringID do jogador
amountnumberValor a creditar em centavos
gameIdstringID da rodada — mesma referência do debit
typestringSempre "CREDIT"

Response esperada (200 OK)

{
  "balance": 52500,
  "transactionId": "tx_credit_referencia"
}
CampoTipoDescrição
balancenumberSaldo do jogador após o crédito (em centavos)
transactionIdstringReferê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"
}
CampoTipoDescrição
externalPlayerIdstringID do jogador
amountnumberValor a reverter (o mesmo do debit) em centavos
gameIdstringID da rodada — mesma referência do debit
typestringSempre "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:

HTTPBodyQuando 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

RequisitoDetalhes
IdempotênciaMesmo gameId + type deve retornar 200 sem processar duplicata
AtomicidadeDebit deve ser atômico — evite race conditions com transações de banco
LatênciaResponder em menos de 5 segundos em todos os endpoints
HTTPSEndpoint de callback deve usar TLS 1.2+ em produção
Validação HMACValidar assinatura em todo request antes de processar
ValoresTrabalhar sempre em centavos inteiros — sem ponto flutuante

On this page