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 — e nem ela, se a casa usar as contas do Skull Book (§4.8): aí o widget cadastra, autentica e recupera a senha do apostador, e o operador não mantém banco nem página de cadastro.
- 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 — em qualquer sentido, inclusive saindo do seamless — ou transação pendente na carteira do operador (prêmio, estorno ou cobrança ainda sem confirmação). 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.
Suspender o operador fecha a venda, não o pagamento: bilhetes já aceitos de uma casa suspensa continuam sendo liquidados, reconciliados e creditados na carteira dela. Desligar a casa (OFFBOARDED) é recusado enquanto houver bilhete em aberto ou transação de carteira pendente. 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.
POST /transfers (S2S, X-API-Key) com {bettor_ref, client_ref, amount_cents, direcao: "in" | "out"}. O client_ref é a sua chave de idempotência, inteira e por operador (2026-09-22): repetir a chamada com o mesmo client_ref e os mesmos parâmetros devolve 201 {status: "APPLIED", replayed: true} sem mover dinheiro de novo; o mesmo client_ref com outro valor, outro apostador ou outra direção é 409 tx_conflict, e nada se move. Saldo insuficiente na saída é 402 insufficient_funds. Nenhum caractere do client_ref é descartado, e dois operadores podem usar o mesmo sem se afetarem.
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; e ao cobrar a diferença quando um bilhete é reliquidado para um prêmio menor |
| Crédito | POST {base}/credit | ao liquidar um bilhete premiado; e ao pagar a diferença quando um bilhete é reliquidado para um prêmio maior |
| 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. A aposta ébet_<bilhete>, o prêmiowin_<bilhete>, o estornorb_<bilhete>, e a N-ésima reliquidação de um bilhete move só a DIFERENÇA do pagamento:rs<N>c_<bilhete>(crédito) ours<N>d_<bilhete>(débito). A cobrança de uma reliquidação só é enviada depois de os créditos daquele bilhete terem sido aplicados. Cobrança recusada (decisão da plataforma, 2026-09-22): se a sua carteira recusar o débito de uma reliquidação — tipicamente porque o apostador já gastou o prêmio —, a cobrança fica terminal na fila de revisão do painel e o valor é seu a recuperar ou absorver: o saldo e a relação com o apostador são seus, e o prêmio saiu da sua carteira. O Skull Book não refaz a cobrança sozinho, não a compensa com o GGR (a fatura segue o desfecho corrigido do bilhete) e não credita a diferença em lugar nenhum. - 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.
A mesma regra vale para as respostas de debit, credit e rollback: o corpo DEVE trazer o status. Um 200 sem status é tratado como desconhecido — nunca como aplicado —, e só APPLIED num débito aceita a aposta (ROLLED_BACK recusa; qualquer outra coisa entra no procedimento de incerteza).
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.
No crédito de prêmio (e na cobrança de uma reliquidação) a recusa explícita é final: repetir a mesma chamada daria a mesma resposta, então nós paramos de repetir, marcamos a transação como recusada e abrimos a pendência na fila de revisão do painel — um prêmio devido que a sua carteira não aceitou é assunto para resolver entre as equipes, não para um laço de tentativas. A exceção é ref_not_found, que reconsultamos algumas vezes (até 5, com espera crescente) antes de desistir. Indisponibilidade (5xx, timeout) continua sendo repetida até ter resposta.
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 |
| confirmado, mas a aposta cai depois (teto do livro, falha do nosso ledger, queda do nosso processo) | COMMITTED | 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.
A regra vale nos dois sentidos: todo bilhete que termina recusado depois de um débito que pode ter sido aplicado recebe o estorno desse débito, pelo mesmo rollback com lápide — não importa se quem recusou foi a sua carteira, o nosso limite de exposição ou uma falha nossa. Antes de estornar nós consultamos a transação: débito que nunca existiu não gera estorno.
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.
Operador que já integra a esoccerapi (2026-09-26): em vez de um adaptador de carteira, o Skull Book fala o contrato da esoccerapi — jogos, register síncrono antes do débito, aviso de resultado no corpo dela. O operador debita e credita sozinho (carteira operador). Ver CONTRATO-COMPAT-ESOCCERAPI.md.
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.
Sem site com login? Pule para o §4.8: no modo
identity_mode=booknão existe handoff, par de chaves nem endpoint no seu servidor.
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 |
nome | não | nome completo; só preenche quando vazio (como o cpf) |
nascimento | não | AAAA-MM-DD; só preenche quando vazio. Menor de 18 anos recusa a sessão (handoff_rejected) |
kyc | não | "verified" = você afirma, assinando, que verificou a identidade. Com kyc_mode=external, o apostador aparece verificado com origem operador (e o kid que assinou) por 365 dias, renovados a cada handoff |
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.
O event_id que você manda é o canônico, o mesmo da API do servidor e dos webhooks (fonte:esporte:id). Dentro do widget o jogo circula com um id opaco (§5); a tradução é do widget, e você não precisa fazer nada.
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.
4.8 Contas do Skull Book (identity_mode=book) — sem handoff (2026-09-22)
Para a casa que não tem site com login, ou não quer manter banco de apostadores: o widget cadastra, autentica, confirma o e-mail e recupera a senha. O snippet fica sem getHandoff:
<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' });
</script>O modo é do operador e se escolhe no painel (Configuração → Cadastro de apostadores). O servidor diz ao carregador qual é, na resposta do bootstrap — a página não escolhe. Regras, todas aplicadas no servidor:
- Carteira
managedobrigatória. Noseamlesse notransferquem conhece o apostador é o seu site; aqui é o book. Ligarbookcom outra carteira é recusado (409 carteira_incompativel), e trocar a carteira de uma casabooktambém. - KYC interno obrigatório. Quem cadastrou foi o book, então quem verifica também: aposta, depósito e saque só com KYC aprovado e não vencido (§5.9).
- A troca de modo só acontece antes do primeiro apostador (
409 apostadores_existentes). Um apostador de handoff não tem senha; uma conta do book não temsubno seu site — trocar tiraria o acesso deles ao próprio saldo. - Visitante navega sem conta. Vitrine, estatísticas, home e cotação funcionam antes do login; apostar, depositar, sacar e a conta pedem login.
- Handoff é recusado numa casa
book(handoff_rejected): seria apostador sem conta entrando pela porta dos fundos.
O que o cadastro coleta segue o art. 31 da Portaria SPA/MF nº 1.231/2024: nome completo, CPF (conferido pelos dígitos verificadores e imutável depois), data de nascimento (18+, no fuso de Brasília), gênero, nacionalidade, endereço, país de domicílio, telefone, e-mail, o IP do cadastro, a senha (letra + número + caractere especial), o aceite dos termos, a declaração de não impedimento (Lei 14.790/2023, art. 26) e a resposta obrigatória sobre publicidade (art. 11, VI — ofertar no cadastro). O documento com foto chega pelo KYC.
O que o book ainda não faz sozinho (dependem de fornecedor contratado): prova de vida por biometria facial (art. 31, §3º, I), conferência do CPF na base da Receita e consulta a listas de impedidos. Os três aparecem no checklist do KYC como pendentes — nunca como "ok" presumido. O e-mail sai por SMTP (BOOK_SMTP_URL); sem ele, confirmação de e-mail e recuperação de senha respondem 503 email_indisponivel.
Para o apostador de uma casa book, o bettor_ref de webhooks e exportações é gerado por nós, com prefixo bk_. A exportação bettors.csv traz o cadastro inteiro — é a sua base de apostadores.
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.
O id de evento aqui é opaco (desde 2026-09-23): ev:<esporte>:<código>. Ele identifica o jogo para o widget — a mesma partida tem sempre o mesmo código na sua casa, e códigos diferentes em casas diferentes — mas não diz de que fonte o jogo vem nem o id dele lá. Não tente interpretá-lo além do esporte (a segunda parte). A API do servidor (/v1), os webhooks e o painel continuam com o id canônico (fonte:esporte:id), que é o que a liquidação e a sua conciliação usam; as rotas abaixo também aceitam o canônico na entrada, de modo que um id vindo do seu catálogo funciona igual (ver §4.5).
A trilha do bilhete (GET /bets/{id} → timeline) traz só o ciclo de vida que o apostador entende — aceita, liquidada, re-liquidada, anulada, em revisão — como {kind, at}. A auditoria interna da liquidação fica no painel.
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} |
POST /visitante | nenhuma; exige Origin + Sec-Fetch-* | {frame_id, operator_slug} | {visitor_token, expires_in, identity_mode, wallet_mode, locale, …} — só casa book (§5.9) |
O bootstrap devolve também identity_mode (operator ou book), e as respostas de sessão trazem identity_mode.
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: [...], truncado: bool, total_na_vitrine: int, total_vendavel: int} | mercados ocultos pelo operador já vêm filtrados; seleção travada por kill-switch vem marcada suspended. truncado diz que a vitrine tinha mais eventos do que o teto de listagem (BOOK_LISTAGEM_LIMITE, 250) e total_na_vitrine diz quantos — a grade é parcial e a contagem por esporte que você derivar dela também. O corte é uma cota proporcional por fonte+esporte: cada grupo perde a mesma fração e nenhum vai a zero. ?vista=resumo (05/09): cada item traz só os mercados-chave do esporte (até 3: gol = 1X2/total_goals/handicap_line, ponto = 1X2/total_points/handicap_points, mesa = 1X2/handicap_points/total_points, com escada de reserva; cada um reduzido à fileira FT de preços mais equilibrados) mais mercados_total e ofertas_total; &extra=chave1,chave2 (até 3, só com resumo; 400 extra_demais/extra_sem_resumo) acrescenta ao resumo os mercados favoritos do apostador que o evento tiver, na mesma fileira principal; a lista cheia passou de 5 MB com os props do lote D. regras (02/10/2026): {market_key: texto} com a regra de liquidação dos mercados do item que têm uma (hoje, a contagem de cartões: "vermelho vale 2"), para a tela dizer junto do mercado; {} quando nenhum tem. No resumo, só as dos mercados que ficaram. |
GET /events?ids=a,b,c | mesma forma da lista (truncado: false, total_na_vitrine = ids pedidos), itens com TODOS os mercados vendáveis do evento (mesmas regras: oculto sai, sem preço sai, kill-switch trava) | é o detalhe (1 id) e o cupom (n pernas, reprecificadas por aqui a cada 2 s enquanto aberto); até 16 ids, 400 ids_demais acima; id que não está na vitrine simplesmente não vem; aceita vista=resumo junto; ETag/304 como a lista |
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, atraso de aposta ao vivo + reconferência da oferta, crédito do operador, e por fim o lançamento no ledger.
Aposta ao vivo tem atraso. Em evento em andamento o POST /bets só responde depois de live_bet_delay_seconds (padrão 3 s; configurável por operador e por escopo no painel — "Atraso ao vivo (s)"), e nesse instante a oferta é reconferida contra o catálogo: preço que mudou responde 409 odd_mudou; oferta suspensa, sumida ou vencida responde 409 quote_rejected. É a defesa contra quem vê o gol antes da nossa captura — o gol chega ao feed durante a espera. Preço parado é aceito. Pré-jogo não espera. Dimensione o timeout do seu cliente para o atraso mais o caminho quente (débito seamless, 1,5 s): 8 s é folga suficiente.
replayed: true significa que aquele client_ref já tinha sido usado por este mesmo apostador (bettor_ref) 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. O servidor confere o modo por conta própria: POST /withdrawals fora do managed responde 409 withdrawal_unavailable (2026-09-22) — nesses modos o saque é pelo site do operador.
| 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} |
Três regras que costumam surpreender:
- O depósito exige CPF, e o Pix tem de sair de conta do próprio apostador. Apostador sem CPF recebe
403 bettor_sem_cpfnoPOST /deposits(o mesmo código da aposta) e nenhuma cobrança chega ao provedor. A cobrança nasce vinculada ao CPF do apostador; quando o provedor informa quem pagou e o CPF é outro, o dinheiro não vira saldo: o depósito ficaUNMATCHEDcom o motivopagador_divergenteno painel, para o operador devolver ao pagador (Portaria SPA/MF 615/2024: conta de mesma titularidade). - 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). Os dois caminhos são o mesmo código, e os dois retomam o crédito que ficou pelo caminho: pagamento que o provedor já confirmou (status: "CONFIRMED") durante uma queda do nosso ledger é creditado na passada seguinte, sem nova pergunta ao provedor e sem crédito em dobro. O aviso deposit.credited sai uma vez só, por quem fez a transição.
Depósito não pago fica PENDING até o provedor encerrar a cobrança — não até um prazo nosso: FAILED quando ela expira ou falha, RETURNED quando é estornada. Um pagamento que chega depois de 24 h ainda é creditado: a conferência é de hora em hora até 72 h e diária até 30 dias. Passados 30 dias sem palavra final do provedor, a conferência automática para e o depósito fica no painel como "sem desfecho" (o aviso de webhook do provedor ainda o reabre).
Cripto pago a menos ou a mais. Credita o valor efetivamente recebido, em BRL pela cotação do próprio provedor (preço × recebido ÷ pedido na moeda, arredondado para baixo ao centavo). Nesse caso amount_cents de GET /deposits e GET /deposits/{id} é o valor creditado — o mesmo do extrato — e vêm também requested_amount_cents (o pedido) e recebimento ("parcial" ou "excedente").
Saque com aprovação (2026-08-26). O operador escolhe no painel entre aprovar automaticamente, aprovar sempre à mão ou só acima de um valor. No modo manual o pedido responde
status: "REQUESTED"(o dinheiro já saiu da conta gráfica) e viraAPPROVEDouRETURNEDpela decisão de alguém no painel — webhookswithdrawal.requested,withdrawal.approved,withdrawal.returned. No modo "acima de R$ X" o valor comparado é o pedido somado ao que o mesmo apostador pediu nas últimas 24 h, fora os que falharam ou voltaram (2026-09-22): fracionar o saque não escapa da revisão.
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 (inclui marketing_optin) | {prefs} |
POST /pulse (204) | {tela, event_id?, esporte?, liga?} | — |
GET /support | — | {items, nao_lidas, atendente_online} |
POST /support (201) | {assunto, corpo, categoria, bet_id?, motivo_codigo?, motivo_subcodigo?} | {id, estado} |
GET /support/{id} | — | a conversa com as mensagens |
POST /support/{id}/messages (201) | {corpo} | {id, estado} |
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.
GET /kyc (2026-09-22) devolve também verificado (o MESMO predicado do portão), vence_em, origem (operador, revisao ou provedor) e itens — o checklist, com o que falta à vista. status é o efetivo: aprovado vencido sai EXPIRED. Reenviar documentos com o KYC aprovado e vigente é recusado (409 pedido_invalido); vencido, é a revalidação anual.
Idade (+18). No modo operator é obrigação do OPERADOR: o handoff assinado é a afirmação de que o titular de sub é maior de idade. Se o handoff trouxer nascimento, o book confere e recusa menor; sem ele, o book não tem como conferir. No modo book, quem confere é o book: a data de nascimento é obrigatória no cadastro (18+ no fuso de Brasília) e a revisão do KYC confere o documento contra ela. O aviso +18 do widget é comunicação ao apostador, não uma trava.
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).
O limite de depósito soma, na janela, os depósitos pendentes, confirmados e creditados — o que é dinheiro ou pode virar. Cobrança que o provedor encerrou sem pagamento (FAILED) ou estornou (RETURNED) deixa de contar. Pedidos simultâneos do mesmo apostador são conferidos um de cada vez: não há como furar o limite disparando vários POST /deposits ao mesmo tempo.
Central de atendimento (2026-08-27). O SAC dentro do widget — direito do apostador pelo art. 23, IV da Portaria SPA/MF 1.231/2024, e canal obrigatório pelo art. 28, IX.
Ticket e chat são o mesmo objeto. Não há dois canais: há uma conversa. O GET /support devolve atendente_online, e é isso que muda o comportamento — com alguém do outro lado o widget promete resposta em minutos e faz poll de 3s (lê como chat); sem ninguém, promete resposta depois e cai para 20s (lê como ticket com SLA). A presença do atendente vale 2 minutos sem batida: não existe evento de saída confiável, então quem para de bater some sozinho, e a promessa nunca sobrevive a quem a faria.
Estados — sobre de quem é a vez, não sobre o processo interno da casa:
| Estado | Significa |
|---|---|
aberta | a bola está com a casa (entra na fila, o SLA corre) |
respondida | a bola está com o apostador |
fechada | encerrada — responder reabre |
A conversa pode nascer ancorada numa recusa. motivo_codigo carrega o código do erro que a originou (e motivo_subcodigo, quando o detalhe traz um — caso de partida_encerrando). É o CÓDIGO que viaja, nunca a mensagem: código é contrato, mensagem muda de idioma. Com ele o operador vê no painel a causa junto da reclamação, e o cruzamento "quantas reclamações por motivo" não exige parsear texto.
⚠️ motivo_codigo não é validado contra vocabulário, de propósito. Quem dispara é o apostador que acabou de levar a recusa; um código desconhecido não pode impedi-lo de pedir ajuda. O vocabulário é cobrado no CI, não no caminho do usuário.
O apostador só alcança as próprias conversas: o bettor_id da sessão entra em toda consulta, e id de conversa alheia responde 404, sem revelar que existe.
Retenção. Conversa de SAC é prova, não operacional: só a fechada é podada, o prazo conta do fechamento e o padrão é 5 anos (operators.suporte_retencao_dias, entre 180 e 3650). Conversa aberta nunca é apagada — seria transformar pedido sem resposta em dado inexistente.
Audiência do widget (2026-08-27). POST /pulse é telemetria: o widget diz em que tela o apostador está, e o operador vê isso no painel dele. Três regras:
telavem de um vocabulário FECHADO (inicio,grade,evento,cupom,conta,carteira,mensagens); qualquer outra →400 pedido_invalido.event_id,esporteeligasó valem emtela: "evento". Em outra tela são descartados, mesmo se enviados — senão quem voltasse para a grade continuaria contado como "assistindo" um jogo já fechado.- A identidade da aba é o
frame_idda sessão, nunca do corpo. O apostador não escolhe quantas abas ele parece ter.
Cadência esperada: a cada 30 s e a cada troca de tela. A rota falha em silêncio no servidor — telemetria não derruba navegação.
O que o operador enxerga: sessoes (abas abertas) e apostadores (pessoas distintas) são números diferentes e ambos aparecem — duas abas do mesmo apostador são 2 e 1. No histórico, pico é o máximo simultâneo e minutos_de_tela é a soma das amostras por minuto; somar minutos não dá visitantes únicos, dá atenção.
O histórico agregado não guarda bettor_id: é contagem, não lista. A presença com identidade é efêmera (podada em minutos), o histórico sem identidade vive 90 dias. É o princípio da 027 aplicado a analytics — dado que sobrevive muito tempo não pode dizer quem olhou o quê.
Consentimento de marketing (2026-08-27). PUT /preferences aceita marketing_optin (booleano), e o campo tem três estados — a diferença entre dois deles é normativa, não estética:
prefs.marketing_optin | Significa | Recebe promocional? | O widget oferta? |
|---|---|---|---|
| chave AUSENTE | nunca foi perguntado | não | sim — barra de oferta |
false | respondeu que não | não | não |
true | aderiu | sim | não |
O art. 11, VI da Portaria SPA/MF nº 1.231/2024 manda "ofertar aos apostadores, no momento do cadastro, a opção por aderir ou não" e garantir que possam alterá-la. Por isso o widget mostra a oferta enquanto a chave não existir, e um PUT parcial (só default_stake_cents, por exemplo) não grava marketing_optin — silêncio não vira recusa nem adesão.
Classe da mensagem (2026-08-27). Toda mensagem da casa tem uma classe, e ela decide quem a mensagem alcança:
| Classe | O que é | Alcança autoexcluído? | Exige marketing_optin? |
|---|---|---|---|
transacional | fato da conta: depósito, saque, KYC, manutenção | sim | não |
jogo_responsavel | alerta e sugestão de limite/autoteste/autoexclusão | sim | não |
promocional | publicidade: oferta, bônus, novidade | não | sim |
As duas primeiras alcançam todos de propósito: o excluído tem direito ao próprio dinheiro e ao próprio histórico (§ jogo responsável), e o art. 4º, VII manda enviar a sugestão de limite "independentemente de solicitação" — nem o opt-out a silencia. A terceira responde ao art. 11, VII ("abster-se de encaminhar material publicitário a apostadores que tenham solicitado sua autoexclusão e aos excluídos por decisão judicial").
A trava vale na LEITURA, não na escrita: uma promocional gravada antes de o apostador se autoexcluir deixa de aparecer para ele no instante da exclusão, sem processo agendado. O painel do operador mostra o alcance já descontado antes do envio, e alcance gravado na mensagem é a contagem daquele momento.
Mensagens AUTOMÁTICAS (2026-09-06). Além do que o operador escreve à mão, a plataforma manda mensagem sozinha em dois regimes:
Gatilhos — o operador liga no painel (Automação). Nascem desligados, e cada um tem classe FIXA, escolhida pelo gatilho e não pelo operador:
| Gatilho | Classe | Dispara quando |
|---|---|---|
deposito_confirmado | transacional | o Pix do depósito é creditado |
saque_pago | transacional | o Pix do saque é liquidado |
bilhete_premiado | transacional | um bilhete é liquidado como ganho |
conta_limitada | transacional | a casa bloqueia a conta |
saldo_baixo | promocional | apostou nas últimas 24h e o saldo caiu abaixo do valor |
sem_apostar_dias | promocional | já apostou e está parado há N dias |
sem_primeira_aposta | promocional | cadastrado há N dias e nunca apostou |
Escada de jogo responsável — risco_alerta → risco_limite → risco_autoteste → risco_autoexclusao. Não se desliga (art. 4º, VII: a sugestão vai "independentemente de solicitação"); o operador só reescreve o texto, e apagar a regra faz voltar ao texto padrão da plataforma. Um degrau por semana, sem pular e sem descer.
O perfil de risco que alimenta a escada é explicável por exigência do art. 5º, III: sinais nomeados com peso à vista (chasing +25, perda_liquida +20, escalada_de_stake +20, frequencia +15, limite_proprio_no_teto +15, madrugada +10, derrotas_seguidas +10), alto a partir de 40. Os limiares são da plataforma, não do operador — metodologia que cada casa calibrasse deixaria de ser descritível na política.
Antes de cada envio automático, quatro travas, nesta ordem:
| Trava | Alcança | Motivo gravado |
|---|---|---|
| autoexclusão / exclusão judicial | só promocional | autoexclusao |
sem marketing_optin | só promocional | sem_optin |
perfil de risco alto | só promocional | risco_alto |
| horário de silêncio da casa | só promocional | silencio |
| teto semanal de publicidade | só promocional | cap_da_classe |
| teto da própria regra | todas as classes | cap_da_regra |
A supressão é gravada com o motivo, não apenas evitada: é a linha que responde "por que este autoexcluído recebeu publicidade?". O painel mostra enviadas e suprimidas lado a lado. O teto semanal conta só o AUTOMÁTICO — o que o operador compõe à mão no painel não é governado pelo motor.
Periodicidade declarada (art. 5º, II da Portaria SPA/MF 1.231/2024) — decisão de 2026-09-08 (L9 do livro consolidado): a periodicidade que a plataforma declara é a das comunicações automáticas — o teto semanal por classe, gravado e auditável por disparo. Comunicações que o operador compõe à mão no painel são de responsabilidade do operador, que se obriga por contrato a respeitar a mesma periodicidade declarada; a plataforma registra cada envio manual com autor e data, mas não o soma ao teto — fazer o motor censurar o operador foi decisão explícita da 043 ("o motor não é censor do operador"). Esta cláusula existe para que a resposta a um fiscal seja uma frase, não uma investigação: o automático é governado e medido; o manual é declarado e assinado.
Recompensa PÓS-FATO (2026-09-06) — e por que não existe bônus. A plataforma credita recompensa de campanha de fidelidade em dinheiro sacável, na mesma conta do saldo, como um ajuste de categoria reward. Não é bônus, e a diferença é a lei:
Lei nº 14.790/2023, art. 29, I — é vedado ao agente operador conceder, sob qualquer forma, adiantamento, antecipação, bonificação ou vantagem prévia, ainda que a mero título de promoção, para a realização de aposta.
A SPA/MF, na FAQ de Questões Técnicas, confirma (Q33: bônus de entrada e de boas-vindas estão vedados) e delimita o que sobra (Q78: promoções e programas de fidelidade são permitidos, desde que a recompensa não seja incentivo a cadastro ou primeira aposta, nem condicionada a aporte financeiro).
O desenho segue essa linha em quatro pontos, e cada um é verificável:
| Ponto | Como fica | O que ele evita |
|---|---|---|
| Dinheiro sacável, sem rollover | ajuste reward em player:<id>:cash | vantagem "para apostar" (art. 29, I) |
| Período encerrado | paga a semana ISO ou o mês que já terminou | antecipação sobre aposta que não aconteceu |
| Base = perda líquida ou volume apostado | depósito não entra em conta nenhuma | recompensa condicionada a aporte (Q78) |
Só duas campanhas: cashback_semanal, fidelidade_mensal | não existe gatilho de boas-vindas | incentivo a cadastro/primeira aposta (Q78) |
Antes de cada crédito, cinco travas, e a supressão é gravada com o motivo: autoexclusao, risco_alto (perfil alto não recebe — cashback por perda mira quem perdeu, e é quem o art. 4º, VII manda proteger), abaixo_do_piso, orcamento_da_campanha e o teto por apostador. Pagar duas vezes o mesmo período é impossível por chave única (operador, campanha, apostador, período).
O que a plataforma NÃO valida no seu texto (2026-09-06). A vedação do art. 29 alcança a comunicação, não só o lançamento contábil. Nós travamos o que é nosso: o motor não credita bonificação, não paga período em curso e não condiciona recompensa a depósito. O texto que você escreve é seu — títulos de cartaz da home, rótulos de botão e mensagens compostas no painel passam por validação de idioma, tamanho e HTML, e por nenhuma validação de conteúdo.
Isso significa que um cartaz escrito "bônus de 100% no primeiro depósito" é publicável pela API e é uma infração sua, não um recurso da plataforma. Como agente operador da Lei 14.790/2023, a responsabilidade pelo material de divulgação é de quem detém a outorga. Três frases que não devem aparecer em texto nenhum do seu widget:
- qualquer promessa de bônus, bonificação ou crédito para apostar;
- qualquer vantagem condicionada a depósito ("deposite X e ganhe Y");
- qualquer recompensa oferecida como incentivo a cadastro ou primeira aposta.
O que É publicável: recompensa já creditada (o próprio widget avisa por mensagem transacional, com valor e período), programa de fidelidade descrito sem promessa de vantagem prévia, e conteúdo de produto.
Mercados favoritos (2026-08-26). PUT /preferences aceita favorite_markets: lista de até 50 chaves esporte:market_key (ex.: football:total_goals). O widget mostra uma estrela em cada mercado do detalhe, agrupa os favoritos no topo e, na grade, troca as odds da linha pelas do primeiro favorito que a partida oferta. Chave fora do formato → 400 pedido_invalido.
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.
5.7 Mensagens da casa (2026-08-26)
O operador escreve no painel (para um apostador, um segmento ou todos); o widget mostra o sino e, quando importante, uma faixa no topo até ser dispensada.
| Método | Rota | Resposta |
|---|---|---|
GET | /messages?limit=20 | {items: [{id, titulo, corpo, cta_rotulo, cta_url, importante, criado_em, lida_em}], nao_lidas} — textos já no idioma da sessão |
POST | /messages/{id}/read | {ok: true} (idempotente) |
Só mensagens não expiradas e destinadas ao apostador. Apostador excluído continua lendo avisos. O operador recebe o webhook message.created ao enviar.
5.8 Marca da casa (2026-08-26) — público, por slug
GET /embed/v1/{slug}/branding não exige sessão: é o que o widget mostra antes de qualquer login (nome no cabeçalho, ícone, aviso +18). Cache de 60 s.
{ "nome": "Casa Demo", "tagline": {"pt-BR": "Aposte com quem entende"},
"icone": "trophy", "icone_estilo": "circulo",
"mostrar_18": true, "aviso_cookies": true,
"legal": { "termos": {"pt-BR": "…"}, "privacidade": {…}, "cookies": {…}, "lgpd": {…} },
"atualizado_em": "2026-08-26T20:00:00+00:00" }nomenulo → o widget mostra "Esportes".iconeé um nome da lista curada do Lucide embarcada no widget (o painel mostra a lista; nome fora dela é recusado).- As quatro páginas legais sempre vêm preenchidas: o que o operador não escreveu sai do texto-modelo com o nome da casa — um ponto de partida, não aconselhamento jurídico. Slug desconhecido →
404 operador_nao_encontrado.
5.9 Conta do apostador e KYC com "verificado" (2026-09-22)
Visitante (só casa book). POST /visitante consome o frame (uso único, só dentro de iframe) e devolve um visitor_token de 12 h. Ele é Bearer nas rotas de leitura (GET /events, /events/resultados, /events/{id}/stats, /events/{id}/lineups, POST /quote, GET /theme, GET /home) e nas rotas de conta marcadas "visitante" abaixo; em qualquer outra rota responde 401 login_necessario.
| Rota | Bearer | Corpo | Resposta |
|---|---|---|---|
POST /conta/cadastro (201) | visitante + Sec-Fetch-* | nome_completo, cpf, data_nascimento, genero, nacionalidade, email, telefone, endereco{cep, logradouro, numero, complemento, bairro, cidade, uf}, pais_domicilio, senha, aceite_termos, declaracao_nao_impedido, marketing_optin | sessão + conta |
POST /conta/entrar | visitante + Sec-Fetch-* | {identificador, senha} (e-mail ou CPF) | sessão + conta |
POST /conta/retomar | visitante + Sec-Fetch-* | {refresh_token} | sessão + conta |
POST /conta/senha/codigo (202) | visitante | {identificador} | {enviado: true} — sempre, com ou sem conta |
POST /conta/senha/redefinir | visitante | {identificador, codigo, nova_senha} | {ok: true} |
GET /conta | sessão | — | perfil (dados, e-mail, telefone, endereço, kyc) — nos dois modos |
PATCH /conta | sessão (book) | {telefone?, endereco?} | perfil |
POST /conta/sair (204) | sessão | — | — |
POST /conta/email/codigo (202) | sessão (book) | — | {enviado, expira_em_s} |
POST /conta/email/confirmar | sessão (book) | {codigo} | perfil |
POST /conta/senha/trocar | sessão (book) | {senha_atual, nova_senha} | {ok: true} |
A sessão emitida aqui é a mesma do handoff (access de 5 min, refresh rotativo com revogação da família por reuso). Retomar ("continuar conectado") troca um refresh guardado no storage do documento do embed por uma sessão no frame do visitante de agora — só para a mesma casa e a mesma origem. Sair revoga a família da sessão. Redefinir a senha revoga todas as sessões do apostador; trocar revoga as outras.
Login não diz se a conta existe: e-mail/CPF desconhecido e senha errada respondem igual (401 credenciais_invalidas), no mesmo tempo. Cinco erros seguidos travam a conta por 15 min (429 conta_travada, com retry_after_s) — nem a senha certa entra. O apostador autoexcluído ou bloqueado entra: o dinheiro e o histórico são dele; os portões de dinheiro continuam valendo.
Códigos por e-mail: 6 dígitos, 15 minutos, 5 tentativas, um vivo por finalidade (pedir outro anula o anterior); guardados como HMAC. Errado, vencido, esgotado ou usado respondem igual: 422 codigo_invalido.
"Verificado" é kyc_status = APPROVED e não vencido — um predicado só, usado pelo portão, pelo widget e pelo painel. A aprovação vale 365 dias (art. 34: revalidação anual); vencida, o portão fecha com kyc_required (status_kyc: "EXPIRED"). Aprovar no painel exige o revisor marcar o checklist — documento com foto válido, dados do documento = cadastro, selfie = pessoa do documento — e é recusado se o cadastro der menos de 18 anos. O webhook bettor.kyc_reviewed ganhou verificado e origem.
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 (stake, odd, pernas, retorno), kill-switch ou exposição | |
insufficient_funds | 402 | saldo insuficiente | |
bettor_not_eligible | 403 | apostador inexistente ou não ACTIVE | |
bettor_sem_cpf | 403 | apostador sem CPF cadastrado (aposta e depósito) | |
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; ou aposta que não pôde ser confirmada do nosso lado (nada foi aceito, o dinheiro volta — tente novamente) | |
carteira_indisponivel | 503 | modo seamless: a carteira do operador não respondeu, não está cadastrada, ou o débito não foi confirmado (estorno já agendado) | |
deposit_unavailable | 503 | nenhum conector de recebimento configurado | |
withdrawal_unavailable | 503/409 | 503: envio de saque desligado no ambiente ou sem conector Pix-out; 409: a carteira da casa não é managed (o saque é pelo site do operador) | |
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 |
6.4 Conta do apostador (2026-09-22)
| Código | HTTP | Quando | Extras |
|---|---|---|---|
visitante_recusado | 403/401 | visitante sem metadados de iframe, frame inválido/usado, ou rota de visitante chamada com outro token | |
identidade_do_operador | 403 | rota do modo book numa casa operator (ou o contrário) | |
login_necessario | 401 | token de visitante numa rota que exige conta | |
cadastro_invalido | 422 | campos do cadastro/perfil recusados | campos: {campo: código} (obrigatorio, nome_incompleto, cpf_invalido, data_invalida, email_invalido, telefone_invalido, cep_invalido, senha_fraca, aceite_obrigatorio, invalido) e mensagens: {campo: frase em pt-BR} |
cpf_ja_cadastrado | 409 | já existe conta com o CPF nesta casa | |
email_ja_cadastrado | 409 | já existe conta com o e-mail nesta casa | |
menor_de_idade | 403 | nascimento com menos de 18 anos | |
credenciais_invalidas | 401 | identificador ou senha errados (não diz qual) | |
conta_travada | 429 | 5 erros seguidos de senha | retry_after_s |
codigo_invalido | 422 | código errado, vencido, esgotado ou usado | |
email_indisponivel | 503 | casa sem SMTP configurado, ou o envio falhou | |
senha_fraca | 422 | senha fora da regra (8+, letra, número e especial) |
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 (o valor creditado), bettor_id, psp_payment_id; cripto pago a menos ou a mais traz também recebimento (parcial/excedente) e requested_amount_cents |
withdrawal.requested | withdrawal_id, status, amount_cents, bettor_id, auditoria_pendente — saque esperando decisão no painel |
withdrawal.approved | withdrawal_id, status, decided_by (quando foi decisão humana) |
withdrawal.settled | withdrawal_id, status, e2e_id, decided_by (quando foi resolução humana) — sai quando o ledger lançou a saída |
withdrawal.returned | withdrawal_id, status, decided_by e/ou motivo (recusado_pelo_psp, devolvido_pelo_psp, resolucao_humana) — sai quando o dinheiro voltou à conta gráfica |
withdrawal.indeterminate | withdrawal_id, status — o envio ao PSP ficou sem resposta; o worker reconsulta o PSP a cada ciclo, e o painel tem a saída humana (§9.2). Exige atenção |
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, pago_pelo_credito_cents (quanto o crédito pré-pago paga desta fatura), saldo_devedor_cents (o que falta pagar; 0 = a fatura já nasce quitada pelo crédito), cassino_ggr_cents, revshare_cassino_cents e cassino_incluido (true: o cassino paga a mesma escala do esporte sobre o GGR dele, na faixa própria, e amount_cents já o inclui; o mínimo mensal é um só) — ggr_cents é o GGR por fato do mês: bilhetes pela data da primeira liquidação, mais o delta das reliquidações feitas no mês (uma reliquidação não reabre a fatura do mês original; ela entra no mês em que aconteceu) |
message.triggered | message_id, chave, classe, bettor_id — uma mensagem AUTOMÁTICA saiu (o operador com CRM próprio pluga o dele aqui). Supressão não emite evento: o que não foi entregue não é um fato do apostador, e a auditoria dela mora no painel |
bettor.adjusted (categoria reward) | o crédito da recompensa reusa o evento de ajuste que já existia, com category: "reward" e o reason no formato <campanha> · <período> · <base>. Não há evento novo: para o operador, recompensa é um ajuste de saldo — o que muda é quem o originou |
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 |
GET | /bets/settled?since=&limit= | — | {bets[], since, proximo_since, sobreposicao_s, limit, mais} — liquidações por CURSOR |
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.
O saque S2S segue as mesmas regras do widget (2026-09-22): só no modo managed (fora dele, 409 modo_incompativel), com kyc_mode=internal só para apostador com KYC aprovado e não vencido (403 kyc_required, com status_kyc), e a chave Pix do próprio titular (422).
Liquidações por cursor (GET /bets/settled, 2026-08-26). É o canal de PULL do webhook bet.settled: o webhook continua sendo o caminho principal, mas vira DEAD depois de 10 tentativas — e um WAF que bloqueie o nosso IP mata a entrega em silêncio. Com o pull o operador puxa, no ritmo dele:
- Guarde o maior
settled_atque você já processou (o cursor). - Chame
GET /bets/settled?since=<cursor − 30 s>&limit=500(ou simplesmente use oproximo_sinceda resposta anterior — ele já vem com os 30 s descontados). - A SOBREPOSIÇÃO de 30 s é de propósito: bilhetes já vistos voltam. Liquide por
client_refde forma idempotente (a segunda vez é no-op) — push e pull são dois canais do MESMO evento e podem entregar o mesmo bilhete duas vezes. mais: truesignifica que a página encheu: chame de novo comproximo_since.sinceinválido é400; semsincea janela é a última hora. Ordem:settled_atcrescente. Só o seu operador (RLS + filtro explícito).
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, perfil de risco por apostador com os sinais à vista); automação de mensagens (gatilhos, horário de silêncio, teto semanal de publicidade e a trilha de disparos, com as suprimidas e o motivo); recompensa (campanhas de cashback e fidelidade, com percentual, teto por apostador, piso, orçamento do período e a trilha de creditadas e suprimidas); 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.
Acrescentado em 2026-08-26. Visão geral (alertas, KPIs, primeiros passos); fila de saques com política de aprovação (auto / manual / acima de R$ X), SLA de 2 h, aprovar e devolver com motivo; ajuste manual de saldo do apostador (crédito/débito com motivo, teto por operador, trilha e webhook bettor.adjusted); ficha 360° do apostador (saldo do ledger, extrato, apostas, depósitos, saques, limites, exclusão, KYC, notas internas, tags, bloquear apostas); depósitos com detalhe, pendentes há mais de X minutos e conciliação diária (cada depósito do book procurado no ledger pela reference; o extrato do PSP como laudo diário, só para PSP que expõe extrato); mensagens para um apostador, um segmento (ativos, saque pendente, KYC, tag) ou todos; bilhete com o evento de cada perna (placar, estado) e "Sugerir liquidação" — a mesma máquina do settler em dry-run, com "Aplicar" pelo caminho de liquidação existente; home por esporte (hub) no editor da Home; tema com tokens derivados (avançado).
Saques: a decisão antes do dinheiro (2026-09-22). Aprovar e devolver são decisões gravadas na linha do saque ANTES de qualquer dinheiro se mover. Dois operadores no mesmo item da fila: só a primeira decisão vale, e a outra recebe 409 transicao_invalida sem mover nada. A devolução responde ledger_sync: COMMITTED (o dinheiro voltou à conta gráfica) ou PENDING (decisão gravada; o book-worker conclui o lançamento no ledger). Um pedido cujo débito o ledger não confirmou — queda no meio do pedido — não é decidível pelo painel: o book-worker pergunta ao ledger pela referência e o conclui (voltando à política de aprovação do operador) ou, passados 10 minutos sem débito, o marca FAILED sem mover dinheiro.
Ajuste manual e recompensa movem a custódia (2026-09-22). No modo managed, o crédito manual (e o crédito de recompensa, que usa o mesmo caminho) passa o valor do caixa do operador para a custódia segregada no PSP de entrada dele, e o débito faz o inverso — como um prêmio de aposta. É o que mantém a custódia igual ao que se deve aos apostadores e o que permite sacar um crédito manual. No transfer o ajuste não toca custódia de PSP (o dinheiro está com o operador); no seamless o ajuste é recusado (422 ajuste_invalido: o saldo vive na carteira do operador) e as campanhas de recompensa não rodam. Uma recompensa que falhou por teto de ajuste do operador ou por recusa do ledger é tentada de novo a cada passada (de hora em hora) enquanto o período ainda é o último encerrado; uma cujo crédito ficou incerto é concluída pela mesma chave, sem pagar duas vezes. Não há limite de apostadores por campanha: o lote de cada passada pula quem já tem registro no período.
Ajuste manual idempotente (2026-09-22). POST /admin/v1/operators/{id}/bettors/{bid}/adjustments aceita idempotency_key (8–80 caracteres [A-Za-z0-9:_-]), uma por intenção — o painel gera ao abrir o formulário. Repetir com a mesma chave devolve o MESMO ajuste em vez de lançar outro; a mesma chave com outro valor, tipo, categoria ou apostador é 409 chave_reutilizada. Se o ledger não confirmar a tempo, a resposta é 503 ajuste_indeterminado: o lançamento pode ter entrado, e repetir com a mesma chave é seguro. De qualquer jeito, o book-worker conclui o ajuste interrompido pela referência no ledger (trilha e webhook bettor.adjusted uma vez só) ou, passados 10 minutos sem lançamento, o marca FAILED sem mover dinheiro.
Saque INDETERMINATE: o que é e como sair (2026-09-22). Só um caso leva a ele: o envio do Pix ao PSP ficou sem resposta (timeout, erro 5xx, aprovação recusada depois de o pedido existir no PSP) — não sabemos se o dinheiro saiu, e reenviar é o único jeito de pagar duas vezes. Os outros desfechos têm destino próprio: consulta que falhou por rede deixa o saque SENT e pergunta de novo; recusa limpa do PSP (4xx ao criar o pagamento) devolve o dinheiro (withdrawal.returned, motivo: recusado_pelo_psp); o PSP pedindo para esperar (429) mantém APPROVED; ledger fora do ar depois de o PSP confirmar deixa o saque SETTLED com o lançamento pendente, que o worker conclui. Saindo de INDETERMINATE: (1) o worker reconsulta o PSP a cada ciclo e liquida ou devolve sozinho se o PSP souber a resposta; (2) se o PSP não souber, o operador confere no painel/extrato do PSP e usa Resolver no detalhe do saque — POST /admin/v1/operators/{id}/withdrawals/{wid}/resolve com {decisao: "pago" | "nao_pago", evidencia, e2e_id?}. "Pago" liquida; "não pago" devolve à conta do apostador. A evidência (mín. 10 caracteres: o que foi conferido, onde, quando) é obrigatória e fica na trilha (withdrawal.indeterminate_resolved) com o autor. Nunca reenvie o Pix por fora do book: se o PSP depois concluir o pagamento original, o apostador recebe duas vezes.
Webhook do PSP (2026-09-22). Cadastrar a credencial de um PSP devolve, uma única vez, o caminho e o token do aviso daquele PSP (webhook: {caminho: "/psp/<psp>/<slug>", token, como_enviar}); recadastrar gera outro token. Configure-o no painel do PSP: na Woovi, a URL do webhook com o token no campo authorization; no NOWPayments, a URL do IPN terminando em ?token=<token> (ele não permite cabeçalho). O aviso só acelera a conferência: o valor e o status do corpo são ignorados, e sem aviso o depósito é creditado do mesmo jeito, na varredura seguinte.
Marca & páginas (2026-08-26). GET/PUT /admin/v1/operators/{id}/branding (merge parcial: nome, tagline, ícone e estilo, mostrar_18, aviso_cookies, legal por página) e POST …/branding/legal/{pagina}/restaurar (volta ao modelo). O GET traz o efetivo, personalizadas (páginas escritas pelo operador) e padroes.legal. Auditado como branding.update / branding.legal_restaurada. A aba Tema ganhou 15 temas prontos com contraste conferido e filtro claros/escuros.
9.3 Exportações
GET /admin/v1/operators/{id}/exports/{tipo}.csv?from=&to= com tipo em bets, transactions ou bettors. from e to são DIAS YYYY-MM-DD, os dois inclusivos, cortados no fuso de São Paulo (da meia-noite do primeiro à meia-noite seguinte ao último) — o mesmo corte da fatura. Data com hora, ou to anterior a from, é recusada com 422. Os horários no arquivo saem em UTC, com o deslocamento. O arquivo traz SEMPRE a linha de cabeçalho, mesmo sem linhas no período.
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, a batida dos processos de fundo (pagamentos, liquidação e vigia: idade e se parou) e, quando há catálogo montado, a idade dos dados e um resumo do vigia por esporte — se cada esporte está sendo ofertado e com que frescor, sem dizer de qual fornecedor vem. Sobre dinheiro ele diz uma coisa só: se há alarme aberto (alarme_de_dinheiro: sim, não, ou null quando o vigia não conseguiu julgar) — nunca operador, saldo, crédito, exposição ou saque. 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.
O detalhe — cada alarme de dinheiro por operador, o estado do canal de alerta, o laudo de cada processo e o estado de cada fonte de dados — fica em GET /admin/v1/saude, no painel, restrito ao dono da plataforma.
| 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. Desde 22/set a regra vale também para os segredos de dinheiro: o book-webhook não recebe a credencial do ledger, a chave que decifra as credenciais de PSP nem as da cobrança em cripto — só as três variáveis que usa, conferidas por um gate — e não está na rede interna do ledger.
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. E "não sei" é só o que de fato não se sabe: no saque, consulta que falhou, recusa limpa do PSP e ledger caído depois do Pix confirmado têm destino próprio. Do INDETERMINATE se sai pela verdade — a reconsulta ao PSP ou a decisão humana com evidência (§9.2) —, nunca por reenvio.
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, saque enviado que não confirma no prazo regulatório e saque com o lançamento contábil pendente; depósito pago pelo PSP que não virou saldo; bilhete preso no meio da liquidação, bilhete aberto além do prazo da fonte e bilhete liquidado sem valor gravado; transação da carteira do operador sem resolução e débito de aposta recusada sem estorno; mês encerrado sem fatura; webhook de operador esgotado (DEAD); processo de fundo parado (o de pagamentos e o de liquidação); exposição acima do teto; crédito pré-pago do operador se esgotando. Operador suspenso ou desligado continua vigiado enquanto houver dinheiro em aberto, e "não consegui olhar" é alarme, nunca "tudo certo". O canal de alerta é conferido no boot e a cada envio (canal ausente ou recusando é alarme por si só), os alarmes de dinheiro não dependem do cache da vitrine, e o próprio vigia é vigiado: o processo de pagamentos avisa quando ele para. 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. Todo dia o laudo compara também o book com o ledger — cada depósito confirmado procurado pela reference —, o que é sempre possível. PSP que não expõe extrato (Woovi e NOWPayments não expõem) aparece no laudo como "indisponível por desenho", e não como conciliação parada.
11. Identidade. kyc_mode=external é o padrão: o handoff assinado é a afirmação de identidade, e a responsabilidade é do operador (o claim kyc: "verified" deixa isso registrado, com o kid). kyc_mode=internal faz o iframe coletar documentos e o operador aprovar no painel, com checklist, bloqueando aposta, depósito e saque até a aprovação — e de novo quando ela vence, em 365 dias. Casa com identity_mode=book é sempre internal.
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.