Documentação da API

Referência da API

Cobranças e saques PIX, por HTTP

Crie cobranças, solicite saques, consulte saldo e receba a mudança de status por webhook. Autenticação por Client ID e Client Secret em toda requisição.

Guia de integração

A especificação inteira em um texto só, para colar no seu editor.

Reúne a URL base, a autenticação e os endpoints reais (cashin, cashout, consultar status, saldo, transferência e webhook) com os campos, os limites e as mensagens de erro de cada um. Escolha a linguagem e copie.

A linguagem ajusta uma linha do texto. O conteúdo vem desta documentação.

Introdução

A API é REST e todo o tráfego é JSON. Use suas credenciais para criar cobranças (Cash In), solicitar saques (Cash Out), consultar status e saldo e transferir entre contas da plataforma. Informe um webhook_url para ser notificado automaticamente a cada mudança de status.

URL base

Todos os caminhos abaixo são relativos a esta URL.

Base URLBase URL
https://keropay.co

Endpoints em resumo

São cinco operações. Não há outras rotas públicas: o que não está nesta tabela não faz parte da API.

OperaçãoMétodoCaminhoPermissãoWebhook
Criar cobrançaPOST/api/cashincashinSim
Solicitar saquePOST/api/cashoutcashoutSim
Consultar statusGET/api/consult-transactioncashinNenhum
Consultar saldoGET/api/balancebalanceNenhum
Transferir entre contasPOST/api/transfertransfersNão tem

Formato das respostas

Toda resposta é um objeto JSON com o campo ok na raiz. É por ele, e só por ele, que se decide sucesso ou falha.

SucessoJSON
{
  "ok": true,
  "...": "campos da operação"
}
FalhaJSON
{
  "ok": false,
  "error": "Mensagem do erro"
}
O status HTTP das rotas de negócio é sempre 200, inclusive nos erros. O código do erro vem no cabeçalho X-App-Error-Code, nunca na linha de status. A única resposta que sai com status diferente é o limitador de requisições (429), e ela também traz ok: false, então checar ok cobre os dois casos. Detalhes e o que isso quebra no seu código: Erros.
Dica: gere sua chave em Dashboard → API e marque as permissões que a sua integração vai usar. A chave nasce com cashin e cashout; balance e transfers você liga quando precisar, na mesma tela. A permissão cashin cobre tanto criar a cobrança quanto consultar o status dela.

Início rápido

Do zero ao primeiro PIX confirmado. Cinco passos, na ordem em que uma integração real acontece.

  1. Gere a chave e ligue as permissões Em Dashboard → API. Guarde o Client Secret como senha: ele não é exibido de novo. Marque balance e transfers só se for usar: permissão desligada é superfície de ataque a menos.
  2. Cadastre o IP de saída do seu servidor Obrigatório para Cash Out e Transferência. São endereços IPv4 exatos: faixas /24 e curingas não são aceitos. Se o seu servidor sai por vários IPs, cadastre todos.
  3. Crie a cobrança Um POST em /api/cashin com o valor e o documento do pagador. Exiba o copyPaste e o QR para quem vai pagar, e guarde o request_number: é a sua chave da transação daqui em diante.
    Primeira cobrançacURL
    curl -X POST https://keropay.co/api/cashin \
      -H "Content-Type: application/json" \
      -H "X-Client-Id: SEU_CLIENT_ID" \
      -H "X-Client-Secret: SEU_CLIENT_SECRET" \
      -d '{"amount": 19.90, "name": "Cliente Pix", "document": "12345678900"}'
  4. Receba a confirmação por webhook Informe webhook_url na cobrança e responda 200 rápido. O webhook é o gatilho, não a verdade: antes de liberar o pedido, confirme com Consultar transação. Trate o recebimento de forma idempotente.
  5. Concilie confirmed não é final: uma cobrança paga pode virar contested, chargedback ou refunded depois, e nesses casos o dinheiro sai do seu saldo. Reconcilie sempre pelo último status recebido. Nunca cacheie confirmed como imutável.
Não existe ambiente de sandbox. As chamadas desta documentação vão para produção e movimentam dinheiro de verdade. Para testar de ponta a ponta, use Cash In com um valor baixo e pague o próprio PIX: é o caminho completo (cobrança → pagamento → webhook → consulta de status) sem risco relevante. Nunca teste Cash Out ou Transferência com valores que você não pode perder: as duas rotas são definitivas.

Autenticação

Envie as credenciais em toda requisição, por cabeçalhos ou via Basic Auth. As duas formas são equivalentes.

Cabeçalhos (recomendado)

HeadersHeaders
X-Client-Id: SEU_CLIENT_ID
X-Client-Secret: SEU_CLIENT_SECRET

Basic Auth

AuthorizationAuthorization
Authorization: Basic base64("CLIENT_ID:CLIENT_SECRET")

Permissões da chave

Cada rota exige uma permissão. Uma chave sem a permissão da rota é recusada, mesmo estando ativa e correta.

PermissãoLiberaEstado inicialExige IP na allowlist
cashinCriar cobrança e consultar o status delaLigadaNão
cashoutSolicitar saque PIXLigadaSim
balanceConsultar saldoDesligadaNão
transfersTransferir para outra conta da plataformaDesligadaSim
A falta de permissão não responde igual em todas as rotas. Em quatro delas o erro é “Permissão insuficiente” com X-App-Error-Code: 403. No Cash In não: a checagem responde antes, com 400 e a mensagem “Não foi possível gerar seu pagamento. Entre em contato com nossa equipe!”. Não use o código 403 para detectar falta de permissão no Cash In.

Quando a chave está certa e mesmo assim é recusada

Além do KYC, o estado da conta pode barrar uma credencial válida. Os quatro casos abaixo chegam com X-App-Error-Code: 403 e valem para todas as rotas, inclusive as de leitura. Nenhum deles se resolve repetindo a chamada ou trocando de chave.

MensagemO que aconteceuComo sai desse estado
Conta não verificadaO KYC da conta dona da chave ainda não foi aprovado.Concluir a verificação no painel.
Acesso à API desabilitado para esta contaO administrador desligou o acesso de API dessa conta.Só o suporte religa. Repetir não adianta.
Cadastro incompleto — atualize seus dados no painelA conta foi marcada para recadastro e ainda tem dados obrigatórios faltando.O titular completa o cadastro no painel. Até lá, a API inteira fica bloqueada.
Chave inativaA chave existe, mas foi desativada.Reativar ou gerar outra em Dashboard → API.
Conta bloqueadaA conta foi bloqueada pela operação por descumprimento das diretrizes.Nenhuma rota responde enquanto durar, nem com outra chave. Só o suporte revê o bloqueio.
Credenciais ausentes ou inválidas retornam { "ok": false, "error": "Credenciais ausentes" } ou "Credenciais inválidas", com X-App-Error-Code: 401. Lembre-se: essa resposta chega com HTTP 200: detecte a falha pelo campo ok, nunca pelo status. Veja Erros.

Cash In: criar cobrança

Gera um QR Code / copia-e-cola PIX para o pagador. Requer permissão cashin.

POST https://keropay.co/api/cashin perm: cashin
Só POST. Esta rota recusa qualquer outro verbo (GET, PUT, PATCH, DELETE) com “Método inválido” (X-App-Error-Code: 405) e nada é criado. Os dados são lidos apenas do corpo JSON: parâmetros na query string são ignorados, então uma chamada por GET com ?amount=... não funciona. OPTIONS responde ok sem criar nada (preflight).

Parâmetros do corpo

CampoTipoObrigatórioDescrição
amountnumberSimValor da cobrança (deve ser > 0). Há um teto por cobrança (veja abaixo).
namestringNãoNome do pagador.
documentstringDependeCPF/CNPJ do pagador (apenas dígitos). Formalmente opcional, mas parte do processamento exige o documento, e sem ele a cobrança é recusada (com a mensagem genérica “Falha ao gerar PIX”, veja abaixo). Não há como prever quando será exigido: envie sempre.
emailstringNãoE-mail do pagador.
phonestringDependeCelular do pagador com DDD (só dígitos). Obrigatório no boleto.
payment_methodstringNãopix (padrão), credit_card ou boleto. Cartão e boleto só funcionam quando estão liberados para a sua conta. Veja Cartão de crédito e boleto.
addressobjectDependeEndereço do pagador: zip_code, street, number, complement, neighborhood, city, state (UF). Obrigatório no boleto; no cartão, opcional (preenche a página de pagamento).
descriptionstringNãoDescrição / identificador do pedido.
webhook_urlstringNãoURL para notificação de status (aceita o sinônimo postbackUrl).
splitsarrayNãoDivisões de valor: { username, percentage }, { username, mode:"fixed", fixed } ou { username, mode:"both", percentage, fixed }. Soma ≤ 100%. Incide sobre o valor bruto (amount) e é debitada do lojista. Veja Split de comissões.
Requisição
curl -X POST https://keropay.co/api/cashin \
  -H "Content-Type: application/json" \
  -H "X-Client-Id: SEU_CLIENT_ID" \
  -H "X-Client-Secret: SEU_CLIENT_SECRET" \
  -d '{
    "amount": 123.45,
    "name": "Cliente Pix",
    "document": "12345678900",
    "description": "Pedido #123",
    "webhook_url": "https://seusistema.com/webhooks/keropay",
    "splits": [ { "username": "usuario1", "percentage": 10 } ]
  }'
