Contrato de Integração

Tudo que um desenvolvedor precisa para embutir a casa de apostas: modelos de carteira, instalação do iframe, API do apostador, avisos assinados e critérios de produção.

Documento canônico para o desenvolvedor do operador. Descreve tudo o que é preciso para embutir a casa de apostas, mover dinheiro e receber os avisos: os modelos de carteira, o iframe, a API do apostador, os webhooks e o painel.

Este documento é ao mesmo tempo documentação e contrato: onde houver "DEVE", "NÃO PODE" ou "É RECUSADO", o texto descreve uma regra que o sistema aplica, não uma recomendação. Onde houver "hoje", descreve o estado atual de uma decisão que ainda pode mudar com aviso.

Ele substitui o CONTRATO-EMBED-V1.md, que cobria só o iframe e ficou parado enquanto a superfície dobrava de tamanho. O mapa do que ainda falta para o produto está em GAPS-GO-LIVE.md.


1. Como a integração é feita

A casa de apostas é embutida como iframe, servido de uma origem própria (embed.skull-api.xyz), separada da origem da API. Isso não é detalhe de infraestrutura: é o que faz um XSS na página do widget não ser um XSS na origem que guarda os tokens.

São quatro peças, e só a segunda exige código do lado do operador:

  1. O widget — a vitrine, o cupom, o bilhete e a conta do apostador. Vem pronto; o operador escolhe cores, mercados e idioma.
  2. O endpoint de handoff — no servidor do operador. Ele afirma, assinando com a chave privada dele, "este apostador é meu e está autenticado agora". É a única peça obrigatória de código. Ver capítulo 4.
  3. A carteira — de quem é o saldo do apostador. Três modelos possíveis, e a escolha muda o que o operador precisa construir. Ver capítulo 3.
  4. Os avisos — webhooks assinados para cada fato relevante (aposta aceita, bilhete liquidado, depósito creditado, saque pago). Ver capítulo 7.

O que nunca atravessa a fronteira: a chave privada do operador nunca sai do servidor dele, e o widget nunca recebe do site hospedeiro nada que se pareça com preço, valor ou saldo — só identificadores.


2. Antes de começar: credenciais

O operador recebe, pelo painel:

CredencialFormatoOnde vivePara quê
publishable_keypk_ + 16–64 [a-z0-9]no HTML da página, públicaidentifica o operador no bootstrap
API key (S2S)opacasó no servidorchamadas servidor-a-servidor
Token do paineloptk_ + opacosó com pessoasacesso ao painel do operador
Par de chaves de assinaturaES256 ou RS256privada no servidor do operadorassinar o handoff

Além disso é preciso cadastrar, no painel:


3. Modelos de carteira — a decisão que estrutura tudo

Esta é a primeira decisão da integração, porque ela determina o que o operador precisa construir, o que o apostador vê dentro do iframe e quem responde pelo saldo.

O modo é uma propriedade do operador (wallet_mode), com três valores, e é configurado no painel, na aba Configuração.

Trocar de modo é recusado enquanto houver bilhete em aberto. A aposta foi aceita contra um saldo que estava num lugar e seria liquidada contra outro — é um "não" barato que evita um incidente caro. E ligar o seamless sem carteira homologada também é recusado: sem ela o operador não aceitaria aposta nenhuma.

A homologação não é um ping. Nós pedimos uma consulta de transação inexistente e exigimos NOT_FOUND com HTTP 200. É o requisito que não dá para compensar do nosso lado, e é melhor descobrir que falta no cadastro do que na primeira falha de rede com dinheiro no meio.

3.1 managed — a carteira é do Skull Book

O padrão, e o caminho mais curto para o ar. O saldo do apostador vive no nosso ledger; depósito por Pix e saque acontecem dentro do iframe, usando a credencial de PSP do próprio operador (o dinheiro cai na conta dele, nunca na nossa; nós guardamos a credencial cifrada e operamos por conta dele).

O operador implementa apenas o handoff. Não há API de carteira para construir.

Escolha este modo quando o esportivo for o único produto com saldo. Se houver cassino, ou uma plataforma que já gerencia carteira, o jogador acabaria com duas carteiras — e aí o modo certo é o próximo.

3.2 seamless — a carteira é do operador

Implementado e provado em 2026-08-19. A casa demo (demo.skull-api.xyz) implementa este contrato e serve de exemplo pronto para copiar — inclusive a lápide do estorno e a consulta que responde 200 com estado explícito. Provado ponta a ponta: saldo do apostador na carteira do operador caiu de R$100,00 para R$85,00 numa aposta de R$15,00 aceita pelo Skull Book, com o identificador externo guardado do nosso lado para conciliação.

O saldo vive na plataforma do operador. Dentro do iframe, as telas de carteira, depósito e saque desaparecem: esses fluxos são do site hospedeiro. O saldo exibido no topo vem da carteira dele.

Cada aposta debita a carteira do operador; cada prêmio credita; cada anulação estorna. É o modelo que todo provedor de cassino usa, e por isso um operador que já roda cassino provavelmente já tem essa API pronta — nesse caso não pedimos nada novo, escrevemos um adaptador para o dialeto existente.

O restante deste capítulo é o contrato dessa API.

3.3 transfer — o operador transfere saldo

