PIX Cash-Out por Chave
Realiza uma transferência PIX utilizando a chave PIX do destinatário.
Endpoint
POST /api/external/pix/cash-outHeaders
| Header | Tipo | Obrigatório | Descrição |
|---|---|---|---|
Authorization | String | Sim | ApiKey {client_id}:{client_secret} |
Content-Type | String | Sim | application/json |
hmac | String | Sim | Assinatura HMAC-SHA512 do body (hex) |
Idempotency-Key | String | Não | Chave única para evitar processamento duplicado (max 256 chars) |
Autenticação
Veja Autenticação. A assinatura HMAC deve ser gerada conforme descrito em HMAC-SHA512.
Idempotency-Key — comportamento de replay
Quando presente, a API armazena a resposta (apenas em 2xx) por 24 horas e retorna a resposta em cache para qualquer novo POST com a mesma combinação (método, path, Idempotency-Key). O cache é escopado por endpoint (mesma chave em /cash-out e /refund não colide).
- Na resposta replay, a API inclui o header
X-Idempotent-Replay: truee ecoa oIdempotency-Keyenviado. - Chaves acima de 256 caracteres retornam
400 Bad Request. - A chave é opcional. Caso não envie, a API processa cada POST como uma nova transação (o
end_to_end_iddeterminístico ainda garante idempotência na camada BACEN/SPI, mas pode gerar rejeição comfailure_reason: "DUPL"se a primeira tentativa já tiver sido liquidada).
Permissão obrigatória
A API Key deve ter a permissão transfer:write para enviar PIX. Sem ela, a requisição retorna 403 Forbidden. Veja como configurar permissões.
Request Body
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
amount | Integer | Sim | Valor em centavos. R$ 30,00 = 3000 |
pix_key | String | Sim | Chave PIX do destinatário |
pix_key_type | String | Não | Tipo da chave: cpf, cnpj, email, phone, evp. Se omitido, detectado automaticamente a partir da chave. |
description | String | Não | Descrição da transferência (max 140 caracteres) |
external_id | String | Não | Identificador do seu sistema para rastreamento. Max 128 chars após trim. Apenas caracteres a-zA-Z0-9._:-. Retornado em respostas e webhooks. Valores inválidos (chars não permitidos, > 128 chars, vazio após trim) são silenciosamente descartados — a transação prossegue com external_id: null. Valide no seu lado antes de enviar se precisar garantir a persistência. |
recipient_ispb | String | Não | ISPB da instituição do destinatário para roteamento manual (8 dígitos). Quando informado, direciona o pagamento ao PSP especificado. Não envie o ISPB da Owem (37839059) — requests intra-institucionais retornam erro same_institution (PIX interno não suportado). |
end_to_end_id | String | Não | End-to-End ID no formato BACEN (E{ISPB}{YYYYMMDDHHmm}{entropy}). Recomendado omitir — o backend gera um E2E determinístico a cada tentativa (mesmo amount + pix_key + merchant_id → mesmo E2E). Esse determinismo garante idempotência no SPI/BACEN mesmo sem Idempotency-Key. Só envie manualmente em cenários de reprocessamento coordenado. |
purpose | String | Não | Finalidade da transferência (campo livre para uso interno e compliance). |
Valores monetários
Valores de entrada são em centavos (R$ 1,00 = 100). Valores de resposta são em unidades base (R$ 1,00 = 10000). Para converter a resposta para reais, divida por 10.000. Nunca use ponto flutuante.
Exemplo
curl -X POST https://api.owem.com.br/api/external/pix/cash-out \
-H "Authorization: ApiKey $CLIENT_ID:$CLIENT_SECRET" \
-H "Content-Type: application/json" \
-H "hmac: $HMAC" \
-d '{
"amount": 3000,
"pix_key": "12345678901",
"pix_key_type": "cpf",
"description": "Pagamento fornecedor",
"external_id": "order-9876"
}'Resposta de Sucesso -- 200 / 202
{
"worked": true,
"final": false,
"transaction_id": "PIXOUT20260309a1b2c3d4e5f6",
"end_to_end_id": "E37839059202603091530abcdef01",
"external_id": "order-9876",
"amount": 300000,
"fee_amount": 350,
"net_amount": 300350,
"status": "accepted",
"detail": "PIX enviado para processamento"
}HTTP 200 vs 202
- HTTP 200: Transação já liquidada (
final: true,status: "settled"). - HTTP 202: Transação aceita para processamento (
final: false). Acompanhe o status via polling ou webhook.statuspode ser"accepted"(fluxo normal),"queued"(rate-limit aplicado — retry automático a cada 3s por até 120min) ou"pending_approval"(aguardando aprovação via workflow de dupla-alçada, quando habilitado).
| Campo | Tipo | Descrição |
|---|---|---|
worked | Boolean | true indica que a requisição foi aceita |
final | Boolean | true quando a transação atingiu estado terminal (liquidada ou rejeitada). false quando ainda em processamento |
transaction_id | String | Identificador único da transação |
end_to_end_id | String | Identificador End-to-End no SPI/BACEN (formato E{ISPB}...) |
external_id | String | Seu identificador, retornado tal como enviado. null se não informado |
amount | Integer | Valor da transferência em unidades base (÷ 10.000 para reais). 300000 = R$ 30,00 |
fee_amount | Integer | Tarifa cobrada em unidades base (÷ 10.000 para reais) |
net_amount | Integer | Valor bruto debitado na conta pagadora, em unidades base. Calculado como amount + fee_amount (o debito total inclui a tarifa). Não é o valor que o destinatário recebe — ele recebe apenas amount. Exemplo: amount=300000 + fee_amount=350 → net_amount=300350 (R$ 30,035 debitados da sua conta, R$ 30,00 creditados no destinatário) |
status | String | Um de: accepted (HTTP 202, processamento síncrono normal), settled (HTTP 200, liquidação imediata — rara em fast-track), queued (HTTP 202, entrou na fila de retry automático por rate-limit DICT — session 155), pending_approval (HTTP 202, aguardando aprovação). Veja os status terminais em Consultar Cash-Out por ID -- Valores do campo status |
detail | String | Mensagem descritiva |
Sentido de net_amount em cash-out difere de cash-in
Em cash-out, net_amount = amount + fee_amount (débito bruto na conta pagadora). Em cash-in (QR Code pago), o backend trata net_amount como valor líquido creditado após a tarifa ser descontada. Essa assimetria é histórica — trate net_amount sempre como "movimentação efetiva na sua conta naquela direção". Para conciliação contábil, prefira operar com os campos amount e fee_amount separadamente.
Códigos de Rejeição
A API pode rejeitar um cash-out por validação de entrada (antes de enviar ao SPI), por erro de integração com o provedor / DICT (durante o envio síncrono), ou por rate-limit com retry automático em fila. Rejeições BACEN via PACS.002 RJCT chegam de forma assíncrona e aparecem apenas via consulta de status ou webhook pix.payout.rejected.
Formato da resposta de erro
As rejeições síncronas do cash-out retornam em dois formatos distintos — escolha o parser correto conforme a origem do erro:
Formato A — Validação ou integração do Orchestrator (códigos same_institution_transfer, insufficient_balance, dict_key_not_found, dict_rate_limited, dict_bucket_exhausted vindos do caminho síncrono OnZ → Orchestrator): HTTP 400 ou 422, body {"status": "failed", "errors": [{"code": "<código>", "params": [...]}]}. Gerado pelo FallbackController a partir de %OutboundPayment.Result{error_stage: :validation | :integration}.
Formato B — Erro de controller pré-Orchestrator (ex: invalid or missing amount, ambiguous key do Helpers.validate_amount / KeySanitizer): HTTP 400, body {"errors": {"bad_request": "mensagem"}}.
Roteie via data.status === "failed" (Formato A) vs data.errors.bad_request (Formato B).
Erros de validação (HTTP 400 / 422)
| HTTP | Formato | Campo com código | Significado |
|---|---|---|---|
| 400 | B | errors.bad_request: "invalid or missing amount" | amount ausente, zero, negativo ou não-inteiro |
| 400 | B | errors.bad_request: "ambiguous key" | Chave de 11 dígitos sem pix_key_type — pode ser CPF ou telefone. Resolva via Validação CPF e informe pix_key_type explicitamente |
| 400 | B | errors.bad_request: "invalid pix_key" | Chave não passou nas regras de formato (CPF checksum inválido, email malformado, etc.) |
| 422 | A | errors[0].code: "same_institution_transfer" | recipient_ispb é o próprio ISPB da Owem (37839059). PIX intra-institucional não é suportado — use TEF interno. Nota: esta validação retorna HTTP 422 (não 400) com a estrutura {status: "failed", errors: [{code: "same_institution_transfer", params: []}]} |
| 422 | A | errors[0].code: "insufficient_balance" | Saldo disponível menor que amount + fee_amount. Considera hold ativo (gotcha min(TB, PG)) |
Mudança de shape para same_institution
Versões anteriores destes docs afirmavam HTTP 400 com detail: "same_institution". O comportamento real é HTTP 422 com o shape do Formato A (errors como array de {code, params}). Clientes que fazem if (status === 400 && body.detail === "same_institution") não disparam na prática — utilize if (status === 422 && body.errors?.[0]?.code === "same_institution_transfer").
Erros de integração com o provedor / DICT (HTTP 400)
Quando o OnZ retorna HTTP 4xx síncrono (antes de PACS.008 chegar ao BACEN), o backend classifica o erro via Fluxiq.UseCases.Pix.ReasonCodes.classify_provider_error/2 e retorna Formato A com HTTP 400 (error_stage: :integration):
Código (errors[0].code) | Significado | Ação recomendada |
|---|---|---|
dict_key_not_found | Chave PIX não localizada no DICT/BACEN (OnZ HTTP 404) | Verifique com o pagador; a chave pode ter sido removida ou nunca registrada |
dict_key_blocked | Chave bloqueada (ex: suspeita de fraude, OnZ HTTP 403) | Contato com o titular da chave |
dict_lookup_failed | Falha ao consultar DICT (mensagem "consulta dict" no body OnZ) | Retry em 5-30s |
dict_rate_limited | OnZ retornou 429 com mensagem "rate limit" ou "limite de consultas dict" | Backoff exponencial antes de retry |
dict_bucket_exhausted | OnZ retornou body com menção a "bucket" / "balde de fichas" | Retry em 60-120s; evite rajadas |
provider_rejected | OnZ rejeitou com erro 4xx genérico não classificado | Veja errors[0].params para contexto (HTTP original do OnZ); reabra caso com suporte Owem |
provider_schema_error | OnZ retornou HTTP 422 — PACS.008 com formato inválido (erro interno) | Reporte imediatamente — não tente refazer, é bug do backend |
provider_unknown_error | Status fora de 400..499 que entrou neste caminho | Log completo disponível no suporte |
HTTP é 400 (não 429)
Versões anteriores destes docs mostravam HTTP 429 para dict_rate_limited e dict_bucket_exhausted no caminho síncrono. O FallbackController.call/2 mapeia %OutboundPayment.Result{error_stage: :integration} para HTTP 400 Bad Request — nunca 429. O único caminho que retorna HTTP 202 para rate-limit é a fila de retry automático (veja seção abaixo).
Rate-limit com retry automático (HTTP 202 queued)
Quando o adaptador interno (não o OnZ) detecta que o limite foi atingido antes de enviar a requisição ao OnZ, o backend enfileira a transação para retry automático. Esse caminho é ativado pelo flag pix_out_retry_queue_enabled (ON em PRD desde session 155).
Dois cenários disparam a fila:
| Origem | reason_code (webhook pix.payout.queued) | Causa |
|---|---|---|
ClientLimiter per-merchant | DICT_CLIENT_RATE_LIMITED | Merchant excedeu o quota de consultas DICT por minuto (default DICT_CLIENT_MAX_PER_MIN=120, configurável via env). Proteção para evitar que um único cliente monopolize o bucket BACEN compartilhado. Aplica-se somente ao caminho cache-MISS — destinos recorrentes em cache não contam. |
DictBucket.Guard global | DICT_BUCKET_EXHAUSTED | Balde de fichas DICT do participante OnZ junto ao BACEN esgotado. Refill empírico DICT_BUCKET_REFILL_RATE=18/min (session 155 incidente R Torres), capacity 250 fichas (rating G, tabela §13.1 Manual DICT BACEN). |
Resposta HTTP quando enfileirado (gerada por FallbackController a partir de %Result{status: :queued}):
{
"status": "queued",
"type": "pix",
"transaction_id": "PIXOUT20260309a1b2c3d4e5f6",
"end_to_end_id": "E37839059202603091530abcdef01",
"outbound_request_id": "0A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D",
"amount": 300000,
"message": "Payment rate-limited, enqueued for automatic retry (TTL 120 min)",
"estimated_retry_seconds": 3,
"queue_ttl_seconds": 7200
}Mecânica do retry:
- Worker Oban
Fluxiq.Workers.PixOutRetryWorkerre-tenta a cada 3 segundos (alinhado com o refill rate BACEN de ~3,3s por ficha). - TTL total: 7200 segundos (120 minutos). Após expirar, o worker faz void do TB pending e dispara
pix.payout.failedcomreason_code: "DICT_QUEUE_TIMEOUT". - Max attempts: 50 (snooze do Oban não conta como attempt). Unique constraint por
request_idimpede jobs duplicados. - Webhook imediato: ao entrar na fila, o backend dispara
pix.payout.queuedcomreason_code(DICT_CLIENT_RATE_LIMITEDouDICT_BUCKET_EXHAUSTED) ereason_description. Este é o único webhook emitido durante os 120 minutos — o próximo serápix.payout.confirmed(sucesso) oupix.payout.failed(timeout).
DICT_CLIENT_MAX_PER_MIN ≠ DICT_BUCKET_REFILL_RATE
São dois limites independentes com causas distintas:
DICT_CLIENT_MAX_PER_MIN=120: quota por merchant, janela deslizante de 60s, contabilizada em Redis antes da consulta DICT. Quando atingido → reason_codeDICT_CLIENT_RATE_LIMITED. Se o flag de retry queue estiver ON (PRD atual), a resposta é HTTP 202queued. Se OFF, a requisição cai no caminhoResult.success(:accepted)e depende do StaleChecker para eventualmente voidar a transação — comportamento legado.DICT_BUCKET_REFILL_RATE=18(por minuto) + capacity 250: limite global do balde BACEN compartilhado por todo o ISPB OnZ. Reset de tokens a cada ~3,3s. Quando o bucket chega a zero → reason_codeDICT_BUCKET_EXHAUSTED, mesma semântica de HTTP 202queued(se flag ON).
O flag pix_out_retry_queue_enabled está ON em produção desde session 155 (20/04/2026). Para clientes em homologação, o comportamento pode variar — sempre trate ambos status === "queued" e status === "accepted" como não-terminais.
Permissão e autenticação (HTTP 401 / 403)
| HTTP | detail | Significado |
|---|---|---|
| 401 | Invalid HMAC signature | Assinatura HMAC não bate. Confira a ordem alfabética dos campos no body serializado — veja HMAC-SHA512 |
| 401 | Invalid API Key | client_id:client_secret incorreto |
| 403 | permission 'transfer:write' required | API Key sem permissão para PIX |
| 403 | IP not whitelisted | IP de origem fora da allowlist da API Key |
Vocabulário de códigos — UPPERCASE × lowercase
Os códigos estruturados do cash-out vêm de dois dicionários distintos, consistentes no backend via Fluxiq.UseCases.Pix.ReasonCodes:
| Namespace | Convenção | Origem | Exemplos |
|---|---|---|---|
| BACEN SPI | UPPERCASE | Rejeições assíncronas via PACS.002 RJCT (chegam após o 202) — visíveis em GET /transactions/:id e webhook pix.payout.rejected | AC03, AB03, ED05, DUPL, AM02, FF08, BE01 |
| Provider / Adapter | lowercase snake_case | Rejeições síncronas do OnZ antes do PACS.008 atingir o BACEN — usadas em errors[0].code neste endpoint | dict_key_not_found, dict_rate_limited, same_institution_transfer, provider_schema_error |
| Fila de retry | UPPERCASE (prefixo DICT_) | Webhook pix.payout.queued / pix.payout.failed quando há retry automático | DICT_CLIENT_RATE_LIMITED, DICT_BUCKET_EXHAUSTED, DICT_QUEUE_TIMEOUT |
Ao fazer switch programático de erros, normalize para uppercase ou lowercase no seu lado para evitar branches duplicados. Não espere AM02 em respostas síncronas — BACEN codes só aparecem em consultas GET pós-aceite.
Webhooks correspondentes
- Rejeições síncronas (Formato A/B acima) não disparam webhook — o cliente já recebeu o erro na resposta HTTP.
- Enfileiramento por rate-limit (HTTP 202
queued) disparapix.payout.queuedimediatamente comreason_code+reason_description. - Rejeições assíncronas (PACS.002 RJCT após aceite 202) disparam
pix.payout.rejectedcomreason_codeBACEN (AC03, AB03, ED05, DUPL etc.) ereason_descriptionem inglês. - Voids de orfãs (>30min sem PACS.002) disparam
pix.payout.failedcomreason_code: "orphan_force_voided". - Expiração da fila de retry (120min) dispara
pix.payout.failedcomreason_code: "DICT_QUEUE_TIMEOUT".
Tipos de Chave PIX
| Tipo | Formato | Exemplo |
|---|---|---|
cpf | 11 dígitos (sem pontuação) | 12345678901 |
cnpj | 14 dígitos (sem pontuação) | 12345678000199 |
email | Endereço de e-mail | nome@empresa.com.br |
phone | DDD + número (11 dígitos) | 11999998888 |
evp | UUID v4 | a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d |
Chaves de 11 dígitos — Ambiguidade CPF vs Telefone
Chaves com exatamente 11 dígitos podem ser tanto um CPF quanto um telefone celular (DDD + 9xxxx-xxxx). Quando a chave é ambígua, a API rejeita com HTTP 400 e failure_reason: "ambiguous key".
Solução recomendada:
- Use o endpoint Validação CPF (
POST /api/external/cpf/validate) para verificar se os 11 dígitos formam um CPF válido - Se
valid: true→ enviepix_key_type: "cpf"no cash-out - Se
valid: false→ é um telefone, enviepix_key_type: "phone"(a API adiciona automaticamente o prefixo+55)
// Exemplo de fluxo automatizado
async function resolveKeyType(key) {
if (key.length !== 11 || /\D/.test(key)) return null; // sem ambiguidade
const { data } = await api.post('/api/external/cpf/validate', { cpf: key });
return data.valid ? 'cpf' : 'phone';
}Dica: envie telefones como 11 dígitos puros (DDD + número). A API adiciona o prefixo +55 automaticamente. Evite enviar o +55 manualmente — pode causar falha na validação HMAC em alguns clientes.
Próximos Passos
Após criar a transferência, acompanhe o status via:
- Consultar por ID
- Consultar por E2E ID
- Consultar por Tag
- Consultar por External ID --
GET /api/external/transactions/ref/{external_id}
Ou receba a confirmação automaticamente via Webhook.