Resposta 200JSON
{
  "ok": true,
  "status": "pending",
  "amount": 123.45,
  "request_number": "veo_...",
  "transaction_id": "...",
  "copyPaste": "000201...",
  "qr_url": "https://keropay.co/api/requests?route=pix-qr&d=...&s=...",
  "qr_img": "iVBORw0KGgoAAAANSUhEUg...",
  "checkout_url": "",
  "expire_minutes": 30,

  "qr": "000201...",
  "copy_paste": "000201...",
  "code": "000201...",
  "pix_code": "000201...",
  "pix_qr_base64": "iVBORw0KGgoAAAANSUhEUg...",
  "transaction": "..."
}
Use copyPaste (payload PIX) e qr_url para exibir o QR. Guarde o request_number para consultar o status. O formato completo, campo a campo, está em Padrão de resposta: ele é o mesmo para qualquer adquirente.
Valor mínimo e máximo por cobrança. Existe um intervalo definido pela operação, que pode mudar a qualquer momento: não fixe esses números no seu código. Fora dele a cobrança é recusada com X-App-Error-Code: 400 e nada é criado: “Valor máximo para PIX é de R$ <VALOR>” (por exemplo, “Valor máximo para PIX é de R$ 5.000,00”) ou “Valor mínimo para PIX é de R$ <VALOR>”, por exemplo “Valor mínimo para PIX é de R$ 5,00”. A mensagem já informa o limite: repasse-a ao usuário. Cada ponta é opcional e independente: sem nenhuma configurada vale só a regra geral de amount > 0.

Os limites do Cash In e do Cash Out são diferentes: o Cash In tem mínimo e máximo por cobrança; o Cash Out tem mínimo e não tem máximo.
Decida pela resposta, não pelo texto do erro. Boa parte das falhas do Cash In responde com a mesma mensagem, “Falha ao gerar PIX”: document ausente, indisponibilidade momentânea e erro técnico chegam todos com o mesmo texto. Não faça parsing da mensagem para descobrir a causa: o que distingue os casos é o cabeçalho X-App-Error-Code (400 para recusa de validação, 502 para falha no processamento). Só “Valor inválido”, “Valor máximo para PIX…”, “Valor mínimo para PIX…”, os erros de split e os de autenticação/permissão chegam com texto específico.

Cartão de crédito e boleto

A mesma rota cobra por cartão e boleto com payment_method. Os dois vêm desligados na API e são liberados por conta pela operação. Sem liberação, a cobrança é recusada com “Forma de pagamento não habilitada para esta conta: credit_card” (ou boleto) e X-App-Error-Code: 422, e nada é criado. Só em BRL.

Boleto. Exige name, email, document (CPF ou CNPJ válido), phone e address completo, porque o boleto sai registrado no nome de quem paga. A resposta traz o objeto boleto: line (linha digitável, 47 dígitos), line_formatted, barcode (44 dígitos, para você desenhar o código de barras), due_date (AAAA-MM-DD) e url, uma página nossa com o boleto para imprimir. O status vira confirmed quando o boleto compensa, até 3 dias úteis depois do pagamento.

Cartão de crédito. O número do cartão nunca passa pela API. A resposta traz checkout_url (igual a card.payment_url): mande o comprador para essa página, onde ele preenche os próprios dados e o cartão, escolhe as parcelas e paga. Os dados do comprador no corpo (name, email, document, phone, address) são opcionais e só preenchem a página. Cartão recusado não encerra a cobrança: o comprador pode tentar outro cartão no mesmo link, até 5 vezes. Parcelado tem juros pagos pelo comprador; o amount é sempre o valor da sua venda e é sobre ele que a sua taxa incide. Quando aprovado, o status vira confirmed, mas o valor entra no seu saldo só depois do prazo de liberação da sua conta.

A consulta de status e o webhook trazem payment_method nas cobranças de cartão e boleto (e installments no cartão). As cobranças PIX não trazem esses campos. Prazo até expired: boleto, vencimento mais 3 dias; cartão pela API, 24 horas.

Requisição
{
  "amount": 150.00,
  "payment_method": "boleto",
  "name": "Maria Souza",
  "email": "[email protected]",
  "document": "12345678909",
  "phone": "11987654321",
  "address": { "zip_code": "01310100", "street": "Avenida Paulista", "number": "1000",
               "complement": "", "neighborhood": "Bela Vista", "city": "São Paulo", "state": "SP" },
  "webhook_url": "https://seusistema.com/webhooks/keropay"
}
Resposta 200
{
  "ok": true,
  "status": "pending",
  "amount": 150.00,
  "request_number": "pgm_...",
  "transaction_id": "or_...",
  "copyPaste": "",
  "qr_url": "",
  "qr_img": "",
  "checkout_url": "https://keropay.co/pagamento/pgm_...",
  "expire_minutes": 8640,
  "fee": 3.49,
  "payment_method": "boleto",
  "boleto": {
    "line": "23793381286000000000300000000400184340000015000",
    "line_formatted": "23793.38128 60000.000003 00000.000400 1 84340000015000",
    "barcode": "23791843400000150003381260000000000000000040",
    "due_date": "2026-10-25",
    "url": "https://keropay.co/pagamento/pgm_..."
  }
}

Padrão de resposta do Cash In

O mesmo formato para todo adquirente, hoje e nos próximos. Escreva contra esta tabela, não contra o que você observou numa resposta.

O formato não muda com o adquirente. Quem processa o PIX é escolhido pela plataforma e pode mudar entre uma cobrança e a outra (inclusive por failover automático). Por isso a resposta é sempre a mesma: todas as chaves abaixo existem em toda cobrança, com o mesmo significado. Campo sem valor vem como string vazia, nunca ausente, nunca null. Não teste se a chave existe; teste o conteúdo.

Campos canônicos

CampoTipoSempre presenteDescrição
statusstringsimNa criação é sempre pending. Cobrança recém-criada nunca nasce paga.
amountnumbersimValor da cobrança em reais (BRL). Ver Moeda.
request_numberstringsimNossa referência da cobrança. É a chave para consultar o status.
transaction_idstringsimId da transação no processador.
copyPastestringsimCampo canônico. EMV copia-e-cola que o pagador cola no banco.
qr_urlstringsimURL do PNG do QR, sempre em domínio nosso, nunca no do processador. Sempre preenchida quando há copia-e-cola. A URL é imutável: pode ser cacheada à vontade.
qr_imgstringsimO mesmo QR em base64 puro (PNG), sem o prefixo data:image/png;base64,. Também sempre preenchida quando há copia-e-cola: a plataforma desenha a imagem quando o processador não manda uma. Garantimos o formato e o conteúdo, não os bytes: a dimensão em pixels pode variar quando a imagem vem pronta do processador, então dimensione por CSS.
checkout_urlstringsimPágina de pagamento hospedada pelo processador, quando existir. Vazio nos demais.
expire_minutesnumbersimValidade da cobrança em minutos.

Apelidos

Carregam o mesmo valor dos nomes canônicos. Em código novo, prefira os nomes da tabela acima.

ApelidoÉ igual a
qr, copy_paste, code, pix_codecopyPaste
pix_qr_base64qr_img
transactiontransaction_id

Como desenhar o QR sem condicional por adquirente

Use qr_img se ele vier preenchido (monte data:image/png;base64, + valor); caso contrário use qr_url direto em <img src>. E mostre sempre copyPaste como copia-e-cola. Essa ordem funciona para todos os processadores, hoje e nos próximos.

Exibindo o QR
<?php
// Uma funcao, nenhum "if provider". As duas chaves vem preenchidas quando ha
// copia-e-cola; a ordem abaixo so escolhe qual delas custa menos ao seu HTML.
function qrSrc(array $res): string
{
    if ($res['qr_img'] !== '') {
        return 'data:image/png;base64,' . $res['qr_img']; // sem requisicao extra
    }
    return $res['qr_url'];                                // sem base64 no HTML
}
O checkout hospedado devolve exatamente este mesmo padrão, acrescido de orderId e dos apelidos qrCode e qrUrl.
Dois erros comuns ao ler a resposta. qr_img nunca vem com o prefixo data:image/png;base64,: monte o data-URI do seu lado. E qr nunca traz o base64 da imagem: para imagem use qr_img ou qr_url.

Split de comissões

Divide parte da cobrança com outras contas da plataforma. Configurado por cobrança, no campo splits do Cash In.

A porcentagem do split incide sempre sobre o valor bruto da cobrança (amount), não sobre o líquido já descontada a taxa.

