# Cancel Subscription Source: https://docs.purin.cash/api-reference/assinaturas/cancel-subscription /api-reference/openapi.yaml post /v1/subscriptions/{id}/cancel Cancela a autorização no banco e as cobranças recorrentes ainda não liquidadas. O que já foi pago não muda. Dispara o webhook subscription.cancelled com reason "merchant". Idempotente: cancelar de novo devolve o mesmo resultado. # Create Subscription Source: https://docs.purin.cash/api-reference/assinaturas/create-subscription /api-reference/openapi.yaml post /v1/subscriptions Cria uma assinatura por Pix Automático (o padrão de recorrência do Banco Central) vinculada a um produto ativo. O brCode devolvido é um QR composto: o cliente paga o primeiro ciclo e, no mesmo ato, autoriza os próximos no app do banco. A partir daí cada renovação é cobrada sozinha, um período depois do primeiro pagamento, e chega como um payment.paid com subscriptionId e subscriptionCycle. A autorização é presa ao CPF ou CNPJ do titular da conta que paga (customer.taxId). Funciona também no modo sandbox (chaves ps_test_), com dados simulados. # Get Subscription Source: https://docs.purin.cash/api-reference/assinaturas/get-subscription /api-reference/openapi.yaml get /v1/subscriptions/{id} Devolve a assinatura com o estado da autorização, o próximo débito e cada cobrança recorrente já enviada ao banco, com as tentativas de liquidação. O id pode ser o paymentId (psa_sub_…) ou o subscriptionId. # List Subscriptions Source: https://docs.purin.cash/api-reference/assinaturas/list-subscriptions /api-reference/openapi.yaml get /v1/subscriptions Retorna a lista de assinaturas do ambiente da chave utilizada, ordenadas da mais recente para a mais antiga, com paginação por limit/offset e filtro opcional por status. É uma linha por assinatura; cada renovação paga aparece em GET /v1/payments como um pagamento próprio. # Create Card Payment Source: https://docs.purin.cash/api-reference/cartao/create-card-payment /api-reference/openapi.yaml post /v1/card-payments Cria um pagamento no cartão de crédito via Stripe Checkout e retorna a checkoutUrl para redirecionar o cliente. A sessão de checkout expira em 30 minutos. Não disponível no modo sandbox (chaves ps_test_). # Get Card Payment Source: https://docs.purin.cash/api-reference/cartao/get-card-payment /api-reference/openapi.yaml get /v1/card-payments/{orderCode} Consulta os detalhes de um pagamento no cartão pelo orderCode retornado na criação. Não disponível no modo sandbox (chaves ps_test_). # List Card Payments Source: https://docs.purin.cash/api-reference/cartao/list-card-payments /api-reference/openapi.yaml get /v1/card-payments Retorna a lista de pagamentos no cartão, ordenados do mais recente para o mais antigo, com paginação por limit/offset e filtro opcional por status. No modo sandbox (chaves ps_test_) a lista retorna vazia. # Get Wallet Source: https://docs.purin.cash/api-reference/carteira/get-wallet /api-reference/openapi.yaml get /v1/wallet Retorna o saldo da carteira da loja. O campo withdrawable é o saldo sacável agora (balance - disputeBlocked - comprometido com subcontas): o valor retido por disputas (MED) e a soma dos saldos das subcontas são descontados do saldo bruto, e é esse o limite máximo aceito pelo POST /v1/payouts. Dinheiro atribuído a subconta sai pela subconta, não pela conta principal. Funciona com chaves ps_live_ e ps_test_. No sandbox o saldo é simulado a partir dos pagamentos de teste (mesma regra do GET /v1/sandbox/wallet), a MESMA conta vale (withdrawable desconta o comprometido com as subcontas de teste) e a resposta inclui "sandbox": true, com disputeBlocked e cryptoBalanceLtc sempre 0. No sandbox, pendingRelease é a soma dos pagamentos de teste ainda pendentes. # Create Charge Source: https://docs.purin.cash/api-reference/cobrancas/create-charge /api-reference/openapi.yaml post /v1/charges Cria uma cobrança PIX avulsa com valor customizado, sem produto vinculado. Retorna o BR Code (copia-e-cola) e a imagem do QR code para pagamento. Se `callbackUrl` for informado, um webhook `charge.paid` assinado com HMAC-SHA256 (header `X-Webhook-Signature`) é enviado quando o pagamento for confirmado. Para dividir a cobrança entre contas PurinCash, use o recurso Split Charges (POST /v1/split-charges). # Get Charge Source: https://docs.purin.cash/api-reference/cobrancas/get-charge /api-reference/openapi.yaml get /v1/charges/{paymentId} Consulta os detalhes de uma cobrança avulsa pelo paymentId, incluindo status, cliente, metadata e datas. Use para confirmar o pagamento (polling) como alternativa ao webhook do callbackUrl. Cobranças com split (prefixo psplit_) retornam o formato descrito em GET /v1/split-charges/{paymentId}. # List Charges Source: https://docs.purin.cash/api-reference/cobrancas/list-charges /api-reference/openapi.yaml get /v1/charges Lista as cobranças (psc_ e psplit_) da mais recente para a mais antiga, com os mesmos filtros estritos da lista de pagamentos: valor fora do vocabulário devolve 400, e ?subaccountId= malformado devolve 400 (inexistente, 404). Fecha a reconciliação por lista das vendas atribuídas via cobrança: antes, cobrança com subconta só aparecia no GET individual. # Get Dispute Source: https://docs.purin.cash/api-reference/disputas/get-dispute /api-reference/openapi.yaml get /v1/disputes/{id} Uma disputa pelo ID, com as provas enviadas, a situação da defesa e a linha do tempo dos status (os mesmos eventos enviados por webhook). Precisa da permissão disputas.gerenciar. No sandbox retorna 404. # List Disputes Source: https://docs.purin.cash/api-reference/disputas/list-disputes /api-reference/openapi.yaml get /v1/disputes Lista as disputas (MED, o Mecanismo Especial de Devolução) da conta, das mais recentes para as mais antigas. Enquanto uma disputa está aberta ou foi perdida, o valor contestado fica retido do saldo sacável (campo disputeBlocked do GET /v1/wallet). Precisa da permissão disputas.gerenciar. No sandbox retorna lista vazia com "sandbox": true. # Submit Evidence Source: https://docs.purin.cash/api-reference/disputas/submit-evidence /api-reference/openapi.yaml post /v1/disputes/{id}/evidence Responde uma disputa aberta (canRespond: true) com arquivos, links e um texto, que vira PDF. A resposta é entregue ao banco do pagador; a mesma do painel. Prefira arquivos: vários bancos recebem a defesa só como arquivo, e links em documents vão citados no texto. Pode ser chamado mais de uma vez enquanto a disputa estiver aberta. Corpo de até 25 MB. Precisa da permissão disputas.gerenciar. Só em produção. # Get Delivery Source: https://docs.purin.cash/api-reference/entregas/get-delivery /api-reference/openapi.yaml get /v1/deliveries/{paymentId} Consulta o status de entrega de um pagamento e retorna o conteúdo entregue, se disponível. Aceita IDs de pagamento (prefixo psa_) e de cobrança (prefixo psc_). O campo deliveredContent é null se o pagamento estiver pendente ou se o produto tiver entrega manual. # Referência da API Source: https://docs.purin.cash/api-reference/introducao 35 endpoints, um formato de erro, uma autenticação. Com playground pra testar aqui mesmo. **Base URL:** `https://api.purin.cash` Todos os endpoints ficam sob `/v1` e exigem o header de autenticação: ``` Authorization: Bearer ps_live_sua_chave ``` > [!TIP] > > Cada página desta seção tem um playground. Cole uma chave `ps_test_` e dispare a > requisição direto do navegador, sem sair da documentação. ## Convenções > [accordion] Dinheiro vai em centavos, inteiro > > `valueCents: 4990` é R\$ 49,90. Os campos terminados em `Cents` são sempre inteiros. > > Duas exceções, que vale conhecer antes de escrever o parser: cartão e saque usam > `amount` em reais decimal, e produto da loja usa `price` como **texto** no padrão > brasileiro (`"49,90"`). > [accordion] O prefixo do ID diz o que é > > | Prefixo | Recurso | > | - | - | > | `psa_` | Pagamento de `POST /v1/payments` | > | `psa_sub_` | Assinatura | > | `psc_` | Cobrança de `POST /v1/charges` | > | `psplit_` | Cobrança com split | > | Sem prefixo | Cartão usa `orderCode`, produto usa ObjectId | > [accordion] Erro tem sempre a mesma forma > > ```json theme={null} > { "error": "Descrição do que deu errado" } > ``` > > Trate pelo código HTTP, não pelo texto. Lista completa em [Erros](/guias/erros). > [accordion] Listagem pagina com limit e offset > > `limit` vai de 1 a 100 (padrão 50) e `offset` pula resultados. A resposta traz `total`, > `limit` e `offset` junto do array. > [accordion] metadata é string, não objeto > > Mande um JSON já serializado, de até 2 KB. Ele volta exatamente igual na consulta e no > webhook, sem parse do nosso lado. > > ```json theme={null} > { "metadata": "{\"pedido\":\"1042\"}" } > ``` > > A única exceção é cobrança com [split](/guias/splits), que não aceita `metadata`. > [accordion] Data é sempre ISO 8601 em UTC > > `2026-03-18T12:05:00.000Z`. Converta para o fuso do usuário só na exibição. ## O mapa > [card] Receber > > `/v1/payments` para PIX e LTC. > > `/v1/charges` para PIX avulso e split. > > `/v1/card-payments` para cartão. > > `/v1/subscriptions` para recorrência. > [card] Catálogo > > `/v1/products` para produtos de cobrança. > > `/v1/store/products` para os da loja do Discord. > > `/v1/deliveries` para o conteúdo entregue. > [card] Dinheiro > > `/v1/wallet` para saldo e retenções. > > `/v1/subaccounts` para saldo por cliente seu. > > `/v1/payouts` para sacar. > > `/v1/disputes` para contestações. > [card] Teste > > `/v1/sandbox/*` para simular pagamento e conferir saldo de teste. > > Só com chave `ps_test_`. ## Limites que valem lembrar | O quê | Limite | | - | - | | Requisições | 120 por minuto, somando todas as rotas `/v1/*` | | Saques | 10 por hora, 5 se for turbo | | Chaves de API ativas | 10 por conta | | Produtos de cobrança | 500 por conta e por ambiente | | Subcontas | 1000 por conta e por ambiente | | Cobrança com split | R\$ 5.000,00 por cobrança | | Beneficiários no split | 9, além de você | Detalhes e como lidar com `429` em [Limites de requisição](/guias/limites). > [card] [Nunca usou a API?](/guias/primeira-cobranca) > > O guia de primeira cobrança vai do zero ao webhook recebido, tudo em sandbox. # List Store Products Source: https://docs.purin.cash/api-reference/loja/list-store-products /api-reference/openapi.yaml get /v1/store/products Lista os produtos da loja do Discord, com categoria, variações e estoque. É uma rota de leitura: para criar ou editar produtos da loja, use o painel. Não confunda com `GET /v1/products`, que lista os produtos de cobrança criados pela própria API. São coleções separadas, e por isso `/v1/products` pode devolver uma lista vazia mesmo com a loja cheia. O conteúdo do estoque (as chaves, contas ou links entregues ao comprador) e as instruções de entrega não são retornados aqui. Variações de seller ainda não aprovadas ficam de fora da resposta, porque não são vendáveis. # Create Payment Source: https://docs.purin.cash/api-reference/pagamentos/create-payment /api-reference/openapi.yaml post /v1/payments Cria um pagamento e retorna os dados para o cliente pagar. Suporta PIX (padrão) e LTC (Litecoin) via `paymentMethod`. Envie `productId` (o preço vem do produto) ou `valueCents` (valor avulso em centavos); se ambos forem enviados, o preço do produto prevalece. Pagamentos LTC não estão disponíveis no sandbox. Se `callbackUrl` for informado, um webhook `payment.paid` assinado com HMAC-SHA256 (header `X-Webhook-Signature`) é enviado quando o pagamento for confirmado. # Get Payment Source: https://docs.purin.cash/api-reference/pagamentos/get-payment /api-reference/openapi.yaml get /v1/payments/{paymentId} Consulta os detalhes de um pagamento pelo paymentId, incluindo status, método de pagamento, cliente, metadata e datas. Use para confirmar o pagamento (polling) como alternativa ao webhook do callbackUrl. # List Payments Source: https://docs.purin.cash/api-reference/pagamentos/list-payments /api-reference/openapi.yaml get /v1/payments Retorna a lista de pagamentos com paginação e filtros por status e tipo, ordenada da mais recente para a mais antiga. Os filtros são estritos: valor fora do vocabulário (status/type desconhecido, limit/offset inválido) devolve 400 em vez de cair no padrão em silêncio, e subaccountId malformado devolve 400 (inexistente, 404) em vez de uma lista vazia que esconderia o erro. Cobranças psc_ têm listagem própria em GET /v1/charges. # Create Product Source: https://docs.purin.cash/api-reference/produtos/create-product /api-reference/openapi.yaml post /v1/products Cria um produto para ser referenciado em pagamentos e assinaturas. Limite de 500 produtos por conta por ambiente. # Delete Product Source: https://docs.purin.cash/api-reference/produtos/delete-product /api-reference/openapi.yaml delete /v1/products/{id} Exclui permanentemente um produto pelo seu ID. # Get Product Source: https://docs.purin.cash/api-reference/produtos/get-product /api-reference/openapi.yaml get /v1/products/{id} Obtém os detalhes de um único produto pelo seu ID. # List Products Source: https://docs.purin.cash/api-reference/produtos/list-products /api-reference/openapi.yaml get /v1/products Retorna a lista de produtos criados, ordenada da mais recente para a mais antiga. Por padrão, apenas produtos ativos são retornados. # Update Product Source: https://docs.purin.cash/api-reference/produtos/update-product /api-reference/openapi.yaml put /v1/products/{id} Atualiza um produto existente. Todos os campos são opcionais, e apenas os campos enviados são alterados. # Sandbox Transactions Source: https://docs.purin.cash/api-reference/sandbox/sandbox-transactions /api-reference/openapi.yaml get /v1/sandbox/transactions Lista as transações do ambiente de teste (pagamentos e cobranças avulsas), das mais recentes para as mais antigas. Requer chave ps_test_ (retorna 403 com chave ps_live_). # Sandbox Wallet Source: https://docs.purin.cash/api-reference/sandbox/sandbox-wallet /api-reference/openapi.yaml get /v1/sandbox/wallet Retorna o saldo simulado do ambiente de teste, calculado a partir dos pagamentos e cobranças de teste. Os pagos somam em available e os pendentes em pending. Requer chave ps_test_ (retorna 403 com chave ps_live_). # Simulate Charge Paid Source: https://docs.purin.cash/api-reference/sandbox/simulate-charge-paid /api-reference/openapi.yaml post /v1/sandbox/charges/{paymentId}/simulate-paid Simula a confirmação de uma cobrança avulsa no ambiente de teste. Requer chave ps_test_ (retorna 403 com chave ps_live_). Marca a cobrança como "paid" e dispara o webhook charge.paid com "sandbox": true, IGUAL ao evento da cobrança em produção (na callbackUrl da cobrança, ou na URL geral da conta). Cobranças já pagas apenas retornam o estado atual. # Simulate Payment Paid Source: https://docs.purin.cash/api-reference/sandbox/simulate-payment-paid /api-reference/openapi.yaml post /v1/sandbox/payments/{paymentId}/simulate-paid Simula a confirmação de um pagamento no ambiente de teste. Requer chave ps_test_ (retorna 403 com chave ps_live_). Marca o pagamento como "paid" e, se o pagamento tiver callbackUrl, dispara o webhook payment.paid com "sandbox": true. Pagamentos já pagos apenas retornam o estado atual. # Create Payout Source: https://docs.purin.cash/api-reference/saques/create-payout /api-reference/openapi.yaml post /v1/payouts Solicita um saque PIX ou LTC do saldo disponível. O débito do saldo é atômico. O valor máximo sacável é o campo withdrawable do GET /v1/wallet (balance - disputeBlocked - comprometido com subcontas): o saldo retido por disputas (MED) abertas ou perdidas não perdoadas não é sacável, e o valor já atribuído a subcontas também não sai pela conta principal (transfira to_master antes, ou saque pela subconta no painel). Pedidos acima do sacável são rejeitados com 400 (o campo available da resposta indica o valor sacável, e subaccountsCommitted aparece quando há valor comprometido). Saque PIX exige chave PIX verificada no dashboard (403 caso contrário). Saque LTC exige cryptoAmount e endereço LTC em formato válido, e sai NA HORA da carteira da loja, sem aprovação: status completed com txHash (a transação na rede), processing (enviando, ou transmitido sem confirmação da rede: não repita o pedido) ou pending (não saiu agora; nada foi transmitido e o envio tenta de novo sozinho). O mesmo saque LTC repetido em 10 segundos é recusado com 429. Rate limit de 10 solicitações por hora por conta (somando todas as chaves; 429 ao exceder). O sandbox aplica as MESMAS regras: valida o saldo simulado (400 com available quando não cabe), exige PIX verificado (403), DEBITA o saldo simulado e o saque aparece em GET /v1/payouts. Nada de dinheiro real se move. # Decode PIX Code Source: https://docs.purin.cash/api-reference/saques/decode-pix-code /api-reference/openapi.yaml post /v1/payouts/decode Lê um código copia e cola (BR Code) e devolve o que há dentro dele SEM mover dinheiro: destino, valor, se o valor é fixo e a taxa que será somada. É o passo recomendado antes de POST /v1/payouts com method "pix_code": o pagamento é imediato e não tem cancelamento, então descobrir destino e valor pela resposta do pagamento é tarde. Usa o mesmo escopo do pagamento (saques.pix). Não disponível em sandbox. # List Payouts Source: https://docs.purin.cash/api-reference/saques/list-payouts /api-reference/openapi.yaml get /v1/payouts Lista os saques solicitados pela loja, do mais recente para o mais antigo. O campo walletAddress é sempre mascarado (6 primeiros caracteres + ***). No sandbox lista os saques simulados, com "sandbox": true em cada item. Os status vêm crus, em português: pendente, concluido, negado, processing (turbo aguardando o banco) e falhou. # Create Split Charge Source: https://docs.purin.cash/api-reference/splits/create-split-charge /api-reference/openapi.yaml post /v1/split-charges Cria uma cobrança PIX que, ao ser paga, é dividida automaticamente entre VOCÊ (dono da chave de API) e 1 a 9 beneficiários (contas PurinCash já cadastradas). Em `splits` você lista SOMENTE os outros beneficiários. Você não se inclui: fica com o resto (100% menos a soma) e deve obrigatoriamente ter a maior fatia. A soma das percentages deve ser menor que 100.00. A taxa do gateway sai inteira da sua parte: cada beneficiário recebe a porcentagem cheia dele sobre o valor bruto; você recebe o restante menos a taxa (se a taxa passar da sua parte, você recebe 0). Emails dos beneficiários são mascarados em todas as respostas (LGPD). Se `callbackUrl` for informado, um POST assinado com HMAC-SHA256 (header X-Webhook-Signature) é enviado quando o pagamento for confirmado, com payload incluindo paymentId, status "paid" e splits mascarados. # Get Split Charge Source: https://docs.purin.cash/api-reference/splits/get-split-charge /api-reference/openapi.yaml get /v1/split-charges/{paymentId} Consulta o status de uma cobrança com split pelo paymentId (prefixo psplit_). Após o pagamento, retorna a taxa do gateway, o valor líquido e o quanto cada beneficiário recebeu (amountCents) com a data do crédito (creditedAt). Emails dos beneficiários são mascarados (LGPD). # Create Subaccount Source: https://docs.purin.cash/api-reference/subcontas/create-subaccount /api-reference/openapi.yaml post /v1/subaccounts Cria uma subconta: um ledger de saldo próprio para um cliente seu, dentro da sua conta. Cobranças criadas com `subaccountId` creditam o saldo da subconta quando o pagamento confirma, e você consulta quanto pertence a cada cliente sem manter essa contabilidade do seu lado. A subconta não é uma conta PurinCash: ela não tem login, não tem chave de API e o dinheiro real permanece na carteira da sua conta. O saldo dela é uma camada de atribuição sobre o seu, e por isso criar subconta não exige documento nem aprovação. Se `externalId` for informado e já existir outra subconta com o mesmo valor neste ambiente, a resposta é `409` com o `subaccountId` da existente, o que permite tratar a criação como idempotente. Limite de 1000 subcontas por conta e por ambiente. # Get Subaccount Source: https://docs.purin.cash/api-reference/subcontas/get-subaccount /api-reference/openapi.yaml get /v1/subaccounts/{id} Detalhes de uma subconta, incluindo saldo atual e total recebido. # Get Subaccount Payout Source: https://docs.purin.cash/api-reference/subcontas/get-subaccount-payout /api-reference/openapi.yaml get /v1/subaccounts/{id}/payouts/{payoutId} Consulta um saque desta subconta: se o PIX saiu, se ainda está em processamento, ou se falhou e o valor voltou para a subconta. `payoutId` aceita qualquer um dos identificadores que você tem do saque, e todos devolvem a mesma resposta: - o `withdrawal.id` ou o `code` (`TURBO-...`) da resposta de `POST /v1/subaccounts/{id}/payout`. O `code` também vem no `202` e no webhook `subaccount.payout`; - `saq_idem_` seguido da sua `idempotencyKey`, para saques feitos com ela. É o caminho de quem tomou um timeout e ficou sem resposta nenhuma; - o `payoutId` (`saq_...`), que é o `refId` do lançamento `payout` no extrato da subconta; - o código `SUB-...` de um saque recusado antes de chegar ao banco, que aparece no lançamento de estorno do extrato. `status`: - `completed`: o PIX saiu e o banco confirmou. - `processing`: o saque ainda não terminou. Inclui o saque em verificação (o `202` da chamada de saque) e o que acabou de ser pedido, que pode vir com `withdrawalId` e `code` ainda `null`. O dinheiro PODE ter saído: não repita o saque, consulte de novo mais tarde. - `failed`: nada foi enviado, e `failureReason` diz o motivo. Com `reversed` true, o valor já voltou para a subconta (o estorno está no extrato, na data de `reversedAt`) e dá para sacar de novo com uma `idempotencyKey` nova. Com `reversed` false, o estorno ainda não aconteceu: fale com o suporte. Valores em centavos: `amountCents` é o bruto debitado da subconta (o mesmo do pedido de saque), `feeCents` é o markup de saque da subconta, `platformFeeCents` é a taxa do saque e `netAmountCents` é o que a chave PIX recebe. Os dois últimos são `null` quando o saque não chegou a ser montado para envio. `pixKey` volta mascarada, como em `GET /v1/payouts`. `recipientName` e `recipientBankName` só aparecem em saque que não falhou. Permissão: `subcontas.gerenciar` ou `subcontas.sacar`. A chave que só faz repasses consegue acompanhar os repasses que dispara. Saque de outra subconta, de outra conta ou de outro ambiente responde `404`. Saque de subconta só existe em chave live, então uma chave de sandbox sempre recebe `404` aqui. # List Subaccount Transactions Source: https://docs.purin.cash/api-reference/subcontas/list-subaccount-transactions /api-reference/openapi.yaml get /v1/subaccounts/{id}/transactions Extrato da subconta: vendas creditadas e transferências, mais recentes primeiro. `amountCents` vem com sinal: crédito positivo, débito negativo. Somar a coluna reproduz o `balanceCents`. # List Subaccounts Source: https://docs.purin.cash/api-reference/subcontas/list-subaccounts /api-reference/openapi.yaml get /v1/subaccounts Lista as subcontas da conta, mais recentes primeiro. Cada item já vem com medBlockedCents e availableCents, iguais aos do GET individual: uma tela de repasse em lote não precisa de uma chamada por subconta. # Payout From Subaccount Source: https://docs.purin.cash/api-reference/subcontas/payout-from-subaccount /api-reference/openapi.yaml post /v1/subaccounts/{id}/payout Envia PIX para fora debitando ESTA subconta. O dinheiro sai da carteira da conta principal (é ela que tem lastro), mas a atribuição é da subconta: o saldo dela é debitado antes, e se o banco recusar depois, o valor volta para ela, não para o caixa geral. Motor idêntico ao do saque turbo de `POST /v1/payouts` com `turbo: true`. Os corpos de resposta são os mesmos, mais os casos próprios daqui (subconta inexistente, saldo da subconta, chave de idempotência já usada ou estornada). `amountCents` é BRUTO. Ele é o valor debitado da subconta; a taxa de saque sai de dentro dele, e quem recebe a chave PIX fica com `amountCents / 100 - fee`. A resposta traz os três (`amount`, `fee`, `netAmount`) para conferência. Se depois da taxa sobrar menos de R$ 0,01, a chamada é recusada com `400`. Se a subconta tiver markup de saque (`payoutFeePercent` / `payoutFeeFixedCents`), ele sai ANTES de tudo: a subconta é debitada o `amountCents` cheio, o saque é criado pelo valor já sem o seu markup, e a diferença fica na carteira da conta principal sem nenhum movimento extra. Ou seja, o `amount` da resposta JÁ VEM sem o seu markup, e o `fee` dela é só a taxa da plataforma. O valor do markup você lê no extrato da subconta, no `feeCents` do lançamento `payout`. Markup que consome o valor inteiro é `400`, com a taxa citada na mensagem. Se o banco recusar depois, o estorno devolve à subconta o valor CHEIO, markup incluído: você não fica com a taxa de um saque que não aconteceu. Valor mínimo: o piso que o administrador configurou para a conta (`minAmount` vem no corpo do `400` quando o valor fica abaixo dele). Sem piso configurado, o único mínimo é o físico acima. Teto: R$ 5.000,00 por operação. Permissão: `subcontas.sacar`, e ela NUNCA é herdada. Chave criada antes desta permissão existir não a recebe de graça: ligue no painel, na própria chave. Sem ela, `403`. Somente chaves live. Chave de sandbox recebe `400`: a subconta de teste não tem lastro, e sacar dela mandaria PIX de verdade. Limite: 5 chamadas por hora por conta (ajustável pelo administrador), em contador PRÓPRIO. Ele não consome a cota de `POST /v1/payouts`. Chamadas recusadas não gastam cota. Idempotência: mande `idempotencyKey` NO CORPO (esta API não usa header de idempotência). Formato `[A-Za-z0-9_-]`, até 64 caracteres. A chave vira o `refId` do lançamento no extrato da subconta e fica guardada com ele, sem prazo de expiração: repetir a mesma chave meses depois continua sendo reconhecido como repetição. O escopo é a subconta, então a mesma chave pode ser usada em subcontas diferentes. Sem `idempotencyKey`, cada chamada é um saque novo, e um timeout que na verdade deu certo vira PIX em dobro. # Transfer Subaccount Balance Source: https://docs.purin.cash/api-reference/subcontas/transfer-subaccount-balance /api-reference/openapi.yaml post /v1/subaccounts/{id}/transfer Move saldo entre a subconta e a conta principal, nos dois sentidos. É uma operação contábil: nenhum PIX é enviado e o dinheiro real não sai da carteira da sua conta. Muda apenas a quem o valor está atribuído. `to_master` debita a subconta (exige saldo suficiente; a checagem é atômica, duas transferências concorrentes não deixam saldo negativo). `to_subaccount` credita a subconta a partir do pool NÃO ATRIBUÍDO da conta principal (sacável da carteira menos o que as subcontas já detêm: o `unattributedCents` do `GET /v1/wallet`): aporte atribui dinheiro que existe, não cria. Sem lastro é `400`; corrida entre duas transferências pelo mesmo lastro desfaz a perdedora com `409` (repita). `between_subaccounts` move direto desta subconta para outra da mesma conta, informada em `toSubaccountId`, sem passar pela conta principal. Exige saldo suficiente na origem (o valor retido por disputa fica na origem) e destino ativo. O extrato ganha um lançamento em cada subconta, com o mesmo `transferId`. Não depende de lastro: o total atribuído às subcontas não muda. `amountCents` é número JSON inteiro de 1 a 100000000000 (R$ 1 bilhão). String, decimal ou acima do teto: `400`, sem coerção. Idempotência: mande `idempotencyKey` NO CORPO (esta API não usa header de idempotência). A repetição devolve `200` com a transferência original e `idempotentReplay` true, em vez de mover o valor duas vezes. Para pagar um cliente de verdade, transfira `to_master` e faça um saque normal em `POST /v1/payouts` com a chave PIX dele. # Update Subaccount Source: https://docs.purin.cash/api-reference/subcontas/update-subaccount /api-reference/openapi.yaml put /v1/subaccounts/{id} Atualiza nome, e-mail, metadata ou situação. Envie só o que quer mudar. `externalId` é imutável: enviar no PUT é `400`. `active` precisa ser boolean de verdade (`"false"` entre aspas é `400`), `metadata` precisa ser string de até 2048, e nada é truncado em silêncio: fora do limite é `400`. Desativar (`active: false`) impede a subconta de receber novas cobranças e transferências de entrada; cobrança criada ANTES da desativação ainda credita quando paga. O saldo continua consultável e pode ser transferido de volta pra conta principal. Não existe exclusão: o histórico do extrato é permanente. # Ambientes e sandbox Source: https://docs.purin.cash/guias/ambientes Como testar o fluxo inteiro, inclusive webhook, sem mover um centavo. A API tem dois ambientes e uma URL só. Quem decide onde a requisição cai é o prefixo da chave. > [card] Produção > > Chave `ps_live_`. > > Cobrança de verdade, saldo de verdade, saque de verdade. > [card] Sandbox > > Chave `ps_test_`. > > Nada é enviado a banco nenhum. O PIX gerado não é pagável. > [!WARNING] > > O `brCode` do sandbox não é um código PIX de verdade. Ele volta como > `SANDBOX_PIX_`, uma string sem valor nenhum: não escaneia, não cola em app > de banco, ninguém paga por acidente. > > Se você renderizar isso num QR Code na sua tela de teste, o QR vai existir e não vai > funcionar. É esperado. Os dados são isolados de ponta a ponta. Uma cobrança criada com `ps_test_` não aparece para `ps_live_`, e o saldo de sandbox é calculado só a partir das transações de teste. Se o prefixo da chave não bater com o ambiente em que ela foi criada, a API responde `401` em vez de escolher um ambiente por conta própria. ## Simulando pagamento Como ninguém consegue pagar um PIX de sandbox, você marca como pago pela API: ```bash Cobrança (psc_) theme={null} curl -X POST https://api.purin.cash/v1/sandbox/charges/psc_a1b2c3d4/simulate-paid \ -H "Authorization: Bearer ps_test_sua_chave" ``` ```bash Pagamento (psa_) theme={null} curl -X POST https://api.purin.cash/v1/sandbox/payments/psa_a1b2c3d4/simulate-paid \ -H "Authorization: Bearer ps_test_sua_chave" ``` O status vira `paid` e o webhook sai para a `callbackUrl` que você informou na criação, exatamente como aconteceria em produção. Não existe aprovação de ninguém no meio. A chamada só funciona com chave `ps_test_` e só alcança cobrança da sua própria conta: com chave `ps_live_` a resposta é `403`. > [!TIP] > > Chame `simulate-paid` **duas vezes** na mesma cobrança. O status não muda de novo, mas o > webhook sai outra vez com o mesmo `X-Webhook-Id`. É o jeito mais barato de verificar se o > seu handler é idempotente antes de precisar disso em produção. > [!NOTE] > > O `event` do webhook de simulação acompanha o recurso, IGUAL à produção: > `payment.paid` para `psa_` e `charge.paid` para `psc_`. O payload é enxuto: `event`, > `paymentId`, `amountCents`, `status`, `paidAt`, `customer`, `metadata` e > `sandbox: true`. (Antes o simulate de cobrança mandava `payment.paid` e o handler > construído no sandbox nunca via o evento real; foi alinhado.) ## Conferindo o resultado > [card] [GET /v1/sandbox/wallet](/api-reference/introducao) > > Saldo simulado, somado a partir das transações de teste. > [card] [GET /v1/sandbox/transactions](/api-reference/introducao) > > Extrato do que você criou em sandbox, com paginação. `GET /v1/wallet` também funciona com chave de teste. Nesse caso a resposta vem com `"sandbox": true`, e `disputeBlocked` e `cryptoBalanceLtc` ficam sempre em zero. ## O que não roda em sandbox > [accordion] Cartão de crédito > > `POST /v1/card-payments` depende do provedor de checkout e não é simulado. Teste o > cartão direto em produção com um valor baixo. > [accordion] Litecoin > > `paymentMethod: "ltc"` responde `400` com chave de teste. A cotação e o endereço vêm > da rede real, então não há como simular. > [accordion] Disputas > > Os endpoints de `/v1/disputes` respondem `404` em sandbox. Contestação nasce de uma > transação real. > [accordion] Saques > > O saque em sandbox é registrado e dispara os eventos `withdrawal.test.requested` e > `withdrawal.test.completed`, mas nenhum valor sai. ## Indo para produção Troque a variável de ambiente com a chave. Nada mais muda: mesma URL, mesmos campos, mesmos nomes de evento. > [!TIP] > > Vale a pena rodar o mesmo teste automatizado nos dois ambientes, com a chave vindo de > variável. Se o teste passa com `ps_test_` e quebra com `ps_live_`, o problema está em > configuração de conta e não no seu código. # Assinaturas Source: https://docs.purin.cash/guias/assinaturas Pix Automático: o cliente paga uma vez, autoriza no banco, e a renovação é cobrada sozinha. Assinatura é **Pix Automático**, o padrão de recorrência do Banco Central. Você cria uma vez, com o CPF de quem vai pagar; o cliente recebe um QR composto que paga o primeiro ciclo e, no mesmo ato, autoriza os próximos no app do banco. A partir daí cada renovação é cobrada sozinha, um período depois, e dispara webhook na mesma `callbackUrl`. > [!NOTE] > > Assinatura sempre nasce de um produto cadastrado. O valor e o nome saem dele, então > antes de criar a assinatura você precisa de um `productId` ativo. Veja > [Produtos](/guias/produtos). ## Criando ```bash theme={null} curl -X POST https://api.purin.cash/v1/subscriptions \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "productId": "65f0c2a1e4b0a1b2c3d4e5f6", "customer": { "name": "João Silva", "taxId": "529.982.247-25", "externalId": "user_42" }, "frequency": "MONTHLY", "dayGenerateCharge": 15, "callbackUrl": "https://minhaloja.com/webhooks/purincash", "metadata": "{\"plano\":\"premium\"}" }' ``` **productId** `string` *required* Produto ativo que define valor e nome da assinatura. **customer.name** `string` *required* Nome do titular da conta que vai pagar, como está no banco. **customer.taxId** `string` *required* CPF ou CNPJ do titular da conta que vai pagar. A autorização é presa a este documento: o banco confere que é o dono da conta quem está aprovando. Documento inválido é 400. **frequency** `string` (default: MONTHLY) `WEEKLY`, `MONTHLY`, `QUARTERLY`, `SEMIANNUALLY` ou `ANNUALLY`. **dayGenerateCharge** `integer` Dia do mês do débito automático, de 1 a 28. Sem ele, o dia da compra. Não vale para a periodicidade semanal. **callbackUrl** `string` Recebe os eventos da assinatura (`subscription.*`) e o `payment.paid` de **cada** ciclo, não só do primeiro. **metadata** `string` String JSON de até 2 KB, devolvida sem alteração em todo evento. ```json Resposta theme={null} { "paymentId": "psa_sub_a1b2c3d4", "status": "pending", "type": "subscription", "subscriptionId": "RN12345678202609200000000001", "authorizationStatus": "pending_authorization", "amountCents": 4990, "currency": "BRL", "productName": "Plano Premium", "frequency": "MONTHLY", "environment": "live", "pix": { "brCode": "00020101021226760014br.gov.bcb.pix...", "qrCodeImage": "data:image/png;base64,...", "paymentLinkUrl": null }, "nextChargeDate": "2026-10-20", "dayGenerateCharge": 15 } ``` Mostre o `brCode` como qualquer PIX. O cliente paga o primeiro ciclo na hora e o app do banco pede, na mesma tela, a autorização das próximas cobranças. > [!TIP] > > O `dayGenerateCharge` vai até 28 de propósito. Assinatura marcada para dia 30 pularia > fevereiro, e isso é uma classe inteira de bug que ninguém quer no faturamento. ## O que acontece depois **1. Primeiro pagamento** Chega um `payment.paid` do `paymentId` da criação (`subscriptionCycle: 0`). É uma venda como outra qualquer. **2. Autorização** Quando o banco aprova a recorrência chega `subscription.authorized` e o `authorizationStatus` vira `active`. Pagamento e autorização são atos diferentes e podem chegar em qualquer ordem. Se o cliente pagar e não autorizar, você fica com uma venda avulsa e um `authorizationStatus` que expira ou é recusado. **3. Renovações** Alguns dias antes de cada vencimento a cobrança é enviada ao banco do cliente, que debita sozinho na data. Cada ciclo pago chega como `payment.paid` com `subscriptionId` e `subscriptionCycle` (1, 2, 3…) e um `paymentId` próprio (`psa_sub_…_c1`, `_c2`…). **4. Quando não liquida** `subscription.charge_failed` traz o ciclo, o vencimento e o código do banco. A assinatura continua ativa: quando a política permite, a retentativa é pedida sozinha (até 3 em 7 dias) e o ciclo seguinte segue normal. **5. Fim** `subscription.cancelled` quando você cancela, o cliente cancela no banco, a autorização é recusada ou vence. Não há novas cobranças depois dele. Os eventos estão detalhados em [Webhooks → Assinaturas](/webhooks/assinaturas). ## Acompanhando ```bash theme={null} curl "https://api.purin.cash/v1/subscriptions?limit=20&status=paid" \ -H "Authorization: Bearer $PURINCASH_KEY" ``` A listagem aceita `limit` (1 a 100, padrão 50), `offset` e `status`, e traz uma linha por assinatura com `authorizationStatus`, `nextChargeDate` e `cyclesPaid`. As renovações pagas aparecem em `GET /v1/payments` como pagamentos próprios. Para uma assinatura só, com cada cobrança recorrente e suas tentativas: ```bash theme={null} curl "https://api.purin.cash/v1/subscriptions/psa_sub_a1b2c3d4" \ -H "Authorization: Bearer $PURINCASH_KEY" ``` ```json theme={null} { "paymentId": "psa_sub_a1b2c3d4", "subscriptionId": "RN12345678202609200000000001", "status": "paid", "authorizationStatus": "active", "frequency": "MONTHLY", "amountCents": 4990, "nextChargeDate": "2026-11-20", "firstChargeDate": "2026-10-20", "cyclesPaid": 2, "charges": [ { "cycle": 1, "paymentId": "psa_sub_a1b2c3d4_c1", "dueDate": "2026-10-20", "amountCents": 4990, "status": "concluida", "paidAt": "2026-10-20T09:10:00.000Z", "attempts": [{ "settlementDate": "2026-10-20", "status": "PAGA" }] } ] } ``` ## Cancelando ```bash theme={null} curl -X POST "https://api.purin.cash/v1/subscriptions/psa_sub_a1b2c3d4/cancel" \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "reason": "cliente pediu" }' ``` Cancela a autorização no banco e as cobranças ainda não liquidadas. O que já foi pago não muda. O cliente também pode cancelar pelo app do banco: nesse caso o `subscription.cancelled` chega do mesmo jeito. ## Controlando o acesso do assinante A API não bloqueia o cliente sozinho. Quem decide se o acesso continua é você, e o dado que sustenta essa decisão é o webhook. **1. Guarde o subscriptionId no seu usuário** É a chave estável entre as cobranças. O `paymentId` muda a cada ciclo. **2. Renove a validade a cada payment.paid** Ao receber um pagamento com aquele `subscriptionId`, empurre a data de expiração do acesso para frente conforme a frequência. **3. Deixe o acesso expirar sozinho** Se o ciclo passou sem pagamento, a data vence e o acesso cai. O `subscription.cancelled` é o aviso de que não vale esperar pela próxima. > [!WARNING] > > Sempre confirme por `subscriptionId` antes de renovar. Um assinante com duas assinaturas > ativas do mesmo produto é raro, mas acontece, e renovar pelo produto em vez da > assinatura dá acesso de graça. > [!NOTE] > > **Vendas pelo bot do Discord** já fazem isso sozinhas. Na variação marcada como > assinatura você escolhe o que cada renovação entrega (um item novo do estoque, ou só > manter o acesso), se os cargos saem quando a assinatura para, a carência em dias e o > lembrete por DM antes do débito. A renovação cobra o preço da variação: cupom, desconto > VIP e adicionais valem só no primeiro pagamento. # Autenticação Source: https://docs.purin.cash/guias/autenticacao Uma chave, um header. Como pegar, como guardar e o que fazer quando vaza. Toda requisição para `https://api.purin.cash` leva a chave de API no header `Authorization`, no esquema Bearer: ``` Authorization: Bearer ps_live_sua_chave_aqui ``` Não existe OAuth, não existe login por usuário e senha na API, e não existe chave no query string. ## Pegando a chave **1. Abra o painel** [purin.cash/dashboard/api](https://purin.cash/dashboard/api) **2. Escolha o ambiente** Produção gera `ps_live_`. Sandbox gera `ps_test_`. **3. Copie na hora** A chave completa aparece uma vez só. Depois disso o painel mostra apenas os últimos caracteres, para você identificar qual é qual. Cada conta pode manter até 10 chaves ativas ao mesmo tempo. Use isso a seu favor: uma chave por ambiente e por serviço deixa você revogar uma sem derrubar o resto. ## O prefixo define o ambiente | Prefixo | Ambiente | O que acontece | | - | - | - | | `ps_live_` | Produção | Cobrança real, dinheiro real, saldo real | | `ps_test_` | Sandbox | Tudo simulado, nada movimenta dinheiro | Não existe parâmetro de ambiente. A chave decide, e os dados são isolados: uma chave de teste nunca lista uma cobrança de produção. Mais detalhes em [Ambientes e sandbox](/guias/ambientes). ## Guardando a chave > [!WARNING] > > Quem tem a sua chave consegue criar cobrança em seu nome e, com [PIX saindo na > hora](/guias/saques#quando-o-pix-sai-na-hora), mandar dinheiro pra fora sem passar por aprovação. > Trate a chave com o mesmo cuidado da senha do painel. > [card] Faça > > Variável de ambiente ou cofre de segredos. > > Chamada sempre de servidor pra servidor. > > Uma chave por serviço, para revogar sem parar tudo. > > `ps_test_` no desenvolvimento e na CI. > [card] Não faça > > Chave no bundle do front, no app mobile ou no `.env` versionado. > > Chave em log, em print, em ticket de suporte. > > A mesma chave em produção e homologação. > > Chave hardcoded "só pra testar rápido". Se desconfiar que vazou, revogue no painel e gere outra. A revogação vale na hora: a chave antiga passa a responder `401` na requisição seguinte. ## Erros de autenticação Todos vêm como `401` com o corpo padrão `{ "error": string }`. | Mensagem | O que aconteceu | | - | - | | `API key required. Use: Authorization: Bearer ps_live_...` | Header ausente, ou sem o `Bearer ps_` na frente | | `Invalid API key prefix. Use ps_live_ or ps_test_` | A chave não começa com nenhum dos dois prefixos | | `Invalid or revoked API key` | Chave inexistente, digitada errada ou revogada | | `API key environment mismatch. Please generate a new key.` | O prefixo não bate com o ambiente em que a chave foi criada. Gere uma nova | ```json theme={null} { "error": "Invalid or revoked API key" } ``` > [!TIP] > > Recebeu `401` com a chave certa? Confira se sobrou espaço, quebra de linha ou aspas na > variável de ambiente. É de longe a causa mais comum. # Receber por cartão Source: https://docs.purin.cash/guias/cartao Checkout hospedado: você redireciona, a gente cobra e devolve o resultado. No cartão você não recebe o número do cartão em momento nenhum. A API devolve uma URL de checkout hospedado, o cliente paga lá, e você recebe o resultado por webhook e por consulta. Isso mantém dado de cartão fora do seu servidor, que é exatamente onde ele deve ficar. > [!WARNING] > > Cartão não funciona em sandbox. Com chave `ps_test_` a resposta é erro. Para testar, > use produção com um valor baixo. ## Criando a cobrança ```bash theme={null} curl -X POST https://api.purin.cash/v1/card-payments \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "valueCents": 4990, "description": "Plano Premium", "callbackUrl": "https://minhaloja.com/webhooks/purincash", "customer": { "name": "João Silva", "email": "joao@exemplo.com", "externalId": "user_42" }, "successUrl": "https://minhaloja.com/obrigado", "cancelUrl": "https://minhaloja.com/carrinho", "metadata": "{\"pedido\":\"1042\"}" }' ``` ```json Resposta 201 theme={null} { "orderCode": "JUE7Y9MPSX", "status": "pending", "amountCents": 4990, "currency": "BRL", "checkoutUrl": "https://checkout.stripe.com/c/pay/cs_live_...", "expiresAt": "2026-03-18T12:30:00.000Z" } ``` Redirecione o cliente para `checkoutUrl`. A sessão vale 30 minutos. > [!NOTE] > > Cartão é o único recurso identificado por `orderCode` em vez de `paymentId`. É esse > código que você usa para consultar e é ele que volta no webhook. ## Para onde o cliente volta | Campo | Quando é usado | | - | - | | `successUrl` | O cliente terminou o pagamento | | `cancelUrl` | O cliente desistiu e voltou | > [!WARNING] > > Cair na `successUrl` não significa pagamento aprovado. É só o navegador voltando, e > qualquer pessoa consegue abrir essa URL na mão. Libere o produto pelo webhook > `card_payment.paid` ou pela consulta, nunca pelo redirecionamento. ## Consultando ```bash theme={null} curl https://api.purin.cash/v1/card-payments/JUE7Y9MPSX \ -H "Authorization: Bearer $PURINCASH_KEY" ``` ```json theme={null} { "orderCode": "JUE7Y9MPSX", "status": "paid", "amount": 49.90, "amountCents": 4990, "currency": "BRL", "description": "Plano Premium", "checkoutUrl": null, "paidAt": "2026-03-18T12:05:00.000Z", "createdAt": "2026-03-18T12:00:00.000Z" } ``` Status possíveis: `pending`, `paid`, `expired`, `refunded` e `failed`. Para listar, use `GET /v1/card-payments` com `limit`, `offset` e `status`. ## Cartão tem prazo de liberação > [!NOTE] > > Diferente do PIX, o valor do cartão entra como **a liberar** antes de virar saldo > sacável. Em `GET /v1/wallet` esse montante aparece em `pendingRelease`, e ele não entra > no `withdrawable`. Vale considerar isso no seu fluxo de caixa: vendeu no cartão hoje não quer dizer que dá pra sacar hoje. O detalhe de cada campo está em [Carteira](/guias/carteira). ## Diferenças no webhook O evento é `card_payment.paid` e a forma muda um pouco em relação ao PIX: ```json theme={null} { "event": "card_payment.paid", "orderCode": "JUE7Y9MPSX", "amount": 49.90, "status": "paid", "paidAt": "2026-03-18T12:05:00.000Z", "customer": { "name": "João Silva", "email": "joao@exemplo.com" }, "metadata": "{\"pedido\":\"1042\"}" } ``` > [!WARNING] > > Repare em duas coisas: o identificador é `orderCode` e não `paymentId`, e o valor vem > em `amount` (reais, decimal) e não em `amountCents`. Se o seu handler for genérico, > trate os dois formatos. Detalhes completos em [Webhook de cartão](/webhooks/cartao). # Carteira Source: https://docs.purin.cash/guias/carteira Saldo bruto, retido, a liberar e sacável. Quatro números que não são a mesma coisa. `GET /v1/wallet` devolve o saldo da loja separado por situação. A confusão comum é achar que "saldo" é o quanto dá pra sacar, e não é. ```bash theme={null} curl https://api.purin.cash/v1/wallet \ -H "Authorization: Bearer $PURINCASH_KEY" ``` ```json theme={null} { "currency": "BRL", "balance": 1250.00, "balanceCents": 125000, "disputeBlocked": 230.70, "withdrawable": 1019.30, "withdrawableCents": 101930, "pendingRelease": 480.00, "pendingReleaseCents": 48000, "subaccountsCommittedCents": 45900, "unattributedCents": 56030, "cryptoBalanceLtc": 0.15068859 } ``` ## O que cada número significa **balance** `number` Saldo bruto da carteira, em reais. É o total que entrou e ainda não saiu, sem descontar retenção. **disputeBlocked** `number` Valor retido por [disputas](/guias/disputas) abertas ou perdidas que ainda não foram perdoadas. Fica indisponível até a contestação ser resolvida. **withdrawable** `number` **É este que importa na hora de sacar.** Vale `balance - disputeBlocked - subaccountsCommittedCents/100`, e é o teto aceito por `POST /v1/payouts`. Dinheiro atribuído a [subconta](/guias/subcontas) não sai pela conta principal: saque pela própria subconta, ou traga de volta com `to_master` antes. **subaccountsCommittedCents** `integer` Soma dos saldos das suas subcontas: a fatia do saldo que pertence aos seus clientes e que o saque da conta principal **não alcança**. Só existe em centavos. **pendingRelease** `number` Vendas no cartão aguardando o prazo de liberação. Ainda **não** está em `balance` nem em `withdrawable`. É previsão de caixa, não dinheiro disponível. **unattributedCents** `integer` O que a carteira tem sem dono: é o teto que a transferência `to_subaccount` aceita atribuir. Com o `withdrawable` já descontando o comprometido, os dois números coincidem. `null` quando a soma das subcontas não pôde ser calculada. **cryptoBalanceLtc** `number` Saldo em Litecoin, com 8 casas decimais. Sacável por `POST /v1/payouts` com `method: "ltc"`. Todos os valores em reais têm um par em centavos (`balanceCents`, `withdrawableCents`, `pendingReleaseCents`). > [!TIP] > > Use os campos em centavos para qualquer conta. Ponto flutuante em dinheiro é como você > ganha uma diferença de um centavo no relatório e perde uma tarde procurando. ## A relação entre eles ``` balance = o que entrou e liquidou − disputeBlocked = retido por contestação = withdrawable = o que POST /v1/payouts aceita hoje pendingRelease = cartão que ainda vai virar balance ``` Pedir um saque acima de `withdrawable` devolve `400` por saldo insuficiente, mesmo que `balance` cubra o valor. Se isso te pegou de surpresa, quase sempre a resposta está em `disputeBlocked`. ## Em sandbox A rota funciona com chave `ps_test_`. O saldo é somado a partir das transações de teste, a resposta traz `"sandbox": true`, e `disputeBlocked` e `cryptoBalanceLtc` ficam sempre em zero. > [card] [Tirar o dinheiro](/guias/saques) > > PIX, LTC ou USDT, direto pela API. > [card] [Destravar retenção](/guias/disputas) > > Como responder uma contestação e liberar o valor. # Receber em Litecoin Source: https://docs.purin.cash/guias/cripto Mesmo endpoint do PIX, um campo a mais. E três diferenças que mudam o seu fluxo. Não existe endpoint separado para cripto. Você usa o mesmo `POST /v1/payments` com `paymentMethod: "ltc"`. O valor continua indo em `valueCents`, em reais. A conversão para LTC é feita na hora, pela cotação do momento. ```bash theme={null} curl -X POST https://api.purin.cash/v1/payments \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "paymentMethod": "ltc", "valueCents": 4990, "description": "Plano Premium", "callbackUrl": "https://minhaloja.com/webhooks/purincash" }' ``` ```json Resposta theme={null} { "paymentId": "psa_a1b2c3d4e5f6", "status": "pending", "type": "one_time", "paymentMethod": "ltc", "amountCents": 4990, "currency": "BRL", "ltc": { "address": "ltc1q...", "amount": 0.15068859, "amountBrl": 49.9, "ltcPriceBrl": 331.2 }, "expiresAt": "2026-03-18T12:25:00.000Z" } ``` ## O valor exato importa > [!WARNING] > > Cobre exatamente o `ltc.amount`, com todas as casas decimais. Os últimos satoshis são > aleatórios de propósito: é assim que a gente sabe qual pagamento chegou quando duas > cobranças têm o mesmo preço. Valor diferente não é reconhecido automaticamente. Na prática isso significa que você mostra `ltc.amount` como está, sem arredondar para exibição. Um `toFixed(4)` na interface é o suficiente para o cliente pagar o valor errado. ## Três diferenças em relação ao PIX > [card] Expira em 25 min > > PIX expira em 30 minutos, LTC em 25. O `expiresAt` sempre manda. > [card] 3 confirmações > > Não é instantâneo. O webhook sai quando a rede fecha as confirmações. > [card] Sem sandbox > > Com chave `ps_test_` a resposta é `400`. Cotação e endereço vêm da rede real. ## Erros específicos | Código | Motivo | | - | - | | `400` | Chave de sandbox, ou `paymentMethod` fora de `pix` e `ltc` | | `502` | Cotação de LTC indisponível no momento. Tente de novo em instantes | | `503` | A loja não tem carteira LTC configurada no painel | ## Consulta e webhook Iguais aos do PIX. A consulta é `GET /v1/payments/{paymentId}` e o webhook chega em `callbackUrl` quando as confirmações fecham. > [!TIP] > > Como a confirmação demora, cripto combina bem com entrega assíncrona: confirme o pedido > na hora com status "aguardando rede" e libere o produto quando o webhook chegar. Prender > o cliente numa tela de espera por vários minutos derruba conversão. ## E o USDT? USDT aparece na PurinCash só na saída, como forma de saque, não como forma de receber. Está em [Saques](/guias/saques#saque-em-usdt). # Disputas Source: https://docs.purin.cash/guias/disputas O cliente contestou o PIX. Como acompanhar, responder com provas e saber o resultado, tudo pela API. Disputa é a contestação de um PIX pelo pagador (MED, o Mecanismo Especial de Devolução). O comprador aciona o banco dele dizendo, por exemplo, que não recebeu o produto, e o valor daquela venda fica retido no seu saldo até o banco decidir. Enquanto a disputa está aberta ou depois que ela é perdida, o valor aparece em `disputeBlocked` na [carteira](/guias/carteira) e sai do `withdrawable`. Não dá pra sacar o que está contestado. Tudo o que o painel faz com disputa a API também faz: listar, consultar, responder com provas e receber o resultado por webhook. A chave precisa da permissão **Gerenciar disputas** (`disputas.gerenciar`). ## O fluxo **1. Chega o webhook dispute.opened** A disputa acabou de abrir. O corpo traz o `disputeId`, o `orderCode` e o `endToEndId` da venda. Veja o [corpo dos eventos](/webhooks/visao-geral#corpo-dos-eventos-de-contestacao). **2. Você junta as provas da venda** Comprovante de entrega, log de acesso, conversa com o comprador. Use o `orderCode` para achar a venda do seu lado. **3. Envia a defesa** `POST /v1/disputes/{id}/evidence` com os arquivos e a sua explicação. A defesa vai direto ao banco do pagador. **4. Acompanha o resultado** `dispute.under_review` quando o banco começa a analisar, e depois `dispute.won` (o valor volta ao saldo) ou `dispute.lost` (o valor volta ao pagador). ## Status | `status` | `statusLabel` | O que significa | Aceita resposta | | - | - | - | - | | `aberta` | aberta | O pagador contestou e o valor está retido | Sim | | `pendente` | em análise | A defesa chegou e o banco está analisando | Não | | `resolvida` | ganha | Decidida a seu favor. O valor volta ao saldo | Não | | `perdida` | perdida | Decidida a favor do pagador | Não | Não precisa replicar essa regra do seu lado: o campo `canRespond` já diz se a disputa aceita resposta agora. > [!NOTE] > > Disputa nasce de uma transação real, então não existe em sandbox. Com chave `ps_test_`, a lista volta vazia com `"sandbox": true`, a consulta responde `404` e o envio de provas responde `400`. ## Listando ```bash theme={null} curl "https://api.purin.cash/v1/disputes?status=aberta,pendente&limit=50" \ -H "Authorization: Bearer $PURINCASH_KEY" ``` ```json Resposta 200 theme={null} { "disputes": [ { "id": "665f1c2ab9e8d40012a4c789", "code": "DSP-7KQ2ZD", "status": "aberta", "statusLabel": "aberta", "canRespond": true, "amount": 49.9, "currency": "BRL", "reason": "Produto não recebido", "providerDisputeId": "8f1e2d3c-4b5a-6978-8c9d-0e1f2a3b4c5d", "endToEndId": "E00000000202610101432abcdef123456", "orderCode": "PED-1234", "buyer": "joao_silva", "buyerDiscordId": "123456789012345678", "product": "Plano Pro", "subAccountId": "", "defense": { "sentAt": null, "failedAt": null, "failReason": "" }, "evidences": [], "resolvedAt": null, "createdAt": "2026-10-10T14:32:10.000Z", "updatedAt": "2026-10-10T14:32:10.000Z" } ], "total": 1, "limit": 50, "offset": 0 } ``` **status** `string` Um status ou vários separados por vírgula: `aberta,pendente`. Valor fora de `aberta`, `pendente`, `resolvida` e `perdida` responde `400`. **orderCode** `string` Só as disputas desse pedido. **endToEndId** `string` Só as disputas desse PIX. **from** `string` Abertas a partir desta data (ISO 8601). **to** `string` Abertas até esta data (ISO 8601). **limit** `integer` (default: 50) De 1 a 100. Use com `offset` para paginar. ### Campos que valem atenção **canRespond** `boolean` `true` quando a disputa aceita resposta agora. Pode vir `false` numa disputa `aberta` nos primeiros minutos, enquanto o banco ainda não registrou a contestação. **providerDisputeId** `string` Identificador da contestação no banco. Vem vazio logo depois da abertura. **defense** `object` A situação da sua defesa. **campos** **sentAt** `string | null` Quando a defesa chegou ao banco. **failedAt** `string | null` Quando a última tentativa de envio falhou. **failReason** `string` O motivo dessa falha. Vazio quando não falhou. **subAccountId** `string` A [subconta](/guias/subcontas) da venda contestada, quando houver. O valor fica retido nela. > [!TIP] > > Mesmo com o webhook, vale rodar a listagem com `status=aberta` uma vez por dia. Disputa tem prazo, e perder por não ter respondido é o pior jeito de perder. ## Respondendo Só disputa com `canRespond: true` aceita resposta. Você pode mandar arquivos, links e um texto explicando o caso, em qualquer combinação. Um dos três é obrigatório. ```bash theme={null} curl -X POST https://api.purin.cash/v1/disputes/665f1c2ab9e8d40012a4c789/evidence \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "files": [ { "name": "entrega-pedido-1234.pdf", "data": "data:application/pdf;base64,JVBERi0xLjQK..." } ], "textForPdf": "O comprador recebeu o produto em 10/10/2026 às 14:35, como mostra o comprovante anexo. O acesso foi usado no mesmo dia." }' ``` ```json Resposta 200 theme={null} { "uploaded": 2, "sentToBank": true } ``` **files** `array` Até 5 arquivos de até 5 MB cada, em PDF, PNG, JPEG ou WebP. Cada item é `{ "name", "data" }`, com `data` em data URL base64. A gente guarda o arquivo e anexa à defesa. **documents** `array` Até 10 links públicos de provas, cada um como `{ "url", "description" }`. Link sem `http` ou `https` é descartado. **textForPdf** `string` A sua explicação do caso. Vira um PDF anexado à defesa. **uploaded** `integer` Quantas provas entraram neste envio: arquivos, links válidos e o PDF do texto. **sentToBank** `boolean` `true` quando a defesa já foi entregue ao banco. `false` quando ficou registrada aqui e a nossa equipe encaminha. > [!WARNING] > > **Prefira `files`.** Vários bancos recebem a defesa só como arquivo: o que vai em `files` segue anexado, e o que vai em `documents` aparece citado no texto. Se o seu comprovante está numa página, gere um PDF e mande como arquivo. Para transformar um arquivo em data URL no Node: ```js theme={null} import fs from "node:fs"; const data = "data:application/pdf;base64," + fs.readFileSync("entrega.pdf").toString("base64"); ``` O corpo inteiro da requisição pode ter até 25 MB. Enquanto a disputa aceitar resposta, você pode enviar de novo: cada envio soma provas, não substitui as anteriores. ## Consultando uma disputa ```bash theme={null} curl https://api.purin.cash/v1/disputes/665f1c2ab9e8d40012a4c789 \ -H "Authorization: Bearer $PURINCASH_KEY" ``` Volta a mesma disputa da listagem, com as provas já enviadas e o campo `history`, a linha do tempo dos status. Cada item corresponde a um webhook enviado: ```json theme={null} { "id": "665f1c2ab9e8d40012a4c789", "status": "pendente", "statusLabel": "em análise", "canRespond": false, "defense": { "sentAt": "2026-10-10T16:05:00.000Z", "failedAt": null, "failReason": "" }, "history": [ { "event": "dispute.opened", "from": null, "to": "aberta", "at": "2026-10-10T14:33:00.000Z" }, { "event": "dispute.under_review", "from": "aberta", "to": "pendente", "at": "2026-10-10T16:10:00.000Z" } ] } ``` Os links das provas são assinados e expiram. Não guarde a URL: guarde o `id` e consulte de novo quando precisar. Disputa que não é da sua conta responde `404`, e não `403`. É proposital: a API não confirma a existência de recurso de terceiro. ## Webhooks Quatro eventos acompanham a disputa: `dispute.opened`, `dispute.under_review`, `dispute.won` e `dispute.lost`. Eles chegam nas mesmas URLs de webhook da conta, com o mesmo corpo, mudando só o `event` e o `status`. O corpo completo está em [Como funcionam os webhooks](/webhooks/visao-geral#corpo-dos-eventos-de-contestacao). O `X-Webhook-Id` desses eventos é `evento:disputeId`, por exemplo `dispute.opened:665f1c2ab9e8d40012a4c789`. Cada mudança de status de cada disputa tem a sua chave, então duas disputas do mesmo pedido nunca se confundem na sua deduplicação. ## O que costuma funcionar como prova > [card] Prova de entrega > > Log de acesso, e-mail de entrega com data e hora, código de rastreio, print do sistema mostrando a liberação. > [card] Prova de aceite > > Termos aceitos, confirmação de recebimento, conversa em que o comprador reconhece que recebeu. > [card] Identidade do comprador > > O `endToEndId` da transação, o e-mail usado na compra, o `externalId` que amarra ao usuário da sua base. > [card] Contexto de uso > > Histórico mostrando que a conta foi usada depois da compra. É o que mais derruba "produto não recebido". > [!TIP] > > Junte as provas de forma programática assim que o pagamento confirma, não quando a disputa chega. Trinta dias depois o log já rotacionou e o print não existe mais. ## Erros | Código | Motivo | | - | - | | `400` | Disputa que não está mais `aberta`, corpo sem `files`, `documents` nem `textForPdf`, nenhum arquivo ou link válido, mais de 5 arquivos, arquivo acima de 5 MB, `status` inválido no filtro, ID em formato inválido, ou envio em sandbox | | `404` | Disputa inexistente, de outra conta, ou consulta em sandbox | | `409` | A disputa ainda não pode ser respondida (`canRespond: false`). Tente em alguns minutos | | `502` | O banco recusou ou não respondeu. O campo `detail` traz o motivo. Tente de novo | # Entrega automática Source: https://docs.purin.cash/guias/entrega Como buscar a chave, a conta ou o link que o comprador recebeu. Quando a cobrança está amarrada a um produto da loja, o conteúdo (chave, conta, link) é liberado no instante em que o pagamento confirma. `GET /v1/deliveries/{paymentId}` é como você lê esse conteúdo do seu lado, para reenviar por e-mail, mostrar numa tela de pedido ou registrar no seu banco. ## Amarrando o produto A ligação é feita na **criação** da cobrança, pelo campo `supplier`. Ele funciona igual em `POST /v1/charges` e em `POST /v1/payments`: ```bash theme={null} curl -X POST https://api.purin.cash/v1/charges \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "valueCents": 1990, "description": "Conta Premium", "callbackUrl": "https://minhaloja.com/webhooks/purincash", "supplier": { "productId": "prod_aim_assist", "variationIndex": 0 } }' ``` **supplier.productId** `string` ID **público** do produto da loja, no formato `prod_xxx`. Você pega no painel, no produto. Não é o `_id` que aparece em `GET /v1/store/products`, e não é o `productId` de produto de cobrança. **supplier.variationIndex** `integer` (default: 0) Qual variação, pela posição na lista: `0` é a primeira, `1` a segunda. Índice que não existe devolve `400` dizendo quantas variações o produto tem. > [!WARNING] > > O `supplier` **não** define o preço. Quem manda no valor continua sendo `valueCents` (ou > o produto de cobrança, se você usou `productId`). Amarrar um produto de R\$ 50 numa > cobrança de R\$ 5 não corrige o valor: gera uma cobrança de R\$ 5. Quando a variação vem de fornecedor externo, a API valida duas coisas na criação e recusa na hora se algo não fecha: | Código | Motivo | | - | - | | `403` | Você não tem acesso aprovado àquele fornecedor | | `400` | O valor da cobrança é menor que o custo do fornecedor. O erro diz o custo exato | Isso evita vender abaixo do custo por engano, que é um erro que só apareceria no fechamento do mês. > [!NOTE] > > O fornecedor externo só entra em cobrança PIX: é no pagamento PIX que o item é buscado nele > e a divisão é feita. Em `ltc` ou `btc` a cobrança é criada normalmente, mas sem o > fornecedor: o item dele não é entregue, não há divisão, e a resposta traz o aviso em > `warnings`. Para o fornecedor entregar, use `paymentMethod: "pix"` (ou deixe o campo de fora). ## Lendo o que foi entregue ```bash theme={null} curl https://api.purin.cash/v1/deliveries/psa_a1b2c3d4e5f6 \ -H "Authorization: Bearer $PURINCASH_KEY" ``` ```json theme={null} { "paymentId": "psa_a1b2c3d4e5f6", "status": "paid", "amountCents": 4990, "paidAt": "2026-03-18T12:05:00.000Z", "deliveredContent": "LICENSE-KEY-ABC-123", "customer": { "name": "João Silva", "email": "joao@exemplo.com", "externalId": "user_42" } } ``` Aceita tanto ID de pagamento (`psa_`) quanto de cobrança (`psc_`). ## Quando `deliveredContent` vem `null` > [accordion] O pagamento ainda não foi confirmado > > Enquanto o `status` for `pending`, não existe entrega. Espere o webhook. > [accordion] O produto tem entrega manual > > Nesse caso o conteúdo não é gerado automaticamente, e não há nada para esta rota > devolver. > [accordion] A cobrança não tinha produto vinculado > > Sem `supplier` na criação, não existe o que entregar. É só um valor. > > Esse é o caso mais comum de "criei tudo certo e `deliveredContent` vem `null`": o > vínculo precisa ser feito na criação, não dá pra amarrar depois. Nenhum desses casos é erro: a resposta é `200` com `deliveredContent: null`. `404` só acontece quando o pagamento não existe ou não é da sua conta. ## O conteúdo também chega no webhook O evento `payment.paid` traz `deliveredContent` quando há entrega automática. Se você já processa o webhook, normalmente não precisa chamar esta rota. Ela é útil em dois momentos: quando o cliente pede a chave de novo e você não quer guardar o conteúdo no seu banco, e quando você está reconciliando um pagamento antigo cujo webhook se perdeu. > [!WARNING] > > `deliveredContent` costuma ser exatamente o que o cliente comprou: licença, credencial > ou link. Trate como dado sensível, não jogue em log e não exponha em endpoint público > sem autenticação. # Erros Source: https://docs.purin.cash/guias/erros Um formato só, sete códigos, e quais deles vale reenviar. Todo erro da API vem como JSON com um campo: ```json theme={null} { "error": "Descrição do que deu errado" } ``` > [!WARNING] > > Trate erro pelo **código HTTP**, nunca pelo texto. A mensagem existe para você ler no > log e pode mudar sem aviso. O código não muda. ## Códigos | Código | Significa | Reenviar? | | - | - | - | | `400` | Parâmetro ausente, inválido ou fora do limite | Só depois de corrigir | | `401` | Chave ausente, inválida, revogada ou com prefixo errado | Não. Veja [Autenticação](/guias/autenticacao) | | `403` | Chave válida, operação não permitida (sandbox com chave live, conta sem verificação) | Não. Resolva no painel | | `404` | Recurso não existe ou não é da sua conta | Não | | `409` | Conflito de estado (saque turbo já em andamento) | Sim, depois de esperar | | `429` | Passou do [limite de requisições](/guias/limites) | Sim, com backoff | | `500` | Erro inesperado no servidor | Sim, com backoff | | `502` | Falha temporária junto ao provedor | Sim, com backoff | | `503` | Recurso indisponível no momento (carteira LTC ausente, cripto fora do ar) | Sim, mais tarde | > [!NOTE] > > `404` em vez de `403` para recurso de outra conta é proposital. A API não confirma que um > id existe se ele não é seu. ## Mensagens que você vai encontrar > [accordion] Validação (400) > > | Mensagem | Causa | > | - | - | > | `valueCents must be >= 80 (R$ 0.80), or provide productId` | Valor abaixo do mínimo da loja. O número na mensagem é o mínimo real da sua conta | > | `priceCents must be >= 100 (R$ 1.00)` | Produto abaixo de R\$ 1,00 | > | `callbackUrl must be a valid, public HTTPS URL` | URL sem HTTPS, com IP, `localhost` ou rede privada | > | `paymentMethod must be 'pix' or 'ltc'` | Método desconhecido | > | `Invalid product ID format` | O id não é um ObjectId válido | > [accordion] Autenticação (401) > > | Mensagem | Causa | > | - | - | > | `API key required. Use: Authorization: Bearer ps_live_...` | Header ausente ou malformado | > | `Invalid API key prefix. Use ps_live_ or ps_test_` | Prefixo desconhecido | > | `Invalid or revoked API key` | Chave inexistente ou revogada | > | `API key environment mismatch. Please generate a new key.` | Prefixo não bate com o ambiente da chave | > [accordion] Permissão (403) > > | Mensagem | Causa | > | - | - | > | `Sandbox endpoint requires ps_test_ key` | Rota de sandbox chamada com chave de produção | > | `PIX not verified. Complete verification in the dashboard first.` | Saque antes de verificar a chave PIX | > [accordion] 404 e 502 > > | Mensagem | Causa | > | - | - | > | `Product not found` | Produto inexistente, inativo ou de outra conta | > | `Charge not found` | Cobrança inexistente ou de outra conta | > | `Payment not found` | Pagamento inexistente ou de outra conta | > | `LTC price unavailable` | Cotação de Litecoin fora do ar no momento | ## Como tratar ```js theme={null} async function chamar(url, opcoes, tentativas = 4) { for (let i = 0; i < tentativas; i++) { const res = await fetch(url, opcoes); if (res.ok) return res.json(); const { error } = await res.json().catch(() => ({ error: res.statusText })); // Erro do cliente: reenviar não resolve, só queima requisição. if (res.status < 500 && res.status !== 429) { throw new Error(`${res.status}: ${error}`); } // 429, 5xx: vale esperar. Backoff com jitter pra não sincronizar rajada. const espera = 2 ** i * 1000 + Math.random() * 1000; await new Promise((r) => setTimeout(r, espera)); } throw new Error("limite de tentativas excedido"); } ``` > [!TIP] > > Registre o corpo `{ "error": ... }` junto do código HTTP e do `paymentId` nos seus logs. > Quando você chamar a gente [no Discord](https://discord.gg/8eyQQFZZxY), é essa tripla que > resolve em uma resposta em vez de cinco. # Limites de requisição Source: https://docs.purin.cash/guias/limites 120 por minuto no geral, 10 por hora em saque. E como não bater neles. ## Os limites | Escopo | Limite | Janela | | - | - | - | | Todas as rotas `/v1/*` | 120 requisições | 60 segundos | | `POST /v1/payouts` | 10 saques | 1 hora, por chave | | `POST /v1/payouts` com `turbo` | 5 saques | 1 hora, por chave, dentro do limite acima | O limite geral é somado entre todos os endpoints. Não existe cota separada por recurso. ## Headers Toda resposta traz os headers padrão, então dá pra desacelerar antes de bater no teto: | Header | Conteúdo | | - | - | | `RateLimit-Limit` | Total permitido na janela | | `RateLimit-Remaining` | Quanto ainda resta | | `RateLimit-Reset` | Segundos até a janela reiniciar | ## Quando estoura ``` HTTP/1.1 429 Too Many Requests RateLimit-Limit: 120 RateLimit-Remaining: 0 RateLimit-Reset: 42 { "error": "Rate limit exceeded. Check the RateLimit-* response headers for your account's limit and reset time." } ``` > [!WARNING] > > Não faça retry imediato de um `429`. Você continua bloqueado até a janela virar, e cada > tentativa só empurra o problema pra frente. Espere pelo menos o `RateLimit-Reset`, e use backoff exponencial com jitter: ```js theme={null} async function comRetry(fn, maxTentativas = 5) { for (let i = 0; i <= maxTentativas; i++) { const res = await fn(); if (res.status !== 429) return res; const reset = Number(res.headers.get("RateLimit-Reset")) || 2 ** i; const jitter = Math.random() * 1000; await new Promise((r) => setTimeout(r, reset * 1000 + jitter)); } throw new Error("rate limit: máximo de tentativas excedido"); } ``` O jitter não é enfeite: sem ele, todos os seus workers acordam no mesmo instante e batem no limite de novo, juntos. ## O jeito de não chegar perto > [card] [Use webhook em vez de polling](/webhooks/visao-geral) > > É de longe a maior economia. Uma cobrança consultada a cada 3 segundos por 30 minutos > são 600 requisições. O webhook resolve em uma. > [card] Se precisar consultar, espace > > Intervalo de 5 segundos ou mais, e pare no primeiro status final (`paid`, `expired`, > `refunded`). > [card] Pagine com limite alto > > As listagens aceitam `limit` até 100. Buscar de 10 em 10 gasta 10 vezes mais > requisição pelo mesmo resultado. > [card] Cacheie o que não muda > > Catálogo de produto não precisa ser relido a cada request do seu usuário. > [!TIP] > > Se você tem vários serviços integrando, dê uma chave para cada um. Assim o limite de > saque de um job não é consumido pelo checkout, e você descobre rápido qual serviço está > gastando requisição à toa. # Receber por PIX Source: https://docs.purin.cash/guias/pix Os dois endpoints de PIX, quando usar cada um e como reconciliar sem passar vergonha. Existem dois jeitos de gerar um PIX, e a diferença não é estilo: eles aceitam recursos diferentes. **Os dois aceitam valor solto.** Não é que um seja "com produto" e o outro "sem". Em `/v1/payments` o `productId` é opcional: sem ele, você manda `valueCents` e funciona igual. O que separa de verdade é o que cada um faz **além** disso. | Você quer | Use | ID | | - | - | - | | Só gerar um PIX de um valor | `POST /v1/charges` | `psc_` | | Dividir o valor com outras contas | `POST /v1/charges` com `splits` | `psplit_` | | O preço vir de um produto cadastrado | `POST /v1/payments` com `productId` | `psa_` | | Cobrar em [Litecoin](/guias/cripto) | `POST /v1/payments` com `paymentMethod: "ltc"` | `psa_` | | [Assinatura](/guias/assinaturas) recorrente | `POST /v1/subscriptions` | `psa_sub_` | > [!TIP] > > Regra prática: **quer só um valor, usa `charges`.** É o caminho mais curto, e é o único > que faz split. Vá pra `payments` quando precisar de catálogo, cripto ou assinatura. Uma coisa que **os dois** aceitam: o campo `supplier`, que amarra a cobrança a um produto da sua loja pra [entrega automática](/guias/entrega#amarrando-o-produto). Isso é independente de qual endpoint você escolheu. ## Criando ```bash Valor livre theme={null} curl -X POST https://api.purin.cash/v1/charges \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "valueCents": 1990, "description": "Plano Pro", "expiresIn": 900, "callbackUrl": "https://minhaloja.com/webhooks/purincash", "customer": { "name": "João Silva", "email": "joao@exemplo.com" }, "metadata": "{\"pedido\":\"1042\"}" }' ``` ```bash A partir de um produto theme={null} curl -X POST https://api.purin.cash/v1/payments \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "productId": "665f1a2b3c4d5e6f7a8b9c0d", "callbackUrl": "https://minhaloja.com/webhooks/purincash", "customer": { "name": "João Silva", "externalId": "user_42" } }' ``` ```json Resposta theme={null} { "paymentId": "psc_a1b2c3d4e5f6", "status": "pending", "amountCents": 1990, "currency": "BRL", "environment": "live", "pix": { "brCode": "00020126580014br.gov.bcb.pix0136...", "qrCodeImage": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..." }, "expiresAt": "2026-03-18T12:30:00.000Z" } ``` > [!NOTE] > > Em `/v1/payments`, se você mandar `productId` e `valueCents` ao mesmo tempo, o preço do > produto vence. Produto em moeda diferente de BRL é convertido para real na hora da > cobrança. > > Sem `productId`, o `valueCents` manda e a `description` vira o nome do pagamento > (padrão `"Pagamento"` quando você não mandar nenhuma). > [!WARNING] > > Cuidado pra não confundir dois campos com nome parecido: > > `productId` é produto **de cobrança**, criado por `POST /v1/products`. Ele define o > preço. > > `supplier.productId` é produto **da loja**, no formato `prod_xxx`. Ele não define preço > nenhum: serve pra entrega automática. Passar um no lugar do outro devolve `404`. ## Prazo de validade `expiresIn`, em **segundos**. De 60 (um minuto) a 604800 (sete dias). Sem o campo, a cobrança vale 30 minutos, que é o que sempre valeu. ```json theme={null} { "valueCents": 1990, "expiresIn": 900 } ``` Valor fora da faixa devolve `400` em vez de cair no padrão. É de propósito: o erro mais provável é mandar minutos achando que o campo é em minutos, e `expiresIn: 15` virar uma cobrança de quinze segundos seria bem pior que uma mensagem de erro. A resposta traz `expiresAt` com o instante exato do vencimento. Use esse valor no seu contador, em vez de somar o prazo no seu relógio: o que vale é a hora do servidor. > [!NOTE] > > O prazo vale para PIX. Cobrança no cartão e assinatura têm ciclo próprio e ignoram o > campo. ## Valor mínimo O piso da plataforma é **R\$ 0,80** (`valueCents: 80`), mas cada loja pode configurar um mínimo maior no painel. Quando o valor não passa, o erro `400` já diz qual é o mínimo daquela conta: ```json theme={null} { "error": "valueCents must be >= 500 (R$ 5.00), or provide productId" } ``` Vale ler esse número em vez de fixar `80` no seu código. ## Ciclo de vida **1. pending** A cobrança existe e o `brCode` funciona. Dura o `expiresIn` que você escolheu, ou 30 minutos quando você não escolheu nenhum. **2. paid** O PIX caiu. O webhook sai nesse momento e o valor entra no seu saldo. **3. expired** Passou do prazo sem pagamento. Para tentar de novo, crie outra cobrança. **4. refunded / cancelled** Estorno ou cancelamento posterior. Vale conferir esses status antes de entregar algo de valor alto. > [!WARNING] > > `expired` é o estado da cobrança, e não uma garantia de que o dinheiro não entra mais. > Dependendo de qual instituição processa o PIX da sua loja, um pagamento feito depois do > prazo ainda pode ser aceito pelo banco. Quando isso acontece a gente credita o valor e > dispara o `charge.paid` normalmente, porque o cliente pagou de verdade e não receber o > produto seria pior. > > Ou seja: **`expired` não dispensa você de tratar o webhook**. Se o seu fluxo não pode > entregar depois do prazo, decida isso no seu handler comparando `paidAt` com `expiresAt`, > e estorne se for o caso. ## Reconciliando Faça as duas coisas. Elas cobrem falhas diferentes. > [accordion] Webhook: rápido, mas não confiável sozinho > > A entrega é feita assim que o pagamento confirma, com reentrega automática em caso de > falha. Ainda assim, a sua URL é pública e qualquer um pode chamá-la. > > Sempre [valide a assinatura](/webhooks/assinatura) e deduplique pelo header > `X-Webhook-Id`. > [accordion] Consulta: lenta, mas definitiva > > `GET /v1/charges/{paymentId}` e `GET /v1/payments/{paymentId}` devolvem o estado > atual. Use antes de entregar algo caro, e como rede de segurança quando o webhook não > chegou. > > ```bash theme={null} > curl https://api.purin.cash/v1/charges/psc_a1b2c3d4e5f6 \ > -H "Authorization: Bearer $PURINCASH_KEY" > ``` > [accordion] Polling: último recurso > > Se precisar mesmo, use intervalo de 5 segundos ou mais e pare no primeiro status > final. O [limite de 120 requisições por minuto](/guias/limites) vale para tudo > somado. > [!WARNING] > > Antes de liberar o produto, confira `amountCents` além do `status`. Um pagamento pago > com valor menor que o esperado não deveria virar entrega. ## Dados do pagador No webhook `charge.paid` chegam campos que não existem na criação, porque vêm do banco: | Campo | Conteúdo | | - | - | | `payer` | Nome de quem pagou | | `bank` | Banco de origem, no formato `código - nome` | | `endToEndId` | Identificador end-to-end da transação no Bacen | | `txId` | txid da transação | Servem para conciliação bancária e para responder [disputa](/guias/disputas). CPF, telefone e endereço não são enviados em webhook. > [card] [Dividir o valor](/guias/splits) > > Marketplace, comissão ou sociedade, resolvido no momento do pagamento. > [card] [Entregar sozinho](/guias/entrega) > > Chave ou conta liberada assim que o PIX confirma. # Sua primeira cobrança Source: https://docs.purin.cash/guias/primeira-cobranca Do zero ao PIX pago em sandbox, sem mover dinheiro de verdade. Este guia vai até o fim: chave, cobrança, pagamento simulado e webhook recebido. Tudo em sandbox, então nada aqui movimenta dinheiro. **1. Gere uma chave de sandbox** Entre no painel, abra [Equipe & API → Developer API](https://purin.cash/dashboard/api) e gere uma chave de sandbox. Ela começa com `ps_test_`. A chave inteira aparece uma única vez, na hora da criação. Se perder, revogue e gere outra. Cada conta pode manter até 10 chaves ativas. > [!WARNING] > > Chave de API é credencial de servidor. Ela nunca deve aparecer em JavaScript de > navegador, app mobile, repositório público ou print de tela. **2. Crie a cobrança** O endpoint mais direto é `POST /v1/charges`: valor livre, sem precisar cadastrar produto antes. ```bash cURL theme={null} curl -X POST https://api.purin.cash/v1/charges \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "valueCents": 1990, "description": "Plano Pro", "callbackUrl": "https://minhaloja.com/webhooks/purincash", "customer": { "name": "João Silva", "email": "joao@exemplo.com" }, "metadata": "{\"pedido\":\"1042\"}" }' ``` ```js Node.js theme={null} const res = await fetch("https://api.purin.cash/v1/charges", { method: "POST", headers: { Authorization: `Bearer ${process.env.PURINCASH_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ valueCents: 1990, description: "Plano Pro", callbackUrl: "https://minhaloja.com/webhooks/purincash", customer: { name: "João Silva", email: "joao@exemplo.com" }, metadata: JSON.stringify({ pedido: "1042" }), }), }); const cobranca = await res.json(); console.log(cobranca.paymentId, cobranca.pix.brCode); ``` ```python Python theme={null} import os, json, requests resposta = requests.post( "https://api.purin.cash/v1/charges", headers={"Authorization": f"Bearer {os.environ['PURINCASH_KEY']}"}, json={ "valueCents": 1990, "description": "Plano Pro", "callbackUrl": "https://minhaloja.com/webhooks/purincash", "customer": {"name": "João Silva", "email": "joao@exemplo.com"}, "metadata": json.dumps({"pedido": "1042"}), }, timeout=15, ) cobranca = resposta.json() print(cobranca["paymentId"], cobranca["pix"]["brCode"]) ``` ```php PHP theme={null} $ch = curl_init("https://api.purin.cash/v1/charges"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("PURINCASH_KEY"), "Content-Type: application/json", ], CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode([ "valueCents" => 1990, "description" => "Plano Pro", "callbackUrl" => "https://minhaloja.com/webhooks/purincash", "customer" => ["name" => "João Silva", "email" => "joao@exemplo.com"], "metadata" => json_encode(["pedido" => "1042"]), ]), ]); $cobranca = json_decode(curl_exec($ch), true); echo $cobranca["paymentId"], PHP_EOL, $cobranca["pix"]["brCode"]; ``` A resposta vem assim: ```json Resposta 201 theme={null} { "paymentId": "psc_a1b2c3d4e5f6", "status": "pending", "amountCents": 1990, "currency": "BRL", "environment": "sandbox", "pix": { "brCode": "00020126580014br.gov.bcb.pix0136...", "qrCodeImage": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..." }, "expiresAt": "2026-03-18T12:30:00.000Z" } ``` Guarde o `paymentId` junto do seu pedido. É por ele que você vai reconciliar tudo depois. > [!TIP] > > Quer amarrar a cobrança ao seu banco de dados sem tabela auxiliar? Use `metadata`. > Ele volta intacto no webhook e na consulta, então dá pra guardar ali o id do pedido. **3. Mostre o PIX pro cliente** São dois caminhos, e você normalmente oferece os dois na mesma tela: | Campo | O que fazer com ele | | - | - | | `pix.brCode` | Renderize como texto com um botão de copiar. É o copia e cola. | | `pix.qrCodeImage` | Imagem do QR pronta pra exibir. Pode ser URL http ou data URL base64. Jogue no src de uma tag img sem inspecionar o conteudo. | Se preferir gerar o QR você mesmo, use qualquer biblioteca de QR Code passando o `brCode` como conteúdo. O resultado é o mesmo. > [!WARNING] > > Cobrança PIX expira em 30 minutos (`expiresAt`). Depois disso o código para de > funcionar e o status vira `expired`. Se o cliente sumiu e voltou, crie outra. > [!NOTE] > > Você está em sandbox, então esse `brCode` é falso: vem como `SANDBOX_PIX_...` e > nenhum banco reconhece. Serve pra você montar a tela; quem confirma o pagamento é o > passo seguinte. **4. Simule o pagamento** Em sandbox ninguém vai pagar de verdade, então você marca como pago na mão: ```bash theme={null} curl -X POST https://api.purin.cash/v1/sandbox/charges/psc_a1b2c3d4e5f6/simulate-paid \ -H "Authorization: Bearer $PURINCASH_KEY" ``` Isso muda o status para `paid` e dispara o webhook na sua `callbackUrl`, igualzinho ao que acontece em produção. Não passa por aprovação de ninguém: é essa chamada e pronto. > [!NOTE] > > Para pagamentos criados em `POST /v1/payments` o endpoint é > `/v1/sandbox/payments/{paymentId}/simulate-paid`. A diferença é só o recurso. **5. Receba o webhook** A gente faz `POST` na sua URL com o corpo do evento e o header `X-Webhook-Signature`: ```json theme={null} { "event": "charge.paid", "paymentId": "psc_a1b2c3d4e5f6", "amountCents": 1990, "status": "paid", "paidAt": "2026-03-18T12:05:00.000Z", "customer": { "name": "João Silva", "email": "joao@exemplo.com" }, "metadata": "{\"pedido\":\"1042\"}", "sandbox": true } ``` Antes de liberar qualquer coisa pro cliente, [valide a assinatura](/webhooks/assinatura). A sua URL é pública, então qualquer pessoa consegue mandar um JSON dizendo `"status": "paid"`. Sem servidor exposto ainda? Use um túnel local (`ngrok http 3000`, `cloudflared tunnel`) ou um coletor tipo webhook.site só pra ver o payload chegando. **6. Confirme pela API** Webhook é notificação, não fonte da verdade. Antes de entregar o produto, confirme: ```bash theme={null} curl https://api.purin.cash/v1/charges/psc_a1b2c3d4e5f6 \ -H "Authorization: Bearer $PURINCASH_KEY" ``` Se o `status` vier `paid` e o `amountCents` bater com o que você cobrou, pode liberar. ## Passando pra produção Troque `ps_test_` por `ps_live_`. Só isso. Nenhuma URL muda, nenhum parâmetro muda. Antes de virar a chave, dá uma passada em [Antes de ir pra produção](/guias/producao). > [card] [Vender produto cadastrado](/guias/produtos) > > Cadastre o preço uma vez e cobre por `productId`. > [card] [Entregar automaticamente](/guias/entrega) > > Chave, conta ou link liberado no instante do pagamento. # Antes de ir pra produção Source: https://docs.purin.cash/guias/producao A lista curta do que costuma quebrar no primeiro dia com dinheiro de verdade. Você testou em sandbox e funcionou. Estes são os pontos que sandbox não pega. ## Segurança > [accordion] A chave live não está no bundle do front > > Procure por `ps_live_` no build final, não só no código. Um `console.log` esquecido ou > uma variável sem prefixo de servidor já entrega a chave. > > Se ela estiver exposta, alguém pode criar cobrança em seu nome e, com [PIX saindo > na hora](/guias/saques#quando-o-pix-sai-na-hora), mandar dinheiro pra fora. > [accordion] O webhook valida a assinatura > > A sua `callbackUrl` é pública. Sem [validar o > `X-Webhook-Signature`](/webhooks/assinatura), qualquer pessoa manda um JSON com > `"status": "paid"` e leva o produto de graça. > > Confira também que você está assinando o **corpo cru**, antes do parse. Um > `express.json()` no lugar errado quebra a validação de um jeito que passa em teste e > falha em produção. > [accordion] O valor é conferido antes da entrega > > Não basta o `status` ser `paid`. Compare o `amountCents` recebido com o valor que você > esperava para aquele pedido antes de liberar qualquer coisa. > [accordion] O webhook secret está fora do repositório > > Variável de ambiente ou cofre. Se vazou, regenere no painel: o antigo para de funcionar > na hora. ## Confiabilidade > [accordion] O handler é idempotente > > Webhook é reentregue quando a entrega falha, e a mesma confirmação pode chegar mais de > uma vez. Deduplique pelo header `X-Webhook-Id`, que é estável entre as tentativas. > > O teste é simples: mande o mesmo payload duas vezes e veja se o cliente recebe o > produto duas vezes. > [accordion] Você responde 200 rápido e processa depois > > O timeout é de 5 segundos. Se o seu handler manda e-mail, gera licença e escreve em > três tabelas antes de responder, ele vai estourar e você vai receber reentrega de > coisa que já processou. > > Grave o evento, responda `200`, processe em fila. > [accordion] Existe reconciliação além do webhook > > Webhook é notificação, não fonte da verdade. Tenha um job que varre pedidos parados em > `pending` há mais de uma hora e confirma o estado por `GET`. > [accordion] Você não faz retry de 202 e processing > > Em [saques](/guias/saques), `202` e `status: "processing"` significam que o dinheiro > pode já ter saído. Reenviar paga duas vezes. ## Configuração da conta **1. Chave PIX verificada** Sem verificação, o primeiro saque devolve `403`. Resolva isso antes de precisar do dinheiro, não depois. **2. URL de webhook cadastrada no painel** Os webhooks de [pedido](/webhooks/pedidos) e de [saque](/webhooks/saques) usam a URL configurada na conta, não a `callbackUrl` da cobrança. **3. Valor mínimo conferido** O painel permite subir o mínimo da loja acima de R\$ 0,80. Se o seu produto mais barato fica abaixo dele, a cobrança falha em produção e passava em sandbox. **4. Carteira LTC configurada, se for usar cripto** Sem carteira, `paymentMethod: "ltc"` devolve `503`. ## Operação > [card] Log com o tripé certo > > Código HTTP, corpo do erro e `paymentId`. Com esses três, uma dúvida > [no Discord](https://discord.gg/8eyQQFZZxY) vira uma resposta em vez de cinco. > [card] [Alerta de disputa](/guias/disputas) > > Rode `GET /v1/disputes?status=aberta` diariamente. Contestação tem prazo. > [card] [Uma chave por serviço](/guias/autenticacao) > > Dá pra revogar uma sem derrubar o resto, e dá pra saber quem gastou o limite. > [card] [Backoff no cliente](/guias/limites) > > Com jitter. Sem ele, seus workers batem no `429` todos juntos. ## O teste final Faça uma venda real de valor baixo, do início ao fim: cobrança, pagamento, webhook, entrega e saque. É o único teste que cobre configuração de conta, e leva dez minutos. > [card] [Travou em algum ponto?](https://discord.gg/8eyQQFZZxY) > > Chama no Discord com o código HTTP, o corpo do erro e o `paymentId`. Costuma sair na > primeira resposta. # Produtos Source: https://docs.purin.cash/guias/produtos Existem dois tipos de produto com o mesmo nome. Aqui está qual é qual. Esta é a parte da API que mais gera ticket de suporte, então vale começar pelo ponto: > [!WARNING] > > `GET /v1/products` pode devolver uma lista vazia com a sua loja cheia de produtos. Não é > bug. São duas coleções diferentes. > [card] Produto de cobrança > > Criado **pela API**, com `POST /v1/products`. > > Tem `priceCents` (número inteiro). > > É o único aceito em `POST /v1/payments` e `POST /v1/subscriptions`. > > Vive em `/v1/products`. > [card] Produto da loja > > Criado **no painel**, na sua loja do Discord. > > Tem `price` (texto tipo `"49,90"`), categoria, variações e estoque. > > Não serve para gerar cobrança pela API. > > Vive em `/v1/store/products`, somente leitura. Os dois não vêm juntos por padrão porque a forma é diferente. Código fazendo `priceCents / 100` viraria `NaN` ao receber um produto de loja, e quem repassasse um id de loja para criar cobrança levaria `404` sem entender o motivo. ## Pedindo os dois de uma vez | Chamada | Devolve | | - | - | | `GET /v1/products` | Só os de cobrança. É o padrão e não mudou | | `GET /v1/products?include=all` | Os dois, com um campo `type` em cada item | | `GET /v1/products?include=store` | Só os da loja | | `GET /v1/store/products` | Só os da loja | ```json GET /v1/products?include=all theme={null} { "products": [ { "type": "api", "_id": "60f7...", "name": "Plano Premium", "priceCents": 4990 }, { "type": "store", "id": "70a2...", "name": "Conta Full", "price": "249,90", "variations": [] } ] } ``` Todas aceitam `?includeInactive=true` para incluir os desativados. > [!TIP] > > Se `GET /v1/products` vier vazio e você tiver produtos na loja, a resposta traz um campo > `hint` apontando a rota certa. Ele é aditivo, então quem só lê `products` não sente > diferença. ## Produtos de cobrança ```bash Criar theme={null} curl -X POST https://api.purin.cash/v1/products \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Plano Premium", "description": "Acesso completo por 30 dias", "priceCents": 4990, "metadata": "{\"sku\":\"premium-01\"}" }' ``` **name** `string` *required* Até 200 caracteres. **priceCents** `integer` *required* Preço em centavos. Mínimo de 100, ou seja R\$ 1,00. **description** `string` Até 500 caracteres. **currency** `string` (default: BRL) Produto em outra moeda é convertido para real na hora de gerar a cobrança. **metadata** `string` String JSON de até 2 KB, devolvida sem alteração. O `PUT` aceita os mesmos campos, todos opcionais, mais `active` para ativar ou desativar. Mande apenas o que quer mudar. Limite de 500 produtos por conta e por ambiente. > [!NOTE] > > Repare que o mínimo de um produto (R\$ 1,00) é diferente do mínimo de uma cobrança avulsa > (R\$ 0,80, ajustável pela loja). Não é inconsistência: produto é catálogo, cobrança é > transação. ## Produtos da loja Leitura apenas. Para criar ou editar, use o painel. ```bash theme={null} curl "https://api.purin.cash/v1/store/products?includeInactive=true" \ -H "Authorization: Bearer $PURINCASH_KEY" ``` ```json theme={null} { "products": [ { "type": "store", "id": "70a2b1c3d4e5f6a7b8c9d0e1", "publicId": "plano-premium", "name": "Plano Premium", "description": "Acesso completo por 30 dias", "price": "49,90", "category": "Assinaturas", "active": true, "deliveryType": "automatica", "variations": [ { "id": "a1b2...", "name": "Mensal", "price": "49,90", "stock": 42, "unlimited": false }, { "id": "c3d4...", "name": "Anual", "price": "499,00", "stock": null, "unlimited": true } ] } ] } ``` ### Estoque ilimitado vem como `null` > [!WARNING] > > Variação com fornecedor externo, estoque via API ou estoque fictício infinito não tem um > número para contar. Nesses casos `stock` vem `null` e `unlimited` vem `true`. > > O valor **não** é `0`, justamente porque zero você leria como esgotado e esconderia um > produto que está à venda. Na prática, a checagem correta é: ```js theme={null} const disponivel = variacao.unlimited || variacao.stock > 0; ``` ### O que não sai nessa rota > [!NOTE] > > O conteúdo do estoque (a chave, a conta ou o link que o comprador recebe) e as > instruções de entrega não são retornados. Variações de seller ainda não aprovadas também > ficam de fora, porque não são vendáveis. Para ver o que foi entregue em um pagamento específico, existe a rota de [entrega](/guias/entrega). # Saques Source: https://docs.purin.cash/guias/saques Tirar o saldo por PIX, LTC ou USDT sem sair do código. O PIX pode sair na hora, e aí não tem volta. Um endpoint só, `POST /v1/payouts`. O que muda é o `method`: chave PIX, código copia e cola, LTC ou USDT. > [card] PIX > > Sai na hora ou entra na fila de aprovação. O `status` da resposta diz qual foi. > [card] LTC > > Debita o saldo em Litecoin e envia na hora, da carteira da sua loja. > [card] USDT > > Converte BRL e entrega USDT na rede BEP20 em segundos. > [!WARNING] > > **Limite padrão de 10 saques por hora, por conta** (todas as chaves somam no mesmo > balde). O envio imediato tem um teto próprio de 5 por hora, que corre por dentro desse. ## Saque PIX ```bash theme={null} curl -X POST https://api.purin.cash/v1/payouts \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "method": "pix", "amount": 100.00, "walletAddress": "loja@exemplo.com" }' ``` ```json Resposta 201 theme={null} { "id": "SAQ-API-A1B2C3D4", "code": "SAQ-API-A1B2C3D4", "method": "pix", "amount": 100.00, "status": "pending", "turbo": false, "sandbox": false } ``` **method** `string` *required* `pix`, `pix_code` (código copia e cola), `ltc` ou `usd`. **amount** `number` *required* Valor em reais. O mínimo é o configurado para a **sua conta** (R\$ 5,00 por padrão; o erro de valor baixo devolve `minAmount` com o mínimo vigente). Máximo de R\$ 999.999,99, sempre limitado ao `withdrawable` da [carteira](/guias/carteira). **walletAddress** `string` *required* Chave PIX, endereço LTC ou endereço USDT BEP20, conforme o `method`. **cryptoAmount** `number` Quantidade em LTC. Obrigatório quando `method` é `ltc`. > [!NOTE] > > A chave PIX precisa estar verificada no painel antes do primeiro saque. Sem isso a > resposta é `403`. ## Saque LTC ```bash theme={null} curl -X POST https://api.purin.cash/v1/payouts \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "method": "ltc", "amount": 100.00, "cryptoAmount": 0.5, "walletAddress": "ltc1q..." }' ``` O `cryptoAmount` é obrigatório aqui e precisa ser maior que zero. O saldo debitado é o `cryptoBalanceLtc` da carteira, não o saldo em reais. O saque LTC sai **na hora**, da carteira da sua loja, sem passar por aprovação. A resposta diz como ele terminou: | `status` | O que significa | O que fazer | | - | - | - | | `completed` | A transação foi para a rede. O `txHash` dela vem na resposta | Conferir no explorer, se quiser | | `processing` | Enviando, ou transmitido sem confirmação da rede | **Não repita o pedido**: a transação pode já estar na rede | | `pending` | Não saiu agora. Nada foi transmitido | Nada: o envio tenta de novo sozinho em alguns minutos | > [!WARNING] > > Transação cripto confirmada na rede não volta. Confira o endereço e a rede antes de > pedir: endereço de outra rede perde o valor. O mesmo saque repetido em 10 segundos é > recusado com `429`, para um retry automático não virar dois envios. ## Ler um código antes de pagar > [!NOTE] > > **O caminho recomendado tem três passos:** `POST /v1/payouts/decode` para ler o código, > a sua confirmação (tela, aprovação interna, o que fizer sentido) e só então > `POST /v1/payouts` com `method: "pix_code"`. Pagar direto funciona, mas aí você só > descobre destino e valor depois que o PIX saiu, e ele não volta. `POST /v1/payouts/decode` diz o que tem dentro do BR Code sem mover dinheiro: destino, valor, se o valor é fixo e a taxa que entra por cima. É com ela que você monta a tela de confirmação, ou a checagem automática que compara o que o código diz com o que você esperava pagar. ```bash theme={null} curl -X POST https://api.purin.cash/v1/payouts/decode \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "brCode": "00020126..." }' ``` ```json theme={null} { "valid": true, "isDynamic": false, "amount": 149.90, "amountLocked": true, "pixKey": "financeiro@fornecedor.com", "merchantName": "FORNECEDOR LTDA", "receiverName": "FORNECEDOR LTDA", "receiverDocument": "12.***.***/0001-**", "expiresAt": "", "txid": "PEDIDO123", "dynamicResolved": false, "dynamicError": "", "fee": 1.00 } ``` | Campo | | | - | - | | `isDynamic` | `true` quando o código aponta para uma cobrança no banco em vez de carregar os dados. Nesse caso é a leitura que resolve valor e destino | | `amountLocked` | O código FIXA o valor. Quando `false`, é você quem diz quanto no pagamento | | `dynamicResolved` | Num dinâmico, se o valor veio de fonte autoritativa. `false` com `amount` nulo significa "não deu pra ler agora", que é bem diferente de "esse QR não tem valor" | | `fee` | A taxa, que é SOMADA ao valor do código. O QR diz quanto o recebedor recebe; o débito é valor mais taxa | | `receiverName` / `receiverDocument` | Quem recebe, confirmado na leitura. O documento vem **mascarado** (`12.***.***/0001-**`): serve pra pessoa conferir na tela, não pra cadastro | | `amount` | Valor do código, em reais. `null` quando o código não traz valor e a leitura também não resolveu | | `expiresAt` / `txid` | Vencimento e identificador da cobrança, quando o código os carrega | Precisa da mesma permissão do pagamento (`saques.pix`): quem pode pagar pode inspecionar. Não existe em sandbox, porque a leitura consulta o banco sobre uma cobrança de verdade. Código inválido, ou destino que não pode ser pago por aqui, vem como `400` com `valid: false` e o motivo. ## Pagar um código copia e cola `method: "pix_code"` paga um BR Code (o "copia e cola" do PIX) com o saldo da carteira. Serve pra quitar cobrança de fornecedor, boleto-QR, conta de serviço: em vez de você informar a chave do destino, o **código** carrega o destino e o valor. Antes disto, [leia o código](#ler-um-codigo-antes-de-pagar). O pagamento é imediato e não tem cancelamento. ```bash theme={null} curl -X POST https://api.purin.cash/v1/payouts \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "method": "pix_code", "brCode": "00020126580014BR.GOV.BCB.PIX0136..." }' ``` ```json Resposta 200 theme={null} { "success": true, "payment": { "id": "665f1a2b3c4d5e6f7a8b9c0d", "code": "PAY-A1B2C3D4", "amount": 100.00, "fee": 1.00, "debited": 101.00, "status": "completed", "endToEndId": "E1234567890...", "txId": "...", "merchantName": "NOME DO RECEBEDOR", "recipientDocument": "12345678909", "isDynamic": false } } ``` > [!WARNING] > > **A taxa é somada, não descontada.** O código diz quanto o recebedor recebe, e a taxa > vem por cima: sai da carteira `amount + fee`, que é o campo `debited`. São esses três > números que precisam bater no seu financeiro. | Campo da resposta | | | - | - | | `id` / `code` | Identificadores do pagamento. O `code` (`PAY-...`) é o que aparece em `GET /v1/payouts` | | `amount` / `fee` / `debited` | Quanto o recebedor recebe, a taxa e o total que saiu da carteira | | `status` | `completed` ou `processing`. Os dois significam que o PIX saiu | | `endToEndId` | O E2E do PIX, o mesmo que aparece no extrato de quem recebeu. Pode vir nulo enquanto o banco não devolve | | `merchantName` / `recipientDocument` | Quem recebeu, como o banco confirmou. Aqui o documento vem completo, diferente do decode, porque é o dado do comprovante | | `isDynamic` | Se o código pagava uma cobrança em vez de carregar os dados | ### Quem manda no valor Quase sempre o código, não você: | Tipo de código | De onde vem o valor | `amount` no corpo | | - | - | - | | QR com valor fixo | Do próprio código | Opcional. Se enviar e divergir, é `400` | | QR dinâmico | Da cobrança, lida no banco na hora do pagamento | Opcional. Se enviar e divergir, é `400` | | QR estático sem valor | Só aí é você quem diz | **Obrigatório** | **brCode** `string` *required* O copia e cola completo. Quebras de linha são ignoradas; o resto vai íntegro, porque é ele que carrega o destino. **amount** `number` Valor em reais. Só entra em QR estático sem valor: nos demais, o valor é o do código, e enviar um número diferente devolve `400` em vez de pagar. Limites: de R\$ 1,00 a R\$ 5.000,00 **por código**, sobre o valor que o recebedor recebe (a taxa corre por fora). Precisa da permissão `saques.pix` na chave. ### Como saber o desfecho Pagamento de código não é saque, então ele tem família própria de evento: `pix_payment.completed` e `pix_payment.failed`. Eles carregam `paymentId`, que é o mesmo `payment.id` devolvido pelo pagamento, pra amarrar os dois sem tabela de-para. Isso importa porque a resposta do pagamento pode vir `processing`: o banco não confirmou ainda, e quem fecha é a conciliação minutos depois. Sem escutar o evento, esse pagamento fica sem desfecho do seu lado. Nunca repita um `processing`: ele pode ter saído. O corpo do evento, campo a campo, incluindo E2E, recebedor e a URL do comprovante, está em [webhooks de saque](/webhooks/saques). ### O que pode barrar antes de pagar | Código | Situação | | - | - | | `403` | A conta ainda não fez o primeiro saque PIX. Faça um saque comum antes | | `403` | Chave PIX da conta não verificada | | `400` com `blocked: true` | O código aponta para um destino da própria plataforma. Para adicionar saldo, use um depósito | | `400` | Código inválido, valor fora da faixa, ou saldo insuficiente (a resposta traz `available`, `required` e `fee`) | | `409` | Já existe um pagamento em processamento | | `429` | Pagamento idêntico (mesmo código e valor) nos últimos 90s, ou outro em andamento | | `503` | Pagamento por código indisponível no momento. Nada foi debitado | > [!NOTE] > > Não funciona em sandbox: o pagamento lê um código real e envia PIX real. Com chave > `ps_test_` a resposta é `400`, tanto aqui quanto no decode. ### Quando o banco não confirma As mesmas três saídas do envio imediato, pelo mesmo motivo: **`200`, `status: "completed"` ou `"processing"`.** O PIX saiu. Em `processing` só falta a confirmação do banco: trate como pago e **não reenvie**. **`400`, `refunded: true`.** O banco recusou e o saldo voltou inteiro, taxa junto: não houve serviço pra cobrar. **`202`, `requiresReview: true`.** Não veio resposta do banco. O pagamento **pode ter saído**, então o valor continua debitado até a verificação. Não repita: acompanhe pelo `code` (`PAY-...`) em `GET /v1/payouts`. ## Quando o PIX sai na hora O saque PIX pode ser enviado dentro da própria requisição, sem passar por fila. É a mesma chamada de sempre: a plataforma decide a rota e o `status` da resposta diz o que aconteceu. ```json Resposta 201 theme={null} { "id": "TURBO-A1B2C3D4", "code": "TURBO-A1B2C3D4", "method": "pix_turbo", "amount": 100.00, "fee": 1.00, "netAmount": 99.00, "status": "completed", "txId": "...", "recipientName": "NOME DO RECEBEDOR", "turbo": true, "sandbox": false } ``` > [!WARNING] > > **Envio imediato não tem volta.** O dinheiro sai no mesmo request e não existe > cancelamento depois. Quem tem a sua chave de API consegue mandar dinheiro pra fora > sem passar por nenhuma aprovação. Guarde a chave como você guardaria a senha do painel. Quando o envio é imediato, valem estas regras: | Regra | Detalhe | | - | - | | Método | Só `pix`. `ltc` e `usd` seguem o fluxo próprio deles | | Valor | Do mínimo da sua conta (R\$ 5,00 por padrão) a R\$ 5.000,00 por saque | | Limite | 5 por hora, dentro do limite geral de 10 | | Taxa | Descontada do envio (veja `fee` e `netAmount` na resposta) | | Concorrência | Um por vez. Envio simultâneo ou em sequência curta devolve `429`; com um envio recente ainda `processing`, `409` | | Falha | Devolve **erro** com `refunded`. Vira pendente só nos casos com `turboReason` abaixo, nunca por falha | É a mesma API do saque comum. **Quem decide a rota é a plataforma**, e o seu código trata **pelo `status` da resposta**, nunca presumindo qual foi. Todo saque PIX pela API sai imediato por padrão. Três casos fogem disso, e em todos o pedido continua valendo: ele entra na fila de aprovação em vez de virar erro. A resposta é `201` com `status: "pending"` e um `turboReason` dizendo por quê: | `turboReason` | Quando | O que fazer | | - | - | - | | `first_withdrawal_manual` | Primeiro saque PIX da conta. Acontece uma vez só | Nada. Depois dele, saem imediatos os saques pra chaves já aprovadas na conta | | `new_pix_key_manual` | A chave PIX ainda não foi aprovada na sua conta, e a conta não tem o envio imediato liberado pra qualquer chave | Nada. Acompanhe pelo webhook. Pra chaves novas saírem imediatas, peça ao suporte a liberação pra qualquer chave | | `turbo_maintenance` | Janela de manutenção do envio imediato | Nada. Acompanhe pelo webhook, como qualquer saque pendente | | `risco_conta` | A análise automática de risco segurou o saque da conta para conferência (padrão de movimentação fora do habitual) | Nada. A conferência é feita pela equipe e o saque sai em seguida, ou é devolvido ao saldo. Acompanhe pelo webhook | | `risco_subconta` | O mesmo, para um saque de subconta. Só aquela subconta fica com o saque suspenso até a conferência | Nada. Acompanhe pelo webhook. As outras subcontas e a conta seguem normais | Em todos o saldo já foi debitado no `201`, igual ao envio imediato. O que muda é quando o dinheiro chega ao destino. ### O que cada resposta significa Escreva o seu handler pra estes casos e ele cobre as duas rotas de uma vez: **`201`, `status: "pending"`.** Entrou na fila de aprovação, pelo motivo que vem em `turboReason`. Você recebe [`withdrawal.completed` ou `withdrawal.denied`](/webhooks/saques) quando resolver. Não reenvie: o saldo já saiu da carteira e um segundo pedido vira um segundo saque. **`201`, `status: "completed"`.** Pago e confirmado. O `recipientName` é o nome que o banco registrou como dono da chave: confira que pagou a pessoa certa. **`201`, `status: "processing"`.** O PIX **já saiu**; só falta a confirmação do banco, que chega pelo webhook. Trate como pago, não como pendente, e não reenvie. **`400`.** O pedido está errado e nada se moveu (ou o saldo já voltou, quando a resposta traz `refunded: true`). A mensagem diz o quê: valor fora da faixa (o erro de mínimo traz `minAmount`), chave vazia, saldo insuficiente (`available` na resposta), ou o banco recusou a chave do recebedor: ```json theme={null} { "error": "A chave PIX foi recusada pelo banco. Confira a chave do recebedor e tente de novo. Banco: Invalid Pix Key. Seu saldo foi restaurado.", "bankMessage": "Invalid Pix Key", "bankErrorCode": "chave_invalida", "definitiveRejection": true, "refunded": true, "code": "TURBO-A1B2C3D4", "turbo": true } ``` `definitiveRejection: true` significa que repetir o mesmo request **nunca** vai funcionar: conserte a chave antes de tentar de novo. **`502`, `refunded: true`.** O envio não pôde ser concluído e o saldo já voltou. Nada saiu. Tente de novo mais tarde. **`202`, `requiresReview: true`.** O único caso que exige cuidado: a transferência **pode ter sido enviada** e a confirmação não chegou. O valor permanece debitado até a verificação: é isso que garante que o mesmo saque nunca vira dois PIX. Não repita o request; acompanhe o `code` em `GET /v1/payouts` e pelo webhook. **`409` / `429`.** Já existe um envio em andamento (ou requests demais em sequência). Dinheiro pode estar em trânsito: aguarde, consulte `GET /v1/payouts` e só então decida reenviar. **`403`.** Chave PIX pendente de verificação. Resolve no painel, em Carteira. **`503`.** A plataforma não conseguiu confirmar as condições do saque e **nada foi debitado**. Repita. Janela de manutenção do envio imediato não cai aqui: ela volta `201` com `turboReason: "turbo_maintenance"`, porque o pedido foi aceito. Regra de bolso: `201` olhe o `status`; `400` conserte e repita; `502`/`503` repita mais tarde; `202`/`409`/`429` congele e consulte; `refunded: true` diz se o saldo já voltou. ## Saque em USDT Converte o saldo em reais e envia USDT na rede BEP20 na hora. ```bash theme={null} curl -X POST https://api.purin.cash/v1/payouts \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "method": "usd", "amount": 100.00, "walletAddress": "0xAbC1230000000000000000000000000000000000", "confirmNotCoinbase": true }' ``` ```json Resposta 201 theme={null} { "success": true, "withdrawal": { "id": "665f1a2b3c4d5e6f7a8b9c0d", "code": "SAQ-U-1A2B3C4D5E6F7A8B", "amountBRL": 100.00, "receiveUSDT": 17.42, "rateBRLPerUSDT": 5.74, "status": "processando", "estimatedTime": "menos de 30 segundos" } } ``` > [!WARNING] > > `confirmNotCoinbase: true` é obrigatório. Depósito da Coinbase não aceita USDT BEP20, e > o valor enviado para lá é perdido, sem recuperação. A confirmação existe para você parar > e conferir a rede do endereço antes de mandar. > [!NOTE] > > Por segurança contra fraude, o saque em USDT só é liberado depois que a loja fizer o > primeiro saque em PIX. Antes disso a resposta é `403`. ## Status **pending** `PIX comum` Aguardando aprovação. **completed** `todos` Pago. **denied** `todos` Negado. O valor volta para o saldo. **processing** `turbo e USDT` O pagamento foi enviado e a confirmação ainda não voltou. **O dinheiro já saiu.** Não repita o request. ## Erros e o que fazer | Código | Situação | O que fazer | | - | - | - | | `400` | Saldo insuficiente, valor abaixo do mínimo da conta (`minAmount` na resposta), endereço inválido, `confirmNotCoinbase` ausente, ou recusa **definitiva** do banco no turbo (`definitiveRejection: true`) | Corrija e reenvie. Recusa definitiva não melhora com retry: conserte a chave | | `403` | Chave PIX não verificada, ou USDT antes do primeiro saque PIX | Resolva no painel | | `409` | Já existe um saque turbo em processamento | Espere e tente de novo | | `429` | Passou de 10 por hora, 5 no turbo, ou request turbo duplicado em 10s | Espere a janela virar | | `502` | O envio imediato não pôde ser concluído. O saldo é devolvido (`"refunded": true`) | Use o saque comum; turbo mais tarde | | `503` | Turbo ou saque em cripto indisponível no momento | Tente mais tarde ou use o saque normal | | `202` | Sem resposta do banco no envio imediato | **Não repita.** O PIX pode ter saído e passa por verificação | > [!WARNING] > > `202` e `processing` são os dois casos em que reenviar o request paga duas vezes. > Trate ambos como sucesso provisório e reconcilie por `GET /v1/payouts`. ## Listando ```bash theme={null} curl "https://api.purin.cash/v1/payouts?limit=20&status=pendente" \ -H "Authorization: Bearer $PURINCASH_KEY" ``` ```json theme={null} { "payouts": [ { "id": "SAQ-API-A1B2C3D4", "code": "SAQ-API-A1B2C3D4", "method": "pix", "amount": 100.00, "cryptoAmount": null, "walletAddress": "12345***", "status": "pendente", "createdAt": "2026-04-27T12:00:00.000Z" } ] } ``` > [!NOTE] > > `walletAddress` volta sempre mascarado, com os primeiros caracteres e `***`. Dado > sensível (CPF, chave PIX completa) não aparece em resposta nem em log. > [!NOTE] > > A listagem devolve o status cru, em português: `pendente`, `concluido`, `negado`, > `processing` (turbo aguardando o banco) e `falhou`. São esses os valores do filtro > `?status=`. O `status` em inglês (`pending`/`completed`/`processing`) aparece só na > resposta do POST. Cada mudança de status também dispara um [webhook de saque](/webhooks/saques). # Dividir o valor (split) Source: https://docs.purin.cash/guias/splits Marketplace, comissão e sociedade resolvidos no instante em que o dinheiro entra. Split é uma cobrança PIX comum com uma lista de beneficiários. Quando o cliente paga, o valor já cai partido na carteira de cada conta, com evento financeiro auditável. Você não precisa receber tudo e repassar depois. Funciona em `POST /v1/charges` com o campo `splits`, ou em `POST /v1/split-charges`, que é o mesmo endpoint com outro nome. ## A regra que confunde todo mundo > [!WARNING] > > **Você não entra na lista.** O `splits` leva só os *outros* beneficiários. Como dono da > chave de API, você é participante implícito e fica com o que sobrar: `100 - soma das > percentages`. Isso tem duas consequências práticas: 1. A soma das `percentage` precisa ser **menor que 100**, nunca igual. 2. A sua fatia precisa ser **estritamente a maior** de todas. Se sobrar para você menos do que para algum beneficiário, a criação é rejeitada com `400`. ## Criando ```bash theme={null} curl -X POST https://api.purin.cash/v1/charges \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "amountCents": 10000, "description": "Venda compartilhada", "callbackUrl": "https://minhaloja.com/webhooks/purincash", "splits": [ { "recipientEmail": "socio@exemplo.com", "percentage": 30 } ], "customer": { "name": "Cliente", "email": "cliente@exemplo.com" } }' ``` ```json Resposta 201 theme={null} { "paymentId": "psplit_a1b2c3d4", "status": "pending", "amountCents": 10000, "currency": "BRL", "environment": "live", "pix": { "brCode": "00020126360014BR.GOV.BCB.PIX...", "qrCodeImage": "data:image/png;base64,iVBORw0KGgo..." }, "splits": [ { "recipientEmail": "vo***@exemplo.com", "percentage": 70, "isOwner": true }, { "recipientEmail": "so***@exemplo.com", "percentage": 30, "isOwner": false } ], "expiresAt": "2026-05-28T12:30:00.000Z" } ``` Repare que a resposta devolve **três** coisas que você não mandou: a sua própria fatia (`isOwner: true`), os e-mails mascarados e o prefixo `psplit_`. > [!NOTE] > > E-mail de beneficiário vem sempre mascarado, em toda resposta e todo webhook. Se você > precisa exibir o parceiro na sua interface, guarde o e-mail do seu lado no momento em > que criou a cobrança. ## Validações | Regra | Limite | | - | - | | Quantidade de beneficiários | 1 a 9, além de você (10 no total) | | `percentage` de cada um | 0.01 a 99.99 | | Soma das `percentage` | Menor que 100.00 | | Sua fatia | Estritamente a maior | | `recipientEmail` | Conta PurinCash existente, única na lista, diferente da sua | | `amountCents` | 80 a 500000 (R\$ 0,80 a R\$ 5.000,00) | Qualquer uma quebrada devolve `400` na criação. Nada é criado pela metade. > [!NOTE] > > O teto de R\$ 5.000 vale só para cobrança com split. Cobrança PIX comum não tem esse > limite. ## Quem paga a taxa A taxa do gateway sai **inteira da sua parte**. Os outros beneficiários recebem a porcentagem cheia sobre o valor bruto. **1. O cliente paga o valor cheio** R\$ 100,00, ou seja, 10000 centavos. **2. Os beneficiários recebem sobre o bruto** O sócio com 30% leva R\$ 30,00. **3. A taxa desconta da sua fatia** Supondo 2% + R\$ 0,50, a taxa é R\$ 2,50. **4. Você fica com o resto** 10000 − 3000 − 250 = 6750 centavos, ou R\$ 67,50. > [!WARNING] > > Se a taxa for maior que a sua fatia, você recebe 0. Nunca fica negativo, mas fica em > zero. Vale conferir isso quando a sua margem é apertada e o valor da cobrança é baixo. ## Arredondamento Cada beneficiário recebe `floor(bruto × percentage)`. O resto é seu, o que garante que a soma creditada mais a taxa feche exatamente com o bruto, sem centavo sumido. Um caso com dízima, para deixar concreto. Cobrança de R\$ 100,00, taxa de R\$ 2,50, dois beneficiários com 33.33% cada, você com 33.34%: | Quem | Conta | Recebe | | - | - | - | | Beneficiário A | `floor(10000 × 0.3333)` | 3333 centavos | | Beneficiário B | `floor(10000 × 0.3333)` | 3333 centavos | | Você | `10000 − 3333 − 3333 − 250` | 3084 centavos | 3333 + 3333 + 3084 + 250 = 10000. Fecha. ## Consultando o resultado `GET /v1/charges/{paymentId}` com um ID `psplit_` traz o detalhamento de cada fatia, com o valor em centavos que foi creditado e quando: ```json theme={null} { "paymentId": "psplit_a1b2c3d4", "status": "paid", "amountCents": 10000, "gatewayFeeCents": 250, "netAmountCents": 9750, "splits": [ { "recipientEmail": "vo***@exemplo.com", "percentage": 70, "isOwner": true, "amountCents": 6750, "creditedAt": "2026-05-28T12:15:00.000Z" }, { "recipientEmail": "so***@exemplo.com", "percentage": 30, "isOwner": false, "amountCents": 3000, "creditedAt": "2026-05-28T12:15:00.000Z" } ], "paidAt": "2026-05-28T12:15:00.000Z" } ``` > [!TIP] > > Use `amountCents` de cada split, e não `percentage × total` recalculado por você. O > arredondamento já foi decidido no crédito, e refazer a conta gera divergência de um > centavo no seu relatório. ## Recebendo o aviso Cobrança com split avisa pelo **mesmo evento** de qualquer outra cobrança PIX, `charge.paid`. É o mesmo fato do domínio, e o handler que você já tem continua valendo. O que distingue vem no corpo: o `paymentId` começa com `psplit_` e chegam `gatewayFeeCents`, `netAmountCents` e o array `splits` com o que cada um recebeu: os mesmos números do `GET` acima, sem precisar consultar. O corpo também traz quem pagou (`payer`, `cpf`, `bank`) quando o banco de origem informa. > [card] [Corpo completo do evento](/webhooks/pagamentos#cobrança-com-split) > > Campo a campo, com exemplo. > [!WARNING] > > O `callbackUrl` da cobrança com split só passou a disparar recentemente. Se você integrou > antes e concluiu que o campo não fazia nada, o evento agora chega. Confira se o seu > handler não vai tratar `psplit_` como cobrança desconhecida. ## Limitação conhecida `metadata` não é aceito em cobrança com splits. Se você precisa amarrar a cobrança a um pedido interno, guarde o `paymentId` do seu lado na hora da criação. # Subcontas Source: https://docs.purin.cash/guias/subcontas Um saldo separado para cada cliente seu, sem você manter essa contabilidade na mão. Se você roda uma plataforma (marketplace, SaaS que cobra pelos clientes, rede de revendedores), uma pergunta aparece rápido: *do dinheiro que entrou, quanto é de quem?* Subconta é a resposta da API pra isso. Você cria uma subconta por cliente, aponta as cobranças pra ela, e o saldo de cada um passa a ser mantido pelo gateway: creditado quando o pagamento confirma, debitado quando você transfere, consultável a qualquer momento com extrato completo. ## O que uma subconta é (e o que não é) > [card] É > > Um ledger com saldo próprio dentro da **sua** conta. > > Criada por API, na hora, sem documento nem aprovação. > > Extrato permanente de tudo que entrou e saiu. > [card] Não é > > Uma conta PurinCash: não tem login, painel nem chave de API própria. > > Um destino de dinheiro real: o valor continua na carteira da **sua** conta. > > Um cadastro com KYC: a relação com o cliente final é sua. > [!NOTE] > > O dinheiro real fica sempre na sua carteira, e o saldo da subconta diz que fatia dele > pertence a cada cliente. É por isso que criar subconta é instantâneo. E é também por > isso que um bug seu de atribuição nunca move dinheiro de verdade: a camada é contábil. ## Criando uma subconta ```bash theme={null} curl -X POST https://api.purin.cash/v1/subaccounts \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Loja do João", "email": "joao@exemplo.com", "externalId": "cliente_42", "metadata": "{\"plano\":\"pro\"}" }' ``` ```json Resposta 201 theme={null} { "subaccountId": "sacc_a1b2c3d4e5f60718293a4b5c6d7e8f90", "name": "Loja do João", "email": "joao@exemplo.com", "externalId": "cliente_42", "active": true, "balanceCents": 0, "totalReceivedCents": 0, "metadata": "{\"plano\":\"pro\"}", "createdAt": "2026-08-04T12:00:00.000Z", "updatedAt": "2026-08-04T12:00:00.000Z" } ``` **name** `string` *required* Nome do cliente. Até 100 caracteres, contados como o JavaScript conta (unidades UTF-16): acentos e cedilha valem 1, mas emoji e outros caracteres fora do plano básico valem 2. Na prática só aparece com nome cheio de emoji. **externalId** `string` O ID do cliente **no seu sistema**. Vale usar sempre: ele é único por ambiente, e criar de novo com o mesmo `externalId` devolve `409` com o `subaccountId` da existente. Ou seja: a criação vira idempotente, e o retry do seu código nunca duplica cliente. **email** `string` Contato do cliente. Informativo, não autentica nada. **metadata** `string` String JSON de até 2 KB, devolvida sem alteração. Omitir é o mesmo que mandar `"{}"`: o campo sempre volta preenchido, nunca ausente nem `null`. Limite de 1000 subcontas por conta e por ambiente. Guarde o `sacc_...` junto do cadastro do seu cliente: é ele que você vai usar em todas as outras chamadas. ## Cobrando para uma subconta Adicione `subaccountId` na criação da cobrança. Funciona em `POST /v1/charges` e em `POST /v1/payments`, com PIX: ```bash theme={null} curl -X POST https://api.purin.cash/v1/charges \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "valueCents": 19900, "description": "Pedido #1042 da Loja do João", "subaccountId": "sacc_a1b2c3d4e5f60718293a4b5c6d7e8f90", "callbackUrl": "https://plataforma.com/webhooks/purincash" }' ``` O resto do fluxo é o de [sempre](/guias/pix): o cliente final paga o PIX, o webhook chega. O passo novo é o seguinte: **o líquido da venda é creditado no ledger da subconta** no mesmo instante em que entra na sua carteira. O crédito é idempotente: reenvio de webhook não credita duas vezes. O webhook `charge.paid` / `payment.paid` vem com o campo `subaccountId` quando a cobrança tem subconta, então o seu handler sabe de quem é a venda sem consultar nada: ```json theme={null} { "event": "charge.paid", "paymentId": "psc_9f8e7d6c5b4a", "amountCents": 19900, "status": "paid", "subaccountId": "sacc_a1b2c3d4e5f60718293a4b5c6d7e8f90", "paidAt": "2026-08-04T12:05:00.000Z" } ``` > [!NOTE] > > O valor creditado no ledger é o **líquido**, o mesmo que entrou na sua carteira, já > sem a taxa do gateway. Assim a soma dos saldos das subcontas nunca promete mais > dinheiro do que existe. ### Onde `subaccountId` não entra | Situação | Comportamento | | - | - | | Cobrança com `splits` | `400`. Split divide entre contas reais; subconta atribui num ledger. Os dois juntos deixariam ambíguo quem possui o quê | | `paymentMethod: "ltc"` em `/v1/payments` | `400`. A confirmação de LTC segue um caminho próprio que não alimenta o ledger | | `paymentMethod` em `/v1/charges` | `400`. Cobrança é PIX; o campo nem existe nela. Pra LTC, use `/v1/payments` | | Cartão (`/v1/card-payments`) | Campo não existe. Subconta é PIX por enquanto | | Assinaturas (`/v1/subscriptions`) | `400` se vier preenchido (`null` e vazio são ignorados). PIX recorrente não credita o ledger; aceitar e descartar faria você perder a atribuição sem aviso | | Saques (`/v1/payouts`) | `400` se vier preenchido (`null` e vazio são ignorados). Esse endpoint não debita ledger de subconta. Pra sacar atribuído, use [`/v1/subaccounts/{id}/payout`](#saque-direto-da-subconta) | | Subconta desativada | `400` na criação da cobrança | > [!WARNING] > > Em `/v1/payments` e `/v1/charges`, onde o campo **é** aceito, `subaccountId: ""` > devolve `400` em vez de criar a venda sem dono. É proposital: o padrão > `subaccountId: cliente.saccId || ""` transformaria um id faltando em dinheiro > caindo no pool geral sem aviso, e você só descobriria fechando o mês. Pra venda > sem atribuição, **omita** o campo. ### Reconciliando depois Cada venda vive na listagem do endpoint que a criou, e as duas aceitam `?subaccountId=` e devolvem o `subaccountId` de cada item (`null` quando não há atribuição): | Você criou com | Reconcilie com | | - | - | | `POST /v1/charges` (ids `psc_`) | `GET /v1/charges?subaccountId=sacc_...` | | `POST /v1/payments` (ids `psa_`) | `GET /v1/payments?subaccountId=sacc_...` | > [!WARNING] > > As duas listas são **separadas**: `/v1/payments` não devolve cobranças `psc_` e > vice-versa. Se você usa os dois endpoints, feche o mês do cliente somando as > duas, ou use o [extrato da subconta](#consultando-saldo-e-extrato), que junta > tudo o que foi creditado, independente da origem. `?subaccountId=` malformado devolve `400` e um `sacc_` que não existe na sua conta devolve `404`, nunca uma lista vazia que pareceria "cliente sem vendas". ## Consultando saldo e extrato ```bash theme={null} curl https://api.purin.cash/v1/subaccounts/sacc_a1b2c3d4.../transactions \ -H "Authorization: Bearer $PURINCASH_KEY" ``` ```json theme={null} { "subaccountId": "sacc_a1b2c3d4e5f60718293a4b5c6d7e8f90", "balanceCents": 45900, "transactions": [ { "type": "sale", "amountCents": 19400, "refId": "psc_9f8e7d6c5b4a", "description": "Venda confirmada", "createdAt": "2026-08-04T12:05:00.000Z" }, { "type": "transfer_out", "amountCents": -30000, "refId": "tr_1a2b3c4d5e6f", "description": "Comissão da plataforma", "createdAt": "2026-08-03T09:00:00.000Z" } ], "total": 2, "limit": 50, "offset": 0 } ``` Os `amountCents` vêm com sinal: crédito positivo, débito negativo. Somar a coluna reproduz o saldo. Cada lançamento tem um `id` próprio (estável, serve pra dedupe) e o `refId` amarra à origem: `psc_`/`psa_` é uma venda, `tr_`/`idem_` é transferência, `saq_` é saque feito pelo painel e `adj_` é correção contábil do suporte. O extrato aceita `?from=` e `?to=` (ISO 8601) pra fechar o mês de um cliente sem baixar tudo, e `?type=` com qualquer um dos tipos: `sale`, `transfer_in`, `transfer_out`, `payout`, `payout_reversal`, `adjust`. `GET /v1/subaccounts` lista todas com saldo, e aceita `?externalId=` pra achar um cliente pelo **seu** ID sem guardar o `sacc_` do lado de lá. O `GET` de uma subconta traz também `medBlockedCents` e `availableCents`: MED (disputa) atribuído a uma venda daquela subconta **bloqueia** o valor nela: não debita, espelhando a carteira mãe. O `availableCents` é o saldo menos esse bloqueio, e é ele que limita repasse enquanto a disputa vive. ## Transferindo saldo `POST /v1/subaccounts/{id}/transfer` move valor entre a subconta e a sua conta, nos dois sentidos. É uma operação contábil, nenhum PIX é disparado: ```bash Cobrar sua comissão theme={null} curl -X POST https://api.purin.cash/v1/subaccounts/sacc_a1b2c3d4.../transfer \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "direction": "to_master", "amountCents": 30000, "description": "Comissão da plataforma de julho" }' ``` ```bash Dar bônus ou estorno theme={null} curl -X POST https://api.purin.cash/v1/subaccounts/sacc_a1b2c3d4.../transfer \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "direction": "to_subaccount", "amountCents": 5000, "description": "Crédito de boas-vindas" }' ``` `to_master` exige saldo suficiente na subconta, e a checagem é atômica: duas transferências concorrentes nunca deixam o saldo negativo. A `description` aparece no extrato. Capriche nela: é o que o seu financeiro vai ler depois. `to_subaccount` exige **lastro**: o valor precisa caber no que a sua carteira tem de sacável e ainda não pertence a nenhuma subconta (o `unattributedCents` do [`GET /v1/wallet`](/guias/carteira)). Aporte atribui dinheiro que existe: não cria. Sem lastro, `400 Insufficient unattributed master balance` com o disponível no corpo. Se duas transferências concorrentes disputarem o mesmo lastro, a que estourar o limite volta atrás com `409`: repita a chamada. ### Direto de uma subconta para outra Para mover saldo entre duas subcontas sem passar pela conta principal, use `between_subaccounts` e informe o destino em `toSubaccountId`: ```bash theme={null} curl -X POST https://api.purin.cash/v1/subaccounts/sacc_a1b2c3d4.../transfer \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "direction": "between_subaccounts", "toSubaccountId": "sacc_f0e1d2c3...", "amountCents": 12000, "description": "Repasse entre parceiros" }' ``` A subconta do caminho é a origem. As duas precisam ser da mesma conta, e o destino precisa estar ativo. A resposta traz `balanceCents` (origem) e `toBalanceCents` (destino), e o extrato ganha um lançamento em cada uma, com o mesmo `transferId`. Como o total atribuído às subcontas não muda, essa transferência não depende de lastro. O valor retido por disputa na origem não pode ser movido. ### Repetindo sem duplicar Mande `idempotencyKey` **no corpo** (não é header) e o retry de rede deixa de ser um risco: a repetição devolve `200` com a transferência original e `idempotentReplay: true`, em vez de mover o valor de novo. ```bash theme={null} curl -X POST https://api.purin.cash/v1/subaccounts/sacc_a1b2c3d4.../transfer \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "direction": "to_master", "amountCents": 30000, "idempotencyKey": "fechamento-2026-07-cliente-42" }' ``` A chave aceita `[A-Za-z0-9_-]`, até 64 caracteres, e é escopada à subconta. Ela vira o `refId` do lançamento com o prefixo `idem_`, e é por isso que o extrato mostra `idem_fechamento-2026-07-cliente-42` no lugar de um `tr_` aleatório. Sem a chave, cada requisição é uma transferência nova. ### As regras do `amountCents` `amountCents` é **número JSON inteiro**, de 1 a 100.000.000.000 (1 bilhão de reais). Fora disso é `400`, sem conserto silencioso: | Enviado | Resposta | | - | - | | `"1000"` (string) | `400`: número JSON, não string | | `10.5` | `400`: inteiro, nada de arredondar por você | | `1e21` ou acima do teto | `400`: acima do teto é dedo no teclado, não pagamento | | `0` ou negativo | `400` | O mesmo vale para os campos de texto: `name` acima de 100, `externalId` acima de 200, `description` acima de 200 e `metadata` acima de 2048 caracteres são `400`: nada é truncado em silêncio. `metadata` precisa **ser** string (serialize com `JSON.stringify` antes); `active` precisa ser boolean de verdade (`"false"` entre aspas é `400`). `externalId` é imutável (o `PUT` recusa) e **case-sensitive**: `cli_1` e `CLI_1` são clientes diferentes. ## Pagando o cliente de verdade Duas formas. A direta é um `POST` só; a de dois passos continua valendo e é a que funciona também em sandbox. ### Saque direto da subconta `POST /v1/subaccounts/{id}/payout` debita o ledger do cliente e manda o PIX na mesma operação. ```json theme={null} { "amountCents": 15000, "pixKey": "cliente@exemplo.com", "idempotencyKey": "repasse-2026-09-01-cli42" } ``` | Campo | | | - | - | | `amountCents` | Inteiro, em centavos. `15000` é cento e cinquenta reais | | `pixKey` | A chave PIX **do seu cliente** | | `idempotencyKey` | Opcional, e é o que impede o retry de pagar duas vezes. Use sempre | Se a subconta tiver [markup de saque](#markup-no-saque), o `amountCents` continua sendo o que é debitado dela por inteiro, e o saque nasce já sem a sua parte. > [!WARNING] > > **Este endpoint precisa do escopo `subcontas.sacar`, e ele nunca é concedido > implicitamente.** Chave sem lista de escopos, que por compatibilidade pode todo o > resto, recebe `403` aqui até alguém marcar a caixinha no painel. É proposital: ligar > um poder novo de mandar dinheiro pra fora não pode dar esse poder, de graça, a todas > as chaves que você já emitiu. `subcontas.gerenciar` também não abre este, porque > aquele mexe em atribuição e este mexe em dinheiro. Resposta `200` (ou `202`, quando o banco ainda não confirmou), no mesmo formato do saque turbo. Chamar de novo com a mesma `idempotencyKey` devolve o saque original com `idempotentReplay: true`, sem pagar nada. Se aquele saque tiver terminado em estorno, a resposta é `409` com `reversed: true`: nada saiu, e a instrução é repetir com uma chave nova, para você não marcar como pago um repasse que não aconteceu. Live-only. Chave de sandbox recebe `400`, porque subconta de teste não tem lastro. Recusa também deixa rastro: a tentativa fica registrada com status `falhou` e o motivo, e aparece em [`GET /v1/payouts`](/guias/saques) junto com os saques normais. O dinheiro volta pro ledger da subconta no mesmo instante, com um lançamento de estorno que cita o motivo. Isso existe pra que um valor que sumiu e voltou no extrato sempre tenha uma contrapartida procurável, em vez de virar um par de linhas sem explicação. ### Consultando o saque `GET /v1/subaccounts/{id}/payouts/{payoutId}` diz como o saque terminou. O `{payoutId}` pode ser qualquer um dos identificadores que você tem do saque, e todos devolvem a mesma resposta: | Você tem | De onde ele vem | | - | - | | `withdrawal.id` ou `code` (`TURBO-...`) | Da resposta do saque. O `code` também vem no `202` e no webhook `subaccount.payout` | | `code` `SAQ-API-...` | Da resposta `202` de um saque que entrou na fila de conferência (`turboReason` diz por quê). O `withdrawal.id` também vem nesse `202` | | `saq_idem_` + a sua `idempotencyKey` | De você mesmo: é o caminho de quem tomou um timeout e ficou sem resposta | | `saq_...` | Do `refId` do lançamento `payout`, no extrato | | `SUB-...` | Do lançamento de estorno de um saque recusado antes de chegar ao banco | ```json theme={null} { "payoutId": "saq_idem_repasse-2026-09-01-cli42", "withdrawalId": "68c0a1b2c3d4e5f60718293a", "code": "TURBO-7A1F2C9D", "subaccountId": "sacc_3f1e9a0b2c4d6e8f0a1b3c5d7e9f1a2b", "status": "completed", "reversed": false, "amountCents": 15000, "feeCents": 0, "platformFeeCents": 100, "netAmountCents": 14900, "pixKey": "client***", "endToEndId": "E12345678202609011200abcdef01234", "recipientName": "Cliente Exemplo", "recipientBankName": "Banco Exemplo", "failureReason": null, "createdAt": "2026-09-01T12:00:00.000Z", "completedAt": "2026-09-01T12:00:02.000Z", "reversedAt": null } ``` | `status` | O que significa | O que fazer | | - | - | - | | `completed` | O PIX saiu e o banco confirmou | Nada | | `processing` | Ainda não terminou, inclusive o saque em verificação do `202` | **Não repita o saque**: o dinheiro pode ter saído. Consulte de novo mais tarde | | `failed` | Nada foi enviado. `failureReason` diz o motivo | Com `reversed: true` o valor já voltou pra subconta e dá pra sacar de novo, com uma `idempotencyKey` nova. Com `reversed: false`, fale com o suporte | `amountCents` é o que saiu da subconta (o mesmo valor do pedido), `feeCents` é o [markup de saque](#markup-no-saque) dela, `platformFeeCents` é a taxa do saque e `netAmountCents` é o que a chave PIX recebe. A chave volta mascarada. Consultar precisa de `subcontas.gerenciar` **ou** `subcontas.sacar`: a chave que só faz repasses consegue acompanhar os repasses que dispara. ### Ou em dois passos **1. Traga o saldo pra conta principal** `POST /v1/subaccounts/{id}/transfer` com `direction: "to_master"` e o valor do repasse. Isso debita o ledger do cliente e documenta o repasse no extrato dele. **2. Saque com a chave PIX do cliente** [`POST /v1/payouts`](/guias/saques) com `method: "pix"` e o `walletAddress` sendo a chave PIX **do cliente**. Repare que aqui o valor é `amount`, em reais, e não em centavos. > [!NOTE] > > O que os dois caminhos têm em comum, e é o que importa: o dinheiro sai pelo mesmo > fluxo de saque de sempre (chave verificada, limites, MED e aprovação). No caminho > direto, o débito no ledger acontece antes de qualquer dinheiro andar e é condicionado > ao saldo da subconta menos o que estiver retido por MED dela. Se o banco negar depois, > o valor volta pro ledger da subconta de origem, e não pro caixa geral. > > O mesmo saque existe no painel, em Subcontas na aba Sacar. > [!WARNING] > > `POST /v1/payouts` recusa `subaccountId` com `400` de propósito. Ali não há baixa no > ledger: o dinheiro sairia da carteira e o extrato da subconta continuaria dizendo que > o valor está lá. Pra sacar atribuído, use o endpoint desta seção. > [!WARNING] > > **O saque da conta principal não alcança o que é das subcontas.** O sacável > (`withdrawable` do `GET /v1/wallet`) desconta a soma dos saldos de todas as > subcontas: se o dinheiro está atribuído a um cliente, ele sai pela subconta, não > por cima dela. Saque acima do sacável leva `400` com o motivo detalhado. Pra > liberar um valor, traga de volta com `to_master` primeiro. ## Taxa da plataforma (markup) Cada subconta pode ter um markup: percentual (`feePercent`, 0 a 100 com até 2 casas) e/ou fixo em centavos (`feeFixedCents`). A cada venda confirmada, o markup sai do líquido ANTES do crédito: a subconta recebe `liquido - markup`, e a sua fatia fica na conta principal sem nenhum movimento extra: você não precisa mais varrer as vendas e cobrar comissão com `to_master` uma a uma. ```bash theme={null} curl -X POST https://api.purin.cash/v1/subaccounts \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Loja do João", "externalId": "cliente_42", "feePercent": 5, "feeFixedCents": 100 }' ``` Com essa configuração, uma venda que rendeu R\$ 100,00 líquidos credita R\$ 94,00 na subconta (5% + R\$ 1,00 ficam com você). O lançamento `sale` do extrato grava o `feeCents` cobrado, então a reconciliação fecha: `liquido = amountCents + feeCents`. Regras que valem a pena saber: * O percentual arredonda **pra baixo** (na dúvida, o centavo é do cliente), e o markup nunca passa do líquido: taxa fixa maior que a venda credita zero, não gera dívida. * Mudar a taxa vale só pra vendas FUTURAS: cada lançamento carrega o `feeCents` da época, nada é reescrito. * Transferências não têm markup. Saques têm o seu próprio, logo abaixo. ### Markup no saque O par acima só vale na venda. Pra cobrar também quando o cliente **saca**, existem dois campos separados: `payoutFeePercent` (0 a 100, até 2 casas) e `payoutFeeFixedCents`. São campos próprios de propósito. Reaproveitar os de venda faria toda subconta que já cobra pra receber passar a cobrar o mesmo pra sacar, sem ninguém ter pedido: quem cobra 5% de comissão não necessariamente quer cobrar 5% de repasse. O padrão é zero nos dois, e zero é exatamente o comportamento de quem nunca configurou nada. ```bash theme={null} curl -X PUT https://api.purin.cash/v1/subaccounts/sacc_a1b2c3d4... \ -H "Authorization: Bearer $PURINCASH_KEY" \ -H "Content-Type: application/json" \ -d '{ "payoutFeePercent": 1, "payoutFeeFixedCents": 200 }' ``` Com essa configuração, um saque de `amountCents: 15000` nessa subconta: | | | | - | - | | Debitado da subconta | R\$ 150,00, o valor **cheio** que você pediu | | Seu markup | R\$ 3,50 (1% + R\$ 2,00) | | Vira saque de | R\$ 146,50, e é daí que sai a taxa da plataforma | > [!NOTE] > > O dinheiro não se move em lugar nenhum. A subconta é debitada o cheio e o saque > nasce pelo líquido, então o seu markup simplesmente **fica** na carteira da conta > principal: sem crédito extra, sem lançamento paralelo, sem uma segunda escrita que > possa falhar no meio de um saque. Onde ler o valor cobrado: no extrato da subconta, o lançamento `payout` grava o `feeCents` do markup ao lado do débito cheio. Vale reparar que a resposta do saque **já vem líquida do seu markup**: o `amount` dela é o valor que virou saque (R\$ 146,50 no exemplo) e o `fee` é a taxa da plataforma, não a sua. * Markup que consome o valor inteiro é `400`, com a taxa citada na mensagem. Saque que só cobra taxa é cobrança disfarçada, não saque. * Se o banco recusar depois, o estorno devolve à subconta o valor **cheio**, markup incluído: você não fica com a taxa de um saque que não aconteceu. * Mudar a taxa vale só pra saques FUTUROS, igual à de venda. * Vale nos dois caminhos: pelo endpoint e pelo painel, em Subcontas na aba Sacar. É o mesmo motor. > [!NOTE] > > No painel as duas taxas ficam no mesmo lugar: ao criar a subconta, e depois em > **Suas taxas neste cliente**, dentro do cliente. Só o dono da conta edita; membro > de equipe vê e não mexe. ## Webhooks de movimentação Todo movimento do ledger que não nasce de uma cobrança avisa a [URLs da conta](/webhooks/visao-geral): `subaccount.transfer` (API **e painel**, com `origin` dizendo quem foi), `subaccount.payout` e `subaccount.payout_reversal`. A venda continua avisando pelo `charge.paid`/`payment.paid` com `subaccountId`. Formato completo em [Webhooks → Subcontas](/webhooks/subcontas). ## Desativando `PUT /v1/subaccounts/{id}` com `active: false`. A subconta some das novas cobranças e não aceita mais entradas **novas**, mas o saldo continua consultável e o `to_master` continua funcionando. Desativar não prende dinheiro. Excluir não existe: o extrato é histórico financeiro, e histórico financeiro não se apaga. > [!NOTE] > > Cobrança criada **antes** da desativação continua creditando a subconta quando for > paga. A desativação fecha a porta pra cobranças novas; o que já estava emitido com o > `subaccountId` daquele cliente pertence a ele: desviar esse crédito pro pool geral é > que seria erro contábil. ## Em sandbox Tudo funciona com chave `ps_test_`, com o mesmo isolamento do resto da API: subcontas de teste são separadas das reais. O `simulate-paid` credita o ledger aplicando o **mesmo markup** configurado na subconta (`feePercent`/`feeFixedCents`), igual à produção: o lançamento `sale` sai com o `feeCents` cobrado, então dá pra testar o ciclo inteiro, taxa incluída, sem mover um centavo: criar subconta, cobrar, simular pagamento, ver o saldo e o markup, transferir. > [card] [Referência completa](/api-reference/introducao) > > Os 6 endpoints de subcontas com playground, na seção Subcontas da Referência. # Documentação PurinCash Source: https://docs.purin.cash/index Como integrar pagamentos PIX, cartão, cripto e assinatura na PurinCash: referência da API, webhooks e um sandbox pra testar o fluxo inteiro sem mover dinheiro. A PurinCash é um gateway de pagamento. A API existe pra você cobrar sem construir a parte difícil: integração bancária, conciliação, retentativa de webhook, controle de saldo. O ciclo é sempre o mesmo, e vale entender ele antes de escrever qualquer linha: 1. **Você cria a cobrança** com o valor e, se quiser, a URL que vai receber o aviso. 2. **A resposta traz o que o cliente precisa pra pagar.** O formato muda conforme o meio: PIX volta como código copia e cola, cartão como URL de checkout hospedado, cripto como endereço de carteira. 3. **O cliente paga.** Você não participa desse passo, e é de propósito: dado de cartão e chave PIX nunca passam pelo seu servidor. 4. **A gente avisa.** Um `POST` assinado na sua URL, o valor entra no seu saldo, e o status fica disponível por consulta pra você conferir quando quiser. Do passo 2 em diante é igual nos quatro meios de pagamento, incluindo o formato do webhook. Aprender um é aprender todos. A API é REST sobre JSON e autentica com um header. Não existe SDK próprio pra instalar nem biblioteca obrigatória: qualquer linguagem que faça requisição HTTP integra, e os exemplos daqui vêm em cURL, JavaScript, Python e PHP. > [card] [Sua primeira cobrança](/guias/primeira-cobranca) > > Do zero ao PIX pago, em sandbox, sem gastar um centavo. > [card] [Referência da API](/api-reference/introducao) > > Os 35 endpoints, com playground pra testar na hora. > [card] [Webhooks](/webhooks/visao-geral) > > Como a gente te avisa, e como você confere que fomos nós. > [card] [Antes de ir pra produção](/guias/producao) > > A lista curta do que costuma quebrar no primeiro dia. ## O caminho mais curto **1. Pegue uma chave de teste** No painel, em [Equipe & API → Developer API](https://purin.cash/dashboard/api), gere uma chave `ps_test_`. Ela é mostrada uma vez só. **2. Crie a cobrança** ```bash theme={null} curl -X POST https://api.purin.cash/v1/charges \ -H "Authorization: Bearer ps_test_sua_chave" \ -H "Content-Type: application/json" \ -d '{ "valueCents": 1990, "description": "Plano Pro" }' ``` **3. Mostre o brCode pro cliente** A resposta traz `pix.brCode`, que é o copia e cola, e `pix.qrCodeImage`, que é a imagem do QR. Com chave `ps_test_` esse código é falso de propósito (vem como `SANDBOX_PIX_...`). Ninguém consegue pagar, e é isso que você quer enquanto testa. **4. Marque como pago você mesmo** Como ninguém vai pagar um PIX de teste, quem confirma é você. Não tem fila nem aprovação de ninguém: ```bash theme={null} curl -X POST https://api.purin.cash/v1/sandbox/charges/psc_a1b2c3d4/simulate-paid \ -H "Authorization: Bearer ps_test_sua_chave" ``` **5. O webhook chega** A cobrança vira `paid` e a gente faz `POST` na sua `callbackUrl` com o evento, igual ao que acontece em produção. Em produção esse passo acontece sozinho quando o PIX cai de verdade. ## O que dá pra cobrar > [card] [PIX](/guias/pix) > > Copia e cola ou QR, confirmação em segundos. É o caminho padrão. > [card] [Cartão de crédito](/guias/cartao) > > Checkout hospedado. Você redireciona e recebe o resultado. > [card] [Assinatura](/guias/assinaturas) > > PIX recorrente, semanal a anual, cobrança gerada sozinha. > [card] [Litecoin](/guias/cripto) > > Endereço LTC com o valor convertido na cotação do momento. > [card] [Split](/guias/splits) > > Divide o valor entre até 10 contas no momento em que o dinheiro entra. > [card] [Subcontas](/guias/subcontas) > > Um saldo por cliente seu, creditado a cada venda, com extrato. > [card] [Saque](/guias/saques) > > Tira o saldo por PIX, LTC ou USDT sem sair do código. ## Dois detalhes que economizam uma tarde > [accordion] O ambiente vem do prefixo da chave, não de um parâmetro > > `ps_live_` opera em produção e `ps_test_` em sandbox. Não existe campo de ambiente no > body nem na URL. Os dados são isolados: chave de teste nunca enxerga cobrança real, e > o contrário também vale. Detalhes em [Ambientes](/guias/ambientes). > [accordion] Existem dois tipos de produto com o mesmo nome > > `GET /v1/products` lista os produtos de cobrança criados pela API. > `GET /v1/store/products` lista os produtos da sua loja do Discord. São coleções > separadas, então a primeira pode devolver lista vazia com a loja cheia. O > [guia de produtos](/guias/produtos) explica quando usar cada uma. > [!NOTE] > > Integrando com ajuda de IA? Esta documentação inteira existe em texto puro em > [docs.purin.cash/llms-full.txt](https://docs.purin.cash/llms-full.txt), gerado a > partir destas páginas. Cole no seu assistente e ele responde sobre a API sem inventar > endpoint. > [card] [Dúvida no meio da integração?](https://discord.gg/8eyQQFZZxY) > > O suporte fica no Discord, com gente que mexe na API. Traga o código HTTP, o corpo do > erro e o `paymentId`. # Validando a assinatura Source: https://docs.purin.cash/webhooks/assinatura Sem isso, qualquer pessoa na internet consegue dizer que pagou. Todo webhook chega assinado com HMAC-SHA256 no header `X-Webhook-Signature`. Validar essa assinatura é o que separa "recebi uma notificação" de "recebi uma notificação nossa". > [!WARNING] > > A sua `callbackUrl` é pública. Sem validação, um `curl` com `{"status":"paid"}` é > suficiente para alguém levar o seu produto sem pagar. ## Onde fica o secret Cada loja tem o seu, em [Dashboard → Equipe & API → Developer API](https://purin.cash/dashboard/api). Guarde numa variável de ambiente do seu backend. Se vazar, regenere pelo painel. O antigo para de funcionar na hora. ### Um secret por URL Na mesma tela, cada URL da conta pode ter o secret dela. Deixe vazio e ela é assinada com o secret da conta, que é como toda URL funciona por padrão: quem já tinha URLs configuradas não precisa mexer em nada. Vale a pena separar quando as URLs vão para sistemas diferentes. Um secret por destino significa que o que vazar no sistema de BI não assina evento para o backend de produção. > [!NOTE] > > O `callbackUrl` que você manda ao criar uma cobrança não está na lista da conta, então > ele é sempre assinado com o secret da conta. ## Como calcular O HMAC é sobre o **corpo cru** da requisição, a string exata que chegou, antes de qualquer `JSON.parse`. Se você parsear e re-serializar, a ordem das chaves ou o espaçamento mudam e a assinatura nunca vai bater. Compare com `timingSafeEqual` ou equivalente. Comparação com `===` vaza informação pelo tempo de execução. ```js Node.js (Express) theme={null} import crypto from "node:crypto"; import express from "express"; const app = express(); // express.raw, não express.json: precisamos dos bytes originais. app.post("/webhooks/purincash", express.raw({ type: "application/json" }), (req, res) => { const assinatura = req.header("X-Webhook-Signature") || ""; const esperado = crypto .createHmac("sha256", process.env.PURINCASH_WEBHOOK_SECRET) .update(req.body) .digest("hex"); const valida = assinatura.length === esperado.length && crypto.timingSafeEqual(Buffer.from(assinatura), Buffer.from(esperado)); if (!valida) return res.status(401).send("assinatura inválida"); const evento = JSON.parse(req.body.toString()); const id = req.header("X-Webhook-Id"); // Grave, responda, processe depois. enfileirar({ id, evento }); res.json({ ok: true }); }); ``` ```php PHP theme={null} true]); ``` ```python Python (Flask) theme={null} import hmac, hashlib, os, json from flask import Flask, request, abort app = Flask(__name__) SECRET = os.environ["PURINCASH_WEBHOOK_SECRET"].encode() @app.post("/webhooks/purincash") def webhook(): corpo = request.get_data() # bytes crus assinatura = request.headers.get("X-Webhook-Signature", "") esperado = hmac.new(SECRET, corpo, hashlib.sha256).hexdigest() if not hmac.compare_digest(esperado, assinatura): abort(401) evento = json.loads(corpo) enfileirar(request.headers.get("X-Webhook-Id"), evento) return {"ok": True} ``` ```go Go theme={null} package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" "os" ) func webhook(w http.ResponseWriter, r *http.Request) { corpo, _ := io.ReadAll(r.Body) mac := hmac.New(sha256.New, []byte(os.Getenv("PURINCASH_WEBHOOK_SECRET"))) mac.Write(corpo) esperado := hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(r.Header.Get("X-Webhook-Signature")), []byte(esperado)) { http.Error(w, "assinatura inválida", http.StatusUnauthorized) return } var evento map[string]any json.Unmarshal(corpo, &evento) enfileirar(r.Header.Get("X-Webhook-Id"), evento) w.WriteHeader(http.StatusOK) } ``` ## Idempotência O header `X-Webhook-Id` vem no formato `evento:id`, como `payment.paid:psa_abc123`. Reentrega do mesmo evento usa o **mesmo** valor. ```js theme={null} async function processar(webhookId, evento) { // Chave única no banco: a segunda inserção falha e você sai fora. const novo = await db.webhooksProcessados.insertIfAbsent(webhookId); if (!novo) return; // já tratamos, nada a fazer await entregarProduto(evento); } ``` > [!WARNING] > > Deduplique **antes** de creditar saldo, enviar licença ou disparar e-mail. Depois de > entregar não tem como voltar atrás, e a reentrega não é hipótese remota: é o > comportamento normal quando o seu servidor demora mais de 5 segundos para responder. ## Erros comuns > [accordion] A assinatura nunca bate > > Quase sempre é middleware de JSON rodando antes do handler e consumindo o corpo. Em > Express, `express.json()` global quebra a validação. Registre a rota do webhook com > `express.raw()` antes do parser global. > [accordion] Funciona local e falha em produção > > Proxy ou CDN reescrevendo o corpo. Verifique se não há compressão, reformatação de JSON > ou WAF alterando a requisição no caminho. > [accordion] Bate às vezes > > Você está comparando a assinatura contra um corpo re-serializado. Guarde os bytes > originais em vez de reconstruir o JSON. # subscription.* Source: https://docs.purin.cash/webhooks/assinaturas Autorizou, renovou, falhou, cancelou. Quatro eventos, uma chave estável: o subscriptionId. Uma assinatura é um **Pix Automático**: o cliente paga o primeiro ciclo e, no mesmo QR, autoriza os próximos no app do banco. Depois disso cada renovação é cobrada sozinha. Tudo que muda nela chega na `callbackUrl` que você mandou ao criar (ou na [URL da conta](/webhooks/visao-geral)). O `paymentId` muda a cada ciclo (`psa_sub_…`, `psa_sub_…_c1`, `psa_sub_…_c2`). O **`subscriptionId`** é o mesmo em todos os eventos: é por ele que você acha o assinante. ## `subscription.authorized` O banco do cliente aprovou a recorrência. A partir daqui as renovações saem sozinhas. ```json theme={null} { "event": "subscription.authorized", "subscriptionId": "RN12345678202609200000000001", "paymentId": "psa_sub_a1b2c3d4", "status": "ativa", "amountCents": 4990, "frequency": "MONTHLY", "nextChargeDate": "2026-10-20", "customer": { "name": "João Silva", "externalId": "user_42" }, "metadata": "{\"plano\":\"premium\"}" } ``` > [!NOTE] > > O primeiro pagamento chega separado, como um `payment.paid` normal do `paymentId` da > criação. Autorização e pagamento são atos diferentes no banco e podem chegar em qualquer > ordem. ## `payment.paid` de renovação Cada ciclo pago é um `payment.paid` como outro qualquer, com dois campos a mais: ```json theme={null} { "event": "payment.paid", "paymentId": "psa_sub_a1b2c3d4_c2", "subscriptionId": "RN12345678202609200000000001", "subscriptionCycle": 2, "amountCents": 4990, "status": "paid", "paidAt": "2026-11-20T09:12:00.000Z", "customer": { "name": "João Silva", "externalId": "user_42" }, "metadata": "{\"plano\":\"premium\"}" } ``` `subscriptionCycle` é `0` no primeiro pagamento e cresce a cada renovação. Renove o acesso pelo `subscriptionId`, nunca pelo `paymentId`. ## `subscription.charge_failed` Uma cobrança recorrente não liquidou. A assinatura continua ativa: quando a política permite, a retentativa é pedida sozinha (até 3 em 7 dias), e o ciclo seguinte é enviado normalmente. ```json theme={null} { "event": "subscription.charge_failed", "subscriptionId": "RN12345678202609200000000001", "cycle": 2, "dueDate": "2026-11-20", "reason": "rejeitada", "detail": "AM04 Saldo insuficiente" } ``` `reason` é `rejeitada`, `expirada` ou `cancelada`. `detail` traz o código e a descrição que o banco devolveu, quando há. ## `subscription.cancelled` Acabou: pela loja (`reason: "merchant"`), pelo cliente ou pelo banco (`cancelada`), recusada na autorização (`rejeitada`) ou vencida (`expirada`). Não haverá novas cobranças. ```json theme={null} { "event": "subscription.cancelled", "subscriptionId": "RN12345678202609200000000001", "status": "cancelada", "reason": "merchant", "detail": "cliente pediu" } ``` > [!WARNING] > > Derrube o acesso pela **data**, não pelo evento: quando um ciclo não é pago, a > validade que você guardou vence sozinha. O `subscription.cancelled` é o aviso de que > não vale esperar pela próxima. Mesmas garantias dos outros eventos: assinatura HMAC no `X-Webhook-Signature`, idempotência por `X-Webhook-Id` e reentrega com backoff. Veja [Validando a assinatura](/webhooks/assinatura). # card_payment.paid Source: https://docs.purin.cash/webhooks/cartao Mesma ideia do PIX, dois campos com nome diferente. É onde o handler genérico quebra. Quando um pagamento no cartão é confirmado, a gente faz `POST` na `callbackUrl` daquela cobrança. Se você não mandou nenhuma, o evento vai pra [URL da conta](/webhooks/visao-geral). ```json theme={null} POST https://minhaloja.com/webhooks/purincash Content-Type: application/json X-Webhook-Signature: a3f5b8c2e1d4f7a9b6c8d2e5f1a4b7c9d2e5f8a1b4c7d0e3f6a9b2c5d8e1f4a7 X-Webhook-Id: card_payment.paid:JUE7Y9MPSX { "event": "card_payment.paid", "orderCode": "JUE7Y9MPSX", "amount": 49.90, "status": "paid", "paidAt": "2026-03-18T12:05:00.000Z", "customer": { "name": "João Silva", "email": "joao@exemplo.com" }, "metadata": "{\"pedido\":\"1042\"}" } ``` ## As duas diferenças que importam > [!WARNING] > > O identificador é **`orderCode`**, não `paymentId`. > > O valor vem em **`amount`** (reais, decimal), não em `amountCents`. Se o seu handler é genérico, normalize na entrada em vez de espalhar `if` pelo código: ```js theme={null} function normalizar(evento) { if (evento.event === "card_payment.paid") { return { id: evento.orderCode, valorCents: Math.round(evento.amount * 100), pagoEm: evento.paidAt, customer: evento.customer, metadata: evento.metadata, }; } // payment.paid e charge.paid return { id: evento.paymentId, valorCents: evento.amountCents, pagoEm: evento.paidAt, customer: evento.customer, metadata: evento.metadata, }; } ``` > [!TIP] > > `Math.round(amount * 100)` e não `amount * 100`. Ponto flutuante transforma `49.90 * 100` > em `4989.999999999999`, e o `!==` contra o valor esperado passa a falhar de forma > aleatória. ## Campos **event** `string` *required* Sempre `card_payment.paid`. **orderCode** `string` *required* Código do pedido no cartão. É o mesmo usado em `GET /v1/card-payments/{orderCode}`. **amount** `number` *required* Valor em reais, decimal. **status** `string` *required* Sempre `paid` neste evento. **paidAt** `string` *required* Data e hora da confirmação, em ISO 8601. **customer** `object` `name` e `email`, quando informados na criação. **metadata** `string` A string JSON enviada na criação. Vem `null` quando não houve. ## Garantias As mesmas do PIX: assinatura HMAC em `X-Webhook-Signature`, idempotência em `X-Webhook-Id`, timeout de 5 segundos e até 7 tentativas com backoff. Detalhes em [Visão geral](/webhooks/visao-geral). > [!WARNING] > > Cartão não tem webhook em sandbox, porque cartão não roda em sandbox. Para testar o > handler, faça uma cobrança real de valor baixo em produção. ## Lembrete sobre a successUrl O cliente cair na sua `successUrl` não é confirmação de pagamento. É só o navegador voltando, e a URL pode ser aberta na mão por qualquer um. Libere o produto por este webhook ou por `GET /v1/card-payments/{orderCode}`. # payment.paid e charge.paid Source: https://docs.purin.cash/webhooks/pagamentos O evento que confirma que o PIX caiu, com os dados que só o banco tem. Quando o PIX é confirmado, a gente faz `POST` na `callbackUrl` informada na criação. Se você não mandou nenhuma, o evento vai pras [URLs da conta](/webhooks/visao-geral). O nome do evento depende do recurso: | Evento | Origem | | - | - | | `payment.paid` | `POST /v1/payments`, IDs `psa_` | | `charge.paid` | `POST /v1/charges`, IDs `psc_` | | `charge.paid` | `POST /v1/split-charges`, IDs `psplit_`. [Veja abaixo](#cobrança-com-split) | | `payment.paid` | `POST /v1/payments` com `paymentMethod: "ltc"` ou `"btc"`, quando a transação confirma na blockchain | O corpo é o mesmo nos dois eventos. No pagamento em cripto não há pagador, CPF nem banco: o `txId` é o hash da transação na blockchain, e `amountReais` é o valor em reais do que chegou de fato. ```json theme={null} POST https://minhaloja.com/webhooks/purincash Content-Type: application/json X-Webhook-Signature: a3f5b8c2e1d4f7a9b6c8d2e5f1a4b7c9d2e5f8a1b4c7d0e3f6a9b2c5d8e1f4a7 X-Webhook-Id: charge.paid:psc_a1b2c3d4 { "event": "charge.paid", "paymentId": "psc_a1b2c3d4", "amountCents": 1990, "amountReais": 19.90, "status": "paid", "paidAt": "2026-03-18T12:05:00.000Z", "description": "Plano Pro", "customer": { "name": "João Silva", "email": "joao@exemplo.com", "externalId": "user_42" }, "metadata": "{\"pedido\":\"1042\"}", "payer": "JOAO DA SILVA", "cpf": "12345678909", "bank": "077 - Banco Inter", "endToEndId": "E1818773820260318120500abc12345", "txId": "abc123def456", "deliveredContent": "LICENSE-KEY-ABC-123" } ``` ## Campos **event** `string` *required* `payment.paid` ou `charge.paid`. **paymentId** `string` *required* Identificador do pagamento. É a chave para reconciliar do seu lado. **amountCents** `number` *required* Valor em centavos. **Confira contra o esperado antes de entregar.** **status** `string` *required* Sempre `paid` neste evento. **paidAt** `string` *required* Data e hora da confirmação, em ISO 8601. **customer** `object` `name`, `email` e `externalId`, como você informou na criação. **metadata** `string` A string JSON que você mandou, devolvida sem alteração. **amountReais** `number` O mesmo valor em reais, decimal. Conveniência para exibição. **description** `string` Descrição informada na criação. **deliveredContent** `string` O que foi entregue automaticamente, quando o produto tem entrega automática. Veja [Entrega](/guias/entrega). **subaccountId** `string` Presente quando a cobrança foi criada com [subconta](/guias/subcontas). O ledger dela já foi creditado quando este evento chega. **sandbox** `boolean` `true` quando o evento veio de um endpoint de simulação. ### Dados do pagador Vêm do banco, não de você. Chegam nos dois eventos, e só quando o banco de origem informa: PIX pago por certas instituições não traz o documento, e alguns não trazem nem o nome. **payer** `string` Nome de quem pagou, como consta no banco. **cpf** `string` CPF ou CNPJ de quem pagou, só com os dígitos: `12345678909`. Sem pontuação, para bater direto com o que você guarda do seu lado. Use para conciliar com o extrato bancário e para responder [disputa](/guias/disputas). Alguns bancos de origem mandam o documento censurado. Nesse caso ele vai como veio, com os asteriscos na posição em que o banco os pôs (`***456789**`), e `cpfMasked` chega junto. Nunca chega um pedaço do número passando por número inteiro. **cpfMasked** `boolean` `true` quando o `cpf` veio censurado do banco de origem. Ausente quando o número chegou completo. **bank** `string` Banco de origem, no formato `código - nome`. **endToEndId** `string` Identificador end-to-end da transação no Bacen. Serve para conciliação bancária e para responder [disputa](/guias/disputas). **txId** `string` txid da transação PIX. Em pagamento cripto (LTC/BTC), o hash da transação na blockchain. > [!NOTE] > > Campo opcional é omitido quando não se aplica, e não enviado como `null`. > [!WARNING] > > `cpf` é dado pessoal de quem pagou, e chega inteiro sempre que o banco de origem o > informa por inteiro. Receba em HTTPS, guarde só o tempo que a sua operação precisa e > não exponha em log de aplicação nem em tela de terceiro. A > chave PIX, o telefone e o endereço do pagador **não** saem em webhook nenhum. ## Cobrança com split Cobrança criada em `POST /v1/split-charges` avisa pelo **mesmo evento**, `charge.paid`. É o mesmo fato, um PIX foi pago, e o handler que você já tem continua valendo. O que muda vem no corpo: o `paymentId` começa com `psplit_` e chegam três campos a mais. ```json theme={null} { "event": "charge.paid", "paymentId": "psplit_9f3c1a7b2e5d8046", "amountCents": 10000, "amountReais": 100.00, "status": "paid", "paidAt": "2026-03-18T12:05:00.000Z", "customer": { "name": "João Silva", "email": "joao@exemplo.com" }, "payer": "JOAO DA SILVA", "cpf": "12345678909", "bank": "077 - Banco Inter", "endToEndId": "E1818773820260318120500abc12345", "gatewayFeeCents": 199, "netAmountCents": 9801, "splits": [ { "recipientEmail": "pa***@exemplo.com", "percentage": 30, "amountCents": 3000, "isOwner": false }, { "recipientEmail": "vo***@minhaloja.com", "percentage": 70, "amountCents": 6801, "isOwner": true } ] } ``` **gatewayFeeCents** `number` A taxa retida, em centavos. Sai da **sua** fatia, não da dos beneficiários. A conta inteira está em [Splits](/guias/splits). **netAmountCents** `number` `amountCents` menos `gatewayFeeCents`. É a soma do que foi creditado em `splits[]`. **splits** `array` A repartição de fato, já creditada. Cada item traz `recipientEmail` (mascarado), `percentage`, `amountCents` e `isOwner`. Você entra na lista com `isOwner: true` e fica com o resto: a soma dos beneficiários é sempre menor que 100%. > [!WARNING] > > Use o `amountCents` de cada item, não `percentage × amountCents` recalculado por você. O > arredondamento já foi decidido no crédito, e refazer a conta gera divergência de centavo > no seu relatório, especialmente na sua própria fatia, de onde a taxa saiu. > [!NOTE] > > Se o seu handler precisa distinguir os dois casos, use o prefixo do `paymentId` > (`psplit_`) ou a presença de `splits`. O campo `event` é o mesmo de propósito: é o mesmo > fato do domínio, e o handler que você já tem continua valendo. > > Split não aceita `metadata`, então esse campo não aparece neste corpo. ## Em sandbox O `simulate-paid` sempre envia `event: "payment.paid"`, inclusive ao simular uma cobrança `psc_`. O corpo é enxuto: `event`, `paymentId`, `amountCents`, `status`, `paidAt`, `customer`, `metadata` e `sandbox: true`. > [!WARNING] > > Se o seu handler faz `if (evento.event === "charge.paid")` para cobranças, ele não vai > disparar em sandbox. Trate os dois nomes no mesmo caminho. ## Tratando ```js theme={null} app.post("/webhooks/purincash", express.raw({ type: "application/json" }), async (req, res) => { if (!assinaturaValida(req)) return res.status(401).end(); const evento = JSON.parse(req.body.toString()); const webhookId = req.header("X-Webhook-Id"); // 1. Idempotência antes de tudo. if (!(await marcarComoVisto(webhookId))) return res.json({ ok: true }); // 2. Responder rápido. O resto vai pra fila. res.json({ ok: true }); if (evento.event === "payment.paid" || evento.event === "charge.paid") { const pedido = await buscarPorPaymentId(evento.paymentId); // 3. Valor precisa bater. Status "paid" sozinho não é suficiente. if (!pedido || pedido.valorCents !== evento.amountCents) { return registrarDivergencia(evento); } await liberarAcesso(pedido, evento.deliveredContent); } }); ``` > [card] [Pagou com cartão?](/webhooks/cartao) > > O evento é outro e a forma do corpo muda. Vale a leitura antes de escrever um handler > genérico. # order.paid Source: https://docs.purin.cash/webhooks/pedidos Para quem vende pelo bot no Discord e quer sincronizar estoque ou CRM. Este evento é específico da loja no Discord. Quando um pedido feito pelo bot é pago, a gente avisa a URL configurada na conta. > [!NOTE] > > Diferente do PIX e do cartão, aqui não existe `callbackUrl` por cobrança. Vale a [URL da > conta](/webhooks/visao-geral), configurada uma vez em [Dashboard → Equipe & API → > Developer API](https://purin.cash/dashboard/api), para todos os pedidos do bot. ```json theme={null} POST https://minhaloja.com/webhooks/purincash Content-Type: application/json X-Webhook-Signature: a3f5b8c2e1d4f7a9b6c8d2e5f1a4b7c9d2e5f8a1b4c7d0e3f6a9b2c5d8e1f4a7 X-Webhook-Id: order.paid:665f1a2b3c4d5e6f7a8b9c0d { "event": "order.paid", "orderId": "665f1a2b3c4d5e6f7a8b9c0d", "orderCode": "ORD-7K3B9X", "total": 49.90, "paidAt": "2026-03-18T12:05:00.000Z", "acquirer": "...", "payer": "JOAO DA SILVA", "cpf": "12345678909", "bank": "077 - Banco Inter" } ``` ## Campos **event** `string` *required* Sempre `order.paid`. **orderId** `string` *required* Identificador interno do pedido. **orderCode** `string` *required* Código curto do pedido, o mesmo que aparece para o comprador no Discord. **total** `number` *required* Valor total em reais, decimal. **paidAt** `string` *required* Data e hora da confirmação, em ISO 8601. **acquirer** `string` Identificador interno de qual processadora tratou o pagamento. Pode mudar sem aviso, então não construa lógica em cima dele. ## Quem pagou Os três campos abaixo são os mesmos de [`charge.paid` e `payment.paid`](/webhooks/pagamentos), com o mesmo formato. Eles vêm do PIX e dependem do banco de origem: instituição que não informa o dado faz o campo ser **omitido** do corpo, nunca enviado vazio ou como `null`. **payer** `string` Nome de quem pagou, como consta no banco. **cpf** `string` CPF ou CNPJ de quem pagou, só com os dígitos: `12345678909`. Sem pontuação, para bater direto com o que você guarda do seu lado. Use para conciliar com o extrato bancário e para responder [disputa](/guias/disputas). Alguns bancos de origem mandam o documento censurado. Nesse caso ele vai como veio, com os asteriscos na posição em que o banco os pôs (`***456789**`), e `cpfMasked` chega junto. Nunca chega um pedaço do número passando por número inteiro. **cpfMasked** `boolean` `true` quando o `cpf` veio censurado do banco de origem. Ausente quando o número chegou completo. **bank** `string` Banco de origem, no formato `código - nome`. > [!WARNING] > > `cpf` é dado pessoal de quem pagou, e chega inteiro sempre que o banco de origem o > informa por inteiro. Receba em HTTPS, guarde só o tempo que a sua operação precisa e > não exponha em log de aplicação nem em tela de terceiro. A > chave PIX, o telefone e o endereço do pagador **não** saem em webhook nenhum. ## Para que serve > [card] Sincronizar estoque > > Baixar a quantidade no seu ERP ou planilha assim que a venda confirma, sem depender de > consulta periódica. > [card] Alimentar CRM > > Registrar o cliente e a compra no seu funil no momento certo. > [card] Contabilidade > > Lançar a receita com data e valor exatos, em vez de exportar relatório no fim do mês. > [card] Notificação interna > > Avisar a equipe no canal certo quando entra venda acima de um valor. > [!WARNING] > > Este evento avisa que o pedido foi pago. Ele não substitui o webhook de > [pagamento](/webhooks/pagamentos) nas cobranças criadas pela API: são fluxos separados, > com origens diferentes. As garantias são as mesmas: assinatura HMAC, `X-Webhook-Id` para deduplicar, timeout de 5 segundos e reentrega com backoff. # withdrawal.* Source: https://docs.purin.cash/webhooks/saques Solicitado, pago, negado ou falhou. Os momentos em que o seu saldo muda de lado. Cada mudança de estado de um saque dispara um evento na URL configurada na conta, em [Dashboard → Equipe & API → Developer API](https://purin.cash/dashboard/api). | Evento | Quando | | - | - | | `withdrawal.requested` | O saque foi solicitado | | `withdrawal.completed` | O dinheiro saiu, confirmado | | `withdrawal.denied` | O saque foi recusado antes de sair e o valor voltou pro saldo | | `withdrawal.failed` | O saque foi tentado e não se concretizou. O valor voltou pro saldo | Em sandbox entra a marca de teste no nome: `withdrawal.test.requested`, `withdrawal.test.completed`, e assim por diante. > [!NOTE] > > [Pagar um código copia e cola](/guias/saques#pagar-um-codigo-copia-e-cola) chega nas > mesmas URLs, mas com nome próprio: `pix_payment.completed` e `pix_payment.failed`. Esses > dois trazem `paymentId` no lugar de `withdrawalId`, e o `method` é `pix_payment`. O resto > dos campos é igual ao desta página. Pagamento que você faz não é saque, e misturar os > dois na mesma família quebraria a sua conciliação de saques. ```json Solicitado theme={null} { "event": "withdrawal.requested", "withdrawalId": "665f1a2b3c4d5e6f7a8b9c0d", "code": "SAQ-A1B2C3", "amount": 150.00, "method": "pix", "walletAddress": "chave-pix-ou-carteira", "status": "pendente", "requestedAt": "2026-03-18T14:00:00.000Z", "sandbox": false } ``` ```json Pago theme={null} { "event": "withdrawal.completed", "withdrawalId": "665f1a2b3c4d5e6f7a8b9c0d", "code": "SAQ-A1B2C3", "amount": 150.00, "method": "pix", "walletAddress": "chave-pix-ou-carteira", "status": "concluido", "endToEndId": "E12345678202603181530abcdef01234", "recipientName": "JOAO DA SILVA", "recipientTaxId": "12345678909", "receiptUrl": "https://api.purin.cash/uploads/img/9f8e7d6c1a2b....webp", "processedAt": "2026-03-18T15:30:00.000Z" } ``` ```json Negado theme={null} { "event": "withdrawal.denied", "withdrawalId": "665f1a2b3c4d5e6f7a8b9c0d", "code": "SAQ-A1B2C3", "amount": 150.00, "method": "pix", "walletAddress": "chave-pix-ou-carteira", "status": "negado", "reason": "Chave PIX inválida", "receiptUrl": "https://api.purin.cash/uploads/img/1a2b3c4d5e6f....webp", "processedAt": "2026-03-18T15:30:00.000Z" } ``` ```json Pagamento de código theme={null} { "event": "pix_payment.completed", "paymentId": "665f1a2b3c4d5e6f7a8b9c0d", "code": "PAY-A1B2C3D4", "amount": 100.00, "method": "pix_payment", "walletAddress": "financeiro@fornecedor.com", "status": "concluido", "endToEndId": "E12345678202603181200abcdef01234", "recipientName": "FORNECEDOR LTDA", "recipientTaxId": "12345678000199", "receiptUrl": "https://api.purin.cash/uploads/img/5e6f7a8b9c0d....webp", "processedAt": "2026-03-18T12:00:03.000Z" } ``` ## Campos **event** `string` *required* O evento da tabela acima, ou `pix_payment.completed` / `pix_payment.failed` quando é pagamento de código. **withdrawalId** `string` *required* Identificador do saque. Em `pix_payment.*` o campo se chama `paymentId`, e é o mesmo `payment.id` que o pagamento devolveu. **code** `string` *required* Código legível da operação: `SAQ-A1B2C3` no saque comum, `TURBO-...` no PIX enviado na hora e `PAY-...` no pagamento de código. É por ele que o suporte localiza a operação. **amount** `number` *required* Valor em reais, decimal. **method** `string` *required* `pix`, `pix_turbo`, `ltc`, `usd` ou `pix_payment` (pagamento de código copia e cola). **walletAddress** `string` *required* Destino. Chave PIX, endereço LTC ou endereço USDT, conforme o método. No pagamento de código, a chave que o código apontava. **status** `string` *required* `pendente`, `concluido`, `negado` ou `falhou`. **reason** `string` Só quando não deu certo: o motivo, em texto pronto pra mostrar. Não vem em `concluido`. **endToEndId** `string` O E2E do PIX, o mesmo número que aparece no extrato de quem recebeu. É o que resolve contestação com o outro banco. Ausente quando ainda não veio ou quando o método não é PIX. **recipientName** `string` Nome de quem recebeu, como o banco confirmou. **recipientTaxId** `string` CPF ou CNPJ de quem recebeu, **inteiro**, pra você amarrar o saque a um cadastro seu. Nome sozinho não fecha conciliação, porque nome se repete. Pode vir mascarado quando é assim que a informação chega. É dado pessoal de terceiro: receba em HTTPS e guarde só o que precisar. **receiptUrl** `string` URL do comprovante desenhado, em imagem, o mesmo papel que o painel mostra. Serve pra repassar ao seu cliente ou anexar num chamado: dá pra embutir direto num ``. É imutável, então pode cachear. O campo some quando não foi possível gerar, então trate a ausência como normal. **requestedAt** `string` Presente em `withdrawal.requested`. **processedAt** `string` Presente nos eventos de desfecho. > [!NOTE] > > Repare que o `status` aqui vem em português (`pendente`, `concluido`, `negado`), enquanto > as cobranças usam inglês (`pending`, `paid`). São domínios diferentes da API e cada um > manteve o vocabulário da sua área. > [!WARNING] > > No comprovante em imagem o documento do recebedor sai mascarado, mesmo quando o > `recipientTaxId` do JSON vem inteiro. A imagem é feita pra ser repassada adiante; o JSON > vai só pra sua URL. ## Um uso que compensa Saque que não sai devolve o valor pro saldo, mas ninguém fica olhando o painel esperando isso. Um alerta em `withdrawal.denied` e `withdrawal.failed` costuma ser a diferença entre resolver no mesmo dia e descobrir na semana seguinte: ```js theme={null} if (evento.event === "withdrawal.denied" || evento.event === "withdrawal.failed") { await avisarFinanceiro({ titulo: `Saque ${evento.code} não saiu`, valor: evento.amount, metodo: evento.method, em: evento.processedAt, }); } ``` > [!WARNING] > > Este webhook não cobre todos os estados. O `processing` do [PIX enviado na > hora](/guias/saques#quando-o-pix-sai-na-hora), que significa "o dinheiro saiu e o banco ainda não > confirmou", é visto por `GET /v1/payouts`. Não trate ausência de > `withdrawal.completed` como saque não realizado. As garantias são as mesmas dos outros: assinatura HMAC em `X-Webhook-Signature`, idempotência em `X-Webhook-Id` e reentrega com backoff. # subaccount.* Source: https://docs.purin.cash/webhooks/subcontas Transferência, saque e estorno: todo movimento do ledger que não nasce de uma cobrança. Movimentos de [subconta](/guias/subcontas) avisam as **URLs da conta**, configuradas em [Dashboard → Equipe & API → Developer API](https://purin.cash/dashboard/api). Vale para movimentos feitos pela API **e pelo painel**: uma transferência manual do dono da loja chega no seu sistema do mesmo jeito. | Evento | Quando | | - | - | | `subaccount.transfer` | Transferência entre a subconta e a conta principal, nos dois sentidos | | `subaccount.payout` | Saque turbo debitando a subconta saiu (ou pode ter saído e está em revisão) | | `subaccount.payout_reversal` | O banco negou o saque e a atribuição voltou pra subconta | A venda **não** ganha evento próprio aqui: ela já avisa pelo [`charge.paid`/`payment.paid`](/webhooks/pagamentos), que carrega o `subaccountId`. > [!NOTE] > > Sandbox não emite eventos de subconta. Evento de teste caindo na URL de produção > confundiria mais do que ajudaria: valide o fluxo com o extrato > (`GET /v1/subaccounts/{id}/transactions`), que funciona igual nos dois ambientes. ```json Transferência theme={null} { "event": "subaccount.transfer", "id": "tr_1a2b3c4d5e6f7a8b9c0d1e2f", "subaccountId": "sacc_a1b2c3d4e5f60718293a4b5c6d7e8f90", "transferId": "tr_1a2b3c4d5e6f7a8b9c0d1e2f", "direction": "to_master", "amountCents": 30000, "balanceCents": 15900, "description": "Comissão da plataforma", "origin": "panel", "createdAt": "2026-08-05T14:00:00.000Z" } ``` ```json Saque theme={null} { "event": "subaccount.payout", "id": "TURBO-A1B2C3D4", "subaccountId": "sacc_a1b2c3d4e5f60718293a4b5c6d7e8f90", "code": "TURBO-A1B2C3D4", "amountCents": 50000, "status": "completed", "createdAt": "2026-08-05T14:05:00.000Z" } ``` ```json Estorno theme={null} { "event": "subaccount.payout_reversal", "id": "TURBO-A1B2C3D4", "subaccountId": "sacc_a1b2c3d4e5f60718293a4b5c6d7e8f90", "code": "TURBO-A1B2C3D4", "amountCents": 50000, "reason": "banco_recusou", "createdAt": "2026-08-05T14:06:00.000Z" } ``` ## Campos **event** `string` *required* `subaccount.transfer`, `subaccount.payout` ou `subaccount.payout_reversal`. **subaccountId** `string` *required* A subconta movimentada. **amountCents** `integer` *required* Valor do movimento em centavos, sempre positivo. O sentido vem de `direction` (transferência) ou do próprio tipo de evento (saque debita, estorno credita). **transferId** `string` Só em `subaccount.transfer`. É o mesmo `refId` do extrato (`tr_` ou `idem_`). **direction** `string` Só em `subaccount.transfer`: `to_master`, `to_subaccount` ou `between_subaccounts`. **toSubaccountId** `string` Só em `subaccount.transfer` com `between_subaccounts`: a subconta que recebeu. O `subaccountId` do evento é a origem, e é enviado um único evento por transferência. **toBalanceCents** `integer` Só em `between_subaccounts`: saldo da subconta de destino logo após o movimento. **balanceCents** `integer` Só em `subaccount.transfer`: saldo da subconta logo após o movimento. **origin** `string` Só em `subaccount.transfer`: `api` ou `panel`. É como você descobre que o dono da loja moveu saldo pelo painel. **code** `string` Nos eventos de saque: o código do saque, o mesmo que aparece em `GET /v1/payouts` e no extrato da subconta. É com ele que você consulta o saque a qualquer momento, em `GET /v1/subaccounts/{id}/payouts/{code}`. **status** `string` Só em `subaccount.payout`: `completed` (banco confirmou) ou `processing` (o PIX saiu ou pode ter saído; a confirmação vem depois). Nos dois casos o débito no ledger FICA: não trate `processing` como falha. **reason** `string` Só em `subaccount.payout_reversal`: motivo interno do estorno. > [!WARNING] > > `subaccount.payout` com `status: "processing"` seguido de > `subaccount.payout_reversal` com o mesmo `code` é o ciclo completo de um saque que > o banco acabou negando: o débito aconteceu, depois voltou. Espelhe os dois no seu > ledger em vez de ignorar o primeiro. As garantias são as mesmas dos outros eventos: assinatura HMAC em `X-Webhook-Signature`, idempotência em `X-Webhook-Id` (formato `evento:id`) e reentrega com backoff. # Como funcionam os webhooks Source: https://docs.purin.cash/webhooks/visao-geral Quem avisa quem, o que a sua URL precisa ter e o que acontece quando ela cai. Quando algo acontece com o seu dinheiro, a gente faz um `POST` na sua URL com o evento em JSON. É assim que você sabe que o PIX caiu sem ficar consultando a API. ## Duas configurações diferentes > [card] URLs da conta, no painel > > Até **cinco**, configuradas em [API e > Desenvolvedores](https://purin.cash/dashboard/api). São o destino padrão de > **todo** evento da conta. > > Se você só quer um endereço para tudo, configure uma e ignore o resto. > [card] callbackUrl, por cobrança > > Você manda no corpo da requisição ao criar. Vale só para aquela cobrança, e > tem prioridade sobre as URLs da conta. > > Use quando cobranças diferentes precisam avisar sistemas diferentes. > [!NOTE] > > As duas não somam: quando a cobrança tem `callbackUrl`, o evento vai **só** para > ela. As URLs da conta preenchem o que ficou sem destino, não duplicam o que já tem. > [!NOTE] > > Com mais de uma URL da conta, cada uma recebe uma cópia do evento e tem a **própria** > fila de reentrega. Um endereço fora do ar não atrasa nem cancela a entrega nos outros, e > a tela de entregas mostra qual deles falhou. > > Todas recebem a mesma assinatura e o mesmo `X-Webhook-Id`. Se dois dos seus sistemas > gravam no mesmo banco, deduplique por esse header nos dois. ## O que cada URL precisa ter | Requisito | Detalhe | | - | - | | HTTPS | Obrigatório. `http://` é recusado na criação | | Domínio público | Só nome de domínio. IP numérico, IPv6, `localhost` e rede privada são bloqueados | | Resposta rápida | `2xx` em até **5 segundos**. Fora disso conta como falha | | Tamanho | Até 500 caracteres | URL inválida devolve `400` na criação da cobrança, com `callbackUrl must be a valid, public HTTPS URL`. O erro vem na hora, não na entrega. > [!TIP] > > Em desenvolvimento, use um túnel (`ngrok http 3000`, `cloudflared tunnel`) para expor o > seu localhost com HTTPS público. Passar `http://localhost:3000` não funciona por design. ## Headers | Header | Conteúdo | | - | - | | `Content-Type` | Sempre `application/json` | | `X-Webhook-Signature` | HMAC-SHA256 em hex do corpo cru. Veja [Validando a assinatura](/webhooks/assinatura) | | `X-Webhook-Id` | Chave de idempotência no formato `evento:id`, por exemplo `payment.paid:psa_abc123` | > [!WARNING] > > `X-Webhook-Signature` vem em **todo** webhook, sem opt-in. Se você não valida, a sua URL > aceita qualquer POST da internet dizendo que um pagamento foi aprovado. ## Reentrega Se a entrega falhar (timeout, erro de rede ou status fora de `2xx`), o evento entra na fila e é reenviado. | Tentativa | Quando | | - | - | | 1 | Na hora do evento | | 2 | 1 minuto depois | | 3 | 5 minutos | | 4 | 30 minutos | | 5 | 2 horas | | 6 | 6 horas | | 7 | 12 horas | São até **7 tentativas**, cobrindo pouco mais de 20 horas. O corpo reenviado é idêntico ao original e o `X-Webhook-Id` não muda. > [!WARNING] > > É exatamente por causa da reentrega que o seu handler precisa ser idempotente. Deduplique > pelo `X-Webhook-Id` antes de fazer qualquer coisa que não dá pra desfazer, como creditar > saldo ou enviar licença. ## Eventos Todos caem nas URLs da conta. A coluna abaixo diz quem consegue desviar disso. | Evento | Dispara quando | Destino específico | | - | - | - | | `payment.paid` | Pagamento `psa_` confirmado | `callbackUrl` da cobrança | | `charge.paid` | Cobrança `psc_` confirmada | `callbackUrl` da cobrança | | `card_payment.paid` | Pagamento no cartão confirmado | `callbackUrl` da cobrança | | `order.paid` | Pedido da loja do Discord pago | `order_webhook_url` da loja | | `withdrawal.requested` | Saque solicitado | Não tem | | `withdrawal.completed` | Saque pago | Não tem | | `withdrawal.denied` | Saque negado antes de sair; saldo devolvido | Não tem | | `withdrawal.failed` | Saque tentado e não concretizado; saldo devolvido | Não tem | | `pix_payment.completed` | [Pagamento de código copia e cola](/guias/saques#pagar-um-codigo-copia-e-cola) concluído | Não tem | | `pix_payment.failed` | Pagamento de código recusado; saldo devolvido | Não tem | | `dispute.opened` | Contestação aberta contra uma venda sua | Não tem | | `dispute.under_review` | Contestação em análise pelo banco | Não tem | | `dispute.won` | Contestação ganha, valor liberado | Não tem | | `dispute.lost` | Contestação perdida, valor devolvido ao cliente | Não tem | | `subaccount.transfer` | Transferência de/para uma [subconta](/webhooks/subcontas), pela API ou pelo painel | Não tem | | `subaccount.payout` | Saque turbo debitando uma subconta | Não tem | | `subaccount.payout_reversal` | Banco negou o saque; atribuição voltou pra subconta | Não tem | Em sandbox, a marca de teste entra depois do prefixo da família: os eventos de saque saem como `withdrawal.test.requested` e `withdrawal.test.completed`. Pagamento de código tem família própria (`pix_payment.*`) em vez de reusar `withdrawal.*`: quem já concilia saques não passa a receber pagamento no mesmo balde. O identificador acompanha a família: o corpo traz `paymentId`, que é o mesmo `payment.id` devolvido pelo pagamento. ### Corpo dos eventos de contestação Os quatro `dispute.*` mandam o mesmo corpo, mudando o `event` e o `status`: ```json theme={null} { "event": "dispute.lost", "disputeId": "652f1c9a4b2e8f0012ab34cd", "code": "DSP-7K2M9Q", "status": "perdida", "statusLabel": "perdida", "previousStatus": "pendente", "orderCode": "A1B2C3D4E5", "endToEndId": "E00038166202603181230abcdef12345", "amount": 149.9, "currency": "BRL", "reason": "Nao reconheco a compra", "occurredAt": "2026-03-18T12:30:00.000Z" } ``` `previousStatus` vem `null` na primeira vez que avisamos daquela contestação. Use o `code` para casar com o que aparece no painel. Nesses eventos o `X-Webhook-Id` é `evento:disputeId`, por exemplo `dispute.opened:652f1c9a4b2e8f0012ab34cd`. Cada mudança de status de cada disputa tem a sua chave. Para responder a disputa pela API, veja [Disputas](/guias/disputas). ## O jeito certo de tratar **1. Leia o corpo cru** Antes de qualquer parse de JSON. A assinatura é calculada sobre os bytes exatos que chegaram. **2. Valide a assinatura** Com comparação timing-safe. Se não bater, responda `401` e pare por aí. **3. Deduplique pelo X-Webhook-Id** Já processou esse id? Responda `200` e não faça nada. **4. Grave e responda 200** Salve o evento e responda imediatamente. O processamento pesado vai para uma fila. **5. Confira o valor antes de entregar** `amountCents` precisa bater com o que você esperava. Status `paid` sozinho não basta. > [!NOTE] > > Não conte com a ordem de chegada. Se a ordem importa para você, confirme o estado atual > com um `GET` na API antes de decidir. > [card] [Validando a assinatura](/webhooks/assinatura) > > Código pronto em Node, PHP, Python e Go.