O meio-termo: a plataforma do operador transfere saldo para a carteira do apostador dentro do Skull Book (e traz de volta) por chamada S2S. O apostador precisa mover dinheiro entre os produtos, o que é uma experiência pior, mas o operador não constrói nenhuma API de carteira e nenhum caminho quente novo aparece. Serve de plano B quando seamless não é viável.


3.4 O contrato da carteira externa (modo seamless)

Estas regras existem porque, com a carteira do outro lado da rede, uma aposta deixa de ser uma transação e passa a ser duas transações em sistemas diferentes sem commit em duas fases. Todo o desenho abaixo serve a uma única pergunta: quando a rede falhar no meio, quem sabe o que aconteceu?

3.4.1 As cinco operações

Base URL cadastrada por operador. Todas as chamadas são POST com JSON, menos a consulta.

OperaçãoCaminhoQuando chamamos
SaldoPOST {base}/balanceao abrir o widget e após cada movimento
DébitoPOST {base}/debitao aceitar uma aposta
CréditoPOST {base}/creditao liquidar um bilhete premiado
EstornoPOST {base}/rollbackao anular, ou ao desfazer um débito incerto
ConsultaGET {base}/transaction/{tx_id}sempre que não soubermos o desfecho

A quinta é a mais importante das cinco, e é a que integrações mal desenhadas não têm. Sem ela, um timeout é irrecuperável: não dá para repetir (pode debitar duas vezes) nem para desistir (pode ter debitado). Uma carteira sem consulta por identificador não é integrável.

3.4.2 Identificadores

3.4.3 Idempotência (obrigatória)

Repetir uma chamada com o mesmo tx_id DEVE devolver o mesmo resultado, incluindo o mesmo external_tx_id, e NÃO PODE mover dinheiro uma segunda vez.

Se chegar o mesmo tx_id com parâmetros diferentes (outro valor, outro jogador), a resposta DEVE ser o erro tx_conflict — nunca aplicar o novo, nunca ignorar em silêncio.

3.4.4 Consulta: 200 sempre, com estado explícito

GET /transaction/{tx_id} DEVE responder 200 com um status entre APPLIED, ROLLED_BACK e NOT_FOUND.

Responder 404 para transação inexistente é recusado pelo contrato, e o motivo é prático: 404 é ambíguo entre "esta transação nunca existiu" e "você errou a URL". A primeira leitura nos faria recusar uma aposta já paga; a segunda, aceitar uma sem lastro. Um estado explícito remove a adivinhação.

3.4.5 Erros: o "não" normal precisa ser um "não" limpo

O corpo de erro DEVE trazer um código estável:

CódigoSignificaNossa reação
insufficient_fundssaldo insuficienterecusa educada da aposta
player_not_foundjogador desconhecidorecusa + alarme
player_blockedbloqueado/autoexcluído lárecusa educada
tx_conflictmesmo tx_id, outros parâmetrosrecusa + alarme
currency_mismatchmoeda divergenterecusa + alarme
ref_not_foundref_tx_id não existereconsulta

Qualquer outra resposta — 5xx, timeout, corpo ilegível — é tratada como desconhecida, não como falha.

Daí a regra mais importante deste capítulo: saldo insuficiente NÃO PODE chegar como 500 ou como timeout. É o "não" mais comum que existe numa casa de apostas; se ele vier pelo caminho da incerteza, o caminho da incerteza deixa de ser excepcional e todo o desenho desmorona.

3.4.6 Latência: o débito é caminho quente

O débito acontece durante a aceitação da aposta, com o apostador olhando e a odd andando. O contrato é:

credit e rollback são assíncronos do nosso lado — drenados por um processo com repetição — e por isso toleram lentidão. Só o débito é caminho quente.

3.4.7 O estorno precisa deixar lápide

Esta é a regra que integrações reais erram, e o erro faz dinheiro sumir.

Quando um débito fica desconhecido, nós recusamos a aposta e emitimos um rollback daquele tx_id. Só que o débito pode estar apenas atrasado: ele chega ao operador depois do estorno.

Portanto: rollback de um tx_id que ainda não existe DEVE ser aceito (200) e DEVE registrar uma lápide, de modo que um debit posterior com aquele mesmo tx_id seja RECUSADO. Sem a lápide, o estorno não encontra nada, o débito atrasado é aplicado depois, e o apostador fica sem o dinheiro e sem a aposta.

3.4.8 Valores e moeda

Sempre inteiros, em centavos, com a moeda explícita em cada chamada. Nada de ponto flutuante, nada de moeda implícita. Divergência de moeda é currency_mismatch, nunca conversão silenciosa.

3.4.9 Autenticação

HMAC-SHA256 sobre o corpo cru concatenado ao timestamp, no header X-Skull-Signature: t=<unix>,v1=<hex> — a mesma forma dos nossos webhooks de saída, para que o operador implemente a verificação uma vez só. Requisição com timestamp fora de uma janela de 5 minutos é recusada. mTLS é aceito como reforço, mediante combinação.

3.4.10 O que acontece do nosso lado

O bilhete ganha um acompanhamento de sincronia com a carteira, espelhando a máquina que já existe para o ledger interno:

Resultado do débitoEstadoBilhete
confirmadoCOMMITTEDOPEN
recusa explícitaFAILEDREJECTED, com o motivo
desconhecido após reconsultaINDETERMINATEREJECTED + estorno agendado