O split é debitado do lojista depois que ele recebe o líquido. Ele não é acrescentado ao valor que o pagador paga: o pagador paga exatamente o amount da cobrança, independentemente de quantos splits existam.

Exemplo numérico

Cobrança de R$ 100,00, com taxa de 4% e um split de 1%:

EtapaCálculoLojista fica com
Pagador pagavalor da cobrançaR$ 100,00
Taxa (4%)4% de 100,00 (bruto)R$ 96,00 (líquido creditado)
Split (1%)1% de 100,00, não de 96,00R$ 95,00

Vários splits somam entre si, cada um calculado sobre o bruto: não é cascata. Três splits de 1% numa cobrança de R$ 100,00 resultam em R$ 3,00 no total, e não em percentuais aplicados sobre o que sobrou do anterior.

Porcentagem, valor fixo ou os dois

CampoTipoDescrição
usernamestringUsuário que recebe o split. Sinônimo aceito: clientId.
modestringpercent (padrão), fixed ou both. Se omitido, é deduzido pelos campos enviados.
percentagenumberPorcentagem sobre o bruto. Sinônimo aceito: value, que também é porcentagem, não reais.
fixednumberValor em reais, fixo por cobrança.
Os três modosJSON
"splits": [
  { "username": "usuario1", "percentage": 10 },
  { "username": "usuario2", "mode": "fixed", "fixed": 5.00 },
  { "username": "usuario3", "mode": "both", "percentage": 10, "fixed": 2.00 }
]
Teto: taxa + splits do painel + splits da API não podem passar do valor da cobrança. Os splits configurados no painel têm prioridade e ficam reservados: os da API só usam o que sobra. Numa cobrança de R$ 100,00 com taxa de 4% e split de 1% no painel, o teto da API é R$ 95,00.
A cobrança é recusada (X-App-Error-Code: 400, PIX não é criado) se: o username não existir, vier repetido, ou for o próprio dono da cobrança; a soma das porcentagens passar de 100; ou o total dos splits passar do teto acima. Nada é truncado nem ajustado em silêncio.

Cash Out: solicitar saque

Envia um PIX para uma chave. Requer permissão cashout e IP autorizado.

POST https://keropay.co/api/cashout perm: cashout
Requisitos de segurança: a chave precisa da permissão cashout, a requisição deve vir de um endereço IPv4, e esse IP precisa estar cadastrado na allowlist da chave. Fora disso, a resposta é “IP não configurado” ou “IP não permitido” (403).
Só POST. Esta rota recusa qualquer outro verbo com “Método inválido” (X-App-Error-Code: 405) e nenhum saque é criado nem debitado. Os dados são lidos apenas do corpo JSON. A rota de consulta de status é a exceção: ela aceita GET.

Parâmetros do corpo

CampoTipoObrigatórioDescrição
amountnumberSimValor do saque (> 0). Debita amount + fee do saldo. Há um mínimo por conta (veja abaixo).
namestringSimNome do beneficiário.
documentstringSimCPF/CNPJ do beneficiário (apenas dígitos). Validado pelo dígito verificador.
pix_keystringSimChave PIX de destino.
pix_typestringSimTipo da chave (ver abaixo). Se omitido, é deduzido da pix_key.
webhook_urlstringNãoURL para notificação de status. Na prática obrigatório: é o único jeito de saber o desfecho do saque.
autobooleanNãoPede o envio automático do saque (sinônimos: approve, automaticWithdraw). Mesmo com true o saque pode sair manual (auto:false) se a conta não tiver saque automático, se o antifraude marcar para revisão ou se houver trava operacional.
idempotency_keystringNãoAlternativa ao cabeçalho Idempotency-Key (veja Idempotência). Máx. 190 caracteres.

Tipos de chave (pix_type) aceitos:

EMAIL CPF CNPJ EVP QRCODE PHONE

Formatação: para CPF/CNPJ envie apenas dígitos; EMAIL válido; EVP chave aleatória (32 hex) ou UUID; QRCODE o payload Copia e Cola completo. Se pix_type for omitido, um Copia e Cola colado vira QRCODE automaticamente.

Requisição
curl -X POST https://keropay.co/api/cashout \
  -H "Content-Type: application/json" \
  -H "X-Client-Id: SEU_CLIENT_ID" \
  -H "X-Client-Secret: SEU_CLIENT_SECRET" \
  -H "Idempotency-Key: pedido-9f3c1a-saque-01" \
  -d '{
    "amount": 250.00,
    "name": "Fulano de Tal",
    "document": "00000000000",
    "pix_key": "[email protected]",
    "pix_type": "EMAIL",
    "webhook_url": "https://seusistema.com/webhooks/keropay"
  }'
Resposta 200JSON
{
  "ok": true,
  "e2e": "WD_...",
  "fee": 2.5,
  "status": "pending",
  "auto": false
}
A resposta não devolve o campo amount: guarde o valor que você enviou e use e2e como seu identificador do saque. Quando o saque é despachado automaticamente, vem também transaction_id: { "ok": true, "e2e": "WD_...", "transaction_id": "...", "fee": 2.5, "status": "pending", "auto": true }. Sobre idempotent e deduplicated, veja Idempotência.

Valores de status do saque

StatusSignificado
pendingSaque criado e aceito. O valor (amount + fee) já foi debitado do seu saldo, mas ainda não há confirmação de que o PIX saiu. É o status de praticamente toda resposta de criação, inclusive com auto: true.
completedO PIX foi efetivamente pago ao beneficiário. Terminal no uso normal.
canceledO saque falhou, foi recusado pelo adquirente ou foi cancelado/estornado. O valor (amount + fee) voltou para o seu saldo.

São estes 3. Não existe status failed, rejected nem refunded em saque: todos esses desfechos chegam como canceled.

Não existe rota de consulta de saque. A consulta de transação atende somente Cash In (a resposta sempre traz "type": "cashin"). O único jeito de saber o desfecho de um saque depois de criado é o Webhook, então informe webhook_url e guarde o e2e retornado para casar com a notificação.
Um saque pending pode estar retido para aprovação manual (trava operacional, antifraude em revisão ou pausa de saques). Isso não muda o status: ele continua pending e nenhum campo da resposta diferencia os dois casos. Não interprete um pending prolongado como erro e não reenvie o pedido: fora da janela de 30 segundos, e sem Idempotency-Key, o reenvio cria um segundo saque.

Validações que recusam antes de debitar

Todas as recusas abaixo acontecem antes de qualquer débito: nada é criado nem debitado. Todas chegam com X-App-Error-Code: 422, e nenhuma deve ser retentada automaticamente: o pedido só passa se o caller corrigir os dados.

Valor mínimo

A sua conta tem um valor mínimo de saque definido pela operação, e ele pode mudar a qualquer momento, então não fixe esse número no seu código. Abaixo do mínimo, a recusa é “Valor mínimo de saque é R$ <VALOR>”, por exemplo “Valor mínimo de saque é R$ 5,00”. A própria mensagem já informa qual é o mínimo: repasse-a ao usuário em vez de tentar adivinhar o valor. Sem mínimo configurado vale só a regra geral de amount > 0.

Este mínimo é o da sua conta e vale para o Cash Out. O Cash In tem mínimo e máximo próprios, por cobrança: são limites diferentes deste.

Chave de telefone não é aceita

Enviar pix_type PHONE, TELEFONE ou CELULAR (ou uma pix_key com formato de telefone, mesmo declarando outro tipo) é recusado com “Saque via chave de telefone não é permitido. Use CPF, CNPJ, e-mail ou chave aleatória.”

Pix Copia e Cola depende de habilitação

QRCODE precisa estar habilitado para a sua conta. Quando não está, a recusa é “Esta conta não aceita Pix Copia e Cola como destino de saque no momento. Use uma chave PIX (CPF, CNPJ, e-mail, telefone ou aleatória).”

A habilitação é operacional e pode mudar: trate essa recusa como estado, não como erro permanente de código. Se você precisa do recurso ligado, fale com o suporte.

CNPJ alfanumérico ainda não é aceito

A Receita Federal passou a emitir CNPJ no formato alfanumérico (com letras nas 8 primeiras posições, ex.: 12ABC34501DE35), mas o adquirente que executa o PIX ainda não aceita esse formato. O saque com esse documento é recusado, e nada é debitado.

Regra exata: se o document (ou a pix_key, quando pix_type for CNPJ) contiver qualquer letra, a resposta é “CNPJ alfanumérico ainda não é aceito para saque pelo adquirente. Use um CNPJ numérico.” CNPJ 100% numérico funciona normalmente.

Não remova as letras antes de enviar. Mande o valor como está e trate a recusa: “limpar” o documento transforma um CNPJ alfanumérico num documento mutilado e válido só na aparência.

Dígito verificador de CPF/CNPJ

