Skip to content

PIX Cash-Out por Chave

Realiza uma transferência PIX utilizando a chave PIX do destinatário.

Endpoint

POST /api/external/pix/cash-out

Headers

HeaderTipoObrigatórioDescrição
AuthorizationStringSimApiKey {client_id}:{client_secret}
Content-TypeStringSimapplication/json
hmacStringSimAssinatura HMAC-SHA512 do body (hex)
Idempotency-KeyStringNãoChave ú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: true e ecoa o Idempotency-Key enviado.
  • 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_id determinístico ainda garante idempotência na camada BACEN/SPI, mas pode gerar rejeição com failure_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

CampoTipoObrigatórioDescrição
amountIntegerSimValor em centavos. R$ 30,00 = 3000
pix_keyStringSimChave PIX do destinatário
pix_key_typeStringNãoTipo da chave: cpf, cnpj, email, phone, evp. Se omitido, detectado automaticamente a partir da chave.
descriptionStringNãoDescrição da transferência (max 140 caracteres)
external_idStringNãoIdentificador 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_ispbStringNãoISPB 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_idStringNãoEnd-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.
purposeStringNãoFinalidade 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

bash
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

json
{
  "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. status pode 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).
CampoTipoDescrição
workedBooleantrue indica que a requisição foi aceita
finalBooleantrue quando a transação atingiu estado terminal (liquidada ou rejeitada). false quando ainda em processamento
transaction_idStringIdentificador único da transação
end_to_end_idStringIdentificador End-to-End no SPI/BACEN (formato E{ISPB}...)
external_idStringSeu identificador, retornado tal como enviado. null se não informado
amountIntegerValor da transferência em unidades base (÷ 10.000 para reais). 300000 = R$ 30,00
fee_amountIntegerTarifa cobrada em unidades base (÷ 10.000 para reais)
net_amountIntegerValor 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=350net_amount=300350 (R$ 30,035 debitados da sua conta, R$ 30,00 creditados no destinatário)
statusStringUm 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
detailStringMensagem 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)

HTTPFormatoCampo com códigoSignificado
400Berrors.bad_request: "invalid or missing amount"amount ausente, zero, negativo ou não-inteiro
400Berrors.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
400Berrors.bad_request: "invalid pix_key"Chave não passou nas regras de formato (CPF checksum inválido, email malformado, etc.)
422Aerrors[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: []}]}
422Aerrors[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)SignificadoAção recomendada
dict_key_not_foundChave 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_blockedChave bloqueada (ex: suspeita de fraude, OnZ HTTP 403)Contato com o titular da chave
dict_lookup_failedFalha ao consultar DICT (mensagem "consulta dict" no body OnZ)Retry em 5-30s
dict_rate_limitedOnZ retornou 429 com mensagem "rate limit" ou "limite de consultas dict"Backoff exponencial antes de retry
dict_bucket_exhaustedOnZ retornou body com menção a "bucket" / "balde de fichas"Retry em 60-120s; evite rajadas
provider_rejectedOnZ rejeitou com erro 4xx genérico não classificadoVeja errors[0].params para contexto (HTTP original do OnZ); reabra caso com suporte Owem
provider_schema_errorOnZ retornou HTTP 422 — PACS.008 com formato inválido (erro interno)Reporte imediatamente — não tente refazer, é bug do backend
provider_unknown_errorStatus fora de 400..499 que entrou neste caminhoLog 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:

Origemreason_code (webhook pix.payout.queued)Causa
ClientLimiter per-merchantDICT_CLIENT_RATE_LIMITEDMerchant 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 globalDICT_BUCKET_EXHAUSTEDBalde 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}):

json
{
  "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.PixOutRetryWorker re-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.failed com reason_code: "DICT_QUEUE_TIMEOUT".
  • Max attempts: 50 (snooze do Oban não conta como attempt). Unique constraint por request_id impede jobs duplicados.
  • Webhook imediato: ao entrar na fila, o backend dispara pix.payout.queued com reason_code (DICT_CLIENT_RATE_LIMITED ou DICT_BUCKET_EXHAUSTED) e reason_description. Este é o único webhook emitido durante os 120 minutos — o próximo será pix.payout.confirmed (sucesso) ou pix.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_code DICT_CLIENT_RATE_LIMITED. Se o flag de retry queue estiver ON (PRD atual), a resposta é HTTP 202 queued. Se OFF, a requisição cai no caminho Result.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_code DICT_BUCKET_EXHAUSTED, mesma semântica de HTTP 202 queued (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)

HTTPdetailSignificado
401Invalid HMAC signatureAssinatura HMAC não bate. Confira a ordem alfabética dos campos no body serializado — veja HMAC-SHA512
401Invalid API Keyclient_id:client_secret incorreto
403permission 'transfer:write' requiredAPI Key sem permissão para PIX
403IP not whitelistedIP 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:

NamespaceConvençãoOrigemExemplos
BACEN SPIUPPERCASERejeições assíncronas via PACS.002 RJCT (chegam após o 202) — visíveis em GET /transactions/:id e webhook pix.payout.rejectedAC03, AB03, ED05, DUPL, AM02, FF08, BE01
Provider / Adapterlowercase snake_caseRejeições síncronas do OnZ antes do PACS.008 atingir o BACEN — usadas em errors[0].code neste endpointdict_key_not_found, dict_rate_limited, same_institution_transfer, provider_schema_error
Fila de retryUPPERCASE (prefixo DICT_)Webhook pix.payout.queued / pix.payout.failed quando há retry automáticoDICT_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) dispara pix.payout.queued imediatamente com reason_code + reason_description.
  • Rejeições assíncronas (PACS.002 RJCT após aceite 202) disparam pix.payout.rejected com reason_code BACEN (AC03, AB03, ED05, DUPL etc.) e reason_description em inglês.
  • Voids de orfãs (>30min sem PACS.002) disparam pix.payout.failed com reason_code: "orphan_force_voided".
  • Expiração da fila de retry (120min) dispara pix.payout.failed com reason_code: "DICT_QUEUE_TIMEOUT".

Tipos de Chave PIX

TipoFormatoExemplo
cpf11 dígitos (sem pontuação)12345678901
cnpj14 dígitos (sem pontuação)12345678000199
emailEndereço de e-mailnome@empresa.com.br
phoneDDD + número (11 dígitos)11999998888
evpUUID v4a1b2c3d4-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:

  1. Use o endpoint Validação CPF (POST /api/external/cpf/validate) para verificar se os 11 dígitos formam um CPF válido
  2. Se valid: true → envie pix_key_type: "cpf" no cash-out
  3. Se valid: false → é um telefone, envie pix_key_type: "phone" (a API adiciona automaticamente o prefixo +55)
javascript
// 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:

Ou receba a confirmação automaticamente via Webhook.

Owem Pay Instituição de Pagamento — ISPB 37839059