O estorno agendado é repetido pelo processo de fundo até ter resposta conclusiva; ele é idempotente, então repetir é seguro. Nunca aceitamos uma aposta sobre um débito não confirmado — aceitar seria assumir um pagamento futuro sobre dinheiro que talvez não exista.

Repare que a regra é o inverso da do saque: no saque, não saber significa nunca reenviar, porque reenviar pode pagar duas vezes. Aqui, não saber significa nunca aceitar, e desfazer.

3.4.11 O espelho contábil

Mesmo sem ser dono do saldo, o Skull Book registra todas as operações no ledger próprio, contra uma conta de carteira externa por operador. Isso mantém os nossos livros fechados, dá base para a conciliação diária contra o extrato do operador e alimenta o faturamento — que não depende dos dados dele, porque somos nós que aceitamos a aposta e conhecemos stake e pagamento.

A conta espelho pode ficar negativa por natureza (ela representa um passivo externo, não um saldo), e por isso é excluída da proteção contra descoberto que vale para as contas de apostador.

3.4.12 Adaptadores para dialetos existentes

Não existe padrão único de seamless wallet no mercado. Quando o operador já tem uma API — normalmente porque integrou cassino antes — nós escrevemos um adaptador para o dialeto dele, com a credencial guardada cifrada, no mesmo molde do nosso registro de PSPs.

O adaptador é aceito desde que a API existente satisfaça três requisitos, que não são negociáveis porque não há como compensá-los do nosso lado:

  1. idempotência por identificador fornecido por nós;
  2. consulta de transação por esse mesmo identificador;
  3. saldo insuficiente como erro estável e distinto de indisponibilidade.

Faltando qualquer um dos três, o modo seamless não é oferecido àquele operador, e a alternativa é transfer.

3.5 Quem responde pelo quê, em cada modo

Responsabilidademanagedseamlesstransfer
Saldo do apostadorSkull BookoperadorSkull Book
Depósito e saqueiframe (PSP do operador)site do operadorsite do operador
Identidade (KYC)external ou internaloperador (external)operador
Limites de jogo responsável e autoexclusãoSkull Bookambos (ver abaixo)Skull Book
Bilhete, odds, liquidação e trilhaSkull BookSkull BookSkull Book

No modo seamless, a autoexclusão merece atenção: quem aceita a aposta somos nós, então os nossos portões continuam valendo. O operador DEVE ou replicar a exclusão para nós (pelo painel ou pela API), ou responder player_blocked no débito. As duas coisas juntas é o correto — a segunda sozinha deixa o apostador excluído montar o bilhete e só descobrir a recusa no fim.


4. Instalando o iframe

4.1 O caminho inteiro, de uma vez

   site do operador                    Skull Book
   ────────────────                    ──────────
1. <script widget.js> ────────────────► serve o carregador
2. SkullBook.init({...})
3.        └── GET /embed/v1/bootstrap?pk=… ──────► devolve frame_id (vale 120 s)
4. cria <iframe src=".../betslip#frame=…">
5.                                      widget avisa: skullbook:ready
6. seu getHandoff(frameId, origin)
        └── chama SEU backend ──► assina o JWT com SUA chave privada
7. carregador entrega o JWT ao iframe (skullbook:session)
8.        └── o widget chama POST /embed/v1/session ──► access + refresh
9. widget operando

Só o passo 6 é código seu. Todo o resto é o carregador que servimos.

4.2 Gerando e registrando o par de chaves

A chave privada nunca sai do seu servidor e nunca é enviada para nós.

# ES256 (recomendado — chave curta, assinatura rápida)
openssl ecparam -name prime256v1 -genkey -noout -out handoff.key
openssl pkcs8 -topk8 -nocrypt -in handoff.key -out handoff.pkcs8.key
openssl ec -in handoff.key -pubout -out handoff.pub

# RS256 (se sua stack já usa RSA)
openssl genrsa -out handoff.key 2048
openssl rsa -in handoff.key -pubout -out handoff.pub

No painel, cadastre o conteúdo de handoff.pub — ele precisa estar no formato -----BEGIN PUBLIC KEY----- (o -pubout do OpenSSL já produz assim).

O kid não é livre: ele identifica o operador e é validado por formato.

tnt_<seu operator_id em hexadecimal, 32 caracteres>_<sufixo seu, 4 a 32 alfanuméricos>

O sufixo é seu para versionar chaves — use algo como 2026a, e ao rotacionar, cadastre a chave nova com sufixo novo, passe a assinar com ela e só então revogue a antiga. Como o kid viaja no header do JWT, as duas convivem durante a transição sem nenhuma janela de indisponibilidade.

4.3 O endpoint de handoff

Ele recebe {frame_id, origin}, confirma que existe um apostador logado na sessão first-party do seu site, e devolve {handoff: "<jwt>"}.

Essa confirmação é a parte que importa: o handoff é a afirmação de identidade inteira. Se ele for emitido sem checar a sessão do usuário, qualquer visitante consegue apostar como qualquer apostador.

Header do JWT

CampoValor
algES256 ou RS256
kido kid cadastrado

O alg do token não é usado para escolher o verificador: nós usamos o algoritmo gravado no cadastro daquela chave. É o que torna impossível o ataque de trocar o algoritmo (none, ou RSA verificado como HMAC).

Claims