O document passa pelo cálculo oficial do DV (11 dígitos = CPF, 14 = CNPJ). DV errado é recusado com “CPF inválido” ou “CNPJ inválido”, conforme o tamanho. Quando pix_type for CPF ou CNPJ, a própria pix_key também é validada, e a recusa é “Chave PIX inválida: CPF com dígito verificador incorreto” / “…CNPJ com dígito verificador incorreto”. EMAIL, EVP e QRCODE não são documentos e ficam de fora dessa checagem.

Documento com DV errado é recusado. Valide o CPF/CNPJ do beneficiário no seu lado antes de chamar a API.

Chave CPF/CNPJ tem de ser o documento do beneficiário

Quando pix_type for CPF ou CNPJ, a pix_key é o documento do dono da chave: não existe chave de CPF de um titular com beneficiário de outro.

Regra exata: se os dígitos de pix_key forem diferentes dos dígitos de document, a recusa é “Para chave PIX do tipo CPF, o campo document deve ser o mesmo documento da chave. Chave informada: X · document informado: Y.” Chaves EMAIL, EVP e QRCODE ficam de fora: nelas o document continua sendo o beneficiário e pode ser qualquer titular.

Limites por CPF/CNPJ de destino

Sua conta pode ter um número máximo de saques e/ou uma soma máxima para o mesmo documento de destino, numa janela de 24 horas móveis (não “desde a meia-noite”). Ao estourar, o saque é recusado com X-App-Error-Code: 403 e nada é criado nem debitado:

  • “Limite de saques para este CPF/CNPJ atingido: máximo de N saque(s) por documento a cada 24h (já foram X).”
  • “Limite de valor para este CPF/CNPJ atingido: máximo de R$ V por documento a cada 24h (já foram R$ X e este saque somaria R$ Y).”

0 ou não configurado = sem limite. Saques cancelados ou estornados não consomem o limite. O campo document é obrigatório em todo saque pela API, então não existe saque fora desse limite. Os valores são definidos pela operação. Para ajustá-los, fale com o suporte.

Saldo bloqueado por disputa. O saldo você consulta em GET /api/balance, mas ele não dispensa tratar “Saldo insuficiente”, porque pode mudar entre a leitura e o saque. O disponível pode cair sem que exista nenhum saque seu: quando uma cobrança já confirmada entra em disputa (os status contested, chargedback e refunded da consulta de status), o valor líquido correspondente é bloqueado/debitado da conta. O efeito prático é um Saldo insuficiente (X-App-Error-Code: 400) num saque que, pela sua contabilidade, caberia. Não é erro temporário. Não fique retentando: concilie pelos webhooks de Cash In e, se não fechar, fale com o suporte.

Consultar transação

Recupera o status atual de um Cash In. Requer permissão cashin, a mesma que cria a cobrança.

GET https://keropay.co/api/consult-transaction?request_number=veo_... perm: cashin
Informe request_number ou transaction_id na query string: um dos dois é obrigatório. Sem nenhum, o erro é “request_number ou transaction_id obrigatório” (400).

Use GET aqui. Diferente das rotas de criar cobrança e saque, que exigem POST, esta rota apenas lê e tem o GET como método primário. HEAD (monitores de uptime) e POST também são aceitos, sempre com os parâmetros na query string. Só PUT, PATCH e DELETE são recusados (405).
Requisição
curl -X GET "https://keropay.co/api/consult-transaction?request_number=veo_abc123" \
  -H "X-Client-Id: SEU_CLIENT_ID" \
  -H "X-Client-Secret: SEU_CLIENT_SECRET"
Resposta 200JSON
{
  "ok": true,
  "type": "cashin",
  "status": "pending",
  "amount": 123.45,
  "fee": 1.23,
  "request_number": "veo_...",
  "transaction_id": "...",
  "e2e": "...",
  "updated_at": "2025-11-24 12:00:00",
  "expire_minutes": 30,
  "remaining": 27
}
remaining (minutos até expirar) só vem quando o status é pending. Quando o status não é pending, vem expired_now (true/false): true indica que esta própria consulta acabou de expirar a cobrança. Se o request_number/transaction_id não existir na sua conta, o erro é “Depósito não encontrado” (404). type é sempre cashin: esta rota não consulta saques.

Valores de status

StatusPagou?Significado
pendingAinda nãoAguardando pagamento. Estado inicial de toda cobrança.
confirmedSimPagamento confirmado e creditado. Único status que significa “recebi o dinheiro”. Não é final (veja abaixo).
expiredEm geral nãoA cobrança passou do prazo sem pagamento confirmado. Ainda pode virar confirmed: o pagamento feito no último instante pode ter a confirmação chegando depois do prazo.
canceledEm geral nãoA cobrança não resultou em crédito: falhou, foi recusada ou cancelada. Na maioria dos adquirentes isso ocorre antes do pagamento; em alguns, também é gravado quando o adquirente reporta a reversão de uma cobrança.
contestedSim, e em disputaCobrança paga e contestada (MED aberto pelo pagador, ou retenção/bloqueio cautelar do adquirente). O valor fica bloqueado durante a análise. Ainda pode voltar para confirmed ou virar chargedback.
chargedbackSim, e devolvidoCobrança paga cujo valor foi devolvido ao pagador após a contestação. Já foi debitado do lojista. Terminal.
refundedSim, e estornadoCobrança paga e estornada. Já foi debitada do lojista.

São estes 7 valores. Só confirmed libera o pedido: trate pending como aguardando, e expired, canceled, contested, chargedback e refunded, além de qualquer status desconhecido, como não pago. A lista pode crescer.

confirmed não é final. Uma cobrança que você já leu como confirmed pode regredir para contested, chargedback ou refunded, e isso significa que o dinheiro saiu do seu saldo.
expired também não é final. Quando o pagamento é feito no último instante e a confirmação chega depois do prazo, a cobrança passa de expired para confirmed, e o webhook avisa. Não apague nem reaproveite o pedido ao receber expired: deixe-o recuperável e, se o confirmed chegar, trate como pago.
Polling tem custo. Esta rota é a de maior volume da API e existe amostragem de log justamente por causa disso. Prefira o webhook como gatilho e use a consulta para confirmar antes de liberar valor. Se precisar mesmo fazer polling, espace as chamadas (a cada 5 a 10 segundos é suficiente para uma cobrança de 30 minutos) e pare assim que o status sair de pending. Um confirmed que chegue depois de expired vem pelo webhook.

Consultar saldo

Devolve o saldo da conta dona da chave: o disponível para saque e o bloqueado em disputa. Requer permissão balance.

GET https://keropay.co/api/balance perm: balance
Sem parâmetros. A conta consultada é sempre a dona da credencial: não existe forma de consultar o saldo de outra conta. A rota só lê, então aceita GET (e HEAD, para monitor de uptime). Não exige IP na allowlist.
Requisição
curl -X GET https://keropay.co/api/balance \
  -H "X-Client-Id: SEU_CLIENT_ID" \
  -H "X-Client-Secret: SEU_CLIENT_SECRET"
Resposta 200JSON
{
  "ok": true,
  "balance": {
    "available": 1520.40,
    "blocked": 300.00,
    "total": 1820.40,
    "currency": "BRL"
  },
  "updated_at": "2026-08-04 10:32:11"
}

Campos da resposta

CampoTipoDescrição
balance.availablenumberDisponível para saque e transferência agora. É contra este número que o Cash Out valida Saldo insuficiente.
balance.blockednumberRetido por disputa (MED, infração ou bloqueio cautelar do adquirente). Não pode ser sacado. Pode voltar para o disponível se a disputa for resolvida a seu favor.
balance.totalnumberavailable + blocked, já somado.
balance.pending_releasenumberVendas no cartão aprovadas e ainda a liberar, líquidas de taxa. Entram em available no fim do prazo de liberação da conta. Fica fora de total. 0 sem vendas no cartão.
balance.currencystringSempre BRL (ver Moeda). O campo existe para que o dia em que houver outra moeda não exija mudança de código.
updated_atstringÚltima movimentação do saldo (YYYY-MM-DD HH:MM:SS, horário de Brasília).
Não use esta rota como trava do seu saque. Entre a leitura do saldo e o Cash Out o número pode mudar: uma cobrança pode entrar em disputa nesse intervalo e o valor sair do disponível. Quem decide se cabe é sempre o Cash Out, no momento em que é chamado. Consultar antes serve para conciliação e para telas, não para substituir o tratamento de Saldo insuficiente.

Transferência entre contas

Move saldo da sua conta para outra conta da plataforma, na hora e sem taxa. Requer permissão transfers.

POST https://keropay.co/api/transfer perm: transfers
Esta rota tira dinheiro da sua conta e o crédito é imediato e definitivo. Não há confirmação em duas etapas nem janela de cancelamento: quando a resposta volta com "status": "completed", o valor já está na conta de destino. Por isso a permissão transfers nasce desligada e exige IP liberado na chave, igual ao Cash Out. Ligue no painel só se a sua integração for mesmo usá-la.
O PIN de saque não é pedido aqui. Ele é o segundo fator de quem opera pelo painel; numa integração servidor-a-servidor viraria uma constante no seu código, sem proteger nada. O que autentica esta rota é a credencial + a allowlist de IP + a permissão.

