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.
- Versão do contrato:
v1(o prefixo das rotas,/embed/v1, é a versão). - Idioma: este documento é publicado em pt-BR; en e es estão no plano (faixa D2 do GAPS-GO-LIVE).
- Ambientes: sandbox (
book-sandbox.skull-api.xyz, dinheiro falso) e produção (book.skull-api.xyz). Nenhuma integração vai para produção sem passar pelo checklist de homologação do último capítulo.
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:
- O widget — a vitrine, o cupom, o bilhete e a conta do apostador. Vem pronto; o operador escolhe cores, mercados e idioma.
- 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.
- 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.
- 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:
| Credencial | Formato | Onde vive | Para quê |
|---|---|---|---|
publishable_key | pk_ + 16–64 [a-z0-9] | no HTML da página, pública | identifica o operador no bootstrap |
| API key (S2S) | opaca | só no servidor | chamadas servidor-a-servidor |
| Token do painel | optk_ + opaco | só com pessoas | acesso ao painel do operador |
| Par de chaves de assinatura | ES256 ou RS256 | privada só no servidor do operador | assinar o handoff |
Além disso é preciso cadastrar, no painel:
- Origens — cada domínio de onde o widget pode ser carregado. Origem não cadastrada e verificada é recusada no bootstrap, e o navegador ainda barra pelo
frame-ancestors.http://localhost:PORTAé aceito, de propósito, para desenvolvimento. - Chave pública de assinatura, com o
kidque o operador vai usar no header do JWT. - Endpoint de webhook, se quiser receber avisos.
- Credencial do PSP, se a carteira for do modelo
managed.
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ção | Caminho | Quando chamamos |
|---|---|---|
| Saldo | POST {base}/balance | ao abrir o widget e após cada movimento |
| Débito | POST {base}/debit | ao aceitar uma aposta |
| Crédito | POST {base}/credit | ao liquidar um bilhete premiado |
| Estorno | POST {base}/rollback | ao anular, ou ao desfazer um débito incerto |
| Consulta | GET {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
- O
tx_idé nosso, determinístico e derivado do fato de domínio (o id do bilhete, o id da liquidação). Não é aleatório: repetir a mesma operação produz o mesmotx_id, que é o que permite reconsultar depois de uma queda. É o mesmo princípio dareferenceque já usamos com o ledger e com os PSPs. - O
player_refé o identificador do apostador no sistema do operador — o mesmosubque ele assina no handoff. external_tx_idé o identificador do lado do operador, devolvido por ele. Nós guardamos para a conciliação.
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ódigo | Significa | Nossa reação |
|---|---|---|
insufficient_funds | saldo insuficiente | recusa educada da aposta |
player_not_found | jogador desconhecido | recusa + alarme |
player_blocked | bloqueado/autoexcluído lá | recusa educada |
tx_conflict | mesmo tx_id, outros parâmetros | recusa + alarme |
currency_mismatch | moeda divergente | recusa + alarme |
ref_not_found | ref_tx_id não existe | reconsulta |
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 é:
debitebalance: p99 ≤ 500 ms.- Nosso timeout: 1500 ms. Estourou, a aposta é recusada e entra o procedimento de incerteza.
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ébito | Estado | Bilhete |
|---|---|---|
| confirmado | COMMITTED | OPEN |
| recusa explícita | FAILED | REJECTED, com o motivo |
| desconhecido após reconsulta | INDETERMINATE | REJECTED + 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:
- idempotência por identificador fornecido por nós;
- consulta de transação por esse mesmo identificador;
- 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
| Responsabilidade | managed | seamless | transfer |
|---|---|---|---|
| Saldo do apostador | Skull Book | operador | Skull Book |
| Depósito e saque | iframe (PSP do operador) | site do operador | site do operador |
| Identidade (KYC) | external ou internal | operador (external) | operador |
| Limites de jogo responsável e autoexclusão | Skull Book | ambos (ver abaixo) | Skull Book |
| Bilhete, odds, liquidação e trilha | Skull Book | Skull Book | Skull 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 operandoSó 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.pubNo 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
| Campo | Valor |
|---|---|
alg | ES256 ou RS256 |
kid | o kid cadastrado |
O
algdo 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
| Claim | Obrigatório | O que é |
|---|---|---|
aud | sim | exatamente skull-embed |
sub | sim | o id do apostador no seu sistema; vira o bettor_ref em toda a integração |
jti | sim | nonce de uso único — gere um novo a cada chamada |
exp | sim | expiração; use algo curto, na casa de 60 s |
frame_id | sim | exatamente o valor recebido em getHandoff |
origin | sim | exatamente o valor recebido em getHandoff |
cpf | não | 11 dígitos, só números |
tenant | não | seu 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ção | Obrigatória | Observação |
|---|---|---|
container | sim | id de um elemento que já exista no DOM |
publishableKey | sim | a chave publicável |
getHandoff | sim | recebe (frameId, origin); devolve a string do JWT (ou uma promessa dela) |
onBet | não | recebe o bilhete aceito |
onError | não | recebe {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:
| Sentido | Mensagem | Conteúdo |
|---|---|---|
| widget → página | skullbook:ready | pede o handoff |
| widget → página | skullbook:resize | nova altura |
| widget → página | skullbook:bet | bilhete aceito |
| widget → página | skullbook:error | erro |
| página → widget | skullbook:session | o handoff |
| página → widget | skullbook:selection | seleção empurrada |
| página → widget | skullbook:viewport | geometria 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:
- A API não é embutível.
book.skull-api.xyzresponde comframe-ancestors 'none'. Só o documento do widget é. POST /embed/v1/sessionnão funciona nocurl. Ele exige os metadados de fetch que só um navegador dentro de um iframe envia. Isso é intencional. Para depurar, use o navegador; para testar o seu handoff isoladamente, decodifique o JWT e confira os claims.
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
| Rota | Auth | Corpo | Resposta |
|---|---|---|---|
GET /bootstrap?pk= | nenhuma; exige Origin | — | {frame_id, operator_slug, expires_in} |
POST /session | nenhuma; exige Origin + Sec-Fetch-* | {handoff, frame_id} | {access_token, expires_in, refresh_token} |
POST /session/refresh | nenhuma; exige Origin | {refresh_token} | {access_token, expires_in, refresh_token} |
expires_indo bootstrap é 120 s — o tempo de vida do frame.- O access token dura 300 s e o refresh 12 h.
- O bootstrap é a única rota com CORS por reflexão da origem (e ela precisa estar cadastrada e verificada). Todo o resto aceita apenas a origem do próprio embed.
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
| Rota | Resposta | Observaçã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.
| Rota | Resposta |
|---|---|
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.
| Rota | Corpo | Resposta |
|---|---|---|
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:
- A chave Pix do saque tem de ser do próprio apostador.
pix_key_cpfdiferente do CPF cadastrado é recusado com422. Não é conservadorismo: uma casa que paga saque para a chave de terceiro é um canal de lavagem. - O QR nunca é imagem nossa. Devolvemos o
qr_payload(o código Pix) e o widget desenha o QR localmente, porque a política de conteúdo do documento proíbe carregar imagem de terceiro.
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
| Rota | Corpo | Resposta |
|---|---|---|
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 /preferences | campos 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:
- Apertar vale na hora; afrouxar ou remover só vale 24 horas depois. A pendência é calculada na leitura, não por processo agendado — não existe janela em que o limite antigo já caiu e o novo ainda não subiu.
- Autoexclusão só estende. Pedido igual ou menor que o vigente responde
409 ja_excluido. Não há caminho de apostador para encurtar exclusão.
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ódigo | HTTP | Quando |
|---|---|---|
origin_not_verified | 403 | sem header Origin, ou origem não cadastrada/verificada |
publishable_key_invalida | 403 | pk ausente ou desconhecida |
operator_suspended | 403 | operador existe mas não está ACTIVE |
handoff_rejected | 403 | handoff recusado — ver 6.2 |
session_expired | 403/401 | refresh desconhecido, revogado, expirado, ou access inválido |
frame_mismatch | 403 | Origin da chamada não é a do documento do embed |
interno | 503 | embed 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:
- requisição sem metadados de fetch de iframe;
Originnão é a do documento do embed;- o handoff não é um JWT, ou não traz
kidno header; kiddesconhecido ou revogado;- assinatura inválida,
auderrado, claim obrigatório faltando, ou expirado; - frame inexistente, já usado ou expirado;
- 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_iddo token diferente do enviado; jtirepetido — handoff é de uso único;- frame já consumido (corrida ou replay);
- 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ódigo | HTTP | Quando | Extras |
|---|---|---|---|
feed_parado | 503 | fonte de dados sem sinal ou além do teto de idade | fonte, idade_segundos |
cotacao_ambigua | 409 | mais de uma oferta candidata sem dimensão informada | dimensoes_disponiveis |
odd_mudou | 409 | preço mudou entre cotar e confirmar | odd_aceita, odd_atual, event_id, market_key, selection_key |
quote_rejected | 409 | oferta sumiu, mercado suspenso, evento encerrado | |
mercado_oculto | 422 | mercado desligado por este operador | |
oferta_suspensa | 409 | kill-switch de risco travou a oferta | |
risk_refused | 403 | teto de bilhete, exposição ou atraso de aposta ao vivo | |
insufficient_funds | 402 | saldo insuficiente | |
bettor_not_eligible | 403 | apostador inexistente ou não ACTIVE | |
bettor_sem_cpf | 403 | apostador sem CPF cadastrado | |
kyc_required | 403 | KYC interno pendente | status_kyc |
self_excluded | 403 | autoexclusão ou pausa ativa | |
responsible_gaming_limit | 403 | limite pessoal excedido | |
servico_indisponivel | 503 | crédito do operador esgotado | |
deposit_unavailable | 503 | nenhum conector de recebimento configurado | |
withdrawal_unavailable | 503 | envio de saque desligado no ambiente | |
kyc_invalido | 422 | imagem fora do formato ou acima de 3 MB | |
limite_invalido | 422 | combinação de limite inexistente | |
limite_do_operador | 403 | limite imposto pelo operador | |
ja_excluido | 409 | exclusão igual ou maior já ativa | |
exclusao_invalida | 422 | nem prazo nem forever informados | |
pedido_invalido | 400/404/422 | cursor 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
| Evento | data |
|---|---|
bet.placed | bet_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.settled | bet_id, outcome (WIN/LOSE/VOID/PARTIAL), status, payout_cents |
bet.resettled | bet_id, de, para, payout_cents |
deposit.credited | reference, amount_cents, bettor_id, psp_payment_id |
withdrawal.approved | withdrawal_id, status |
withdrawal.settled | withdrawal_id, status |
withdrawal.returned | withdrawal_id, status |
bettor.kyc_reviewed | bettor_ref, bettor_id, status, note |
bettor.self_excluded | bettor_ref, bettor_id, until, forever |
invoice.issued | invoice_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 — inclusivebettor.kyc_reviewedebettor.self_excluded, que antes mandavam só obettor_idinterno do Skull Book. Obettor_idcontinua indo por compatibilidade e será removido numa versão futura do contrato, com aviso. Programe contrabettor_ref.
7.4 Entrega, repetição e garantias
- Sucesso é qualquer
2xx. Nosso timeout é de 8 segundos. - Repetição com espera exponencial: 1, 2, 4, 8, 16, 32 minutos e depois de hora em hora, até 10 tentativas; esgotadas, a entrega vira
DEADe aparece no painel para reenvio manual. - O aviso é gravado no mesmo commit do fato. Se a aposta não existiu, o aviso nunca existiu — não há processo que "descubra" fatos depois.
- Entrega ao menos uma vez, sem ordem garantida. Uma entrega em espera pode chegar depois de um evento mais novo. Trate os avisos como idempotentes, usando
X-Skull-Delivery, e nunca deduza estado da ordem de chegada: se receberbet.settledantes debet.placed, consulte. - O aviso é notificação, não fonte da verdade. Para valor, consulte a API.
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étodo | Rota | Corpo | Resposta |
|---|---|---|---|
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:
- Primeiro acesso — devolve
{totp_setup: {secret, otpauth_uri}}. Cadastre no aplicativo autenticador e chame de novo com o código para confirmar. - Segundo fator confirmado, código ausente — devolve
{totp_required: true}. - Código correto — devolve
{session_token, is_admin, operator_id}, um JWT de 12 horas usado no headerAuthorizationdas 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.
| Sandbox | Produção | |
|---|---|---|
| API e painel | book-sandbox.skull-api.xyz | book.skull-api.xyz |
| Documento do widget | embed-sandbox.skull-api.xyz | embed.skull-api.xyz |
| Dinheiro | falso | real |
| Integração de referência | demo.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
- Origens de produção cadastradas e verificadas; nenhuma origem de desenvolvimento (
localhost) sobrevivendo no cadastro de produção. - Chave de assinatura registrada com
kidpróprio; chave privada fora do repositório do operador; rotação combinada por escrito. - Handoff emitindo
jtiúnico por chamada,expcurto,frame_ideoriginecoados exatamente como recebidos. - Widget carregando na origem de produção, com o tema aplicado.
Avisos
- Endpoint de webhook em
https, assinatura verificada, resposta em menos de 5 segundos. - Idempotência por identificador de evento comprovada: o mesmo aviso entregue duas vezes não produz efeito duplo no lado do operador.
Carteira
- Modo escolhido e configurado.
- Se
managed: credencial de PSP cadastrada e com statusTESTED. - Se
seamless: os três requisitos inegociáveis atendidos, e os seis cenários de falha do item 11.3 executados e aprovados. - Se
transfer: transferência de ida e volta testada.
Risco e conformidade
- Limites de exposição e regras revisados (o assistente de perfis do painel serve de ponto de partida, não de resposta final).
- Limites de jogo responsável e autoexclusão testados de ponta a ponta.
- Termos comerciais definidos: percentual, mínimo, e modo de faturamento.
Prova de fogo, com dinheiro real mínimo
- Um depósito de R$1 creditando.
- Uma aposta aceita e aparecendo no extrato.
- Uma liquidação pagando.
- Um saque de R$1 chegando na conta, com identificador de ponta a ponta.
- Os quatro fatos acima conferidos no dinheiro, não no log.
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ário | Resultado esperado |
|---|---|---|
| 1 | Débito com saldo insuficiente | recusa limpa com código estável; nenhuma aposta criada |
| 2 | Mesmo tx_id enviado duas vezes | um único débito; segunda resposta idêntica à primeira |
| 3 | Timeout no débito, transação aplicada no operador | a consulta resolve; a aposta é aceita |
| 4 | Timeout no débito, transação inexistente | a consulta resolve; a aposta é recusada; nenhum estorno pendente |
| 5 | Estorno chegando antes do débito atrasado | a lápide impede o débito atrasado de ser aplicado |
| 6 | Crédito repetido | um único crédito |
O cenário 5 é o que separa uma integração correta de uma que perde dinheiro em produção sob carga.