ClaimObrigatórioO que é
audsimexatamente skull-embed
subsimo id do apostador no seu sistema; vira o bettor_ref em toda a integração
jtisimnonce de uso único — gere um novo a cada chamada
expsimexpiração; use algo curto, na casa de 60 s
frame_idsimexatamente o valor recebido em getHandoff
originsimexatamente o valor recebido em getHandoff
cpfnão11 dígitos, só números
tenantnãoseu slug; se vier, tem de bater com o dono da chave

Sobre o exp: o teto aceito é de 600 segundos. Um token com validade maior é recusado mesmo estando dentro do prazo — validade longa em credencial de uso único é superfície de ataque, não conveniência.

Sobre o cpf: ele só preenche o cadastro quando ainda está vazio; nunca sobrescreve. Se o CPF enviado já pertencer a outro apostador seu, a sessão é recusada. E note que o CPF é obrigatório para apostar e para sacar — sem ele o apostador entra e navega, mas não opera.

Implementação de referência (a mesma que roda em demo.skull-api.xyz):

@app.post("/handoff")
async def handoff(request: Request) -> dict:
    corpo = await request.json()
    # AQUI: validar a sessão first-party e obter o apostador logado.
    apostador = usuario_logado(request)
    agora = int(time.time())
    token = jwt.encode(
        {
            "aud": "skull-embed",
            "sub": apostador.id,
            "cpf": apostador.cpf,          # opcional
            "jti": uuid.uuid4().hex,       # NOVO a cada chamada
            "iat": agora,
            "exp": agora + 60,
            "frame_id": str(corpo.get("frame_id", "")),
            "origin": str(corpo.get("origin", "")),
        },
        CHAVE_PRIVADA, algorithm="ES256", headers={"kid": KID})
    return {"handoff": token}

Em Node, o equivalente com jsonwebtoken:

const token = jwt.sign(
  { aud: 'skull-embed', sub: apostador.id, cpf: apostador.cpf,
    jti: crypto.randomUUID().replace(/-/g, ''),
    frame_id: req.body.frame_id, origin: req.body.origin },
  CHAVE_PRIVADA,
  { algorithm: 'ES256', keyid: KID, expiresIn: 60 });

4.4 O snippet

<div id="skull-widget"></div>
<script src="https://embed.skull-api.xyz/SEU_SLUG/widget.js"></script>
<script>
  window.SkullBook.init({
    container: 'skull-widget',
    publishableKey: 'pk_SUA_CHAVE',
    getHandoff: async function (frameId, origin) {
      const r = await fetch('/handoff', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'same-origin',
        body: JSON.stringify({ frame_id: frameId, origin: origin })
      });
      const j = await r.json();
      return j.handoff;               // a string do JWT
    },
    onBet: function (bilhete) { /* aposta aceita */ },
    onError: function (erro) { /* {erro, mensagem} */ }
  });
</script>
OpçãoObrigatóriaObservação
containersimid de um elemento que já exista no DOM
publishableKeysima chave publicável
getHandoffsimrecebe (frameId, origin); devolve a string do JWT (ou uma promessa dela)
onBetnãorecebe o bilhete aceito
onErrornãorecebe {erro, mensagem} — falha de bootstrap ou erro do widget

Faltando qualquer uma das três obrigatórias, o carregador registra o erro no console e desiste em silêncio — ele nunca lança exceção na sua página.

O iframe é criado com sandbox="allow-scripts allow-forms allow-same-origin", referrerpolicy="strict-origin" e altura inicial de 520 px, ajustada automaticamente conforme o conteúdo. O frame_id viaja no fragmento da URL, de propósito: fragmento não aparece em log de servidor nem em Referer.

4.5 Conversa entre a sua página e o widget

Do seu lado, além dos callbacks, existe uma função pública para empurrar uma seleção para dentro do cupom — útil se você tem banners ou uma vitrine própria:

window.SkullBook.select({
  event_id: '...', market_key: '...', selection_key: '...',
  dimensoes: { linha: '2.5' },   // opcional
  label: 'Mais de 2.5'           // opcional, só exibição
});

Preço não é aceito em campo nenhum, e isso é deliberado: a sua página manda o que o apostador escolheu, nunca por quanto. O preço é sempre o que a cotação disser no momento da aposta.

As mensagens trocadas por postMessage, para quem precisar depurar:

SentidoMensagemConteúdo
widget → páginaskullbook:readypede o handoff
widget → páginaskullbook:resizenova altura
widget → páginaskullbook:betbilhete aceito
widget → páginaskullbook:errorerro
página → widgetskullbook:sessiono handoff
página → widgetskullbook:selectionseleção empurrada
página → widgetskullbook:viewportgeometria visível

Toda mensagem carrega o frame_id e é descartada se ele não bater. O widget fixa a origem da página no primeiro contato e ignora qualquer mensagem posterior de outra origem.

4.6 Origens, testes e o que o navegador barra

Cadastre cada origem de onde o widget será carregado. O formato aceito é https:// com host e porta opcional, e — só para desenvolvimento — http://localhost com porta opcional. Nada de http:// em outro host, e nada de curinga.

Para testar na sua máquina, cadastre http://localhost:3000 (ou a porta que você usar) como origem, e verifique-a. É a única exceção ao https, e ela existe justamente para você não precisar publicar para integrar.