Não é PIX e não tem taxa: debita exatamente o amount.

Parâmetros do corpo

CampoTipoObrigatórioDescrição
tostringSimConta de destino: username ou e-mail. Os dois resolvem para a mesma conta: use o que você tiver.
amountnumberSimValor a transferir (> 0). Debita exatamente este valor: a transferência interna não tem taxa.
external_idstringNãoSeu identificador do repasse (máx. 64; acima disso é erro, não corte). Mande sempre: é ele que impede o débito em dobro no retry. Veja Idempotência.
descriptionstringNãoTexto livre que aparece no extrato das duas contas (máx. 140; o excedente é cortado).
Requisição
curl -X POST https://keropay.co/api/transfer \
  -H "Content-Type: application/json" \
  -H "X-Client-Id: SEU_CLIENT_ID" \
  -H "X-Client-Secret: SEU_CLIENT_SECRET" \
  -d '{
    "to": "loja_parceira",
    "amount": 250.00,
    "external_id": "repasse-2026-08-04-0031",
    "description": "Repasse semanal"
  }'
Resposta 200JSON
{
  "ok": true,
  "status": "completed",
  "e2e": "T20260804103211A1B2C3D4E5F6",
  "amount": 250.00,
  "to_account": 482,
  "to": "loja_parceira",
  "external_id": "repasse-2026-08-04-0031"
}

Erros específicos

MensagemCódigoO que fazer
Destinatário não encontrado404O to não bate com nenhum username ou e-mail. Nada foi debitado. Confira o identificador com o parceiro. Não retente.
Destinatário inválido400Você tentou transferir para a própria conta.
Saldo insuficiente400Nada foi debitado. Lembre que disputa em cobrança já confirmada derruba o disponível (veja Consultar saldo).
Saldo insuficiente: R$ <V> estão retidos por dívida de MED/chargeback.400Variante da anterior que diz o motivo: o valor citado é o mesmo balance.blocked. Não é temporário: o dinheiro fica retido até a disputa ser resolvida.
external_id muito longo (máx. 64)400Encurte o identificador antes de enviar. Ao contrário de description, este campo não é cortado: é recusado.
Cashout desativado403A conta está com saques bloqueados, e a transferência segue o mesmo bloqueio (senão seria uma rota de fuga). Fale com o suporte.
IP não configurado / IP não permitido403Cadastre o IPv4 de saída do seu servidor na chave, em Dashboard → API. Faixas (/24) e * não são aceitos.
Permissão insuficiente / Conta inválida403Marque transfers na chave, em Dashboard → API.
Não foi possível registrar a transferência. Tente novamente.
Não foi possível transferir para este destinatário. Tente novamente.
409Concorrência: duas requisições disputaram o mesmo registro. Nada foi debitado e repetir é seguro, desde que com o mesmo external_id.
Erro ao transferir500Falha interna. Repita com o mesmo external_id; se persistir, contate o suporte antes de tentar de novo.
Não há webhook de transferência. A resposta é síncrona e definitiva: se voltou ok: true, terminou. Guarde o e2e: é por ele que a transferência aparece no extrato das duas contas.

Webhooks

Enviamos um POST (JSON) para o seu webhook_url a cada mudança de status, em Cash In e Cash Out.

Webhook · Cash InJSON
{
  "type": "cashin",
  "status": "confirmed",
  "amount": 123.45,
  "fee": 1.23,
  "request_number": "veo_...",
  "transaction_id": "...",
  "e2e": "",
  "updated_at": "2025-11-24 12:00:00"
}
Webhook · Cash OutJSON
{
  "type": "cashout",
  "status": "completed",
  "amount": 250.00,
  "fee": 2.50,
  "transaction_id": "...",
  "e2e": "WD_...",
  "updated_at": "2025-11-24 12:00:00"
}

Cabeçalhos de toda entrega

CabeçalhoExemploPara que serve
X-Kero-Signaturet=1758600000,v1=81fd61…Assinatura HMAC-SHA256. Confira antes de processar (veja a regra 3 abaixo).
X-Kero-Event-Id48213Id desta entrega. As retentativas automáticas repetem o mesmo id.
X-Kero-Client-Idnp_a1b2c3…Qual chave de API assinou: confira com o segredo dela. Ausente só em transação criada fora da API.
O que é o fee. É a taxa total cobrada nesta transação, na moeda dela: o percentual e a parte fixa do plano da sua conta, com piso na taxa mínima, mais as comissões de split quando houver. O que entra no seu saldo é amount − fee. Não recalcule pela porcentagem: a parte fixa e o piso fazem o valor diferir de “4% do amount”. Use o fee recebido.
Os exemplos acima mostram só o caso feliz. O campo status do webhook é o mesmo valor da consulta de status, sem tradução: em cashin pode ser qualquer um dos 7 (pending, confirmed, expired, canceled, contested, chargedback, refunded) e em cashout qualquer um dos 3 (pending, completed, canceled).

Consequência prática: você vai receber um webhook de cashin com contested, chargedback, refunded ou canceled depois de já ter recebido confirmed da mesma cobrança, e isso significa que o dinheiro saiu do seu saldo. Seu handler não pode ignorar status desconhecido nem tratar confirmed como estado final: ele precisa, no mínimo, registrar a reversão e reabrir ou estornar o pedido correspondente do seu lado.

O caminho inverso também existe: confirmed pode chegar depois de expired, quando o pagamento foi feito no último instante e a confirmação chegou depois do prazo. Nesse caso o dinheiro entrou: reabra o pedido e libere.

As quatro regras do handler

  1. Responda 200, e rápido O timeout é de 10 segundos. Enfileire o processamento pesado e responda antes disso. Uma exceção não tratada no seu código também precisa virar 200: se você devolver 500, o mesmo evento tende a voltar e a repetir o problema.
  2. Seja idempotente O webhook dispara a cada mudança de status e pode repetir o mesmo status. Use request_number (Cash In) ou e2e (Cash Out) como chave e grave o que já processou.
  3. Confira a assinatura, e descarte o que não bater Todo webhook nosso vai assinado, sem exceção. Monte <t>.<corpo bruto>, calcule o HMAC-SHA256 com o Segredo do webhook da chave (Dashboard → API → revelar credenciais, começa com whsec_) e compare com o v1 em tempo constante. Use o corpo como chegou: decodificar e codificar o JSON de novo muda os bytes (300.00 vira 300) e a conta não bate. Recuse t com mais de 5 minutos de diferença. Assinatura ausente ou errada: responda 401 e não processe, porque não veio de nós. Responder 401 faz a entrega ser tentada de novo; se o segredo do seu lado estava errado, corrija e os avisos voltam.
  4. Tenha um ramo default Status desconhecido é não pago, nunca “provavelmente pago”. Não gere enum fechado que estoure exceção em valor inesperado: a lista pode crescer, e o seu handler tem que sobreviver a isso.
Handler completo
<?php
// Handler de webhook. Recebe cashin e cashout na MESMA URL: separe por "type".

// 1) ASSINATURA primeiro, sobre o corpo BRUTO (nunca sobre o JSON reserializado).
//    SEGREDO_WEBHOOK = "Segredo do webhook" da chave, em Dashboard > API.
$corpo = file_get_contents('php://input');
if (!assinaturaValida($_SERVER['HTTP_X_KERO_SIGNATURE'] ?? '', $corpo, SEGREDO_WEBHOOK)) {
    http_response_code(401);   // nao veio de nos: descarte sem processar
    exit;
}
$evt = json_decode($corpo, true) ?: [];

// Responde 200 SEMPRE, e ANTES de processar. Um 500 faz o evento voltar e
// repetir o mesmo problema; passar de 10s tambem faz o envio desistir, e
// consultarStatus(), la embaixo, e uma chamada HTTP.
http_response_code(200);
if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); }

// Fora do PHP-FPM a resposta so sai no fim, entao ainda e preciso garantir o
// 200: o exception handler pega exception E Error (PHP 7+) e deixa o script
// terminar em silencio, em vez de virar pagina de erro com status 500: o
// register_shutdown_function sozinho nao resolve, porque quando o erro ja
// imprimiu alguma coisa os cabecalhos ja foram.
set_exception_handler(function ($e) { error_log('webhook: ' . $e->getMessage()); });
register_shutdown_function(function () { if (!headers_sent()) { http_response_code(200); } });

$type   = $evt['type']   ?? '';
$status = $evt['status'] ?? '';
$chave  = $type === 'cashout' ? ($evt['e2e'] ?? '') : ($evt['request_number'] ?? '');

if ($chave === '' || jaProcessado($type, $chave, $status)) {
    exit;  // idempotencia: mesmo evento duas vezes nao processa duas vezes
}
marcarProcessado($type, $chave, $status);

