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.
https://keropay.coEndpoints 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ção | Método | Caminho | Permissão | Webhook |
|---|---|---|---|---|
| Criar cobrança | POST | /api/cashin | cashin | Sim |
| Solicitar saque | POST | /api/cashout | cashout | Sim |
| Consultar status | GET | /api/consult-transaction | cashin | Nenhum |
| Consultar saldo | GET | /api/balance | balance | Nenhum |
| Transferir entre contas | POST | /api/transfer | transfers | Nã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.
{
"ok": true,
"...": "campos da operação"
}{
"ok": false,
"error": "Mensagem do erro"
}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.
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.
-
Gere a chave e ligue as permissões
Em Dashboard → API. Guarde o
Client Secretcomo senha: ele não é exibido de novo. Marquebalanceetransferssó se for usar: permissão desligada é superfície de ataque a menos. -
Cadastre o IP de saída do seu servidor
Obrigatório para Cash Out e Transferência. São endereços IPv4 exatos:
faixas
/24e curingas não são aceitos. Se o seu servidor sai por vários IPs, cadastre todos. -
Crie a cobrança
Um
POSTem/api/cashincom o valor e o documento do pagador. Exiba ocopyPastee o QR para quem vai pagar, e guarde orequest_number: é a sua chave da transação daqui em diante.Primeira cobrançacURLcurl -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"}' -
Receba a confirmação por webhook
Informe
webhook_urlna cobrança e responda200rá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. -
Concilie
confirmednão é final: uma cobrança paga pode virarcontested,chargedbackourefundeddepois, e nesses casos o dinheiro sai do seu saldo. Reconcilie sempre pelo último status recebido. Nunca cacheieconfirmedcomo imutável.
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)
X-Client-Id: SEU_CLIENT_ID
X-Client-Secret: SEU_CLIENT_SECRETBasic Auth
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ão | Libera | Estado inicial | Exige IP na allowlist |
|---|---|---|---|
cashin | Criar cobrança e consultar o status dela | Ligada | Não |
cashout | Solicitar saque PIX | Ligada | Sim |
balance | Consultar saldo | Desligada | Não |
transfers | Transferir para outra conta da plataforma | Desligada | Sim |
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.
| Mensagem | O que aconteceu | Como sai desse estado |
|---|---|---|
Conta não verificada | O KYC da conta dona da chave ainda não foi aprovado. | Concluir a verificação no painel. |
Acesso à API desabilitado para esta conta | O administrador desligou o acesso de API dessa conta. | Só o suporte religa. Repetir não adianta. |
Cadastro incompleto — atualize seus dados no painel | A 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 inativa | A chave existe, mas foi desativada. | Reativar ou gerar outra em Dashboard → API. |
Conta bloqueada | A 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. |
{ "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. 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
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
amount | number | Sim | Valor da cobrança (deve ser > 0). Há um teto por cobrança (veja abaixo). |
name | string | Não | Nome do pagador. |
document | string | Depende | CPF/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. |
email | string | Não | E-mail do pagador. |
phone | string | Depende | Celular do pagador com DDD (só dígitos). Obrigatório no boleto. |
payment_method | string | Não | pix (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. |
address | object | Depende | Endereç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). |
description | string | Não | Descrição / identificador do pedido. |
webhook_url | string | Não | URL para notificação de status (aceita o sinônimo postbackUrl). |
splits | array | Não | Divisõ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. |
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 } ]
}'<?php
$ch = curl_init('https://keropay.co/api/cashin');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Client-Id: ' . getenv('VP_CLIENT_ID'),
'X-Client-Secret: ' . getenv('VP_CLIENT_SECRET'),
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => 123.45,
'name' => 'Cliente Pix',
'document' => '12345678900',
'description' => 'Pedido #123',
'webhook_url' => 'https://seusistema.com/webhooks/keropay',
]),
]);
$body = curl_exec($ch);
curl_close($ch);
$res = json_decode($body, true);
// Decide pelo campo "ok". O status HTTP e 200 tambem no erro.
if (empty($res['ok'])) {
throw new RuntimeException($res['error'] ?? 'Falha ao gerar PIX');
}
$copiaECola = $res['copyPaste']; // mostre ao pagador
$requestNumber = $res['request_number']; // guarde: e a chave da consultaconst res = await fetch('https://keropay.co/api/cashin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Id': process.env.VP_CLIENT_ID,
'X-Client-Secret': process.env.VP_CLIENT_SECRET,
},
body: JSON.stringify({
amount: 123.45,
name: 'Cliente Pix',
document: '12345678900',
description: 'Pedido #123',
webhook_url: 'https://seusistema.com/webhooks/keropay',
}),
});
// Nao use res.ok nem res.status: o status e 200 mesmo quando a operacao falha.
const data = await res.json();
if (!data.ok) throw new Error(data.error);
const copiaECola = data.copyPaste; // mostre ao pagador
const requestNumber = data.request_number; // guarde: e a chave da consultaimport os
import requests
r = requests.post(
"https://keropay.co/api/cashin",
headers={
"X-Client-Id": os.environ["VP_CLIENT_ID"],
"X-Client-Secret": os.environ["VP_CLIENT_SECRET"],
},
json={
"amount": 123.45,
"name": "Cliente Pix",
"document": "12345678900",
"description": "Pedido #123",
"webhook_url": "https://seusistema.com/webhooks/keropay",
},
timeout=30,
)
# Sem r.raise_for_status(): ele nunca dispara aqui e faria todo erro passar.
data = r.json()
if not data.get("ok"):
raise RuntimeError(data.get("error", "Falha ao gerar PIX"))
copia_e_cola = data["copyPaste"] # mostre ao pagador
request_number = data["request_number"] # guarde: e a chave da consulta{
"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": "..."
}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.
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.
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.
{
"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"
}{
"amount": 297.00,
"payment_method": "credit_card",
"name": "Maria Souza",
"email": "[email protected]",
"webhook_url": "https://seusistema.com/webhooks/keropay"
}{
"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_..."
}
}{
"ok": true,
"status": "pending",
"amount": 297.00,
"request_number": "pgm_...",
"transaction_id": "pgm_...",
"copyPaste": "",
"qr_url": "",
"qr_img": "",
"checkout_url": "https://keropay.co/pagamento/pgm_...",
"expire_minutes": 1440,
"fee": 14.82,
"payment_method": "credit_card",
"card": { "payment_url": "https://keropay.co/pagamento/pgm_...", "installments": null, "buyer_interest": 0 }
}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.
null. Não teste se a chave existe; teste o conteúdo.
Campos canônicos
| Campo | Tipo | Sempre presente | Descrição |
|---|---|---|---|
status | string | sim | Na criação é sempre pending. Cobrança recém-criada nunca nasce paga. |
amount | number | sim | Valor da cobrança em reais (BRL). Ver Moeda. |
request_number | string | sim | Nossa referência da cobrança. É a chave para consultar o status. |
transaction_id | string | sim | Id da transação no processador. |
copyPaste | string | sim | Campo canônico. EMV copia-e-cola que o pagador cola no banco. |
qr_url | string | sim | URL 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_img | string | sim | O 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_url | string | sim | Página de pagamento hospedada pelo processador, quando existir. Vazio nos demais. |
expire_minutes | number | sim | Validade 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_code | copyPaste |
pix_qr_base64 | qr_img |
transaction | transaction_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.
<?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
}// 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(res) {
return res.qr_img
? `data:image/png;base64,${res.qr_img}` // sem requisicao extra
: res.qr_url; // sem base64 no HTML
}# 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.
def qr_src(res: dict) -> str:
if res["qr_img"]:
return "data:image/png;base64," + res["qr_img"] # sem requisicao extra
return res["qr_url"] # sem base64 no HTMLorderId e dos apelidos qrCode e qrUrl.
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.
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%:
| Etapa | Cálculo | Lojista fica com |
|---|---|---|
| Pagador paga | valor da cobrança | R$ 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,00 | R$ 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
| Campo | Tipo | Descrição |
|---|---|---|
username | string | Usuário que recebe o split. Sinônimo aceito: clientId. |
mode | string | percent (padrão), fixed ou both. Se omitido, é deduzido pelos campos enviados. |
percentage | number | Porcentagem sobre o bruto. Sinônimo aceito: value, que também é porcentagem, não reais. |
fixed | number | Valor em reais, fixo por cobrança. |
"splits": [
{ "username": "usuario1", "percentage": 10 },
{ "username": "usuario2", "mode": "fixed", "fixed": 5.00 },
{ "username": "usuario3", "mode": "both", "percentage": 10, "fixed": 2.00 }
]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.
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).
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
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
amount | number | Sim | Valor do saque (> 0). Debita amount + fee do saldo. Há um mínimo por conta (veja abaixo). |
name | string | Sim | Nome do beneficiário. |
document | string | Sim | CPF/CNPJ do beneficiário (apenas dígitos). Validado pelo dígito verificador. |
pix_key | string | Sim | Chave PIX de destino. |
pix_type | string | Sim | Tipo da chave (ver abaixo). Se omitido, é deduzido da pix_key. |
webhook_url | string | Não | URL para notificação de status. Na prática obrigatório: é o único jeito de saber o desfecho do saque. |
auto | boolean | Não | Pede 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_key | string | Não | Alternativa ao cabeçalho Idempotency-Key (veja Idempotência). Máx. 190 caracteres. |
Tipos de chave (pix_type) aceitos:
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.
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"
}'<?php
// A Idempotency-Key e NOVA a cada pedido distinto, e a MESMA no retry do
// mesmo pedido. E a unica protecao contra pagar duas vezes depois de 30s.
$idem = 'saque-' . $pedidoId;
$ch = curl_init('https://keropay.co/api/cashout');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 40,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Client-Id: ' . getenv('VP_CLIENT_ID'),
'X-Client-Secret: ' . getenv('VP_CLIENT_SECRET'),
'Idempotency-Key: ' . $idem,
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => 250.00,
'name' => 'Fulano de Tal',
'document' => '00000000000', // mande como veio: nao remova letras
'pix_key' => '[email protected]',
'pix_type' => 'EMAIL',
'webhook_url' => 'https://seusistema.com/webhooks/keropay',
]),
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($res['ok'])) {
throw new RuntimeException($res['error'] ?? 'Falha ao criar saque');
}
// "idempotent" => este saque JA EXISTIA. Nao some ao seu total, nao crie outro.
$jaExistia = !empty($res['idempotent']);
$e2e = $res['e2e']; // guarde: e por ele que o webhook casa com o saque// A Idempotency-Key e NOVA a cada pedido distinto, e a MESMA no retry do
// mesmo pedido. E a unica protecao contra pagar duas vezes depois de 30s.
const idem = `saque-${pedidoId}`;
const res = await fetch('https://keropay.co/api/cashout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Id': process.env.VP_CLIENT_ID,
'X-Client-Secret': process.env.VP_CLIENT_SECRET,
'Idempotency-Key': idem,
},
body: JSON.stringify({
amount: 250.0,
name: 'Fulano de Tal',
document: '00000000000', // mande como veio: nao remova letras
pix_key: '[email protected]',
pix_type: 'EMAIL',
webhook_url: 'https://seusistema.com/webhooks/keropay',
}),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error);
// "idempotent" => este saque JA EXISTIA. Nao some ao seu total, nao crie outro.
const jaExistia = Boolean(data.idempotent);
const e2e = data.e2e; // guarde: e por ele que o webhook casa com o saqueimport os
import requests
# A Idempotency-Key e NOVA a cada pedido distinto, e a MESMA no retry do
# mesmo pedido. E a unica protecao contra pagar duas vezes depois de 30s.
idem = f"saque-{pedido_id}"
r = requests.post(
"https://keropay.co/api/cashout",
headers={
"X-Client-Id": os.environ["VP_CLIENT_ID"],
"X-Client-Secret": os.environ["VP_CLIENT_SECRET"],
"Idempotency-Key": idem,
},
json={
"amount": 250.00,
"name": "Fulano de Tal",
"document": "00000000000", # mande como veio: nao remova letras
"pix_key": "[email protected]",
"pix_type": "EMAIL",
"webhook_url": "https://seusistema.com/webhooks/keropay",
},
timeout=40,
)
data = r.json()
if not data.get("ok"):
raise RuntimeError(data.get("error", "Falha ao criar saque"))
# "idempotent" => este saque JA EXISTIA. Nao some ao seu total, nao crie outro.
ja_existia = bool(data.get("idempotent"))
e2e = data["e2e"] # guarde: e por ele que o webhook casa com o saque{
"ok": true,
"e2e": "WD_...",
"fee": 2.5,
"status": "pending",
"auto": false
}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
| Status | Significado |
|---|---|
| pending | Saque 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. |
| completed | O PIX foi efetivamente pago ao beneficiário. Terminal no uso normal. |
| canceled | O 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.
"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.
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).”
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.
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.
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.
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.
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).
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"<?php
$url = 'https://keropay.co/api/consult-transaction?request_number=' . urlencode($requestNumber);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'X-Client-Id: ' . getenv('VP_CLIENT_ID'),
'X-Client-Secret: ' . getenv('VP_CLIENT_SECRET'),
],
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($res['ok'])) {
throw new RuntimeException($res['error'] ?? 'Falha ao consultar');
}
// So "confirmed" libera o pedido. Qualquer outro valor, inclusive um que
// voce nao conheca, e tratado como NAO PAGO.
if ($res['status'] === 'confirmed') {
liberarPedido($requestNumber);
}const url = `https://keropay.co/api/consult-transaction?request_number=${encodeURIComponent(requestNumber)}`;
const res = await fetch(url, {
headers: {
'X-Client-Id': process.env.VP_CLIENT_ID,
'X-Client-Secret': process.env.VP_CLIENT_SECRET,
},
});
const data = await res.json();
if (!data.ok) throw new Error(data.error);
// So "confirmed" libera o pedido. Qualquer outro valor, inclusive um que
// voce nao conheca, e tratado como NAO PAGO.
if (data.status === 'confirmed') {
await liberarPedido(requestNumber);
}import os
import requests
r = requests.get(
"https://keropay.co/api/consult-transaction",
params={"request_number": request_number},
headers={
"X-Client-Id": os.environ["VP_CLIENT_ID"],
"X-Client-Secret": os.environ["VP_CLIENT_SECRET"],
},
timeout=15,
)
data = r.json()
if not data.get("ok"):
raise RuntimeError(data.get("error", "Falha ao consultar"))
# So "confirmed" libera o pedido. Qualquer outro valor, inclusive um que
# voce nao conheca, e tratado como NAO PAGO.
if data["status"] == "confirmed":
liberar_pedido(request_number){
"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
| Status | Pagou? | Significado |
|---|---|---|
| pending | Ainda não | Aguardando pagamento. Estado inicial de toda cobrança. |
| confirmed | Sim | Pagamento confirmado e creditado. Único status que significa “recebi o dinheiro”. Não é final (veja abaixo). |
| expired | Em geral não | A 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. |
| canceled | Em geral não | A 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. |
| contested | Sim, e em disputa | Cobranç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. |
| chargedback | Sim, e devolvido | Cobrança paga cujo valor foi devolvido ao pagador após a contestação. Já foi debitado do lojista. Terminal. |
| refunded | Sim, e estornado | Cobranç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.
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 (e HEAD, para monitor de uptime). Não exige IP na allowlist.
curl -X GET https://keropay.co/api/balance \
-H "X-Client-Id: SEU_CLIENT_ID" \
-H "X-Client-Secret: SEU_CLIENT_SECRET"<?php
$ch = curl_init('https://keropay.co/api/balance');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'X-Client-Id: ' . getenv('VP_CLIENT_ID'),
'X-Client-Secret: ' . getenv('VP_CLIENT_SECRET'),
],
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($res['ok'])) {
throw new RuntimeException($res['error'] ?? 'Falha ao consultar saldo');
}
$disponivel = $res['balance']['available']; // e contra este numero que o saque decide
$bloqueado = $res['balance']['blocked']; // retido por disputa: nao sacaconst res = await fetch('https://keropay.co/api/balance', {
headers: {
'X-Client-Id': process.env.VP_CLIENT_ID,
'X-Client-Secret': process.env.VP_CLIENT_SECRET,
},
});
const data = await res.json();
if (!data.ok) throw new Error(data.error);
const disponivel = data.balance.available; // e contra este numero que o saque decide
const bloqueado = data.balance.blocked; // retido por disputa: nao sacaimport os
import requests
r = requests.get(
"https://keropay.co/api/balance",
headers={
"X-Client-Id": os.environ["VP_CLIENT_ID"],
"X-Client-Secret": os.environ["VP_CLIENT_SECRET"],
},
timeout=15,
)
data = r.json()
if not data.get("ok"):
raise RuntimeError(data.get("error", "Falha ao consultar saldo"))
disponivel = data["balance"]["available"] # e contra este numero que o saque decide
bloqueado = data["balance"]["blocked"] # retido por disputa: nao saca{
"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
| Campo | Tipo | Descrição |
|---|---|---|
balance.available | number | Disponível para saque e transferência agora. É contra este número que o Cash Out valida Saldo insuficiente. |
balance.blocked | number | Retido 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.total | number | available + blocked, já somado. |
balance.pending_release | number | Vendas 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.currency | string | Sempre BRL (ver Moeda). O campo existe para que o dia em que houver outra moeda não exija mudança de código. |
updated_at | string | Última movimentação do saldo (YYYY-MM-DD HH:MM:SS, horário de Brasília). |
Transferência entre contas
Move saldo da sua conta para outra conta da plataforma, na hora e sem taxa. Requer permissão transfers.
"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.
Não é PIX e não tem taxa: debita exatamente o
amount.
Parâmetros do corpo
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
to | string | Sim | Conta de destino: username ou e-mail. Os dois resolvem para a mesma conta: use o que você tiver. |
amount | number | Sim | Valor a transferir (> 0). Debita exatamente este valor: a transferência interna não tem taxa. |
external_id | string | Não | Seu 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. |
description | string | Não | Texto livre que aparece no extrato das duas contas (máx. 140; o excedente é cortado). |
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"
}'<?php
// external_id: o MESMO no retry, DIFERENTE em cada repasse legitimo.
// Sem ele a protecao cai para 15 segundos por (destino + valor).
$ch = curl_init('https://keropay.co/api/transfer');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Client-Id: ' . getenv('VP_CLIENT_ID'),
'X-Client-Secret: ' . getenv('VP_CLIENT_SECRET'),
],
CURLOPT_POSTFIELDS => json_encode([
'to' => 'loja_parceira',
'amount' => 250.00,
'external_id' => 'repasse-' . $repasseId, // max. 64 caracteres
'description' => 'Repasse semanal',
]),
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($res['ok'])) {
// NAO retente automaticamente 404 (destinatario) nem 400 (saldo):
// os dois so passam se o caller corrigir algo.
throw new RuntimeException($res['error'] ?? 'Falha ao transferir');
}
// "deduplicated" => e a transferencia JA EXISTENTE, devolvida de novo.
if (empty($res['deduplicated'])) {
registrarRepasse($res['e2e'], $res['amount']);
}// external_id: o MESMO no retry, DIFERENTE em cada repasse legitimo.
// Sem ele a protecao cai para 15 segundos por (destino + valor).
const res = await fetch('https://keropay.co/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Id': process.env.VP_CLIENT_ID,
'X-Client-Secret': process.env.VP_CLIENT_SECRET,
},
body: JSON.stringify({
to: 'loja_parceira',
amount: 250.0,
external_id: `repasse-${repasseId}`, // max. 64 caracteres
description: 'Repasse semanal',
}),
});
const data = await res.json();
if (!data.ok) {
// NAO retente automaticamente 404 (destinatario) nem 400 (saldo):
// os dois so passam se o caller corrigir algo.
throw new Error(data.error);
}
// "deduplicated" => e a transferencia JA EXISTENTE, devolvida de novo.
if (!data.deduplicated) {
await registrarRepasse(data.e2e, data.amount);
}import os
import requests
# external_id: o MESMO no retry, DIFERENTE em cada repasse legitimo.
# Sem ele a protecao cai para 15 segundos por (destino + valor).
r = requests.post(
"https://keropay.co/api/transfer",
headers={
"X-Client-Id": os.environ["VP_CLIENT_ID"],
"X-Client-Secret": os.environ["VP_CLIENT_SECRET"],
},
json={
"to": "loja_parceira",
"amount": 250.00,
"external_id": f"repasse-{repasse_id}", # max. 64 caracteres
"description": "Repasse semanal",
},
timeout=30,
)
data = r.json()
if not data.get("ok"):
# NAO retente automaticamente 404 (destinatario) nem 400 (saldo):
# os dois so passam se o caller corrigir algo.
raise RuntimeError(data.get("error", "Falha ao transferir"))
# "deduplicated" => e a transferencia JA EXISTENTE, devolvida de novo.
if not data.get("deduplicated"):
registrar_repasse(data["e2e"], data["amount"]){
"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
| Mensagem | Código | O que fazer |
|---|---|---|
Destinatário não encontrado | 404 | O 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álido | 400 | Você tentou transferir para a própria conta. |
Saldo insuficiente | 400 | Nada 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. | 400 | Variante 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) | 400 | Encurte o identificador antes de enviar. Ao contrário de description, este campo não é cortado: é recusado. |
Cashout desativado | 403 | A 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 permitido | 403 | Cadastre 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álida | 403 | Marque 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. | 409 | Concorrência: duas requisições disputaram o mesmo registro. Nada foi debitado e repetir é seguro, desde que com o mesmo external_id. |
Erro ao transferir | 500 | Falha interna. Repita com o mesmo external_id; se persistir, contate o suporte antes de tentar de novo. |
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.
{
"type": "cashin",
"status": "confirmed",
"amount": 123.45,
"fee": 1.23,
"request_number": "veo_...",
"transaction_id": "...",
"e2e": "",
"updated_at": "2025-11-24 12:00:00"
}{
"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çalho | Exemplo | Para que serve |
|---|---|---|
X-Kero-Signature | t=1758600000,v1=81fd61… | Assinatura HMAC-SHA256. Confira antes de processar (veja a regra 3 abaixo). |
X-Kero-Event-Id | 48213 | Id desta entrega. As retentativas automáticas repetem o mesmo id. |
X-Kero-Client-Id | np_a1b2c3… | Qual chave de API assinou: confira com o segredo dela. Ausente só em transação criada fora da API. |
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.
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
-
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ê devolver500, o mesmo evento tende a voltar e a repetir o problema. -
Seja idempotente
O webhook dispara a cada mudança de status e pode repetir o mesmo status.
Use
request_number(Cash In) oue2e(Cash Out) como chave e grave o que já processou. -
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 comwhsec_) e compare com ov1em tempo constante. Use o corpo como chegou: decodificar e codificar o JSON de novo muda os bytes (300.00vira300) e a conta não bate. Recusetcom mais de 5 minutos de diferença. Assinatura ausente ou errada: responda401e 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. -
Tenha um ramo
defaultStatus 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.
<?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));
}// Handler de webhook (Express). Recebe cashin e cashout na MESMA URL.
// express.raw, e NAO express.json: a assinatura e sobre os bytes exatos.
const crypto = require('crypto');
app.post('/webhooks/pix', express.raw({ type: 'application/json' }), async (req, res) => {
// 1) Assinatura primeiro. KERO_WEBHOOK_SECRET = "Segredo do webhook" da chave.
if (!assinaturaValida(req.get('X-Kero-Signature') || '', req.body, process.env.KERO_WEBHOOK_SECRET)) {
return res.sendStatus(401); // nao veio de nos: descarte
}
// Responde 200 ANTES de processar. Um 500 faz o evento voltar e repetir
// o mesmo problema, sem nunca sair do lugar.
res.sendStatus(200);
let evt = {};
try { evt = JSON.parse(req.body.toString('utf8')); } catch (e) { return; }
const { type, status } = evt;
const chave = type === 'cashout' ? evt.e2e : evt.request_number;
try {
// A idempotencia fica DENTRO do try: ja respondemos 200, entao a promise
// nao tem quem a espere. Uma rejeicao aqui fora vira unhandled rejection,
// e no Node moderno isso DERRUBA o processo, levando junto os outros
// webhooks em voo.
if (!chave || (await jaProcessado(type, chave, status))) return;
await 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 ((await consultarStatus(chave)) === 'confirmed') {
await liberarPedido(chave);
}
break;
case 'contested':
case 'chargedback':
case 'refunded':
// Chegam DEPOIS de "confirmed": o dinheiro saiu do seu saldo.
await 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.
await cancelarPedido(chave);
break;
default:
// pending e qualquer status novo: NAO PAGO. Registre e siga.
await registrarStatusDesconhecido(chave, status);
}
} else if (type === 'cashout') {
if (status === 'completed') await marcarSaquePago(chave);
else if (status === 'canceled') await devolverSaqueAoSaldo(chave);
else await registrarSaquePendente(chave, status);
}
} catch (err) {
// Ja respondemos 200; so registre para investigar depois.
console.error('webhook', chave, err);
}
});
function assinaturaValida(hdr, corpo, segredo) {
const m = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(hdr);
if (!m || !segredo || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
const esperado = crypto.createHmac('sha256', segredo).update(m[1] + '.').update(corpo).digest('hex');
return crypto.timingSafeEqual(Buffer.from(esperado), Buffer.from(m[2]));
}# Handler de webhook (Flask). Recebe cashin e cashout na MESMA URL.
# from threading import Thread
@app.post("/webhooks/pix")
def webhook_pix():
# Responde 200 ANTES de processar: o timeout do envio e de 10s, e
# consultar_status() la embaixo e uma chamada HTTP. O evento vai POR VALOR
# para a thread: `request` nao existe fora do contexto do Flask.
# Em producao, troque a Thread por uma fila de verdade (Celery, RQ): o
# processo pode reiniciar no meio e a thread morre sem deixar rastro.
# 1) Assinatura primeiro, sobre os bytes brutos (antes de get_json).
corpo = request.get_data()
if not assinatura_valida(request.headers.get("X-Kero-Signature", ""), corpo, SEGREDO_WEBHOOK):
return "", 401 # nao veio de nos: descarte
evt = request.get_json(silent=True) or {}
Thread(target=processar_evento, args=(evt,), daemon=True).start()
return "", 200
def processar_evento(evt):
tipo = evt.get("type", "")
status = evt.get("status", "")
chave = evt.get("e2e") if tipo == "cashout" else evt.get("request_number")
try:
if not chave or ja_processado(tipo, chave, status):
return # idempotencia
marcar_processado(tipo, chave, status)
if tipo == "cashin":
if status == "confirmed":
# Assinatura ja conferida. Consultar o status e uma segunda
# conferencia, barata, antes de liberar valor.
if consultar_status(chave) == "confirmed":
liberar_pedido(chave)
elif status in ("contested", "chargedback", "refunded"):
# Chegam DEPOIS de "confirmed": o dinheiro saiu do seu saldo.
reverter_pedido(chave, status)
elif status in ("canceled", "expired"):
# expired ainda pode virar confirmed (pagamento no ultimo instante,
# confirmado depois do prazo): cancele de um jeito que o ramo
# "confirmed" consiga reabrir e liberar.
cancelar_pedido(chave)
else:
# pending e qualquer status novo: NAO PAGO. Registre e siga.
registrar_status_desconhecido(chave, status)
elif tipo == "cashout":
if status == "completed":
marcar_saque_pago(chave)
elif status == "canceled":
devolver_saque_ao_saldo(chave) # valor + taxa voltaram
else:
registrar_saque_pendente(chave, status)
except Exception:
# Ja respondemos 200; so registre para investigar depois.
app.logger.exception("webhook %s", chave)
# import hmac, hashlib, re, time
def assinatura_valida(hdr, corpo, segredo):
m = re.fullmatch(r"t=(\d+),v1=([a-f0-9]{64})", hdr or "")
if not m or not segredo or abs(time.time() - int(m.group(1))) > 300:
return False
esperado = hmac.new(segredo.encode(), m.group(1).encode() + b"." + corpo, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperado, m.group(2))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.
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.
{
"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]" }
}{
"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:
| Valor | O que aconteceu |
|---|---|
manual | O vendedor revogou na mão, pela tela de alunos. |
refund | O pagamento foi estornado. |
chargeback | Houve chargeback da cobrança. |
med | A 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.
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 Out | Transferência | |
|---|---|---|
| Campo | Idempotency-Key (cabeçalho) ou idempotency_key (corpo) | external_id (corpo) |
| Tamanho máximo | 190 caracteres | 64 caracteres (acima disso é erro) |
| Validade da chave | Única por conta | 24 horas |
| Sem informar, protege por | 30 segundos: conta + chave PIX + tipo + valor | 15 segundos: destino + valor |
| Resposta na repetição | ok: true + idempotent: true | ok: true + deduplicated: true |
| Marca de dedupe automática | deduplicated: true (além de idempotent) | deduplicated: true |
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:
idempotent | deduplicated | Significa |
|---|---|---|
ausente / false | Não se aplica | Saque novo. Registre normalmente. |
true | false | Foi a sua Idempotency-Key que repetiu. |
true | true | O servidor absorveu uma repetição que você não marcou. |
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.
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.
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.
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.
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.
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
| Código | Moeda | Símbolo | Casas decimais | Situação |
|---|---|---|---|---|
BRL | Real brasileiro | R$ | 2 | Em operação (a única, hoje) |
MXN | Peso mexicano | MX$ | 2 | Não disponível |
COP | Peso colombiano | COP$ | 0 (zero) | Não disponível |
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.
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.
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.
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.
{ "ok": false, "error": "Mensagem do erro" }<?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);
}// O unico jeito correto de ler uma resposta desta API.
const res = await fetch(url, { /* ... */ });
const data = await res.json();
// NAO use res.ok nem res.status. Cheque "ok" do corpo.
if (!data.ok) {
const code = Number(res.headers.get('X-App-Error-Code') || res.status || 0);
// 429 e transitorio (limitador); 5xx tambem. O resto so passa se o
// caller corrigir os dados: nao retente em loop.
const transitorio = code === 429 || code >= 500;
throw new ApiError(data.error, code, transitorio);
}# O unico jeito correto de ler uma resposta desta API.
r = requests.post(url, json=payload, timeout=30)
# Sem r.raise_for_status(): ele nunca dispara nas rotas de negocio
# e faria TODA falha passar como sucesso.
data = r.json()
if not data.get("ok"):
code = int(r.headers.get("X-App-Error-Code") or r.status_code or 0)
# 429 e transitorio (limitador); 5xx tambem. O resto so passa se o
# caller corrigir os dados: nao retente em loop.
transitorio = code == 429 or code >= 500
raise ApiError(data.get("error", "Falha"), code, transitorio)Autenticação, permissão e estado da conta
| Código | Erro | Causa |
|---|---|---|
| 401 | Credenciais ausentes / Credenciais inválidas | Client ID/Secret faltando ou incorretos. |
| 403 | Permissão insuficiente | A chave não tem a permissão exigida pelo endpoint. Não vale para o Cash In (veja a linha do 400 abaixo). |
| 400 | Nã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. |
| 403 | IP não permitido / IP não configurado | Cash 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. |
| 403 | Chave inativa / Conta inválida / Conta bloqueada | Chave desativada, conta sem a operação habilitada, ou conta bloqueada pela operação. |
| 403 | Conta não verificada | O KYC da conta dona da chave ainda não foi aprovado. Conclua a verificação no painel. |
| 403 | Acesso à API desabilitado para esta conta | O administrador desligou o acesso de API dessa conta. Repetir ou trocar de chave não resolve: o titular precisa falar com o suporte. |
| 403 | Cadastro incompleto: atualize seus dados no painel | A conta foi marcada para recadastro e ainda tem dados obrigatórios faltando. Até o titular completar o cadastro, toda a API fica bloqueada. |
| 403 | Cashout desativado / Depósito desativado para a conta | A 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ódigo | Erro | Causa |
|---|---|---|
| 400 | Valor inválido / Dados inválidos | Corpo com campos obrigatórios ausentes ou inválidos. No Cash Out são obrigatórios amount, name, document, pix_key e pix_type. |
| 400 | Valor 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. |
| 400 | Valor 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. |
| 422 | Valor 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. |
| 400 | Saldo insuficiente | Saldo 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.” |
| 400 | Erros de split | username inexistente, repetido ou igual ao dono; soma acima de 100%; total acima do teto. A cobrança é recusada e o PIX não é criado. |
| 400 | external_id muito longo (máx. 64) | Transferência: o campo é recusado, não truncado. Encurte antes de enviar. |
| 422 | Saque 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. |
| 422 | 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). | pix_type QRCODE sem o recurso habilitado para a conta. É estado operacional, não erro permanente. Nada é criado nem debitado. |
| 422 | CNPJ 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. |
| 422 | CPF inválido / CNPJ inválido | document reprovado no dígito verificador (11 dígitos = CPF, 14 = CNPJ). |
| 422 | Chave PIX inválida: CPF/CNPJ com dígito verificador incorreto | pix_key do tipo CPF ou CNPJ reprovada no DV. EMAIL, EVP e QRCODE não passam por essa checagem. |
| 422 | Para 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. |
| 422 | Operaçõ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). |
| 403 | Limite de saques / Limite de valor para este CPF/CNPJ atingido | Estourou 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. |
| 403 | Saque bloqueado por verificação de segurança | O antifraude recusou o Cash Out. A mensagem traz o motivo concatenado. |
Protocolo, consulta e falhas técnicas
| Código | Erro | Causa |
|---|---|---|
| 405 | Método inválido | Verbo 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. |
| 404 | Depósito não encontrado | Consulta com request_number/transaction_id inexistente nesta conta. |
| 404 | Destinatário não encontrado | Transferência: o to não bate com nenhum username ou e-mail. Nada foi debitado. Não retente. |
| 400 | request_number ou transaction_id obrigatório | Consulta chamada sem nenhum dos dois identificadores na query string. |
| 400 | Falha ao gerar PIX | Mensagem 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. |
| 409 | Nã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. |
| 409 | Nã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. |
| 409 | Nã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. |
| 429 | Muitas requisições. Tente novamente em instantes. | Chega com HTTP 429 de verdade e sem X-App-Error-Code. Veja Limites. |
| 502 | Falha no gateway (HTTP 5xx) / Falha ao gerar PIX | Erro no processamento. No Cash In o texto é genérico, por isso decida pelo código 502, não pelo texto. |
| 503 | Saques temporariamente suspensos. Tente novamente em instantes. | Cash Out pausado pela operação. Transitório. |
| 500 | Banco indisponível / Erro ao consultar saldo / Erro ao transferir | Falha 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ção | Retentar? | Como |
|---|---|---|
429, 502, 503, 500 | Sim | Com recuo exponencial e a mesma chave de idempotência. São transitórios. |
| Timeout do seu cliente | Sim | A operação pode ter sido processada. Repita com a mesma chave: é exatamente o caso que a idempotência resolve. |
409 de registro de chave | Sim | Nada foi criado. Mesma chave. |
409 de recuperação do original | Confira antes | A operação pode existir. Verifique o extrato. |
Todo 422 do Cash Out | Não | CNPJ alfanumérico, DV, valor mínimo, telefone, Copia e Cola: só passam se o caller corrigir os dados. |
400 de saldo, 404 de destinatário | Não | Nada foi debitado, e repetir dá o mesmo resultado. |
401, 403 | Não | Configuraçã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 é:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
{ "ok": false, "error": "Muitas requisições. Tente novamente em instantes." }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.
| Limite | Onde | Ao estourar |
|---|---|---|
| Valor máximo por cobrança | Cash In | “Valor máximo para PIX é de R$ <V>” · 400 |
| Valor mínimo por cobrança | Cash In | “Valor mínimo para PIX é de R$ <V>” · 400 |
| Valor mínimo por saque | Cash Out | “Valor mínimo de saque é R$ <V>” · 422 |
| Nº de saques por CPF/CNPJ de destino / 24h | Cash Out | “Limite de saques… atingido” · 403 |
| Soma por CPF/CNPJ de destino / 24h | Cash Out | “Limite de valor… atingido” · 403 |
| Teto de split (taxa + painel + API) | Split | Cobrança recusada · 400 |
| Requisições por IP | Todas as rotas | “Muitas requisições…” · 429 |
Tamanho de campo
| Campo | Rota | Máximo | Acima disso |
|---|---|---|---|
Idempotency-Key | Cash Out | 190 caracteres | Não se aplica |
external_id | Transferência | 64 caracteres | Recusado (400) |
description | Transferência | 140 caracteres | Cortado em silêncio |
external_id é recusado, description é cortado. Não use description como identificador.Tempos
| O quê | Valor |
|---|---|
| Validade da cobrança PIX | Vem em expire_minutes na resposta do Cash In (tipicamente 30 minutos) |
| Timeout do seu endpoint de webhook | 10 segundos |
| Janela da dedupe automática de saque | 30 segundos |
| Janela da dedupe automática de transferência | 15 segundos |
Validade do external_id na transferência | 24 horas |
| Janela dos limites por documento de destino | 24 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-Keyem todo Cash Out, eexternal_idem toda transferência. - Gerar chave nova a cada pedido distinto e reusar a mesma só no retry do mesmo pedido.
- Checar
idempotent/deduplicatedantes de somar ao seu total: a repetição volta como sucesso. - Persistir o
e2edo saque e orequest_numberda 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”
documentepix_keyremovendo 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
defaultque trate desconhecido como não pago. - Não gerar enum fechado que estoure exceção em valor inesperado.
- Não tratar
confirmedcomo final:contested,chargedbackerefundedchegam depois e revertem. - Handler de webhook idempotente, respondendo
200em 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 Secretem 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:
balanceetransfersdesligadas 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.
| Termo | Significado |
|---|---|
| Cash In | Entrada de dinheiro: a cobrança PIX que o seu cliente paga. |
| Cash Out | Saída de dinheiro: o PIX que você envia para uma chave. |
request_number | Nossa referência da cobrança. Chave da consulta de status e do webhook de Cash In. |
e2e | Identificador ponta a ponta. Na API, é a chave do saque e da transferência. |
transaction_id | Id da transação no processador (adquirente). |
| Copia e cola / EMV / BR Code | O texto do PIX que o pagador cola no app do banco. Na resposta: copyPaste. |
| Adquirente / processador | Quem executa o PIX de fato. É escolhido pela plataforma e pode mudar por failover: a resposta não muda com ele. |
| MED | Mecanismo 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. |
| Chargeback | Devolução do valor ao pagador após contestação. O dinheiro já saiu do seu saldo. |
| Saldo bloqueado | balance.blocked: retido por disputa. Não pode ser sacado; pode voltar ao disponível. |
| Split | Divisão de parte da cobrança com outra conta da plataforma. Incide sobre o bruto e sai do lojista. |
| Idempotência | Garantia de que repetir a mesma requisição não executa a operação duas vezes. |
| Allowlist de IP | Lista de IPv4 autorizados a usar a chave em Cash Out e Transferência. |
| KYC | Verificaçã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.