O documento do widget responde com uma política de conteúdo montada por operador: frame-ancestors contém exatamente as suas origens verificadas. Se a página que embute não estiver na lista, o navegador recusa o enquadramento — mesmo que o bootstrap tivesse passado. São duas barreiras independentes, de propósito.

Duas coisas que costumam confundir na primeira integração:

4.7 Aparência

O documento do widget é byte a byte idêntico para todos os operadores — nenhum dado de tenant entra no HTML. A personalização acontece em tempo de execução: o widget busca os seus tokens e aplica como variáveis CSS.

Tokens disponíveis: --marca, --marca-escura, --selecao, --selecao-texto, --cta, --cta-hover, --cta-texto, --fundo, --superficie, --borda, --cabecalho, --texto, --texto-mudo, --texto-fraco, --positivo, --negativo, --alerta, --erro, --raio, --transicao.

Os valores passam por uma allowlist restrita (cores, medidas e funções CSS simples). Qualquer coisa contendo url( ou expression é descartada — o tema define a paleta, não carrega recurso externo nem executa nada.


5. Referência da API do apostador (/embed/v1)

Todas as rotas vivem em https://book.skull-api.xyz/embed/v1 (produção) ou https://book-sandbox.skull-api.xyz/embed/v1 (sandbox). Elas são chamadas pelo widget, de dentro do iframe — não pelo servidor do operador. Estão documentadas porque a integração depende de entender o que o apostador pode fazer e quais recusas ele vai ver.

Erro sempre tem a forma {"erro": "<código>", "mensagem": "<texto>"}, mais campos extras em alguns códigos. O código é o contrato; a mensagem é cortesia e pode mudar de texto ou de idioma sem aviso.

5.1 Sessão

RotaAuthCorpoResposta
GET /bootstrap?pk=nenhuma; exige Origin{frame_id, operator_slug, expires_in}
POST /sessionnenhuma; exige Origin + Sec-Fetch-*{handoff, frame_id}{access_token, expires_in, refresh_token}
POST /session/refreshnenhuma; exige Origin{refresh_token}{access_token, expires_in, refresh_token}

Rotação e detecção de roubo. Cada refresh bem-sucedido revoga o token usado e emite um novo, mantendo o mesmo identificador de família. Se um refresh já revogado for reapresentado — o sinal clássico de token roubado — a família inteira é revogada, o que desloga inclusive o dono legítimo. É deliberado: preferimos derrubar a sessão a manter duas vivas sem saber qual é a do dono.

5.2 Oferta e cotação

RotaRespostaObservação
GET /events{items: [...]}mercados ocultos pelo operador já vêm filtrados; seleção travada por kill-switch vem marcada suspended
GET /events/{id}/stats{available, teams, competition, elapsed_seconds, ficha, stats, season, timeline}degrada em silêncio: sem par de dados, responde {"available": false}, nunca erro
GET /events/{id}/lineups{available, teams:{home,away}}idem
POST /quote{selections:[...], total_odds, potential_return_cents}potential_return_cents só aparece se o corpo trouxe stake_cash_cents

POST /quote aceita de 1 a 10 seleções.

5.3 Aposta

POST /bets (201) com {client_ref, stake_cash_cents, selections[1..10]} devolve {bet_id, status, stake_cash_cents, max_profit_cents, replayed, odds_aplicadas}.

Os portões são aplicados nesta ordem, e a ordem importa porque cada um pode recusar antes de o próximo custar dinheiro ou tempo: elegibilidade do apostador, KYC, CPF presente, jogo responsável, cotação, risco, crédito do operador, e por fim o lançamento no ledger.

replayed: true significa que aquele client_ref já tinha sido usado e a resposta é a do bilhete original — repetir a chamada não cria uma segunda aposta. O client_ref é a chave de idempotência do operador; use um valor estável derivado da ação do usuário.

RotaResposta
GET /bets?status=&cursor=&limit={items:[...], next_cursor}
GET /bets/{bet_id}o bilhete completo mais timeline[]

Bilhete de outro apostador responde 404, nunca 403: para a sessão, ele não existe.

5.4 Carteira, depósito e saque

Estas rotas existem no modo managed. No modo seamless o widget não as usa (o saldo vem da carteira do operador) e no modo transfer só a carteira e o extrato aparecem.

RotaCorpoResposta
GET /wallet{balance_cents, currency}
POST /deposits (201){amount_cents}{deposit_id, reference, qr_payload, amount_cents, status}
GET /deposits?cursor=&limit={items:[...], next_cursor}
GET /deposits/{id}{deposit_id, status, amount_cents}
POST /withdrawals (201){amount_cents, pix_key, pix_key_cpf}{withdrawal_id, status}
GET /withdrawals?cursor=&limit={items:[...], next_cursor}
GET /withdrawals/{id}{withdrawal_id, status, amount_cents}
GET /statement?after=&limit={items:[{txid, reference, timestamp, delta_cents}], next_cursor}

Duas regras que costumam surpreender:

O crédito do depósito é feito por processo de fundo. A consulta GET /deposits/{id} também dispara uma reconsulta ao provedor, o que acelera o caso em que o apostador está com a tela aberta — mas o crédito não depende disso (ver capítulo 11, decisão 5).

5.5 Identidade, limites e preferências

RotaCorpoResposta
GET /kyc{mode, status, note}
POST /kyc (201){doc_front, doc_back?, selfie} (base64){status: "PENDING"}
GET /limits{limits:[...], exclusion:{...}}
PUT /limits{kind, win, amount_cents?}o limite resultante
POST /self-exclusion (201){days?, forever?}{active, forever, until, origin}
GET /preferences{prefs}
PUT /preferencescampos parciais{prefs}
GET /theme{tokens}

As imagens de KYC aceitam image/jpeg, image/png e image/webp, com teto de 3 MB por peça; o widget já comprime antes de enviar.

Os limites de jogo responsável combinam kind e win: deposit/loss com day, week ou month, e stake com bet. Duas regras normativas:

Limite definido pelo operador não pode ser alterado pelo apostador (403 limite_do_operador).

5.6 Paginação

As listas de bilhetes, depósitos e saques usam cursor de chave composta: o cursor é "<timestamp ISO>|<uuid>" em base64 URL-safe, e a ordenação é (created_at, id) decrescente. next_cursor só vem quando a página encheu — sua ausência significa fim da lista, e não "tente de novo".

O extrato (/statement) é a exceção: o cursor dele vem do ledger e é opaco. Repasse o valor sem interpretar.


6. Catálogo de erros

Este catálogo é parte do contrato. Códigos podem ser acrescentados sem aviso; um código existente não muda de significado.

6.1 Sessão e acesso

CódigoHTTPQuando
origin_not_verified403sem header Origin, ou origem não cadastrada/verificada
publishable_key_invalida403pk ausente ou desconhecida
operator_suspended403operador existe mas não está ACTIVE
handoff_rejected403handoff recusado — ver 6.2
session_expired403/401refresh desconhecido, revogado, expirado, ou access inválido
frame_mismatch403Origin da chamada não é a do documento do embed
interno503embed não configurado no servidor

6.2 handoff_rejected — dez causas, um código

O código é único de propósito: para quem tenta adivinhar chave, frame ou tenant, todas as recusas precisam parecer iguais. Para você, integrando, a distinção está na mensagem e no log do lado do servidor. As causas:

  1. requisição sem metadados de fetch de iframe;
  2. Origin não é a do documento do embed;
  3. o handoff não é um JWT, ou não traz kid no header;
  4. kid desconhecido ou revogado;
  5. assinatura inválida, aud errado, claim obrigatório faltando, ou expirado;
  6. frame inexistente, já usado ou expirado;
  7. algoritmo fora de ES256/RS256; tenant do token diferente do dono da chave; expiração além do TTL permitido; origem do token diferente da do frame; frame_id do token diferente do enviado;
  8. jti repetido — handoff é de uso único;
  9. frame já consumido (corrida ou replay);
  10. o CPF do handoff pertence a outro apostador do mesmo operador.

Na prática, integrando pela primeira vez, as causas 5, 6 e 8 respondem por quase tudo: relógio fora de hora, frame reaproveitado entre recarregamentos, e jti fixo no código.

6.3 Oferta, aposta e dinheiro

CódigoHTTPQuandoExtras
feed_parado503fonte de dados sem sinal ou além do teto de idadefonte, idade_segundos
cotacao_ambigua409mais de uma oferta candidata sem dimensão informadadimensoes_disponiveis
odd_mudou409preço mudou entre cotar e confirmarodd_aceita, odd_atual, event_id, market_key, selection_key
quote_rejected409oferta sumiu, mercado suspenso, evento encerrado
mercado_oculto422mercado desligado por este operador
oferta_suspensa409kill-switch de risco travou a oferta
risk_refused403teto de bilhete, exposição ou atraso de aposta ao vivo
insufficient_funds402saldo insuficiente
bettor_not_eligible403apostador inexistente ou não ACTIVE
bettor_sem_cpf403apostador sem CPF cadastrado
kyc_required403KYC interno pendentestatus_kyc
self_excluded403autoexclusão ou pausa ativa
responsible_gaming_limit403limite pessoal excedido
servico_indisponivel503crédito do operador esgotado
deposit_unavailable503nenhum conector de recebimento configurado
withdrawal_unavailable503envio de saque desligado no ambiente
kyc_invalido422imagem fora do formato ou acima de 3 MB
limite_invalido422combinação de limite inexistente
limite_do_operador403limite imposto pelo operador
ja_excluido409exclusão igual ou maior já ativa
exclusao_invalida422nem prazo nem forever informados
pedido_invalido400/404/422cursor malformado, recurso inexistente, chave Pix de terceiro

Repare em servico_indisponivel: quando o crédito pré-pago do operador acaba, o apostador vê apenas indisponibilidade. Detalhe comercial entre nós e o operador não vaza para o cliente final dele.


7. Avisos (webhooks)

O operador cadastra um endpoint https no painel e recebe um aviso assinado a cada fato relevante. O segredo (whsec_...) é mostrado uma única vez, na criação; atualizar o endpoint depois não o reexibe nem o rotaciona.

7.1 Envelope

POST <sua url>
Content-Type: application/json
User-Agent: SkullBook-Webhook/1.0
X-Skull-Event: bet.settled
X-Skull-Delivery: <uuid da entrega>
X-Skull-Signature: t=<unix>,v1=<hex>

{"event": "bet.settled", "created_at": "<ISO-8601 UTC>", "data": { ... }}

created_at é a hora do fato, não a da entrega.

7.2 Verificação da assinatura

v1 = HMAC-SHA256(segredo, "<t>.<corpo cru>")

O corpo tem de ser usado cru, byte a byte como chegou. Não reserialize o JSON antes de conferir: reordenar chaves ou normalizar números muda o hash e a verificação falha para avisos legítimos. Compare em tempo constante e recuse t fora de uma janela de tolerância (5 minutos é razoável) para barrar repetição.

7.3 Eventos

Eventodata
bet.placedbet_id, client_ref, bettor_ref, stake_cash_cents, total_odds, max_profit_cents, selections[] (event_id, market_key, selection_key, decimal_odds, event_name)
bet.settledbet_id, outcome (WIN/LOSE/VOID/PARTIAL), status, payout_cents
bet.resettledbet_id, de, para, payout_cents
deposit.creditedreference, amount_cents, bettor_id, psp_payment_id
withdrawal.approvedwithdrawal_id, status
withdrawal.settledwithdrawal_id, status
withdrawal.returnedwithdrawal_id, status
bettor.kyc_reviewedbettor_ref, bettor_id, status, note
bettor.self_excludedbettor_ref, bettor_id, until, forever
invoice.issuedinvoice_id, period, ggr_cents, amount_cents

Um endpoint sem lista de eventos recebe todos.

Em transição. Todos os eventos agora trazem bettor_ref, o identificador do seu sistema — inclusive bettor.kyc_reviewed e bettor.self_excluded, que antes mandavam só o bettor_id interno do Skull Book. O bettor_id continua indo por compatibilidade e será removido numa versão futura do contrato, com aviso. Programe contra bettor_ref.

7.4 Entrega, repetição e garantias


8. API servidor-a-servidor

Para o operador que quer apostar do backend dele, sem o widget.

Autenticação pelo header X-API-Key. O identificador de tenant nunca vem do cliente: o operador é resolvido pela própria chave. Chave ausente ou inválida responde 401; operador não ACTIVE, 403.

MétodoRotaCorpoResposta
POST/bets{bettor_ref, client_ref, stake_cash_cents, selections[], odds_informada?}201 {bet_id, status, max_profit_cents, replayed, odds_aplicadas}
GET/bets/{bet_id}o bilhete
POST/withdrawals{bettor_ref, amount_cents, pix_key, pix_key_cpf}201 {withdrawal_id, status}

Não existe depósito por esta via: depósito nasce de uma cobrança, e cobrança nasce no fluxo do apostador.

odds_informada é o preço que o operador aceitou mostrar. Se o preço tiver mudado, a resposta é 409 odd_mudou com o preço aceito e o atual — cabe ao operador decidir entre reapresentar ou desistir. Omitir o campo significa aceitar o preço vigente no momento da chamada.


9. Painel do operador

https://book.skull-api.xyz/admin — mesma aplicação para o dono da plataforma e para o operador; o token decide o papel, e o operador só enxerga o que é dele, tanto na tela quanto na API.

9.1 Entrada com segundo fator

POST /admin/v1/login com {token, code} tem três desfechos:

  1. Primeiro acesso — devolve {totp_setup: {secret, otpauth_uri}}. Cadastre no aplicativo autenticador e chame de novo com o código para confirmar.
  2. Segundo fator confirmado, código ausente — devolve {totp_required: true}.
  3. Código correto — devolve {session_token, is_admin, operator_id}, um JWT de 12 horas usado no header Authorization das chamadas seguintes.

Depois de confirmado o segundo fator, o token cru deixa de valer sozinho: ele volta a ser apenas credencial de primeiro acesso. Sem o token de administração configurado no servidor, todas as rotas do painel respondem 503 — o painel não sobe meio-aberto.

9.2 O que o operador administra

Chaves e credenciais (rotação da chave de API e da publicável, origens, chaves de assinatura do handoff, credenciais de PSP); oferta (mercados ocultos, catálogo); risco (limites de exposição, regras e kill-switch); operação (apostas, depósitos, saques, apostadores, fila de revisão de liquidação, re-liquidação manual, exposição ao vivo); jogo responsável (limites e exclusão por apostador, revisão de KYC, modo de KYC); integração (webhooks, entregas e reenvio, exportações); financeiro (indicadores, faturas, crédito, uso da API); e aparência (tema).

Ficam com o dono da plataforma, e respondem 403 para o operador: criar e alterar operadores, termos comerciais, fechar e marcar faturas, ajustar crédito manualmente, modo de faturamento, faixas de preço e emissão de token de painel.

9.3 Exportações

GET /admin/v1/operators/{id}/exports/{tipo}.csv?from=&to= com tipo em bets, transactions ou bettors. As datas aceitam YYYY-MM-DD.

Estes arquivos contêm CPF em claro, por serem a base de prestação de contas. Trate-os como dado pessoal sensível: transporte cifrado, acesso restrito e prazo de descarte definido.


10. Saúde e ambientes

GET /health responde sem autenticação com o estado do processo e, quando há catálogo montado, a idade dos dados por fonte e o retrato do vigia. Ele nunca devolve erro de servidor por falha de dependência: reporta o problema no corpo, de propósito, para não entrar em ciclo de reinício.

SandboxProdução
API e painelbook-sandbox.skull-api.xyzbook.skull-api.xyz
Documento do widgetembed-sandbox.skull-api.xyzembed.skull-api.xyz
Dinheirofalsoreal
Integração de referênciademo.skull-api.xyz

11. Ir para produção

Este capítulo tem duas metades. A primeira são as decisões da plataforma: regras que valem para o Skull Book inteiro, tomadas em 18–19/ago, e que explicam por que certas coisas falham fechadas em vez de "funcionarem mais ou menos". A segunda é o checklist de homologação que cada integração precisa passar antes de receber dinheiro de verdade.

11.1 Decisões da plataforma

1. Nada de superfície administrativa meio-aberta. Sem BOOK_ADMIN_TOKEN, toda rota do painel responde 503; sem BOOK_EMBED_JWT_SECRET, o bootstrap do iframe responde 503. A alternativa — subir com autenticação desligada — seria um painel de operação exposto sem que ninguém percebesse. Falhar fechado é a escolha.

Consequência operacional que já custou caro duas vezes: variável de ambiente declarada no compose não é herdada pelos serviços. Cada processo declara o que usa. Os segredos do painel e do embed vivem apenas no bloco do book-api, e deliberadamente não chegam ao book-webhook, que é a única borda que recebe requisição da internet aberta e roda com uma credencial de banco capaz apenas de inserir ponteiros.

2. Segredo que passou por um canal de conversa é segredo queimado. Rotação antes do go-live, sem exceção. O token de administração de produção foi rotacionado em 19/ago e o valor vive só no gerenciador de ambiente.

3. Liquidação em modo seco é estado de teste, não de produção. BOOK_SETTLER_DRY_RUN desligado é critério de go-live, e a prova é um bilhete real liquidando — não o log dizendo que liquidaria.

4. Saque fica atrás de BOOK_ENABLE_WITHDRAWALS até existir PSP real provado. Debitar o apostador sem ter como pagá-lo é o pior erro que este sistema pode cometer, e ele é silencioso: o saldo some da conta e o dinheiro não chega em lugar nenhum. A flag só é ligada depois da prova de fogo do item 6 do checklist.

5. Nenhum caminho de dinheiro pode depender de uma aba aberta. O crédito do depósito é feito por processo de fundo, com o webhook servindo apenas de aceleração. Esta regra nasceu de um defeito real: o crédito só acontecia dentro da consulta que a tela do QR fazia de dois em dois segundos, e quem pagava e fechava a aba ficava sem o dinheiro, sem alarme.

6. O aviso nunca é a verdade; a reconsulta decide. Vale para webhook de PSP, para carteira externa e para qualquer terceiro. Corpo de notificação é ponteiro: diz que algo aconteceu, nunca o que aconteceu. O valor que vale é sempre o da consulta autoritativa feita por nós, pelo identificador que nós escolhemos.

7. Estado honesto de "não sei". Toda máquina de dinheiro tem um estado INDETERMINATE que não é sucesso nem fracasso, gera alarme e nunca é resolvido por adivinhação. Reenviar um pagamento incerto pode pagar duas vezes; aceitar uma aposta incerta pode criar um passivo sem lastro. As duas saídas erradas são simétricas, e o estado explícito é o que impede as duas.

8. Backup antes de dinheiro real. O Postgres carrega o schema de negócio e o armazenamento do ledger — a fonte da verdade contábil. Exige-se dump diário enviado para fora do servidor e teste de restauração agendado. Backup nunca restaurado é fé, não backup.

9. Alerta de dinheiro antes de dinheiro real. O vigia precisa acordar alguém para: saque em INDETERMINATE, webhook de operador esgotado (DEAD), processo de fundo parado, exposição acima do teto, e crédito pré-pago do operador se esgotando. Detecção por "alguém olhou o painel" não conta.

10. Conciliação diária. Ledger contra extrato do PSP e, no modo seamless, contra o extrato da carteira do operador. É o que encontra centavo sumido antes do cliente encontrar.

11. Identidade. kyc_mode=external é o padrão: o handoff assinado é a afirmação de identidade, e a responsabilidade é do operador. kyc_mode=internal faz o iframe coletar documentos e o operador aprovar no painel, bloqueando aposta, depósito e saque até a aprovação.

12. Segundo fator obrigatório no painel. Depois de confirmado o TOTP, o token cru deixa de valer sozinho — ele volta a ser apenas credencial de primeiro acesso.

13. Nenhum operador entra em produção sem homologar no sandbox, pelo checklist abaixo.

11.2 Checklist de homologação

Fazer no sandbox, nesta ordem. Nada aqui é opcional.

Integração

Avisos

Carteira

Risco e conformidade

Prova de fogo, com dinheiro real mínimo

11.3 Os seis cenários de falha da carteira externa

Obrigatórios para homologar o modo seamless. Cada um existe porque já derrubou alguma integração de mercado.

#CenárioResultado esperado
1Débito com saldo insuficienterecusa limpa com código estável; nenhuma aposta criada
2Mesmo tx_id enviado duas vezesum único débito; segunda resposta idêntica à primeira
3Timeout no débito, transação aplicada no operadora consulta resolve; a aposta é aceita
4Timeout no débito, transação inexistentea consulta resolve; a aposta é recusada; nenhum estorno pendente
5Estorno chegando antes do débito atrasadoa lápide impede o débito atrasado de ser aplicado
6Crédito repetidoum único crédito

O cenário 5 é o que separa uma integração correta de uma que perde dinheiro em produção sob carga.