if ($type === 'cashin') {
    switch ($status) {
        case 'confirmed':
            // Assinatura ja conferida. Consultar o status e uma segunda
            // conferencia, barata, antes de liberar valor.
            if (consultarStatus($chave) === 'confirmed') {
                liberarPedido($chave);
            }
            break;

        case 'contested':
        case 'chargedback':
        case 'refunded':
            // Chegam DEPOIS de "confirmed": o dinheiro saiu do seu saldo.
            reverterPedido($chave, $status);
            break;

        case 'canceled':
        case 'expired':
            // expired ainda pode virar confirmed (pagamento no ultimo instante,
            // confirmado depois do prazo): cancele de um jeito que o case
            // 'confirmed' consiga reabrir e liberar.
            cancelarPedido($chave);
            break;

        default:
            // pending e qualquer status novo: NAO PAGO. Registre e siga.
            registrarStatusDesconhecido($chave, $status);
    }
} elseif ($type === 'cashout') {
    switch ($status) {
        case 'completed': marcarSaquePago($chave);       break;
        case 'canceled':  devolverSaqueAoSaldo($chave);  break;  // valor + taxa voltaram
        default:          registrarSaquePendente($chave, $status);
    }
}

function assinaturaValida(string $hdr, string $corpo, string $segredo): bool
{
    if (!preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $hdr, $m)) { return false; }
    if (abs(time() - (int)$m[1]) > 300) { return false; }   // mais de 5 min: replay
    return hash_equals($m[2], hash_hmac('sha256', $m[1] . '.' . $corpo, $segredo));
}
Reenvio pelo suporte. Quando o nosso suporte reenvia um webhook à mão, ele sai com o status atual da transação, assinado igual, e com um X-Kero-Event-Id novo. Por isso a sua idempotência não pode depender só do Event-Id: a chave que protege é request_number (Cash In) ou e2e (Cash Out) junto com o status, como no handler acima.

Webhook de matrícula (Área de Membros)

Existe um segundo webhook, independente do de transação: ele avisa quando um aluno ganha ou perde acesso a um curso. A URL dele não vem no corpo da cobrança: é configurada uma vez em Dashboard → Cursos → Webhook de matrícula e vale para toda a conta. Se você não usa a Área de Membros, ignore esta seção: sem URL configurada, nada é enviado.

São dois webhooks diferentes, em URLs diferentes. O de transação fala de dinheiro (type: cashin/cashout); este fala de acesso (event: enrollment.*). Não os aponte para o mesmo endpoint esperando o mesmo formato: o payload de matrícula não tem type, status de transação nem request_number.
Webhook · Acesso liberadoJSON
{
  "event": "enrollment.granted",
  "sent_at": "2026-08-20T14:03:11-03:00",
  "enrollment_id": 4821,
  "status": "active",
  "granted_at": "2026-08-20 14:03:11",
  "expires_at": null,
  "order_id": 99123,
  "reason": null,
  "course":  { "id": 12, "title": "Curso de Tráfego Pago" },
  "student": { "id": 3310, "name": "Maria Souza", "email": "[email protected]" }
}
Webhook · Acesso revogadoJSON
{
  "event": "enrollment.revoked",
  "sent_at": "2026-08-27T09:11:40-03:00",
  "enrollment_id": 4821,
  "status": "revoked",
  "granted_at": "2026-08-20 14:03:11",
  "expires_at": null,
  "order_id": 99123,
  "reason": "chargeback",
  "course":  { "id": 12, "title": "Curso de Tráfego Pago" },
  "student": { "id": 3310, "name": "Maria Souza", "email": "[email protected]" }
}

O campo reason

Só vem preenchido em enrollment.revoked, e é ele que diz por que o aluno perdeu o acesso:

ValorO que aconteceu
manualO vendedor revogou na mão, pela tela de alunos.
refundO pagamento foi estornado.
chargebackHouve chargeback da cobrança.
medA cobrança entrou em disputa (MED) e o valor foi bloqueado.

Revogação pode ser desfeita. Quando uma disputa é ganha e o pagamento volta a confirmed, o acesso é restaurado e você recebe um enrollment.granted novo para a mesma enrollment_id. Trate o par granted/revoked como estado, não como log: a última mensagem para uma enrollment_id é a que vale. Revogação com reason: "manual" nunca é desfeita automaticamente.

As quatro regras do handler acima valem igual aqui, com uma diferença na chave de idempotência: use enrollment_id + event em vez de request_number. E vale o mesmo aviso: o POST não é assinado. Ele é gatilho para você sincronizar o acesso do seu lado (liberar o canal no Discord, adicionar ao grupo, marcar no seu CRM), nunca prova de pagamento.

A entrega tem reentrega. O aviso é gravado numa fila antes de sair, então um servidor seu fora do ar por alguns minutos não perde o evento: ele volta depois. A consequência é que o mesmo evento pode chegar duas vezes: sem idempotência, o aluno é adicionado duas vezes ao seu grupo.

Idempotência

Um POST que estoura o timeout do seu cliente pode ter sido processado do mesmo jeito. Repetir a chamada, sem proteção, pagaria duas vezes. As duas rotas que movem dinheiro têm proteção, mas elas funcionam de formas diferentes.

As duas proteções, lado a lado

Cash OutTransferência
CampoIdempotency-Key (cabeçalho) ou idempotency_key (corpo)external_id (corpo)
Tamanho máximo190 caracteres64 caracteres (acima disso é erro)
Validade da chaveÚnica por conta24 horas
Sem informar, protege por30 segundos: conta + chave PIX + tipo + valor15 segundos: destino + valor
Resposta na repetiçãook: true + idempotent: trueok: true + deduplicated: true
Marca de dedupe automáticadeduplicated: true (além de idempotent)deduplicated: true
Nos dois casos a repetição volta como sucesso (ok: true) com a operação original, não como erro. Se o seu código soma ao total a cada resposta de sucesso, ele vai contar duas vezes. Cheque idempotent / deduplicated antes de registrar.

Cash Out: Idempotency-Key

Reenviar a mesma chave devolve a resposta do saque já criado, com "idempotent": true, sem criar outro saque nem debitar de novo. Ela tem prioridade sobre a dedupe automática e é a única proteção para um retry que chega depois da janela de 30 segundos.

Quando você não manda a chave, o servidor deriva uma do próprio pedido: conta + chave PIX + tipo da chave + valor + janela de 30 segundos. Nesse caso a resposta vem com "idempotent": true e "deduplicated": true.

Use deduplicated para separar os casos:

idempotentdeduplicatedSignifica
ausente / falseNão se aplicaSaque novo. Registre normalmente.
truefalseFoi a sua Idempotency-Key que repetiu.
truetrueO servidor absorveu uma repetição que você não marcou.
Para pagar de propósito duas vezes o mesmo valor para a mesma chave PIX dentro de 30 segundos, mande Idempotency-Key DIFERENTES. Sem isso (ou repetindo a mesma chave), o segundo pedido volta como replay do primeiro e o dinheiro não sai duas vezes, embora a resposta seja de sucesso. É o caso típico de dois repasses iguais em sequência para o mesmo beneficiário: Idempotency-Key: repasse-1001 na primeira requisição e Idempotency-Key: repasse-1002 na segunda.
Se a reserva da chave falhar, a resposta é “Não foi possível registrar a Idempotency-Key. Tente novamente.” (409). Nenhum saque foi criado: repita a requisição com a mesma chave. Há ainda dois 409 de recuperação (“Não foi possível recuperar o saque original desta Idempotency-Key” e “…desta solicitação. Confira seu extrato antes de tentar de novo.”): nesses, o saque pode existir: como o texto diz, confira o extrato antes de repetir.

Transferência: external_id

Reenvie o retry com o mesmo external_id: por 24 horas a mesma chave devolve a transferência original, com o mesmo e2e, acrescida de "deduplicated": true, em vez de criar outra.

Sem external_id a proteção é bem mais fraca: cai numa janela automática de 15 segundos por (destino + valor). Um retry que volte depois disso (ou um repasse legítimo de mesmo valor para a mesma conta dentro dela) não é distinguível. O campo custa uma linha no seu código e é a única forma de o servidor saber que dois pedidos são o mesmo pedido.
Se o seu SDK expõe external_id, gere um valor padrão quando o caller não informar. Nunca deixe o campo sair vazio por omissão. E lembre do limite: 64 caracteres, recusado acima disso (não truncado).

Moeda

Toda a API opera em reais (BRL). Vale para amount na cobrança e no saque, para as taxas, para o saldo e para os valores enviados no webhook.

A plataforma está sendo preparada para operar em outras moedas (a primeira é o peso mexicano, via SPEI). Isso ainda não está disponível na API pública e não muda nada para quem integra hoje. Quando outra moeda for liberada para a sua conta, ela será anunciada aqui antes, com o campo que informa a moeda de cada valor. Até lá, pode tratar todo número desta documentação como reais, sem condicional e sem checagem.

Não existe parâmetro de moeda

Não há currency, nem qualquer outro nome, no corpo da cobrança, do saque ou da transferência. Em toda conta em operação hoje, mandar um campo de moeda é ignorado em silêncio: a requisição não é recusada e o valor é processado em reais. Enviar "currency": "COP" não cria uma cobrança em pesos: cria uma cobrança em reais, e você só descobre na conciliação.

