Contrato de Integração

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

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

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

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


1. Como a integração é feita

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

São quatro peças, e só a segunda exige código do lado do operador — 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.

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

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


2. Antes de começar: credenciais

O operador recebe, pelo painel:

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

Além disso é preciso cadastrar, no painel:


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

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

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

Trocar de modo é recusado enquanto houver bilhete em aberto — 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çãoCaminhoQuando chamamos
SaldoPOST {base}/balanceao abrir o widget e após cada movimento
DébitoPOST {base}/debitao aceitar uma aposta; e ao cobrar a diferença quando um bilhete é reliquidado para um prêmio menor
CréditoPOST {base}/creditao liquidar um bilhete premiado; e ao pagar a diferença quando um bilhete é reliquidado para um prêmio maior
EstornoPOST {base}/rollbackao anular, ou ao desfazer um débito incerto
ConsultaGET {base}/transaction/{tx_id}sempre que não soubermos o desfecho

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

3.4.2 Identificadores

3.4.3 Idempotência (obrigatória)

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

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

3.4.4 Consulta: 200 sempre, com estado explícito

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

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

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ódigoSignificaNossa reação
insufficient_fundssaldo insuficienterecusa educada da aposta
player_not_foundjogador desconhecidorecusa + alarme
player_blockedbloqueado/autoexcluído lárecusa educada
tx_conflictmesmo tx_id, outros parâmetrosrecusa + alarme
currency_mismatchmoeda divergenterecusa + alarme
ref_not_foundref_tx_id não existereconsulta

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

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 é:

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

3.4.7 O estorno precisa deixar lápide

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

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

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

3.4.8 Valores e moeda

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

3.4.9 Autenticação

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

3.4.10 O que acontece do nosso lado

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

Resultado do débitoEstadoBilhete
confirmadoCOMMITTEDOPEN
recusa explícitaFAILEDREJECTED, com o motivo
desconhecido após reconsultaINDETERMINATEREJECTED + estorno agendado
confirmado, mas a aposta cai depois (teto do livro, falha do nosso ledger, queda do nosso processo)COMMITTEDREJECTED + 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:

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

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

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

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

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


4. Instalando o iframe

4.1 O caminho inteiro, de uma vez

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

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

Sem site com login? Pule para o §4.8: no modo identity_mode=book nã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.pub

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

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

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

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

4.3 O endpoint de handoff

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

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

Header do JWT

CampoValor
algES256 ou RS256
kido kid cadastrado

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

Claims

ClaimObrigatórioO que é
audsimexatamente skull-embed
subsimo id do apostador no seu sistema; vira o bettor_ref em toda a integração
jtisimnonce de uso único — gere um novo a cada chamada
expsimexpiração; use algo curto, na casa de 60 s
frame_idsimexatamente o valor recebido em getHandoff
originsimexatamente o valor recebido em getHandoff
cpfnão11 dígitos, só números
nomenãonome completo; só preenche quando vazio (como o cpf)
nascimentonãoAAAA-MM-DD; só preenche quando vazio. Menor de 18 anos recusa a sessão (handoff_rejected)
kycnã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
tenantnãoseu slug; se vier, tem de bater com o dono da chave

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

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

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

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

Em Node, o equivalente com jsonwebtoken:

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

4.4 O snippet

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

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

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

4.5 Conversa entre a sua página e o widget

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

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

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

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:

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

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

4.6 Origens, testes e o que o navegador barra

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

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

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

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

4.7 Aparência

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

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

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

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:

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

RotaAuthCorpoResposta
GET /bootstrap?pk=nenhuma; exige Origin—{frame_id, operator_slug, expires_in}
POST /sessionnenhuma; exige Origin + Sec-Fetch-*{handoff, frame_id}{access_token, expires_in, refresh_token}
POST /session/refreshnenhuma; exige Origin{refresh_token}{access_token, expires_in, refresh_token}
POST /visitantenenhuma; 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.

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

5.2 Oferta e cotação

