# LeztyPay: documentação completa > Gateway de pagamentos PIX: sua aplicação cria uma cobrança por API, recebe um webhook assinado quando ela é paga e saca o saldo para uma chave PIX. > Esta é a documentação pública da API. Tudo sob /v1, autenticação por chave Bearer, ambiente de teste na mesma base URL. Referência da API (todos os campos): https://docs.leztypay.com/openapi.json ## Prompt Você vai integrar a LeztyPay, um gateway de pagamentos PIX brasileiro: a API cria uma cobrança PIX, entrega um webhook assinado quando ela é paga e permite sacar o saldo para uma chave PIX. Antes de escrever qualquer linha de código, leia estas duas fontes: - https://docs.leztypay.com/llms-full.txt (a documentação inteira em texto) - https://docs.leztypay.com/openapi.json (a referência da API, OpenAPI 3.1, com todos os campos) Ambientes: a mesma API atende os dois, na base URL https://api.leztypay.com. Uma chave que começa com sk_test_ põe a conta no ambiente de teste, onde nenhum dinheiro se move e o resultado de cada cobrança é escolhido pelos dois últimos dígitos do valor. Uma chave sk_live_ vale dinheiro de verdade. Comece no teste e só troque a chave no fim. Minha stack é {{stack}} e minha chave de teste é {{chave_de_teste}}. Implemente nesta ordem: 1. Criação de cobrança PIX, sempre com um header Idempotency-Key próprio por tentativa de cobrança. 2. Receptor do webhook: verifique a assinatura sobre o corpo cru antes de desserializar, responda 2xx rápido e processe em segundo plano. 3. Idempotência no receptor: o mesmo evento pode chegar mais de uma vez e fora de ordem, então deduplique pelo id do evento e reconcilie consultando a cobrança. Regras que você não pode quebrar: - A chave sk_live_ nunca aparece no front-end, em repositório, em log nem em mensagem de erro. Ela fica em variável de ambiente ou cofre, e só o servidor a usa. - Nenhum nome ou formato de campo sai da sua memória: confira cada um no openapi.json. - Se alguma coisa não estiver na documentação, diga que não está em vez de inventar. --- # Visão geral https://docs.leztypay.com/ A LeztyPay é um gateway de pagamentos PIX. A sua aplicação faz três coisas com esta API: cria uma cobrança e mostra o QR Code ao cliente, recebe um webhook assinado no instante em que a cobrança é paga e, quando quiser, saca o saldo para uma chave PIX. Não existe SDK e não precisa: é HTTP com JSON, autenticação por header e um webhook. Todo exemplo desta documentação é `curl` ou `fetch` do Node, sem dependência nenhuma. ## Base URL Uma só, para os dois ambientes: ``` https://api.leztypay.com ``` Toda rota de integração fica sob `/v1`. A autenticação é `Authorization: Bearer `. ## Teste e produção Quem escolhe o ambiente é a chave, não a URL. Uma chave `sk_test_` põe a requisição no ambiente de teste: nenhum dinheiro se move, o QR não é pagável por app de banco de verdade e você decide o resultado de cada cobrança pelos dois últimos dígitos do valor. Uma chave `sk_live_` vale dinheiro. Os dois ambientes não se enxergam. Um objeto criado no teste nunca aparece em produção, e consultar um id do outro ambiente devolve `404`. ## Por onde começar - **Integrando com a ajuda de uma IA?** Comece por [Integre com IA](/prompt): tem um prompt pronto para colar no ChatGPT ou no Claude, que manda o assistente ler esta documentação antes de gerar código. - **Quer ver funcionando primeiro?** O [quickstart](/quickstart) leva você de uma chave de teste até receber o evento `transaction.paid`. - **Procurando um campo?** Ele está na [referência da API](/referencia), gerada do OpenAPI. Nenhuma página escrita à mão repete campo de request ou de response: existe uma fonte de verdade só, e é aquela. ## Para máquinas - [`/llms.txt`](/llms.txt): índice desta documentação no formato llmstxt.org. - [`/llms-full.txt`](/llms-full.txt): a documentação inteira em um arquivo de texto. - [`/openapi.json`](/openapi.json): o documento OpenAPI 3.1 completo. ## Próximo passo [Quickstart](/quickstart): cinco minutos até a primeira cobrança paga no ambiente de teste. --- # Quickstart https://docs.leztypay.com/quickstart Em cinco minutos você cria uma cobrança PIX no ambiente de teste, vê o QR Code e recebe o evento `transaction.paid`. Nada de dinheiro se move e nenhum SDK é instalado. ## 1. Pegue uma chave de teste A chave nasce no painel. Escolha o ambiente de teste: a chave sai com o prefixo `sk_test_` e funciona antes mesmo de o seu KYC ser aprovado. O segredo aparece uma única vez, na criação. Guarde em variável de ambiente, nunca no repositório e nunca no front: ``` export LM_API_URL=https://api.leztypay.com export LM_API_KEY=sk_test_sua_chave_aqui ``` ## 2. Crie a primeira cobrança `amount` é sempre inteiro em centavos: `10000` são cem reais. O header `Idempotency-Key` é obrigatório e é você quem escolhe o valor, normalmente o id do pedido na sua loja. {% example "create-transaction.sh" %} A resposta `201` traz `pix.payload` (o copia e cola) e `pix.qr_base64` (a imagem PNG já em base64). Mostre um dos dois ao cliente. Em Node, sem dependência nenhuma: {% example "create-transaction.mjs" %} ## 3. Faça a cobrança ser paga No ambiente de teste ninguém abre o app do banco. Quem decide o desfecho é você, pelos dois últimos dígitos de `amount`. Um valor terminado em qualquer coisa fora da lista de gatilhos, como os `10000` acima, é pago sozinho poucos segundos depois da criação. Terminando em `01` a cobrança nunca paga e expira. Terminando em `03` a criação já volta recusada. A lista inteira está em [Gatilhos do ambiente de teste](/sandbox/gatilhos). ## 4. Receba o webhook Quando a cobrança é paga, a LeztyPay entrega o evento `transaction.paid` assinado no seu endpoint. Duas formas de apontar o destino: - **`postback_url` na cobrança**, como no exemplo acima. Vale só para aquela cobrança e é o caminho mais rápido para testar. - **Endpoint da conta**, cadastrado no painel. Recebe todos os eventos, tem segredo próprio e é o que você usa em produção. O endpoint precisa responder `2xx` rápido e verificar a assinatura antes de confiar no corpo. Como fazer isso está em [Webhooks](/webhooks). ## 5. Confirme pelo GET Webhook é aviso, não é fonte de verdade. A qualquer momento consulte a cobrança pelo id e confira `status` e `paid_at`: {% example "get-transaction.sh" %} ## Próximo passo Entenda a chave que você acabou de usar em [Autenticação](/autenticacao), e depois [Criar uma cobrança](/cobrancas/criar) para os campos que ficaram de fora deste roteiro. Referência da operação: {% apiRef "createTransaction" %}. --- # Autenticação https://docs.leztypay.com/autenticacao Toda rota sob `/v1` é autenticada por uma chave de API no header `Authorization`: ``` Authorization: Bearer sk_live_... ``` Não existe outro fator, não existe login por usuário e senha na API e não existe sessão. Quem tem a chave tem a conta. ## A chave escolhe o ambiente A base URL é a mesma para os dois ambientes. O prefixo da chave é quem decide: - `sk_live_` opera em produção e move dinheiro de verdade. - `sk_test_` opera no [ambiente de teste](/sandbox). O formato completo é `sk_live_` ou `sk_test_` seguido de 43 caracteres alfanuméricos. Qualquer coisa fora disso é recusada com `401 invalid_api_key` sem consultar o banco. Os dois ambientes não se enxergam. Consultar com uma chave de teste um id criado em produção devolve `404`, nunca `403`: a resposta não confirma que o objeto existe. ## Criar, rotacionar e revogar As três ações acontecem no painel, não pela API. O segredo é mostrado uma única vez, na criação e na rotação; depois disso nem nós conseguimos recuperá-lo. - **Limite:** 10 chaves ativas por loja em cada ambiente. - **Chave de produção exige KYC aprovado.** Sem isso a criação responde `403 kyc_required`. Chave de teste pode ser criada a qualquer momento. - **Rotação:** cria a chave nova e agenda a revogação da antiga para daqui a 24 h. Essa janela existe para você trocar a chave em produção sem derrubar nada: publique a nova, confirme que o tráfego migrou, e a antiga morre sozinha. - **Revogação:** é imediata e não tem volta. Use quando desconfiar que a chave vazou. Requisição com chave revogada responde `401 revoked_api_key`. ## Quando a chave é válida e ainda assim recusa `403 seller_not_allowed` não é problema de chave, é estado da conta: - Em produção, a conta precisa estar ativa e com PIX habilitado. - No ambiente de teste, qualquer estado serve, menos conta encerrada. É o que deixa você integrar enquanto o KYC ainda está em análise. ## Onde a chave nunca pode estar A chave saca dinheiro no seu nome. Ela vive no servidor, em variável de ambiente ou em cofre de segredos, e só. Nunca embarque a chave em aplicativo, em página web, em repositório ou em log. Uma chave de produção num bundle de front é um saque na conta de quem abrir o DevTools. Se acontecer, revogue antes de consertar o código. ## Próximo passo Com a chave em mãos, vá para [Idempotência](/idempotencia): o header `Idempotency-Key` é obrigatório em toda criação de cobrança e de saque. Referência: {% apiRef "getBalance" %} é a chamada mais barata para confirmar que a sua chave funciona e em qual ambiente ela está. --- # Idempotência https://docs.leztypay.com/idempotencia Rede cai no meio de um POST e você não sabe se a cobrança foi criada. Repetir sem cuidado cria duas. A `Idempotency-Key` resolve isso: a segunda chamada devolve a mesma resposta da primeira em vez de criar outro objeto. ## Onde é obrigatória Nas duas criações que movem dinheiro: cobrança e saque. Sem o header a resposta é `400 missing_idempotency_key`. `GET` não precisa (não cria nada) e o cancelamento de saque também não. ## Como escolher a chave O valor é seu. Formato aceito: de 1 a 255 caracteres em `A-Z a-z 0-9 _ - : .`. Use algo derivado do seu domínio, não um valor aleatório novo a cada tentativa. A chave boa é a que a sua própria retentativa consegue reconstruir: `pedido-A123-tentativa-1`, `saque-2026-09-17-001`. Um UUID gerado no início do fluxo e guardado junto do pedido também serve. Um UUID gerado dentro do `catch` do retry não serve para nada. O escopo é loja, ambiente e chave. A mesma `Idempotency-Key` em `sk_test_` e em `sk_live_` são duas chaves diferentes e nunca se confundem. ## O que acontece ao repetir **Mesma chave, mesmo corpo, a primeira já terminou.** Você recebe a resposta salva, com o status original e o header `Idempotent-Replayed: true`. Nada novo foi criado. **Mesma chave, corpo diferente.** `409 idempotency_conflict`. É proteção: se o valor mudou, é outro pedido e merece outra chave. **Mesma chave, a primeira ainda está rodando.** `409 idempotency_in_progress`. Espere alguns segundos e repita a mesma requisição. Uma tentativa que morreu no meio é liberada sozinha depois de 60 segundos e a próxima chamada assume o lugar dela. ## Quando reusar a chave e quando trocar A regra depende do que veio de volta: - **Erro de validação ou de negócio (`400`, `403`, a maior parte dos `409`).** Nada foi criado e o registro da chave é apagado. Corrija o corpo e repita com a **mesma** chave. - **`201`.** Já criou. Repetir com a mesma chave devolve o mesmo objeto. Para criar outro, chave nova. - **`503 acquirer_timeout`, `acquirer_rejected` ou `acquirer_unavailable`.** Essa resposta fica gravada. Repetir com a mesma chave devolve o mesmo `503` para sempre. Para tentar de novo de verdade, use uma **chave nova**. Esse último caso é o que mais pega gente. Um `503` quer dizer que o processador de pagamentos falhou e a cobrança ficou marcada como falha; a nova tentativa é um pedido novo, com chave nova. ## Do outro lado: os seus webhooks Idempotência na entrada da API é metade do trabalho. O seu receptor de webhook também precisa deduplicar, porque um mesmo evento pode chegar duas vezes. Isso está em [Idempotência e ordem](/webhooks/idempotencia). ## Próximo passo [Erros](/erros) mostra o envelope que toda falha usa e o que fazer com cada classe de código. Referência: {% apiRef "createTransaction" %} e {% apiRef "createWithdrawal" %} são as duas operações que exigem o header. --- # Erros https://docs.leztypay.com/erros Toda falha da API responde com o mesmo envelope, em qualquer rota e em qualquer status: ```json { "error": { "type": "invalid_request_error", "code": "amount_too_small", "message": "amount must be >= 100", "param": "amount", "request_id": "req_01J...", "details": { "min": 100 } } } ``` `type`, `code`, `message` e `request_id` vêm sempre. `param` aparece quando a falha é de um valor específico do corpo ou da query. `details` é o único lugar onde cabe dado extra do erro, por exemplo o saldo que faltou ou o limite que estourou; nada de campo solto fora dele. Programe contra `code`, nunca contra `message`. A mensagem é em inglês, curta, e pode mudar sem aviso; o código é contrato. ## Os oito tipos `type` é a família e resolve o tratamento genérico: `invalid_request_error`, `authentication_error`, `permission_error`, `not_found_error`, `idempotency_error`, `rate_limit_error`, `acquirer_error` e `internal_error`. A lista completa de `code` por operação está na [referência da API](/referencia), dentro de cada resposta de erro. Ela não é repetida aqui de propósito: uma cópia à mão apodrece. ## O que fazer com cada classe Isso é o que a referência não diz. É a leitura operacional dos códigos: | Classe | Códigos típicos | O que fazer | |---|---|---| | Corrigir e repetir | `validation_failed`, `invalid_document`, `amount_too_small`, `amount_too_large`, `invalid_expires_in`, `invalid_url`, `unsupported_payment_method`, `missing_idempotency_key` | Conserte o corpo e repita com a **mesma** `Idempotency-Key`. Nada foi criado. | | Repetir depois | `rate_limited`, `acquirer_timeout`, `acquirer_rejected`, `acquirer_unavailable` | Em `rate_limited`, respeite o `Retry-After`. Depois de um `503` do processador, tente de novo com **chave nova**: a resposta de erro ficou gravada na chave antiga. | | Nunca repetir igual | `idempotency_conflict`, `invalid_state`, `insufficient_balance`, `daily_limit_exceeded`, `pix_key_not_verified` | É decisão de negócio, não falha passageira. Repetir o mesmo pedido dá o mesmo erro. | | Conferir a chave | `invalid_api_key`, `revoked_api_key`, `seller_not_allowed`, `kyc_required`, `payout_locked` | A chave ou a conta não podem fazer isso agora. Ver [Autenticação](/autenticacao). | | Não existe | `resource_not_found` | Id errado, de outra conta ou do outro ambiente. Os três respondem igual, de propósito. | | Falar com a gente | `internal_error` | Anote o `request_id` e abra um chamado. | `idempotency_in_progress` é o único `409` que pede retentativa: a sua própria requisição anterior ainda está rodando. Espere e repita igual. ## O `request_id` Toda resposta, com erro ou sem, traz o header `X-Request-Id`, e o mesmo valor aparece em `error.request_id`. Ele é o que localiza a requisição exata nos nossos logs. Registre esse valor junto de todo erro `5xx` que você logar. Um chamado com `request_id` se resolve em minutos; um chamado com "deu erro ontem à tarde" não se resolve. ## Próximo passo [Paginação](/paginacao) para as listagens, ou [Limites](/limites) para o que dispara `rate_limited` e os tetos de valor e de tamanho. Referência: {% apiRef "createTransaction" %} é a operação com mais códigos de erro possíveis, e cada um deles está descrito na resposta correspondente. --- # Paginação https://docs.leztypay.com/paginacao Toda listagem da API pagina por cursor. Não existe `page` nem `offset`. Cursor em vez de número de página não é preferência de estilo: a lista muda enquanto você a percorre. Com `offset`, uma cobrança nova no topo empurra tudo e a página 2 repete um item que a página 1 já trouxe. Com cursor, isso não acontece. ## Como percorrer Peça a primeira página, leia `next_cursor` da resposta, mande esse valor de volta em `cursor` na chamada seguinte, e pare quando `next_cursor` vier nulo. {% example "list-transactions.mjs" %} ## As três regras **`limit` vai de 1 a 100, e o padrão é 50.** Pedir mais que 100 é `400 validation_failed`. **`next_cursor` nulo é o fim, e só ele.** A última página costuma vir mais curta, mas página curta não é sinal de fim: só o `next_cursor` nulo é. Um laço que para quando `data.length < limit` perde itens. **O cursor é opaco.** É uma string que você devolve exatamente como recebeu. Não decodifique, não guarde partes dela, não construa uma à mão. O formato pode mudar sem aviso; o contrato é só "mande de volta o que veio". Cursor que não decodifica responde `400 invalid_cursor`. ## Ordem A ordem é fixa, da mais nova para a mais antiga, pela data de criação, com o id desempatando. Ela é a mesma em toda listagem e não é configurável. Como o critério é estável, item criado depois de você começar a percorrer não aparece na volta: ele entra no topo, que já ficou para trás. Para acompanhar novidades, use os filtros de data ou os webhooks, não a paginação. ## Filtros e totais Os filtros de cada listagem estão na referência da operação. Dois detalhes que valem para todas: - Filtro não muda a ordem nem o formato do cursor. Você pode paginar com filtro à vontade. - Na listagem de cobranças, `with_totals=1` acrescenta os totais do recorte filtrado, calculados sobre tudo e não só sobre a página. Os totais são idênticos em todas as páginas, então peça uma vez só, na primeira. ## Próximo passo [Limites](/limites) fecha os fundamentos: quantas chamadas por minuto, e o que fazer com `429`. Referência: {% apiRef "listTransactions" %} e {% apiRef "listWithdrawals" %}. --- # Limites https://docs.leztypay.com/limites Os tetos desta página são valores, não campos. Eles mudam raramente e, quando mudarem, entram no changelog. ## Chamadas por minuto O limite é por chave de API e separado por tipo de chamada: | Tipo de chamada | Teto por minuto | |---|---| | Escrita (`POST`, `PUT`, `PATCH`, `DELETE`) | 60 | | Leitura (`GET`) | 600 | A janela é fixa de 60 segundos, e não deslizante: o contador zera na virada do minuto. Toda resposta traz três headers com o estado do balde: - `X-RateLimit-Limit`: o teto da classe. - `X-RateLimit-Remaining`: quantas chamadas sobram nesta janela. - `X-RateLimit-Reset`: o instante em que o contador zera, em segundos desde a época Unix. Estourou, a resposta é `429 rate_limited` com o header `Retry-After` em segundos. Espere esse tempo antes da próxima tentativa. Não faça backoff cego: o `Retry-After` já é o número certo, e insistir antes dele só gasta mais `429`. Dois usos que costumam bater no teto e não precisam: pesquisar o status de uma cobrança em laço apertado (use [webhooks](/webhooks)) e listar tudo de novo a cada minuto (use os filtros de data). ## Tamanho do corpo O corpo de uma requisição em `/v1` vai até 256 KB. Acima disso a requisição é cortada antes de chegar no handler. Na prática só `metadata` chega perto: ela aceita no máximo 20 chaves, cada chave até 40 caracteres, cada valor até 500 caracteres, e 4 KB no total. Uma lista de itens vai até 50 entradas. ## Faixas de valor Valor de uma cobrança: de R$ 1,00 a R$ 100.000,00. Em centavos, de `100` a `10000000`. Fora disso é `400 amount_too_small` ou `400 amount_too_large`. A taxa nunca pode consumir a cobrança inteira. Um valor tão baixo que o líquido ficaria zero também responde `400 amount_too_small`, mesmo estando acima de R$ 1,00. Valor de um saque: mínimo de R$ 1,00, e o valor mais a taxa precisam caber no saldo disponível. Pode existir ainda um limite diário na sua conta, em valor e em quantidade; quando bate, a resposta é `409 daily_limit_exceeded` com o limite e o usado em `details`. ## Faixa de prazo O prazo de expiração pedido na criação da cobrança vai de 5 minutos a 30 dias, em segundos: de `300` a `2592000`. O padrão é uma hora. Fora da faixa é `400 invalid_expires_in`. Esse prazo é um pedido, e o processador pode encurtá-lo ou estendê-lo. A data que vale é a que volta na resposta. Detalhes em [Expiração](/cobrancas/expiracao). ## Próximo passo Fundamentos fechados. Siga para [Cobranças](/cobrancas), o recurso principal da API. Referência: {% apiRef "createTransaction" %}. --- # Cobranças https://docs.leztypay.com/cobrancas Uma cobrança é um PIX que você emite para o seu cliente. A API chama esse recurso de `transaction`. Criar uma cobrança devolve um QR Code; a partir daí a LeztyPay acompanha o pagamento e avisa você. Todo valor da API é inteiro em centavos. `10000` são cem reais. Não existe campo em reais nem em ponto flutuante, em lugar nenhum. ## Bruto, taxa, líquido e reserva A criação já devolve a conta fechada: - `amount` é o que o cliente paga. - `fee` é a nossa taxa, calculada com as condições vigentes **no momento da criação** e congelada ali. Reajuste de taxa não muda cobrança já criada. - `net` é `amount` menos `fee`, o que entra na sua conta. - `reserve` é a parte do líquido que fica retida por alguns dias antes de virar saldo disponível. Por que ela existe e quando sai está em [Saques](/saques). ## O ciclo de vida Uma cobrança nasce `pending` e termina em um destes estados: | Status | O que aconteceu | |---|---| | `pending` | Criada, QR válido, esperando o pagamento. | | `paid` | Paga. O líquido entrou. | | `expired` | O prazo acabou sem pagamento. | | `failed` | O processador recusou ou não respondeu na criação. Não existe QR. | | `refunded` | Estornada depois de paga, sempre pelo valor total. | | `chargeback` | Paga e depois contestada pelo cliente, com o valor devolvido. | As transições possíveis são poucas e valem a pena decorar: - `pending` vai para `paid`, `expired` ou `failed`. - `expired` ainda pode virar `paid`. É o pagamento tardio, e acontece de verdade. Ver [Expiração](/cobrancas/expiracao). - `paid` pode virar `refunded` ou `chargeback`. - Nada sai de `refunded`, `chargeback` ou `failed`. Qualquer outra transição é recusada com `409 invalid_state`. ## O que chega no seu webhook Cada transição entrega um evento assinado no seu endpoint: `transaction.paid`, `transaction.expired`, `transaction.failed`, `transaction.refunded`, `transaction.chargeback` e `transaction.reserve_released`, este último quando a reserva daquela cobrança é liberada. A criação **não** emite evento: você já recebeu o objeto na resposta do `POST`. Trate tipo de evento desconhecido sem quebrar. Evento novo pode aparecer a qualquer momento e não é mudança que quebra contrato. ## Próximo passo [Criar uma cobrança](/cobrancas/criar). Referência: {% apiRef "getTransaction" %}. --- # Criar uma cobrança https://docs.leztypay.com/cobrancas/criar Uma chamada, um header de idempotência, e a resposta já traz o QR Code. {% example "create-transaction.sh" %} O mesmo em Node, com tratamento de erro: {% example "create-transaction.mjs" %} A lista completa do que entra e do que volta está na referência da operação. Aqui ficam só as regras que a referência não conta. ## O mínimo Só `amount` e `customer` são obrigatórios. Todo o resto tem padrão. O cliente precisa de nome, e-mail e documento. O documento é CPF ou CNPJ e é validado de verdade, com dígito verificador; pode vir formatado ou só com dígitos, tanto faz. Documento inválido é `400 invalid_document`. `payment_method` aceita apenas `pix`, e é o padrão. Qualquer outro valor é `400 unsupported_payment_method`. ## A taxa é congelada na criação A resposta traz `fee`, `net` e `reserve` já calculados com as condições vigentes naquele instante, e eles não mudam mais. Uma cobrança criada hoje liquida com a taxa de hoje, mesmo que seja paga daqui a uma semana com outra tabela em vigor. Se a taxa consumisse a cobrança inteira, a criação é recusada com `400 amount_too_small`. É por isso que um valor acima do mínimo ainda pode ser recusado por ser pequeno demais. ## Os campos que existem para você, não para nós - **`metadata`** é o seu campo livre: um objeto plano de strings que volta igual em toda consulta e em todo evento. É onde mora o id do pedido na sua loja. Nós não lemos, não indexamos e não interpretamos nada dele. Tetos em [Limites](/limites). - **`items`** é informativo. A soma dos itens não precisa bater com `amount`, e não validamos isso. Serve para o extrato e para o painel ficarem legíveis. - **`description`** viaja junto para o processador de pagamentos. Se você não mandar, geramos uma. - **`tracking`** carrega parâmetros de origem da venda. Só as sete chaves previstas são aceitas; chave desconhecida é `400 validation_failed`. ## `postback_url`: webhook só desta cobrança `postback_url` aponta o destino do webhook **daquela** cobrança, sem passar pelo endpoint da conta. É o caminho rápido para testar e para quem precisa separar destinos por fluxo. Regras: precisa ser `https` e precisa apontar para um destino público. Endereço interno, IP privado ou `localhost` são recusados com `400 invalid_url`, e não é configurável: é o que impede a API de ser usada para varrer a rede de quem a hospeda. Em produção, prefira o endpoint da conta, cadastrado no painel: ele tem segredo próprio, recebe todos os eventos e aparece no histórico de entregas. ## Quando a criação falha - `400` de validação: nada foi criado. Corrija e repita com a **mesma** `Idempotency-Key`. - `403 seller_not_allowed`: a conta não pode cobrar neste ambiente agora. Ver [Autenticação](/autenticacao). - `409 idempotency_conflict` ou `409 idempotency_in_progress`: ver [Idempotência](/idempotencia). - `503 acquirer_timeout`, `acquirer_rejected` ou `acquirer_unavailable`: o processador falhou. A cobrança pode ter ficado registrada como `failed`, sem QR. **Repita com uma chave de idempotência nova**, porque a mesma chave devolveria esse mesmo `503` de novo. Cobrança `failed` responde com `pix.payload` e `pix.qr_base64` nulos. Nunca mostre a tela de pagamento antes de conferir que eles vieram preenchidos. ## Próximo passo [QR Code](/cobrancas/qr-code): o que fazer com o `pix.payload` e com o `pix.qr_base64` que acabaram de chegar. Referência: {% apiRef "createTransaction" %}. --- # QR Code https://docs.leztypay.com/cobrancas/qr-code A criação da cobrança devolve duas formas do mesmo PIX, dentro de `pix`: - **`payload`** é o BR Code em texto, o "copia e cola" que o cliente cola no app do banco. - **`qr_base64`** é a mesma coisa já desenhada, uma imagem PNG codificada em base64. As duas apontam para o mesmo pagamento. Mostrar as duas na tela é o padrão que menos gera suporte: o QR para quem está no computador e paga pelo celular, o copia e cola para quem já está no celular. ## Mostrar a imagem `qr_base64` entra direto num `img`, sem biblioteca: ```html QR Code do PIX ``` Se você preferir desenhar no seu próprio estilo, gere o QR a partir do **`payload`** com qualquer biblioteca de QR Code. O conteúdo do QR é exatamente a string do `payload`, sem prefixo, sem URL e sem transformação. ## Mostrar o copia e cola Um campo somente leitura com um botão que copia o `payload` inteiro. Três cuidados que resolvem quase todo chamado de "o PIX não funciona": - Copie a string **inteira**, sem quebra de linha e sem espaço no fim. - Não deixe o campo editável. - Não aplique máscara, nem quebra visual em blocos. O que é bonito na tela quebra o pagamento se for copiado junto. ## O que nunca fazer com o payload **Não reconstrua o BR Code.** Não gere um payload a partir de uma chave PIX sua, não recalcule o dígito de verificação, não troque o valor, não concatene um identificador. O payload é emitido pelo processador e qualquer alteração o invalida, na melhor das hipóteses. Na pior, o dinheiro vai para o lugar errado e a cobrança nunca é conciliada. **Não interprete o conteúdo.** Não faça parse para extrair valor, chave ou beneficiário e mostrar na tela. Esses dados já estão nos próprios campos da cobrança, que são estáveis; o formato interno do BR Code não é contrato nosso. **Não faça cache entre cobranças.** Cada cobrança tem o payload dela. Reaproveitar o de outra faz dois clientes pagarem a mesma coisa, e só uma das duas é conciliada. ## Quando não existe QR `payload` e `qr_base64` são nulos quando a cobrança está `failed`, isto é, quando o processador recusou ou não respondeu na criação. Confira antes de renderizar, e trate esse caso como erro de checkout, não como tela em branco. `pix.expires_at` vem sempre, inclusive nesse caso. Ele é o prazo real, e nem sempre é o que você pediu: ver [Expiração](/cobrancas/expiracao). ## Próximo passo [Expiração](/cobrancas/expiracao). Referência: {% apiRef "createTransaction" %} e {% apiRef "getTransaction" %}. --- # Expiração https://docs.leztypay.com/cobrancas/expiracao Duas coisas acontecem com uma cobrança depois que ela sai da sua tela: ela expira, ou ela é estornada. As duas têm armadilha. ## O prazo que você pede é um pedido `pix.expires_in_seconds` na criação é o prazo que você **gostaria**. Quem decide é o processador de pagamentos, e ele pode encurtar, estender ou simplesmente ignorar o seu pedido. Alguns processadores expiram tudo à meia-noite, por exemplo. Por isso existe uma regra só: > Use `pix.expires_at` da resposta. Nunca calcule a expiração somando o seu prazo ao horário da > criação. O contador na sua tela de checkout sai de `expires_at`. O seu job de limpeza também. A faixa aceita para o pedido vai de 5 minutos a 30 dias, e o padrão é uma hora ([Limites](/limites)). ## A expiração não é instantânea A cobrança vira `expired` pouco depois de `expires_at`, não exatamente nele. A folga existe de propósito, para um pagamento que chegou no último segundo não ser perdido por corrida de relógio. Então: `expires_at` no passado e `status` ainda `pending` é normal por alguns minutos. Não trate isso como erro nem como expirada; espere o status mudar ou o evento `transaction.expired` chegar. ## Pagamento tardio existe Uma cobrança `expired` **pode** virar `paid`. Acontece quando o processador aceitou um pagamento que entrou na janela dele, mais larga que a nossa. O seu sistema precisa aguentar isso. Concretamente: - Não apague o pedido quando o `transaction.expired` chegar. Marque como não pago e siga. - Continue aceitando `transaction.paid` daquela cobrança depois disso, e libere o produto. - Se o seu fluxo não puder entregar depois do prazo, trate como um caso de estorno, não ignorando o evento. Os eventos podem até chegar fora de ordem no seu endpoint. A verdade final está sempre na consulta pelo id. ## Expirada some das listagens Cobrança `expired` **não** aparece na listagem por padrão, nem entra nos totais. Foi uma decisão de produto: a lista de cobranças é uma lista de coisas que importam, e expirada é ruído em volume. Pedir `status=expired` explicitamente não é permitido e responde `400 validation_failed`. A contagem de expiradas nos totais sai sempre zero. Isso vale só para as listagens. A consulta pelo id, a timeline e o evento `transaction.expired` continuam mostrando tudo normalmente. Detalhes em [Consultar](/cobrancas/consultar). ## Estorno Estorno é sempre do valor **total**; não existe estorno parcial. Ele só é possível em cobrança `paid`, e leva o status para `refunded`. O pedido de estorno é feito pelo painel, pela pessoa dona da conta. **Não existe rota de estorno por chave de API**, e isso é proposital: estorno é operação de dinheiro saindo, e exige a confirmação extra que só a sessão do painel tem. Pela API você **observa** o estorno, e isso é tudo o que a sua integração precisa: - O evento `transaction.refunded` chega no seu webhook. - A consulta pelo id passa a trazer `status` em `refunded` e a data em `refunded_at`. - A timeline registra o lançamento correspondente. O valor sai do saldo no mesmo instante, e o saldo disponível pode ficar negativo por causa disso. `chargeback` segue o mesmo caminho, com o evento `transaction.chargeback`, com a diferença de ser iniciado pelo cliente e não por você. ## Próximo passo [Consultar](/cobrancas/consultar). Referência: {% apiRef "getTransaction" %} e {% apiRef "getTransactionTimeline" %}. --- # Consultar https://docs.leztypay.com/cobrancas/consultar Webhook avisa. Consulta confirma. Toda reconciliação termina num `GET`. ## Pelo id {% example "get-transaction.sh" %} Esta é a fonte de verdade do status. Se o seu sistema e o nosso discordam, o `GET` decide. Id que não existe, id de outra conta e id do outro ambiente respondem os três `404`. A resposta não distingue os casos de propósito, para não confirmar a existência de um objeto que não é seu. ## A listagem {% example "list-transactions.sh" %} Paginação por cursor, explicada em [Paginação](/paginacao). Os filtros disponíveis estão na referência da operação; em resumo, você recorta por status, por janela de criação, por janela de pagamento e por busca. `with_totals=1` acrescenta os totais do recorte: quantas cobranças e quanto em bruto, taxa e líquido, sobre o filtro inteiro e não sobre a página. Peça na primeira página só; o número é o mesmo em todas. ## Expiradas ficam de fora A listagem **não** traz cobrança `expired`, e os totais também não a contam. Pedir `status=expired` responde `400 validation_failed`. Para ver uma cobrança expirada, consulte pelo id. O motivo e as consequências estão em [Expiração](/cobrancas/expiracao). ## Busca por `q` `q` aceita **um** critério por vez, e ele é detectado pelo formato do que você mandar: - Começa com `txn_`: busca pelo id da cobrança. - Contém arroba: busca pelo e-mail exato do cliente. Exato mesmo, não é busca parcial. - Só dígitos: busca pelo documento do cliente. Qualquer outro formato é `400 validation_failed` com `param` igual a `q`. Não existe busca por nome, nem por trecho de e-mail, nem por conteúdo de `metadata`. ## A timeline Quando o suporte pergunta "o que aconteceu com essa cobrança", a resposta está aqui: {% example "get-transaction.mjs" %} A timeline junta, em ordem cronológica, três coisas: as mudanças de status da própria cobrança, os lançamentos financeiros que ela gerou, e **cada tentativa de entrega de webhook**, com a tentativa e o código HTTP que o seu servidor respondeu. É por aí que se descobre que o webhook foi entregue e o seu endpoint devolveu `500`, o que é bem mais comum do que webhook não enviado. ## Quando consultar, e quando não Consulte no fechamento do dia, ao reconciliar, e sempre que o cliente reclamar. Consulte também depois de um `503` na criação, para saber o que sobrou. Não consulte em laço para descobrir se a cobrança foi paga. Isso queima o [limite de leitura](/limites) e chega depois do webhook. Use `transaction.paid`. ## Próximo passo [Webhooks](/webhooks), para parar de perguntar e começar a ser avisado. Referência: {% apiRef "getTransaction" %}, {% apiRef "listTransactions" %} e {% apiRef "getTransactionTimeline" %}. --- # Webhooks https://docs.leztypay.com/webhooks Uma cobrança PIX nasce `pending` e muda de estado sozinha, minutos ou dias depois, quando o cliente paga ou o prazo acaba. Quem decide isso é o banco do pagador, não a sua aplicação e nem a nossa API. O webhook é o jeito de você ficar sabendo no instante em que acontece. Ficar consultando a cobrança em laço funciona mal em todos os aspectos que importam: gasta o seu rate limit, atrasa a confirmação para o cliente na proporção do intervalo de polling e fica mais caro exatamente quando você vende mais. Polling é bom como **rede de segurança** (ver [idempotência e ordem](/webhooks/idempotencia)), não como caminho principal. ## Dois destinos possíveis | | Endpoint da conta | `postback_url` da cobrança | |---|---|---| | Onde se configura | uma vez, no painel ou pela API | campo na criação de cada cobrança | | O que recebe | os tipos de evento que ele assina | só os eventos daquela cobrança | | Segredo | um por endpoint, rotacionável | o segredo de postback da store | | Quantos | até 5 ativos por ambiente | um por cobrança | Os dois recebem o mesmo envelope, assinado do mesmo jeito. Se a cobrança tem `postback_url` **e** a conta tem endpoint assinando o tipo, os dois recebem: são duas entregas independentes, cada uma com o seu próprio ciclo de retentativas. Use o endpoint da conta como regra. O `postback_url` existe para o caso em que cada cobrança pertence a um destino diferente (um marketplace com um webhook por lojista, por exemplo). ## Cadastrar um endpoint `POST /v1/me/webhook-endpoints` ([na referência](/referencia/#tag/webhook-endpoints/POST/v1/me/webhook-endpoints)), com sessão do painel e verificação em duas etapas recente. Chave de API não cria endpoint: é configuração de conta, não operação do dia a dia. A `url` precisa passar na mesma checagem que roda de novo em toda entrega: - esquema `https` e porta 443; - host que resolve para endereço público, nunca IP literal, nunca `localhost`, nunca faixa privada (`10/8`, `172.16/12`, `192.168/16`, `127/8`, `169.254/16`, `100.64/10`, `fc00::/7` e afins); - sem usuário e senha embutidos na URL; - sem redirecionar: um `3xx` na entrega conta como falha, não é seguido. Em `events` vai a lista de tipos que aquele endpoint assina, do [catálogo de eventos](/webhooks/eventos), ou `["*"]` para todos. Tipo que não existe no catálogo é recusado com `invalid_event_type`. O `ping` não é assinável: ele só é entregue quando você pede o teste, e chega em qualquer endpoint. São no máximo 5 endpoints ativos por ambiente (`live` e `test` contam separado). O sexto volta `409 invalid_state`. Remover um endpoint é soft delete: ele some da listagem e do fan-out, e o id passa a devolver `404`. ## O segredo aparece uma vez A criação devolve `secret`, no formato `whsec_` seguido de 43 caracteres. **É a única resposta em que ele trafega.** Depois disso a API só mostra `secret_prefix`, 6 caracteres que servem para você saber qual segredo está em uso, e nunca o valor inteiro. Guarde no seu gerenciador de segredos na hora, como faria com uma chave de API. Perdeu, vazou ou o funcionário que tinha acesso saiu: rotacione em `POST /v1/me/webhook-endpoints/{id}/rotate-secret` ([na referência](/referencia/#tag/webhook-endpoints/POST/v1/me/webhook-endpoints/{id}/rotate-secret)). A resposta traz o segredo novo e `previous_secret_expires_at`, 24 h à frente. Nessa janela **o antigo continua valendo** e toda entrega sai com duas assinaturas, para você trocar a configuração do seu receptor sem derrubar nenhum evento. Como escrever a verificação para aceitar as duas está em [assinatura](/webhooks/assinatura). O segredo do `postback_url` é da store inteira e só reaparece em `POST /v1/me/postback-secret/rotate`, também uma vez só. Ele não tem janela de graça: rotacionar invalida o anterior na hora, então troque o seu lado primeiro se puder. ## O que chega no seu servidor Um `POST` com `Content-Type: application/json`, `User-Agent: LeztyPay-Webhooks/1.0` e estes headers: | Header | Para que serve | |---|---| | `X-Signature` | `t=,v1=`, prova de que o corpo veio de nós. Ver [assinatura](/webhooks/assinatura) | | `X-Event-Id` | `evt_…`, identidade do evento. É por ele que você deduplica | | `X-Event-Type` | o tipo, igual ao `type` do corpo | | `X-Delivery-Id` | `whd_…`, identidade **desta tentativa**, diferente a cada retentativa | | `X-Attempt` | número da tentativa, começando em 1 | | `Idempotency-Key` | mesmo valor de `X-Event-Id`, para quem já tem middleware de idempotência | O corpo é sempre o mesmo envelope, qualquer que seja o tipo: ```json { "id": "evt_01j9x5k8p2q3r4s5t6u7v8w9xy", "object": "event", "type": "transaction.paid", "mode": "live", "created_at": "2026-09-10T12:04:31Z", "data": { "object": { "id": "txn_01j9x5k8p2q3r4s5t6u7v8w9xy", "object": "transaction", "status": "paid" } } } ``` O `data.object` acima está encurtado: ele traz o recurso inteiro, com todos os campos que a consulta dele devolve. `data.object` é o recurso do momento em que o evento aconteceu, no mesmo formato que a consulta daquele recurso devolve: para `transaction.*` é o objeto de {% apiRef "getTransaction" %}, para `withdrawal.*` o de {% apiRef "getWithdrawal" %} e para `seller.*` o de {% apiRef "getMe" %}. Nunca vem dado de outro seller. `mode` diz de qual ambiente veio. Um endpoint cadastrado em `test` só recebe evento de `test`. ## O que o seu receptor tem que fazer 1. **Ler o corpo cru antes de qualquer parser.** A assinatura cobre os bytes exatos que chegaram. 2. **Verificar a assinatura** e recusar com `401` se não bater. 3. **Responder `2xx` em menos de 10 s.** Depois disso a conexão é cortada e a entrega conta como falha. Grave o evento numa fila e processe fora do ciclo da requisição. 4. **Deduplicar por `X-Event-Id`.** O mesmo evento pode chegar mais de uma vez. 5. **Ignorar tipo de evento que você não conhece** respondendo `2xx`. Tipo novo é mudança aditiva e pode aparecer a qualquer momento. Qualquer `2xx` encerra a entrega com sucesso. `3xx` não: ver [retries](/webhooks/retries). --- # Assinatura https://docs.leztypay.com/webhooks/assinatura Seu endpoint de webhook é uma URL pública: qualquer um pode fazer `POST` nela. A assinatura é o que separa um evento nosso de um `transaction.paid` forjado por alguém que descobriu o seu endereço. Verifique **sempre**, inclusive no ambiente de teste, senão você descobre que esqueceu justamente quando for para produção. ## O header Toda entrega traz: ``` X-Signature: t=1789041871,v1=5f1e9c... ``` - `t` é o instante em que a entrega foi montada, em segundos desde a época Unix. - `v1` é o HMAC-SHA256, em hexadecimal minúsculo, da string `t` + `.` + corpo cru, usando o segredo do endpoint (`whsec_…`) como chave. - Pode vir **mais de um** `v1=`. Ver "Rotação" abaixo. Em pseudocódigo, o que nós calculamos: ``` assinatura = hex(hmac_sha256(chave = segredo, mensagem = t + "." + corpo_cru)) ``` ## O corpo cru é o corpo cru O `corpo_cru` são os bytes exatos que chegaram na requisição, na ordem em que chegaram. Não é o objeto desserializado, não é o objeto reserializado, não é o JSON reformatado pelo seu framework. Reserializar troca a ordem das chaves ou o espaçamento, muda um byte e o HMAC não fecha. Esse é o erro que mais aparece, e ele depende do framework: - **Express:** monte a rota com `express.raw({ type: 'application/json' })`. `express.json()` descarta os bytes originais. - **Fastify:** registre um `addContentTypeParser` que guarde o buffer. - **Flask:** `request.get_data()` antes de tocar em `request.json`. - **Django:** `request.body`. - **Laravel:** `$request->getContent()`, nunca `$request->all()`. - **PHP puro:** `file_get_contents('php://input')`. Detalhes por stack Node em [verificar em Node](/webhooks/verificar-node). ## Janela de tempo Recuse a entrega se `|agora - t| > 300` segundos. Sem essa checagem, uma requisição nossa capturada hoje pode ser reenviada por um atacante daqui a um mês com a assinatura ainda válida. São 300 s para os dois lados, não só para o passado: relógio adiantado no seu servidor também derruba a verificação. Se você começar a ver rejeições por tempo em massa, olhe o NTP da sua máquina antes de suspeitar da assinatura. ## Comparação em tempo constante Compare o hexadecimal esperado com o recebido usando a função de tempo constante da sua linguagem (`crypto.timingSafeEqual`, `hmac.compare_digest`, `hash_equals`), nunca `==`. Comparação normal para no primeiro byte diferente, e a diferença de tempo entre "errou no primeiro byte" e "errou no trigésimo" é medível pela rede: dá para reconstruir a assinatura byte a byte. Duas armadilhas na hora de usar essas funções: 1. `timingSafeEqual` do Node **lança exceção** se os buffers tiverem tamanhos diferentes. Compare o comprimento antes, e compare os hexadecimais como bytes ASCII, sem decodificar (hex inválido seria truncado em silêncio). 2. Não saia do laço no primeiro acerto quando houver várias assinaturas. Parar cedo devolve pelo tempo qual delas bateu. ## Rotação: duas assinaturas ao mesmo tempo Ao rotacionar o segredo você recebe o novo e um `previous_secret_expires_at` 24 h à frente. Durante essa janela toda entrega sai com dois `v1=`, o primeiro com o segredo novo e o segundo com o antigo: ``` X-Signature: t=1789041871,v1=,v1= ``` A verificação certa é: **aceite se qualquer `v1=` bater com qualquer segredo que você conhece.** Assim o seu receptor continua funcionando com o segredo antigo enquanto você faz o deploy do novo, e volta a ter um só depois. Quem verifica apenas o primeiro `v1=` quebra na rotação; quem verifica o header inteiro como se fosse uma string única nunca funciona. Passadas as 24 h o segredo antigo é apagado e as entregas voltam a ter um `v1=` só. ## Verificação pronta Estes arquivos são executáveis e não têm dependência nenhuma. Cada um roda o próprio autoteste: assina um payload fixo localmente e confere corpo adulterado, `t` fora da janela, header malformado e o caso das duas assinaturas da rotação. Em Node, o exemplo completo (handler cru e middleware de Express) está em [verificar em Node](/webhooks/verificar-node). ### Python `python3 verify-webhook.py` roda o autoteste. {% example "verify-webhook.py" %} ### PHP `php verify-webhook.php` roda o autoteste. {% example "verify-webhook.php" %} ## Depois que a assinatura bate Verificar a assinatura prova a origem e a integridade, não a unicidade: o mesmo evento assinado corretamente pode chegar duas vezes. A deduplicação é um passo à parte, em [idempotência e ordem](/webhooks/idempotencia). --- # Verificar em Node https://docs.leztypay.com/webhooks/verificar-node O arquivo abaixo é o receptor inteiro em Node 22: verificação da assinatura, handler sem framework e middleware de Express. Só usa `node:crypto` e `node:http`, e roda o próprio autoteste com `node verify-webhook.mjs` (payload fixo assinado localmente, sem rede). Os segredos saem do ambiente. Durante as 24 h de rotação você preenche os dois: ```bash LM_WEBHOOK_SECRET=whsec_... # o segredo atual do endpoint LM_WEBHOOK_SECRET_PREVIOUS=whsec_... # só durante a janela de rotação ``` {% example "verify-webhook.mjs" %} ## Pegando o corpo cru em cada stack A assinatura cobre os bytes que chegaram. Quem entrega objeto já desserializado no lugar do buffer quebra a verificação, e o sintoma é sempre o mesmo: `mismatch` em 100% das entregas, inclusive nas que você mesmo disparou por ping. **Express.** O `express.json()` global é o inimigo, e não basta pôr `express.raw` na rota: se o parser global rodou antes, ele já marcou o corpo como consumido e o `raw` vira no-op. Registre a rota do webhook **antes** do `express.json()` global **e** monte o `express.raw` nela: ```js app.post( '/webhooks/leztypay', express.raw({ type: 'application/json' }), verifyMiddleware, (req, res) => { fila.push(req.event); res.sendStatus(200); }, ); ``` **Fastify.** O parser de JSON é global e roda antes do handler. Guarde o buffer: ```js fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => { req.rawBody = body; done(null, JSON.parse(body.toString('utf8'))); }); ``` **Next.js (App Router).** O `Request` já entrega o corpo cru, basta não usar `await req.json()` antes de verificar: ```js export async function POST(req) { const rawBody = await req.text(); const result = verifySignature(req.headers.get('x-signature'), rawBody, SECRETS); if (!result.ok) return new Response(result.reason, { status: 401 }); await fila.push(JSON.parse(rawBody)); return new Response(null, { status: 200 }); } ``` **Hono, Elysia e afins.** Todo framework que expõe o `Request` da plataforma tem um `text()` equivalente. A regra é a mesma: `text()` primeiro, `JSON.parse` depois da verificação. ## Responder rápido O emissor corta a conexão em 10 s e marca a tentativa como falha. Tudo que o seu handler faz antes de responder entra nesse orçamento: consulta ao banco, chamada a outro serviço, envio de e-mail. O desenho que aguenta: verifique a assinatura, grave o evento numa fila ou numa tabela com o `X-Event-Id` como chave única, responda `2xx`, processe depois. Assim uma lentidão no seu processamento nunca vira retentativa de webhook, e uma entrega duplicada esbarra na chave única em vez de cobrar o cliente duas vezes. Se o seu handler lançar antes de responder, devolva `500`. A entrega será retentada de acordo com [a agenda de retries](/webhooks/retries), que é exatamente o que você quer quando a falha é sua. ## Checklist antes de ir para `live` - [ ] Corpo cru chegando ao verificador, conferido com um ping real. - [ ] Assinatura verificada com comparação em tempo constante e janela de 300 s. - [ ] Aceita mais de um `v1=` no header (senão a próxima rotação derruba tudo). - [ ] Resposta `2xx` em menos de 10 s, com o trabalho pesado fora do handler. - [ ] Deduplicação por `X-Event-Id` gravada antes do efeito colateral. - [ ] Tipo de evento desconhecido responde `2xx` em vez de `400`. - [ ] Segredo fora do repositório, lido do ambiente. --- # Retries https://docs.leztypay.com/webhooks/retries Uma entrega que não termina em `2xx` é retentada. São no máximo **6 tentativas** por destino, com esperas crescentes entre elas. ## A agenda | Tentativa | Sai quando | |---|---| | 1 | assim que o evento acontece | | 2 | 1 min depois da falha da 1ª | | 3 | 5 min depois da falha da 2ª | | 4 | 30 min depois da falha da 3ª | | 5 | 2 h depois da falha da 4ª | | 6 | 12 h depois da falha da 5ª | Se a 6ª também falhar, a entrega fica com `status: dead` e não é mais retentada sozinha. Do evento até o `dead` passam cerca de **14 h e 36 min**. Quem quiser recuperar um evento morto usa o [reenvio manual](/webhooks/reenvio). Cada tentativa é uma linha própria em `webhook-deliveries`, com o seu `X-Delivery-Id` e o `X-Attempt` correspondente. O `X-Event-Id` é o mesmo nas 6. As tentativas de um destino não seguram as de outro: se a sua conta tem endpoint e a cobrança tem `postback_url`, as duas entregas retentam em paralelo, cada uma com a sua agenda. ## O que conta como falha Retentado: | Situação | `error` da entrega | |---|---| | Resposta `4xx` ou `5xx` | `http_` | | Resposta `3xx` | `redirect_not_followed` | | Sem resposta em 10 s | `timeout` | | Conexão recusada, DNS quebrado, TLS inválido | `connection_error` | Redirecionamento é falha de propósito: seguir um `3xx` anularia a checagem de destino que roda antes de cada entrega. Se você mudou de URL, atualize o endpoint em vez de redirecionar. Não retentado, porque repetir não mudaria nada: | Situação | `error` da entrega | |---|---| | Endpoint desativado ou removido entre o evento e a entrega | `endpoint_disabled` | | A URL deixou de passar na checagem de destino (o DNS passou a apontar para rede privada, por exemplo) | `blocked_destination` | | A store não tem segredo de postback configurado | `missing_secret` | Desses três, só o primeiro é comum, e ele se resolve religando o endpoint: os eventos posteriores voltam a ser entregues normalmente, e os que falharam nesse meio-tempo você recupera pelo reenvio manual. Sucesso é qualquer `2xx`. O corpo da sua resposta é lido só até 1 KB, guardado como `response_body_excerpt` para você depurar, e nunca interpretado: não adianta responder `2xx` com um JSON pedindo alguma coisa. ## Desativação automática Um endpoint que acumula **50 entregas `dead` seguidas** sem nenhuma entrega bem-sucedida nos últimos 7 dias é desativado. Na prática isso é um endpoint que saiu do ar e não voltou: em vez de seguir enfileirando entregas que ninguém recebe, nós paramos. Quando acontece, o endpoint fica com `enabled: false`, `disabled_reason: "auto"` e `auto_disabled_at` preenchido, e um aviso aparece no feed do painel. `disabled_reason` é o que distingue essa desativação de um desligamento que você mesmo fez pelo painel, que grava `"manual"`. Para reativar, conserte o seu endpoint e mande `PATCH /v1/me/webhook-endpoints/{id}` com `{ "enabled": true }` ([na referência](/referencia/#tag/webhook-endpoints/PATCH/v1/me/webhook-endpoints/{id})). Religar zera `disabled_reason` e `auto_disabled_at`. Os eventos que aconteceram enquanto ele estava desligado **não** são reenviados sozinhos: use o [reenvio manual](/webhooks/reenvio) ou reconcilie pela consulta dos recursos. Antes de religar, dispare um ping e confira que ele volta `2xx`. ## Acompanhar a saúde do endpoint `GET /v1/me/webhook-endpoints/{id}/stats` ([na referência](/referencia/#tag/webhook-endpoints/GET/v1/me/webhook-endpoints/{id}/stats)) devolve a taxa de sucesso e o total de entregas dos últimos 7 dias, mais quando foi o último ping e se ele deu certo. A taxa vem `null`, não `0`, quando não houve entrega nenhuma na janela: zero entrega não é zero por cento de sucesso. Vale monitorar isso do seu lado. Uma taxa de sucesso caindo costuma aparecer bem antes do primeiro `dead`, e muito antes dos 50 que desativam o endpoint. --- # Idempotência e ordem https://docs.leztypay.com/webhooks/idempotencia Duas garantias que a entrega de webhook **não** dá, e que a sua aplicação precisa cobrir: 1. Cada evento chega **pelo menos** uma vez, não exatamente uma vez. 2. Os eventos chegam em ordem qualquer. Um receptor que assume o contrário funciona no dia do teste e falha no dia do pico. ## Deduplicar por `X-Event-Id` O `X-Event-Id` (`evt_…`, o mesmo valor do `id` no corpo e do header `Idempotency-Key`) identifica o **evento**. Ele é estável: as 6 tentativas da entrega, um reenvio manual meses depois e a cópia que vai para o `postback_url` carregam todos o mesmo `evt_…`. Já o `X-Delivery-Id` (`whd_…`) identifica a **tentativa**, e muda a cada uma. Deduplicar por ele não deduplica nada. A dedupe que aguenta concorrência é uma restrição de unicidade no seu banco, não um `if` na memória do processo: ```sql CREATE TABLE eventos_recebidos ( event_id text PRIMARY KEY, recebido_em timestamptz NOT NULL DEFAULT now() ); ``` Grave o `event_id` na **mesma transação** do efeito que o evento causa (creditar o pedido, liberar o acesso, mandar o e-mail). Violação de chave primária significa "já processei", e a resposta certa é `2xx`, não erro: o emissor não tem como saber que foi duplicata, e responder erro só gera mais retentativas. Gravar primeiro e processar depois, em transações separadas, troca o problema de lugar: se o processo morrer no meio, o evento fica marcado como recebido sem ter tido efeito, e a retentativa vai ser descartada. Quando é que uma duplicata acontece de verdade: - a sua resposta `2xx` se perdeu na rede e a tentativa seguinte saiu; - você pediu um [reenvio manual](/webhooks/reenvio) de algo que já tinha processado; - a cobrança tem `postback_url` **e** a conta tem endpoint assinando o tipo: são duas entregas do mesmo evento, com o mesmo `evt_…`. ## Ordem não é garantida Cada entrega tem a sua própria agenda de retentativas. Basta a primeira falhar uma vez para o evento seguinte, entregue de primeira, chegar antes dela. Com a agenda de [retries](/webhooks/retries), a distância entre dois eventos pode passar de 12 h. E há um caso em que a inversão não é acidente nenhum: **uma cobrança expirada pode ser paga**. O prazo de expiração é um pedido ao banco, não uma garantia, e um pagamento que entra depois dele é legítimo. Você recebe `transaction.expired` e, mais tarde, `transaction.paid` da mesma cobrança. Não trate `expired` como estado final. Duas defesas simples: - **Use o `created_at` do envelope**, não a hora em que você recebeu. Ele é o instante em que o evento aconteceu do nosso lado. - **Não deixe o evento retroceder o seu estado.** Guarde o `created_at` do último evento aplicado por recurso e descarte o que for mais antigo. Cobrança que você já marcou como paga não volta a pendente porque um `transaction.expired` atrasado apareceu. Se a sua regra de negócio depende de uma sequência exata, ela não deve depender do webhook. O webhook é o gatilho; a verdade é a consulta. ## Reconciliar pela consulta O webhook diz "olhe isto agora". Quem diz "é assim que está" é a API. - Para o estado atual de um recurso, consulte {% apiRef "getTransaction" %} ou {% apiRef "getWithdrawal" %}. É o desempate quando dois eventos discordam, e é o que você usa depois de uma janela em que o seu receptor esteve fora do ar. - Para varrer o que aconteceu num período, use o feed de eventos da conta ({% apiRef "listEvents" %}, e {% apiRef "getEvent" %} para um id específico). Serve tanto para conferir se um evento existiu quanto para reprocessar do seu lado sem depender de reenvio. - Um passe periódico sobre as cobranças que ainda estão `pending` do seu lado fecha o buraco de qualquer evento que nunca chegou. Rodar de hora em hora já resolve, e é barato porque a lista é pequena por definição. No ambiente de teste existem gatilhos para exercitar exatamente isso: cobrança que entrega o mesmo evento duas vezes, cobrança que é paga sem nenhum webhook e cobrança que entrega `transaction.expired` antes de `transaction.paid`. Vale rodar os três contra o seu receptor antes de ir para `live`. ## Tipo e campo desconhecidos Tipo de evento novo e campo novo em `data.object` são mudanças aditivas e podem aparecer sem aviso. Um receptor que responde `400` para o que não conhece transforma isso em falha de entrega, retry e, no limite, endpoint desativado. Ignore o que não reconhece e responda `2xx`. Se você quer só alguns tipos, assine só eles no endpoint em vez de filtrar com erro no seu lado. --- # Reenvio https://docs.leztypay.com/webhooks/reenvio Todo `POST` que sai daqui vira uma linha consultável: o que foi enviado, para onde, em que tentativa, com que resposta. É por aí que você descobre se o evento que faltou nunca saiu ou esbarrou no seu servidor, e é de lá que você reenvia. ## Inspecionar entregas Em {% apiRef "listWebhookDeliveries" %} estão as tentativas, da mais recente para a mais antiga, com paginação por cursor. Os filtros que resolvem quase tudo: - `status`: `success`, `failed` ou `dead`. Vale a distinção: `failed` é tentativa isolada que **ainda vai** ser retentada, `dead` é a que esgotou as 6 e parou. - `event_type` para olhar um tipo só, `event_id` para seguir um evento específico pelas tentativas, `endpoint_id` para isolar um destino. - `from` e `to` para a janela de tempo. Cada item traz `target` (`endpoint` para o webhook da conta, `postback` para o `postback_url` da cobrança), `target_host`, `attempt`, `status_code`, `response_ms` e `error`. O detalhe de uma tentativa ({% apiRef "getWebhookDelivery" %}) acrescenta três coisas que a listagem não tem: o `payload` que foi enviado, os `request_headers` daquele `POST` e o `response_body_excerpt`, o primeiro 1 KB do que o seu servidor respondeu. Quando o `status_code` é `500` e você não acha o log do seu lado, o trecho da resposta costuma ter a mensagem de erro inteira. Uma ressalva sobre `request_headers`: o `X-Signature` guardado ali tem **só o `t=`**. A assinatura completa nunca é persistida, nem para nós. Você consegue reproduzir a verificação, porque tem o `payload`, o `t` e o segredo, mas não consegue copiar a assinatura de uma entrega antiga para reencenar a requisição. No painel a mesma coisa está na tela de webhooks, com o histórico por endpoint e o detalhe de cada tentativa. ## Ping `POST /v1/me/webhook-endpoints/{id}/ping` ([na referência](/referencia/#tag/webhook-endpoints/POST/v1/me/webhook-endpoints/{id}/ping)) dispara na hora uma entrega de teste para aquele endpoint e responde `202` com o `delivery_id` da tentativa. É o jeito de conferir a rota, o TLS e a verificação de assinatura sem esperar uma cobrança de verdade. O ping chega assinado como qualquer outro evento, com `type: "ping"` e um `data.object` mínimo (`endpoint_id` e `url`). O seu receptor precisa responder `2xx` a ele: um ping que volta `400` porque o tipo é desconhecido é o mesmo bug que vai descartar todo evento novo depois. `ping` não é assinável em `events`, e a entrega dele vai só para o endpoint que você pediu, nunca para os outros. ## Reenviar uma entrega O reenvio ({% apiRef "retryWebhookDelivery" %}) cria uma tentativa nova, imediata, a partir de qualquer entrega que não tenha dado certo, e responde `202` com o `delivery_id` novo. O que o reenvio manual faz de diferente: - o `X-Attempt` continua de onde parou (`última + 1`), então pode passar de 6; - ele **nunca** vira `dead`, mesmo falhando: você reenvia quantas vezes quiser; - ele **não** agenda a próxima tentativa. Uma falha no reenvio manual para ali. O payload enviado é o mesmo de sempre, com o mesmo `X-Event-Id`: do ponto de vista do seu receptor é uma duplicata, e a [deduplicação](/webhooks/idempotencia) tem que estar funcionando antes de você sair reenviando em lote. Um `409 invalid_state` quer dizer que não há o que reenviar: ou já existe uma entrega bem-sucedida para aquele destino, ou uma tentativa automática com o mesmo número já está agendada. Nos dois casos a resposta certa é seguir em frente. O exemplo abaixo varre as entregas que morreram nas últimas 24 h e reenvia cada uma, tratando o `409` como caso normal: {% example "retry-delivery.mjs" %} ## Quando o reenvio não resolve Reenvio manual só existe para entregas que alguma vez foram criadas. Ele não cobre: - o período em que o endpoint esteve **desativado**, porque as entregas nesse intervalo falharam com `endpoint_disabled` e não têm payload novo a oferecer; - eventos anteriores ao cadastro do endpoint; - o gatilho de teste que paga uma cobrança sem entregar webhook nenhum. Para esses casos o caminho é reconciliar pela consulta: o feed de eventos ({% apiRef "listEvents" %}) para varrer o período e {% apiRef "getTransaction" %} para conferir o estado atual de cada cobrança que você ainda tem como pendente. --- # Catálogo de eventos https://docs.leztypay.com/webhooks/eventos São 17 tipos públicos, em três famílias, mais o `ping`. Um endpoint assina os tipos que quiser, ou `["*"]` para todos os públicos. Todos chegam no mesmo envelope, descrito em [webhooks](/webhooks): `id`, `object: "event"`, `type`, `mode`, `created_at` e `data.object`. O que muda de um tipo para outro é o `type` e o recurso que vem em `data.object`. ## Cobranças `data.object` é a cobrança no estado em que ela ficou, no mesmo formato de {% apiRef "getTransaction" %}. | Tipo | Quando é emitido | |---|---| | `transaction.paid` | O pagamento entrou. É o evento que libera o pedido | | `transaction.expired` | O prazo acabou sem pagamento. **Não é estado final**: ver abaixo | | `transaction.failed` | A cobrança não chegou a ficar pagável (o processador recusou ou não respondeu) | | `transaction.refunded` | Estorno concluído | | `transaction.chargeback` | Contestação perdida. O valor volta a ser debitado do saldo | | `transaction.reserve_released` | A parte retida em reserva daquela cobrança virou saldo disponível | `transaction.expired` seguido de `transaction.paid` na mesma cobrança é situação normal, não erro: o prazo é um pedido ao banco do pagador, e pagamento tardio acontece. Trate em [idempotência e ordem](/webhooks/idempotencia). `transaction.created` **não** é entregue por webhook, nem para quem assina `["*"]`. A criação da cobrança é a resposta síncrona de {% apiRef "createTransaction" %}: você já tem o objeto em mãos e não precisa de um evento para saber que ela existe. ## Saques `data.object` é o saque, no mesmo formato de {% apiRef "getWithdrawal" %}. | Tipo | Quando é emitido | |---|---| | `withdrawal.awaiting` | Saque criado, aguardando aprovação | | `withdrawal.approved` | Aprovado, na fila para ser executado | | `withdrawal.rejected` | Recusado na aprovação. O valor volta para o saldo disponível | | `withdrawal.cancelled` | Cancelado por você antes da aprovação. O valor volta para o saldo | | `withdrawal.processing` | Enviado ao parceiro de pagamento | | `withdrawal.successful` | Liquidado. O dinheiro saiu para a chave PIX | | `withdrawal.failed` | O parceiro recusou ou a transferência não completou. O valor volta para o saldo | O caminho feliz é `awaiting`, `approved`, `processing`, `successful`. Numa conta com aprovação automática de saque o `withdrawal.awaiting` **não existe**: o saque já nasce aprovado e o primeiro evento é o `withdrawal.approved`. `rejected`, `cancelled`, `successful` e `failed` são finais. ## Conta `data.object` é o seller, a mesma base que {% apiRef "getMe" %} devolve. | Tipo | Quando é emitido | |---|---| | `seller.kyc_approved` | Cadastro aprovado. A partir daqui a conta opera em `live` | | `seller.kyc_rejected` | Cadastro recusado. O motivo aparece no painel | | `seller.payout_locked` | Saques bloqueados por análise. Cobranças continuam funcionando | | `seller.payout_unlocked` | Bloqueio de saque removido | ## Ping `ping` não é assinável e não entra em `["*"]`. Ele só sai quando você [pede um teste](/webhooks/reenvio) para um endpoint específico, e chega assinado como qualquer outro evento. `data.object` traz apenas `endpoint_id` e `url`. ## Um envelope inteiro Exemplo de `transaction.paid` como ele chega no seu servidor. Os outros tipos têm exatamente a mesma forma, trocando `type` e o conteúdo de `data.object`: ```json { "id": "evt_01j9x5k8p2q3r4s5t6u7v8w9xy", "object": "event", "type": "transaction.paid", "mode": "live", "created_at": "2026-09-10T12:04:31Z", "data": { "object": { "id": "txn_01j9x5k8p2q3r4s5t6u7v8w9xy", "object": "transaction", "mode": "live", "status": "paid", "payment_method": "pix", "amount": 10000, "fee": 699, "net": 9301, "reserve": 930, "customer": { "name": "Maria Silva", "email": "maria@exemplo.com" }, "metadata": { "order_id": "A123" }, "paid_at": "2026-09-10T12:04:30Z", "end_to_end_id": "E1234567820260910120430abcdef123", "created_at": "2026-09-10T12:00:00Z" } } } ``` O `data.object` acima está encurtado para caber na página. A lista completa de campos de cada recurso, com tipos e valores possíveis, está na [referência da API](/referencia), que é a fonte de verdade: nenhuma página desta documentação repete campo. ## Tipo novo aparece sem aviso Tipo de evento novo é mudança aditiva e entra sem `/v2`. Um receptor correto ignora o que não conhece e responde `2xx`; se ele devolver `400`, a entrega é retentada, e um endpoint que recusa todo evento novo acaba [desativado automaticamente](/webhooks/retries). Se você quer receber só alguns tipos, liste-os em `events` no endpoint em vez de filtrar com erro do seu lado. --- # Ambiente de teste https://docs.leztypay.com/sandbox O ambiente de teste é a mesma API, na mesma base URL, com o mesmo código. O que muda é a chave: uma chave `sk_test_` põe a requisição no teste, uma `sk_live_` põe em produção. Não existe host separado, não existe versão diferente e não existe campo `mode` para mandar na requisição. O ambiente vem da chave, e só dela. ## Os dois mundos não se tocam Uma cobrança criada no teste nunca aparece em produção, e vice-versa. Consultar com a chave de teste um id de produção responde `404`, não `403`: a resposta não confirma que aquele objeto existe. Saldo, saques, eventos, entregas de webhook e limites diários são todos separados por ambiente. ## O que o teste não faz - **Não move dinheiro.** Nenhum valor sai ou entra de conta bancária nenhuma. - **Não gera QR pagável.** O `pix.payload` é um BR Code bem formado, mas nenhum app de banco de verdade paga ele. Quem paga é você, pelo valor da cobrança. Ver [Gatilhos](/sandbox/gatilhos). - **Não exige KYC para cobrar.** A chave de teste funciona com a conta em qualquer estado, menos encerrada. É o que permite integrar o checkout inteiro enquanto a análise dos seus documentos ainda corre. ## O que continua igual ao de produção Tudo o mais. Vale a pena listar, porque é aí que o teste tem valor: - As mesmas validações, os mesmos códigos de erro e o mesmo envelope. - A mesma obrigatoriedade de `Idempotency-Key`, com o mesmo comportamento de replay. - Os mesmos limites de chamadas por minuto e as mesmas faixas de valor. - Os mesmos webhooks, assinados do mesmo jeito, com o mesmo esquema de retentativa. - A mesma paginação e os mesmos filtros. Um checkout que passa no teste passa em produção, desde que você tenha exercitado também os caminhos ruins. ## Saque no ambiente de teste Saque no teste não exige KYC aprovado, mas exige uma chave PIX verificada, e a chave PIX é o documento aprovado no seu KYC. Na prática: até o KYC sair, você testa cobrança, webhook e consulta, mas não o fluxo de saque. Detalhes em [Chave PIX](/saques/chave-pix). ## Próximo passo [Gatilhos](/sandbox/gatilhos): como fazer uma cobrança de teste pagar, expirar, falhar ou duplicar o webhook. Referência: {% apiRef "getBalance" %} devolve o saldo do ambiente da chave que você usou. --- # Gatilhos https://docs.leztypay.com/sandbox/gatilhos No ambiente de teste, quem decide o desfecho da cobrança são os **dois últimos dígitos de `amount`**. Você escolhe o cenário escolhendo o valor. Isso vale apenas com chave `sk_test_`. Em produção o valor é só o valor. ## A tabela | `amount` termina em | O que acontece | |---|---| | qualquer outro | Paga sozinha poucos segundos depois da criação. | | `01` | Nunca paga. Expira em `pix.expires_at`. | | `02` | A criação falha por tempo esgotado no processador: `503 acquirer_timeout`. | | `03` | A criação é recusada pelo processador: `503 acquirer_rejected`. | | `04` | Paga e entrega o mesmo evento **duas vezes**. | | `06` | Paga e recebe uma contestação cerca de 10 segundos depois. | | `07` | Paga **sem entregar webhook**. | | `08` | Entrega `transaction.expired` e depois `transaction.paid`, fora de ordem. | Exemplos: `R$ 100,00` são `10000`, termina em `00`, e paga sozinha. `R$ 100,01` são `10001`, termina em `01`, e expira. `R$ 55,04` são `5504`, termina em `04`, e entrega o evento duas vezes. Esses gatilhos são contrato público: mudar o efeito de um deles entra no changelog. ## O que cada um testa **Caminho feliz (qualquer outro).** O seu checkout inteiro, do QR ao `transaction.paid`. **`01`, expiração.** O contador da tela, o `transaction.expired` e, principalmente, o seu sistema não apagar o pedido ao receber esse evento. Pagamento tardio existe ([Expiração](/cobrancas/expiracao)). **`02` e `03`, falha na criação.** Os dois voltam `503` e a cobrança fica sem QR. Teste que a sua tela de checkout mostra erro em vez de ficar em branco, e que a sua retentativa usa uma `Idempotency-Key` **nova**. Repetir com a mesma chave devolve o mesmo `503` ([Idempotência](/idempotencia)). **`04`, evento duplicado.** O mesmo evento, com o mesmo identificador, chega duas vezes. Se o seu receptor não deduplicar, você entrega o produto duas vezes ou credita o cliente em dobro. É o teste mais importante desta lista. **`06`, contestação.** A cobrança paga volta contestada e o valor sai do seu saldo. Teste o que o seu sistema faz com `transaction.chargeback` numa venda já entregue. **`07`, pago sem webhook.** Simula a entrega que nunca chegou, seja por queda do seu servidor, seja por falha de rede. A cobrança está `paid` e o seu banco de dados não sabe. É o cenário que prova que você precisa de uma rotina de reconciliação por consulta, e não só do webhook. **`08`, fora de ordem.** `transaction.expired` chega antes de `transaction.paid`. Um receptor que aplica eventos cegamente na ordem de chegada marca a venda como perdida e nunca mais a recupera. ## Como usar isso de verdade Rode os oito no seu ambiente de testes automatizados, não à mão. São oito valores e oito asserções sobre o estado final do **seu** banco, e eles cobrem quase todo o suporte que você teria depois. Os gatilhos não mudam nada além do desfecho: validação, erros, limites e assinatura de webhook continuam iguais aos de produção. ## Próximo passo [Saques](/saques), para fechar o ciclo do dinheiro. Referência: {% apiRef "createTransaction" %} e {% apiRef "getTransaction" %}. --- # Saques https://docs.leztypay.com/saques Cobrança paga vira saldo. Saldo vira dinheiro na sua conta por meio de um saque para uma chave PIX. {% example "get-balance.sh" %} Todos os números do saldo são inteiros em centavos, e valem para o ambiente da chave que você usou. ## O que cada número quer dizer - **`available`** é o que você pode sacar agora. É o único número que importa na hora de pedir um saque. - **`reserve`** é o líquido retido, que ainda não virou disponível. Sai automaticamente, no prazo. - **`pending_withdraw`** é o que já está preso num saque em andamento. Saiu de `available` e ainda não saiu da conta. - **`gross`**, **`fees`**, **`refunds`**, **`chargebacks`** e **`withdrawn`** são acumulados históricos, para conferência, não para decisão. `available` **pode ficar negativo**. Um estorno ou uma contestação de uma venda cujo dinheiro já foi sacado tira do saldo o que não está mais lá. Enquanto estiver negativo, nenhum saque passa. ## Por que existe reserva Uma parte do líquido de cada venda paga fica retida por alguns dias. Ela cobre estorno e contestação, que chegam depois da venda. A regra é simples: - A reserva é calculada sobre o **líquido** da cobrança, na criação, com o mesmo percentual e o mesmo prazo congelados na taxa daquela cobrança. - O prazo conta a partir do **pagamento**, não da criação. - Quando vence, o valor vira `available` sozinho e você recebe o evento `transaction.reserve_released`. Se o percentual de reserva da sua conta mudar, cobranças antigas continuam com o percentual delas. Nada é recalculado. ## Quando o dinheiro fica disponível Em ordem, para uma venda: 1. A cobrança é paga. `net` entra, sendo que a parte `reserve` entra retida. 2. A diferença entre `net` e `reserve` já é `available`, imediatamente. 3. Passado o prazo da reserva, o restante vira `available`. Não existe janela de fechamento nem dia de repasse: assim que o valor está em `available`, o saque pode ser pedido. ## Acompanhar pelo evento, não pelo relógio Para saber que a reserva saiu, escute `transaction.reserve_released` em vez de calcular a data. A liberação roda em ciclos, então ela acontece pouco depois do vencimento, não no segundo exato. ## Próximo passo [Chave PIX](/saques/chave-pix): você só saca para uma chave verificada, e ela tem uma regra que surpreende. Referência: {% apiRef "getBalance" %}. --- # Chave PIX https://docs.leztypay.com/saques/chave-pix Saque só vai para uma chave PIX **sua**, cadastrada e verificada. E "sua" aqui é literal. ## A chave é o seu documento A chave de saque é o CPF ou o CNPJ aprovado no seu KYC. Você não escolhe o número: ele é o documento da conta. O cadastro acontece no painel e o corpo do pedido tem só o tipo, `cpf` ou `cnpj`, que precisa bater com o tipo do documento da conta. Não existe campo para digitar a chave. Isso resolve a titularidade por construção. Não há como cadastrar a chave de outra pessoa, e não há como uma chave apontar para uma conta que não é a sua. Consequência direta: **chave de e-mail, de telefone e aleatória não são mais criadas**. Contas que já tinham uma dessas continuam podendo usá-la; contas novas, não. ## Sem KYC não há chave, e sem chave não há saque Cadastrar a chave exige documento aprovado. Sem isso, a resposta é `403 kyc_required`. Isso vale nos dois ambientes. O saque em produção já exige KYC por si só; no ambiente de teste o saque **não** exige KYC, mas exige chave verificada, e a chave exige KYC. Na prática, o fluxo de saque só é testável depois que a sua análise sair. Outras regras do cadastro: no máximo 5 chaves ativas, e a mesma chave não entra duas vezes (`409 invalid_state`). Desativar uma chave não a apaga, porque saques antigos apontam para ela. ## Aprovação do parceiro Dependendo do processador que atende a sua conta, a chave ainda precisa ser registrada e aprovada por ele antes de poder sacar. Isso leva alguns instantes e não exige nada de você. Enquanto essa aprovação não sai, o saque é recusado com `409 pix_key_not_verified`, e `details.reason` traz o motivo. Não é erro: é espera. Tente de novo em seguida. ## O que a API mostra A chave é cadastrada e removida pelo painel. Pela chave de API você **lista** as chaves que podem sacar: ``` GET /v1/pix-keys ``` A listagem por API traz apenas as chaves verificadas e ativas, que são exatamente as que servem para `pix_key_id` no pedido de saque. Se ela vier vazia, não adianta tentar sacar. O `key` volta mascarado nas respostas. Isso é proposital e não tem como desligar. ## Próximo passo [Solicitar saque](/saques/solicitar). Referência: {% apiRef "listPixKeys" %}. --- # Solicitar saque https://docs.leztypay.com/saques/solicitar Um `POST`, com `Idempotency-Key` obrigatória, e o dinheiro sai para a chave PIX escolhida. {% example "create-withdrawal.sh" %} O fluxo completo, escolhendo a chave e conferindo o saldo antes: {% example "create-withdrawal.mjs" %} ## A taxa é cobrada por fora `amount` é o que chega na sua conta bancária. `fee` é a taxa do saque. `total`, que é a soma dos dois, é o que sai do seu saldo. Então pedir o saque do seu `available` inteiro **falha**: falta a taxa. O que cabe é `available` menos `fee`. ## As pré-condições Todas são conferidas no momento do pedido, e cada uma tem o seu erro: - **Conta apta a operar.** Senão `403 seller_not_allowed`. - **KYC aprovado**, em produção. Senão `403 kyc_required`. No ambiente de teste o KYC não é exigido. - **Saque não travado** pela nossa operação. Senão `403 payout_locked`. - **Chave PIX verificada e ativa.** Senão `409 pix_key_not_verified`. Ver [Chave PIX](/saques/chave-pix). - **Valor mínimo de R$ 1,00 e saldo suficiente** para valor mais taxa. Senão `409 insufficient_balance`, com o disponível e a taxa em `details`. - **Dentro do limite diário**, em valor e em quantidade (no máximo 20 saques por dia). Senão `409 daily_limit_exceeded`, e `details.reason` diz qual dos dois estourou. - **Nenhum outro saque em andamento.** Senão `409 invalid_state` com `details.reason` igual a `withdrawal_in_progress`. ## Um saque por vez Essa última merece parágrafo próprio, porque é a que mais surpreende. Enquanto um saque seu não chegar ao fim, nenhum outro é aceito. O motivo é honesto: dois saques concorrentes contra o mesmo saldo viram corrida, e corrida com dinheiro vira saque a descoberto. Com um por vez, a conta fecha sempre. Na prática, se você faz repasses em lote, enfileire e mande um de cada vez, esperando o estado final de cada um. ## O ciclo de vida | Status | O que quer dizer | |---|---| | `awaiting` | Recebido, aguardando aprovação. | | `approved` | Aprovado, na fila para ser enviado. | | `rejected` | Recusado na aprovação. O valor volta para o saldo. | | `processing` | Enviado ao processador, aguardando a liquidação. | | `successful` | Pago. `end_to_end_id` traz o identificador da transferência PIX. | | `failed` | Não foi pago. O valor volta para o saldo, e `failure_reason` diz o motivo. | | `cancelled` | Cancelado por você enquanto ainda estava em `awaiting`. | Dependendo da configuração da sua conta, o saque nasce direto em `approved` e pula a espera. Cada transição entrega um evento no seu webhook: `withdrawal.awaiting`, `withdrawal.approved`, `withdrawal.rejected`, `withdrawal.processing`, `withdrawal.successful`, `withdrawal.failed` e `withdrawal.cancelled`. `successful` e `failed` são os dois estados finais que interessam. ## Cancelar Só em `awaiting`, e não precisa de `Idempotency-Key`: ``` POST /v1/withdrawals/{id}/cancel ``` Saque que já saiu de `awaiting` responde `409 invalid_state`. Não existe cancelamento depois do envio ao processador: a partir de `processing`, o resultado é `successful` ou `failed`, e você espera. ## Quando o resultado demora `processing` pode durar. Enquanto o processador não confirmar, o saque fica nesse estado e nós continuamos consultando. O que fazer: nada, além de escutar o evento. Não crie outro saque, não pergunte em laço apertado, não trate demora como falha. O valor está preso em `pending_withdraw` e vai para um dos dois estados finais. ## Próximo passo Você já tem o ciclo do dinheiro inteiro. Para produção, veja [Webhooks](/webhooks) e depois o checklist de go-live. Referência: {% apiRef "createWithdrawal" %}, {% apiRef "getWithdrawal" %}, {% apiRef "listWithdrawals" %} e {% apiRef "cancelWithdrawal" %}.