Precisão, para não haver surpresa: existe uma única situação em que o campo não é ignorado: uma conta-piloto que o administrador tenha habilitado explicitamente naquela moeda. Nela a requisição é recusada com “Operações em <MOEDA> ainda não liberadas (saldo multi-moeda em implantação)” (X-App-Error-Code: 422), e nada é criado. Ou seja: em nenhum cenário mandar uma moeda produz uma cobrança naquela moeda. O comportamento é ignorar ou recusar, nunca processar.

As moedas do modelo futuro

Nada desta tabela está no ar. Ela existe para que você já conheça o modelo no dia em que for liberado, não para ser implementada agora. Não escreva código que antecipe esse campo, não crie ramificação por moeda e não leia a moeda de nenhuma resposta como se pudesse variar.
CódigoMoedaSímboloCasas decimaisSituação
BRLReal brasileiroR$2Em operação (a única, hoje)
MXNPeso mexicanoMX$2Não disponível
COPPeso colombianoCOP$0 (zero)Não disponível
O peso colombiano (COP) tem ZERO casas decimais. Isso não é uma simplificação nossa: é a convenção da moeda: o peso colombiano não é subdividido em centavos na prática, e o valor cheio é o próprio inteiro. Quem trata dinheiro como “sempre tem centavos” e converte para a menor unidade multiplicando por 100 erra o valor por 100x. Em COP, 5000 é cinco mil pesos, não cinquenta pesos. A quantidade de casas é de cada moeda, não do sistema: não generalize a regra de 2 casas do real para as demais, e não arredonde um valor em COP com duas casas: isso inventa dinheiro que a contraparte não reconhece.
O que fazer hoje: nada. Continue enviando e lendo todos os valores como reais, sem campo de moeda e sem ramificação por moeda no seu código. O único campo de moeda que existe na API é balance.currency na consulta de saldo, e ele é sempre BRL.

Erros

Erros retornam ok: false com uma mensagem. O código do erro vai no cabeçalho X-App-Error-Code.

Leia isto antes de escrever seu tratamento de erro. O status HTTP das rotas de negócio é sempre 200, inclusive quando a operação falha. Elas não devolvem 400, 401, 403, 404, 422 nem 5xx na linha de status. Se o seu código faz algo como if (response.status === 200) { /* deu certo */ } ou response.raise_for_status(), toda falha será lida como sucesso, e um saque recusado ou uma cobrança não criada passarão despercebidos.

A regra correta, sempre: verifique o campo ok do corpo JSON. Se ok for false, a operação falhou; use error para a mensagem e o cabeçalho X-App-Error-Code para o código na tabela abaixo.
Uma exceção, e ela não muda a regra: o limitador de requisições responde com um 429 de verdade na linha de status. Como esse corpo também traz ok: false, quem decide pelo ok continua correto sem mudar nada. O que não funciona é o inverso: assumir que status 200 = sucesso, ou que status ≠ 200 = erro de rede.
Formato de erroJSON
{ "ok": false, "error": "Mensagem do erro" }
Lendo o erro corretamente
<?php
// O unico jeito correto de ler uma resposta desta API.
$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HEADER         => true,   // precisamos do X-App-Error-Code
    CURLOPT_TIMEOUT        => 30,
    /* ... */
]);
$raw     = curl_exec($ch);
$hdrSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);

$headers = substr($raw, 0, $hdrSize);
$res     = json_decode(substr($raw, $hdrSize), true) ?: [];

// NAO cheque o status HTTP. Cheque "ok".
if (empty($res['ok'])) {
    preg_match('/^X-App-Error-Code:\s*(\d+)/mi', $headers, $m);
    $code = (int)($m[1] ?? 0);

    // 429 e transitorio (limitador); 5xx tambem. O resto so passa se o
    // caller corrigir os dados: nao retente em loop.
    $transitorio = ($code === 429 || $code >= 500);
    throw new ApiException($res['error'] ?? 'Falha', $code, $transitorio);
}

Autenticação, permissão e estado da conta

CódigoErroCausa
401Credenciais ausentes / Credenciais inválidasClient ID/Secret faltando ou incorretos.
403Permissão insuficienteA chave não tem a permissão exigida pelo endpoint. Não vale para o Cash In (veja a linha do 400 abaixo).
400Não foi possível gerar seu pagamento. Entre em contato com nossa equipe!Exclusivo do Cash In: a chave existe mas não tem a permissão cashin. Este caso não devolve “Permissão insuficiente” nem 403.
403IP não permitido / IP não configuradoCash Out ou Transferência de um IP fora da allowlist, ou não-IPv4. Faixas CIDR e * não são aceitas: cadastre IPv4 exatos.
403Chave inativa / Conta inválida / Conta bloqueadaChave desativada, conta sem a operação habilitada, ou conta bloqueada pela operação.
403Conta não verificadaO KYC da conta dona da chave ainda não foi aprovado. Conclua a verificação no painel.
403Acesso à API desabilitado para esta contaO administrador desligou o acesso de API dessa conta. Repetir ou trocar de chave não resolve: o titular precisa falar com o suporte.
403Cadastro incompleto: atualize seus dados no painelA conta foi marcada para recadastro e ainda tem dados obrigatórios faltando. Até o titular completar o cadastro, toda a API fica bloqueada.
403Cashout desativado / Depósito desativado para a contaA conta está com a operação correspondente bloqueada. A transferência segue o mesmo bloqueio do saque.

Validação e regra de negócio

CódigoErroCausa
400Valor inválido / Dados inválidosCorpo com campos obrigatórios ausentes ou inválidos. No Cash Out são obrigatórios amount, name, document, pix_key e pix_type.
400Valor máximo para PIX é de R$ <VALOR>Cash In: amount acima do teto por cobrança. O valor é definido pela operação e pode mudar: a mensagem informa qual é. Nada é criado. Não fixe o número no código.
400Valor mínimo para PIX é de R$ <VALOR>Cash In: amount abaixo do piso por cobrança. O valor é definido pela operação e pode mudar: a mensagem informa qual é. Nada é criado. Não fixe o número no código.
422Valor mínimo de saque é R$ <VALOR>Cash Out: amount abaixo do mínimo da conta. Definido pela operação e pode mudar: a mensagem informa qual é. Nada é criado nem debitado.
400Saldo insuficienteSaldo menor que amount + fee no Cash Out, ou que amount na transferência. Uma variante diz o motivo: “Saldo insuficiente: R$ <V> estão retidos por dívida de MED/chargeback.”
400Erros de splitusername inexistente, repetido ou igual ao dono; soma acima de 100%; total acima do teto. A cobrança é recusada e o PIX não é criado.
400external_id muito longo (máx. 64)Transferência: o campo é recusado, não truncado. Encurte antes de enviar.
422Saque via chave de telefone não é permitido. Use CPF, CNPJ, e-mail ou chave aleatória.Chave PIX de telefone no Cash Out, inclusive quando o tipo declarado é outro e a chave tem cara de telefone.
422Esta conta não aceita Pix Copia e Cola como destino de saque no momento. Use uma chave PIX (CPF, CNPJ, e-mail, telefone ou aleatória).pix_type QRCODE sem o recurso habilitado para a conta. É estado operacional, não erro permanente. Nada é criado nem debitado.
422CNPJ alfanumérico ainda não é aceito para saque pelo adquirente. Use um CNPJ numérico.document (ou a pix_key com pix_type CNPJ) contém letras. Nada é criado nem debitado.
422CPF inválido / CNPJ inválidodocument reprovado no dígito verificador (11 dígitos = CPF, 14 = CNPJ).
422Chave PIX inválida: CPF/CNPJ com dígito verificador incorretopix_key do tipo CPF ou CNPJ reprovada no DV. EMAIL, EVP e QRCODE não passam por essa checagem.
422Para chave PIX do tipo CPF/CNPJ, o campo document deve ser o mesmo documento da chave.Os dígitos de pix_key não batem com os de document. A mensagem traz os dois valores recebidos.
422Operações em <MOEDA> ainda não liberadas (saldo multi-moeda em implantação)Campo de moeda enviado numa conta-piloto habilitada naquela moeda. Em conta normal o campo é ignorado (veja Moeda).
403Limite de saques / Limite de valor para este CPF/CNPJ atingidoEstourou o número máximo de saques, ou a soma máxima, para o mesmo documento de destino nas últimas 24h. 0 ou não configurado = sem limite.
403Saque bloqueado por verificação de segurançaO antifraude recusou o Cash Out. A mensagem traz o motivo concatenado.

Protocolo, consulta e falhas técnicas