RotaRespostaObservação
GET /events{items: [...], 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,cmesma 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.

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

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

5.4 Carteira, depósito e saque

Estas rotas existem no modo managed. No modo seamless o widget não as usa (o saldo vem da carteira do operador) e no modo transfer só a carteira e o extrato aparecem. 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.

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

Três regras que costumam surpreender:

O crédito do depósito é feito por processo de fundo. A consulta GET /deposits/{id} também dispara uma reconsulta ao provedor, o que acelera o caso em que o apostador está com a tela aberta — mas o crédito não depende disso (ver capítulo 11, decisão 5). 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 vira APPROVED ou RETURNED pela decisão de alguém no painel — webhooks withdrawal.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

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

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:

EstadoSignifica
abertaa bola está com a casa (entra na fila, o SLA corre)
respondidaa bola está com o apostador
fechadaencerrada — 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:

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_optinSignificaRecebe promocional?O widget oferta?
chave AUSENTEnunca foi perguntadonãosim — barra de oferta
falserespondeu que nãonãonão
trueaderiusimnã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:

ClasseO que éAlcança autoexcluído?Exige marketing_optin?
transacionalfato da conta: depósito, saque, KYC, manutençãosimnão
jogo_responsavelalerta e sugestão de limite/autoteste/autoexclusãosimnão
promocionalpublicidade: oferta, bônus, novidadenãosim

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:

GatilhoClasseDispara quando
deposito_confirmadotransacionalo Pix do depósito é creditado
saque_pagotransacionalo Pix do saque é liquidado
bilhete_premiadotransacionalum bilhete é liquidado como ganho
conta_limitadatransacionala casa bloqueia a conta
saldo_baixopromocionalapostou nas últimas 24h e o saldo caiu abaixo do valor
sem_apostar_diaspromocionaljá apostou e está parado há N dias
sem_primeira_apostapromocionalcadastrado 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:

TravaAlcançaMotivo gravado
autoexclusão / exclusão judicialsó promocionalautoexclusao
sem marketing_optinsó promocionalsem_optin
perfil de risco altosó promocionalrisco_alto
horário de silêncio da casasó promocionalsilencio
teto semanal de publicidadesó promocionalcap_da_classe
teto da própria regratodas as classescap_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:

PontoComo ficaO que ele evita
Dinheiro sacável, sem rolloverajuste reward em player:<id>:cashvantagem "para apostar" (art. 29, I)
Período encerradopaga a semana ISO ou o mês que já terminouantecipação sobre aposta que não aconteceu
Base = perda líquida ou volume apostadodepósito não entra em conta nenhumarecompensa condicionada a aporte (Q78)
Só duas campanhas: cashback_semanal, fidelidade_mensalnão existe gatilho de boas-vindasincentivo 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:

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étodoRotaResposta
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" }

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.

RotaBearerCorpoResposta
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_optinsessão + conta
POST /conta/entrarvisitante + Sec-Fetch-*{identificador, senha} (e-mail ou CPF)sessão + conta
POST /conta/retomarvisitante + Sec-Fetch-*{refresh_token}sessão + conta
POST /conta/senha/codigo (202)visitante{identificador}{enviado: true} — sempre, com ou sem conta
POST /conta/senha/redefinirvisitante{identificador, codigo, nova_senha}{ok: true}
GET /contasessão—perfil (dados, e-mail, telefone, endereço, kyc) — nos dois modos
PATCH /contasessã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/confirmarsessão (book){codigo}perfil
POST /conta/senha/trocarsessã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ódigoHTTPQuando
origin_not_verified403sem header Origin, ou origem não cadastrada/verificada
publishable_key_invalida403pk ausente ou desconhecida
operator_suspended403operador existe mas não está ACTIVE
handoff_rejected403handoff recusado — ver 6.2
session_expired403/401refresh desconhecido, revogado, expirado, ou access inválido
frame_mismatch403Origin da chamada não é a do documento do embed
interno503embed não configurado no servidor

6.2 handoff_rejected — dez causas, um código

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

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

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

6.3 Oferta, aposta e dinheiro

CódigoHTTPQuandoExtras
feed_parado503fonte de dados sem sinal ou além do teto de idadefonte, idade_segundos
cotacao_ambigua409mais de uma oferta candidata sem dimensão informadadimensoes_disponiveis
odd_mudou409preço mudou entre cotar e confirmarodd_aceita, odd_atual, event_id, market_key, selection_key
quote_rejected409oferta sumiu, mercado suspenso, evento encerrado
mercado_oculto422mercado desligado por este operador
oferta_suspensa409kill-switch de risco travou a oferta
risk_refused403teto de bilhete (stake, odd, pernas, retorno), kill-switch ou exposição
insufficient_funds402saldo insuficiente
bettor_not_eligible403apostador inexistente ou não ACTIVE
bettor_sem_cpf403apostador sem CPF cadastrado (aposta e depósito)
kyc_required403KYC interno pendentestatus_kyc
self_excluded403autoexclusão ou pausa ativa
responsible_gaming_limit403limite pessoal excedido
servico_indisponivel503cré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_indisponivel503modo seamless: a carteira do operador não respondeu, não está cadastrada, ou o débito não foi confirmado (estorno já agendado)
deposit_unavailable503nenhum conector de recebimento configurado
withdrawal_unavailable503/409503: 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_invalido422imagem fora do formato ou acima de 3 MB
limite_invalido422combinação de limite inexistente
limite_do_operador403limite imposto pelo operador
ja_excluido409exclusão igual ou maior já ativa
exclusao_invalida422nem prazo nem forever informados
pedido_invalido400/404/422cursor malformado, recurso inexistente, chave Pix de terceiro

6.4 Conta do apostador (2026-09-22)

CódigoHTTPQuandoExtras
visitante_recusado403/401visitante sem metadados de iframe, frame inválido/usado, ou rota de visitante chamada com outro token
identidade_do_operador403rota do modo book numa casa operator (ou o contrário)
login_necessario401token de visitante numa rota que exige conta
cadastro_invalido422campos do cadastro/perfil recusadoscampos: {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_cadastrado409já existe conta com o CPF nesta casa
email_ja_cadastrado409já existe conta com o e-mail nesta casa
menor_de_idade403nascimento com menos de 18 anos
credenciais_invalidas401identificador ou senha errados (não diz qual)
conta_travada4295 erros seguidos de senharetry_after_s
codigo_invalido422código errado, vencido, esgotado ou usado
email_indisponivel503casa sem SMTP configurado, ou o envio falhou
senha_fraca422senha 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

Eventodata
bet.placedbet_id, client_ref, bettor_ref, stake_cash_cents, total_odds, max_profit_cents, selections[] (event_id, market_key, selection_key, decimal_odds, event_name)
bet.settledbet_id, outcome (WIN/LOSE/VOID/PARTIAL), status, payout_cents
bet.resettledbet_id, de, para, payout_cents
deposit.creditedreference, amount_cents (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.requestedwithdrawal_id, status, amount_cents, bettor_id, auditoria_pendente — saque esperando decisão no painel
withdrawal.approvedwithdrawal_id, status, decided_by (quando foi decisão humana)
withdrawal.settledwithdrawal_id, status, e2e_id, decided_by (quando foi resolução humana) — sai quando o ledger lançou a saída
withdrawal.returnedwithdrawal_id, status, decided_by e/ou motivo (recusado_pelo_psp, devolvido_pelo_psp, resolucao_humana) — sai quando o dinheiro voltou à conta gráfica
withdrawal.indeterminatewithdrawal_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_reviewedbettor_ref, bettor_id, status, note
bettor.self_excludedbettor_ref, bettor_id, until, forever
invoice.issuedinvoice_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.triggeredmessage_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 — inclusive bettor.kyc_reviewed e bettor.self_excluded, que antes mandavam só o bettor_id interno do Skull Book. O bettor_id continua indo por compatibilidade e será removido numa versão futura do contrato, com aviso. Programe contra bettor_ref.

7.4 Entrega, repetição e garantias


8. API servidor-a-servidor

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

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

MétodoRotaCorpoResposta
POST/bets{bettor_ref, client_ref, stake_cash_cents, selections[], odds_informada?}201 {bet_id, status, max_profit_cents, replayed, odds_aplicadas}
GET/bets/{bet_id}—o bilhete
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:

  1. Guarde o maior settled_at que você já processou (o cursor).
  2. Chame GET /bets/settled?since=<cursor − 30 s>&limit=500 (ou simplesmente use o proximo_since da resposta anterior — ele já vem com os 30 s descontados).
  3. A SOBREPOSIÇÃO de 30 s é de propósito: bilhetes já vistos voltam. Liquide por client_ref de 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.
  4. mais: true significa que a página encheu: chame de novo com proximo_since.
  5. since inválido é 400; sem since a janela é a última hora. Ordem: settled_at crescente. 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:

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

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

9.2 O que o operador administra

Chaves e credenciais (rotação da chave de API e da publicável, origens, chaves de assinatura do handoff, credenciais de PSP); oferta (mercados ocultos, catálogo); risco (limites de exposição, regras e kill-switch); operação (apostas, depósitos, saques, apostadores, fila de revisão de liquidação, re-liquidação manual, exposição ao vivo); jogo responsável (limites e exclusão por apostador, revisão de KYC, modo de KYC, 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.

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

11. Ir para produção

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

11.1 Decisões da plataforma

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

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

Avisos

Carteira

Risco e conformidade

Prova de fogo, com dinheiro real mínimo

11.3 Os seis cenários de falha da carteira externa

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

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

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