CódigoErroCausa
405Método inválidoVerbo HTTP errado: /api/cashin, /api/cashout e /api/transfer só aceitam POST. A consulta aceita GET, HEAD e POST, recusando só PUT/PATCH/DELETE. Nada é criado nem debitado.
404Depósito não encontradoConsulta com request_number/transaction_id inexistente nesta conta.
404Destinatário não encontradoTransferência: o to não bate com nenhum username ou e-mail. Nada foi debitado. Não retente.
400request_number ou transaction_id obrigatórioConsulta chamada sem nenhum dos dois identificadores na query string.
400Falha ao gerar PIXMensagem genérica do Cash In. Cobre também erro técnico do processamento e document ausente. Com X-App-Error-Code: 502 indica falha técnica externa. Não faça parsing do texto: use o código.
409Não foi possível registrar a Idempotency-Key. Tente novamente.Falha ao reservar a chave de idempotência. Nenhum saque foi criado: repita com a mesma chave.
409Não foi possível recuperar o saque original desta Idempotency-Key / …desta solicitação. Confira seu extrato antes de tentar de novo.A repetição foi detectada mas o saque original não pôde ser lido. O saque pode existir: confira o extrato antes de repetir.
409Não foi possível registrar a transferência / transferir para este destinatário. Tente novamente.Concorrência na transferência. Nada foi debitado; repetir é seguro com o mesmo external_id.
429Muitas requisições. Tente novamente em instantes.Chega com HTTP 429 de verdade e sem X-App-Error-Code. Veja Limites.
502Falha no gateway (HTTP 5xx) / Falha ao gerar PIXErro no processamento. No Cash In o texto é genérico, por isso decida pelo código 502, não pelo texto.
503Saques temporariamente suspensos. Tente novamente em instantes.Cash Out pausado pela operação. Transitório.
500Banco indisponível / Erro ao consultar saldo / Erro ao transferirFalha interna. Repita a requisição (com a mesma chave de idempotência, quando houver); se persistir, contate o suporte.

O que retentar, e o que nunca retentar

SituaçãoRetentar?Como
429, 502, 503, 500SimCom recuo exponencial e a mesma chave de idempotência. São transitórios.
Timeout do seu clienteSimA operação pode ter sido processada. Repita com a mesma chave: é exatamente o caso que a idempotência resolve.
409 de registro de chaveSimNada foi criado. Mesma chave.
409 de recuperação do originalConfira antesA operação pode existir. Verifique o extrato.
Todo 422 do Cash OutNãoCNPJ alfanumérico, DV, valor mínimo, telefone, Copia e Cola: só passam se o caller corrigir os dados.
400 de saldo, 404 de destinatárioNãoNada foi debitado, e repetir dá o mesmo resultado.
401, 403NãoConfiguração de chave, permissão, IP ou estado da conta. Repetir não muda nada.

Limites e rate limit

Onde a API impõe um teto, e o que fazer quando ele aparece.

Limitador de requisições (HTTP 429)

Existe um limitador por IP de origem. Ele é desligado por padrão e ligado pela operação quando necessário, então a maioria das integrações nunca o vê. Quando ele dispara, a resposta é:

Resposta do limitadorHTTP
HTTP/1.1 429 Too Many Requests
Content-Type: application/json

{ "ok": false, "error": "Muitas requisições. Tente novamente em instantes." }
Esta é a única resposta da API com status HTTP diferente de 200, e ela não traz X-App-Error-Code. Isso não muda a regra de leitura: o corpo tem ok: false, então quem decide pelo ok já a trata corretamente. Se você lê o código do erro pelo cabeçalho, faça o fallback para o status HTTP quando o cabeçalho não vier: é o que os exemplos em Erros fazem.

É transitório: respeite um recuo antes de repetir (o dobro do intervalo a cada tentativa, com um teto), e não conte a tentativa como falha definitiva do pedido. A causa mais comum é polling apertado demais na consulta de status. Prefira o webhook.

Limites de valor e de volume

Nenhum destes é fixo: todos são configurados pela operação e podem mudar sem aviso de código. Não codifique nenhum deles.

LimiteOndeAo estourar
Valor máximo por cobrançaCash In“Valor máximo para PIX é de R$ <V>” · 400
Valor mínimo por cobrançaCash In“Valor mínimo para PIX é de R$ <V>” · 400
Valor mínimo por saqueCash Out“Valor mínimo de saque é R$ <V>” · 422
Nº de saques por CPF/CNPJ de destino / 24hCash Out“Limite de saques… atingido” · 403
Soma por CPF/CNPJ de destino / 24hCash Out“Limite de valor… atingido” · 403
Teto de split (taxa + painel + API)SplitCobrança recusada · 400
Requisições por IPTodas as rotas“Muitas requisições…” · 429

Tamanho de campo

CampoRotaMáximoAcima disso
Idempotency-KeyCash Out190 caracteresNão se aplica
external_idTransferência64 caracteresRecusado (400)
descriptionTransferência140 caracteresCortado em silêncio
Repare na diferença de comportamento: external_id é recusado, description é cortado. Não use description como identificador.

Tempos

O quêValor
Validade da cobrança PIXVem em expire_minutes na resposta do Cash In (tipicamente 30 minutos)
Timeout do seu endpoint de webhook10 segundos
Janela da dedupe automática de saque30 segundos
Janela da dedupe automática de transferência15 segundos
Validade do external_id na transferência24 horas
Janela dos limites por documento de destino24 horas móveis

Checklist de produção

Os erros de integração que aparecem com mais frequência no suporte. Vale conferir antes de ligar a chave em produção.

Tratamento de erro

  • Decidir sucesso/falha pelo campo ok, nunca pelo status HTTP.
  • Não usar raise_for_status(), ensureSuccessStatusCode() nem equivalentes: eles jamais disparam nas rotas de negócio.
  • Ler o código em X-App-Error-Code, com fallback para o status HTTP (por causa do 429).
  • Nunca fazer parsing da mensagem de erro para decidir o fluxo: o texto do Cash In é genérico de propósito.
  • Ter timeout em toda chamada, e recuo exponencial só nos erros transitórios.

Dinheiro

  • Enviar Idempotency-Key em todo Cash Out, e external_id em toda transferência.
  • Gerar chave nova a cada pedido distinto e reusar a mesma só no retry do mesmo pedido.
  • Checar idempotent / deduplicated antes de somar ao seu total: a repetição volta como sucesso.
  • Persistir o e2e do saque e o request_number da cobrança: são as chaves de conciliação.
  • Não usar consulta de saldo como pré-condição do saque. Tratar Saldo insuficiente de qualquer forma.
  • Não “limpar” document e pix_key removendo caracteres não-numéricos antes de enviar.

Status e webhook

  • Tratar os 7 status de Cash In e os 3 de Cash Out, com um ramo default que trate desconhecido como não pago.
  • Não gerar enum fechado que estoure exceção em valor inesperado.
  • Não tratar confirmed como final: contested, chargedback e refunded chegam depois e revertem.
  • Handler de webhook idempotente, respondendo 200 em até 10 segundos, inclusive quando o seu código falhar.
  • Confirmar por Consultar status antes de liberar valor: o webhook não é assinado.

Configuração

  • Client Secret em variável de ambiente ou cofre, nunca no repositório.
  • IPv4 de saída cadastrado na chave (todos, se o servidor sai por mais de um).
  • Só as permissões que a integração usa: balance e transfers desligadas se não forem usadas.
  • URL de webhook não adivinhável, só HTTPS, fora de log e de ticket.
  • Nenhum limite (mínimo, máximo, taxa) fixo no código: todos vêm da mensagem de erro.

Glossário

Os termos que aparecem nos campos e nas mensagens desta API.

TermoSignificado
Cash InEntrada de dinheiro: a cobrança PIX que o seu cliente paga.
Cash OutSaída de dinheiro: o PIX que você envia para uma chave.
request_numberNossa referência da cobrança. Chave da consulta de status e do webhook de Cash In.
e2eIdentificador ponta a ponta. Na API, é a chave do saque e da transferência.
transaction_idId da transação no processador (adquirente).
Copia e cola / EMV / BR CodeO texto do PIX que o pagador cola no app do banco. Na resposta: copyPaste.
Adquirente / processadorQuem executa o PIX de fato. É escolhido pela plataforma e pode mudar por failover: a resposta não muda com ele.
MEDMecanismo Especial de Devolução do PIX: o pagador contesta e pede o dinheiro de volta. Leva a cobrança a contested e, se aceito, a chargedback.
ChargebackDevolução do valor ao pagador após contestação. O dinheiro já saiu do seu saldo.
Saldo bloqueadobalance.blocked: retido por disputa. Não pode ser sacado; pode voltar ao disponível.
SplitDivisão de parte da cobrança com outra conta da plataforma. Incide sobre o bruto e sai do lojista.
IdempotênciaGarantia de que repetir a mesma requisição não executa a operação duas vezes.
Allowlist de IPLista de IPv4 autorizados a usar a chave em Cash Out e Transferência.
KYCVerificação de identidade da conta. Sem aprovação, a API responde “Conta não verificada”.

Mudanças

Esta é a primeira versão da API. Quando um comportamento mudar de um jeito que exija ajuste em código já publicado, ele entra aqui.