PIX Cash-Out por Clave
Realiza una transferencia PIX utilizando la clave PIX del destinatario.
Endpoint
POST /api/external/pix/cash-outHeaders
| Header | Tipo | Obligatorio | Descripcion |
|---|---|---|---|
Authorization | String | Si | ApiKey {client_id}:{client_secret} |
Content-Type | String | Si | application/json |
hmac | String | Si | Firma HMAC-SHA512 del body (hex) |
Idempotency-Key | String | No | Clave unica para evitar procesamiento duplicado (max 256 chars) |
Autenticacion
Vea Autenticacion. La firma HMAC debe generarse conforme lo descrito en HMAC-SHA512.
Idempotency-Key — comportamiento de replay
Cuando presente, la API almacena la respuesta (solo en 2xx) por 24 horas y retorna la respuesta en cache para cualquier nuevo POST con la misma combinacion (metodo, path, Idempotency-Key). El cache es scope por endpoint (misma clave en /cash-out y /refund no colisiona).
- En la respuesta replay, la API incluye el header
X-Idempotent-Replay: truey reproduce elIdempotency-Keyenviado. - Claves arriba de 256 caracteres retornan
400 Bad Request. - La clave es opcional. En caso de no enviarla, la API procesa cada POST como una nueva transaccion (el
end_to_end_iddeterminista aun garantiza idempotencia en la capa BACEN/SPI, pero puede generar rechazo confailure_reason: "DUPL"si el primer intento ya fue liquidado).
Permiso obligatorio
La API Key debe tener el permiso transfer:write para enviar PIX. Sin el, la solicitud retorna 403 Forbidden. Vea como configurar permisos.
Request Body
| Campo | Tipo | Obligatorio | Descripcion |
|---|---|---|---|
amount | Integer | Si | Valor en centavos. R$ 30,00 = 3000 |
pix_key | String | Si | Clave PIX del destinatario |
pix_key_type | String | No | Tipo de clave: cpf, cnpj, email, phone, evp. Si se omite, se detecta automaticamente a partir de la clave. |
description | String | No | Descripcion de la transferencia (max 140 caracteres) |
external_id | String | No | Identificador de su sistema para rastreo. Max 128 chars despues de trim. Solo caracteres a-zA-Z0-9._:-. Retornado en respuestas y webhooks. Valores invalidos (chars no permitidos, > 128 chars, vacio despues de trim) son silenciosamente descartados — la transaccion prosigue con external_id: null. Valide en su lado antes de enviar si necesita garantizar la persistencia. |
recipient_ispb | String | No | ISPB de la institucion del destinatario para enrutamiento manual (8 digitos). Cuando informado, dirige el pago al PSP especificado. No envie el ISPB de Owem (37839059) — requests intra-institucionales retornan error same_institution (PIX interno no soportado). |
end_to_end_id | String | No | End-to-End ID en formato BACEN (E{ISPB}{YYYYMMDDHHmm}{entropy}). Recomendado omitir — el backend genera un E2E determinista en cada intento (mismo amount + pix_key + merchant_id → mismo E2E). Ese determinismo garantiza idempotencia en el SPI/BACEN incluso sin Idempotency-Key. Solo envie manualmente en escenarios de reprocesamiento coordinado. |
purpose | String | No | Finalidad de la transferencia (campo libre para uso interno y compliance). |
Valores monetarios
Los valores de entrada son en centavos (R$ 1,00 = 100). Los valores de respuesta son en unidades base (R$ 1,00 = 10000). Para convertir la respuesta a reales, divida por 10.000. Nunca use punto flotante.
Ejemplo
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"
}'Respuesta de Exito -- 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: Transaccion ya liquidada (
final: true,status: "settled"). - HTTP 202: Transaccion aceptada para procesamiento (
final: false). Acompane el status via polling o webhook.statuspuede ser"accepted"(flujo normal),"queued"(rate-limit aplicado — retry automatico cada 3s por hasta 120min) o"pending_approval"(aguardando aprobacion via workflow de doble validacion, cuando habilitado).
| Campo | Tipo | Descripcion |
|---|---|---|
worked | Boolean | true indica que la solicitud fue aceptada |
final | Boolean | true cuando la transaccion alcanzo estado terminal (liquidada o rechazada). false cuando aun en procesamiento |
transaction_id | String | Identificador unico de la transaccion |
end_to_end_id | String | Identificador End-to-End en el SPI/BACEN (formato E{ISPB}...) |
external_id | String | Su identificador, retornado tal como fue enviado. null si no informado |
amount | Integer | Valor de la transferencia en unidades base (÷ 10.000 para reales). 300000 = R$ 30,00 |
fee_amount | Integer | Tarifa cobrada en unidades base (÷ 10.000 para reales) |
net_amount | Integer | Valor bruto debitado en la cuenta pagadora, en unidades base. Calculado como amount + fee_amount (el debito total incluye la tarifa). No es el valor que el destinatario recibe — el recibe solo amount. Ejemplo: amount=300000 + fee_amount=350 → net_amount=300350 (R$ 30,035 debitados de su cuenta, R$ 30,00 acreditados al destinatario) |
status | String | Uno de: accepted (HTTP 202, procesamiento sincronico normal), settled (HTTP 200, liquidacion inmediata — raro en fast-track), queued (HTTP 202, entro en la fila de retry automatico por rate-limit DICT — session 155), pending_approval (HTTP 202, aguardando aprobacion). Vea los status terminales en Consultar Cash-Out por ID -- Valores del campo status |
detail | String | Mensaje descriptivo |
Sentido de net_amount en cash-out difiere de cash-in
En cash-out, net_amount = amount + fee_amount (debito bruto en la cuenta pagadora). En cash-in (QR Code pagado), el backend trata net_amount como valor liquido acreditado despues de la tarifa ser descontada. Esa asimetria es historica — trate net_amount siempre como "movimiento efectivo en su cuenta en esa direccion". Para conciliacion contable, prefiera operar con los campos amount y fee_amount por separado.
Codigos de Rechazo
La API puede rechazar un cash-out por validacion de entrada (antes de enviar al SPI), por error de integracion con el proveedor / DICT (durante el envio sincronico), o por rate-limit con retry automatico en fila. Rechazos BACEN via PACS.002 RJCT llegan de forma asincronica y aparecen solo via consulta de status o webhook pix.payout.rejected.
Formato de la respuesta de error
Los rechazos sincronicos del cash-out retornan en dos formatos distintos — escoja el parser correcto segun el origen del error:
Formato A — Validacion o integracion del Orchestrator (codigos same_institution_transfer, insufficient_balance, dict_key_not_found, dict_rate_limited, dict_bucket_exhausted viniendo del camino sincronico OnZ → Orchestrator): HTTP 400 o 422, body {"status": "failed", "errors": [{"code": "<codigo>", "params": [...]}]}. Generado por el FallbackController a partir de %OutboundPayment.Result{error_stage: :validation | :integration}.
Formato B — Error de controller pre-Orchestrator (ej: invalid or missing amount, ambiguous key del Helpers.validate_amount / KeySanitizer): HTTP 400, body {"errors": {"bad_request": "mensaje"}}.
Enrute via data.status === "failed" (Formato A) vs data.errors.bad_request (Formato B).
Errores de validacion (HTTP 400 / 422)
| HTTP | Formato | Campo con codigo | Significado |
|---|---|---|---|
| 400 | B | errors.bad_request: "invalid or missing amount" | amount ausente, cero, negativo o no-entero |
| 400 | B | errors.bad_request: "ambiguous key" | Clave de 11 digitos sin pix_key_type — puede ser CPF o telefono. Resuelva via Validacion CPF e informe pix_key_type explicitamente |
| 400 | B | errors.bad_request: "invalid pix_key" | Clave no paso las reglas de formato (CPF checksum invalido, email malformado, etc.) |
| 422 | A | errors[0].code: "same_institution_transfer" | recipient_ispb es el propio ISPB de Owem (37839059). PIX intra-institucional no es soportado — use TEF interno. Nota: esta validacion retorna HTTP 422 (no 400) con la estructura {status: "failed", errors: [{code: "same_institution_transfer", params: []}]} |
| 422 | A | errors[0].code: "insufficient_balance" | Saldo disponible menor que amount + fee_amount. Considera hold activo (gotcha min(TB, PG)) |
Cambio de shape para same_institution
Versiones anteriores de estos docs afirmaban HTTP 400 con detail: "same_institution". El comportamiento real es HTTP 422 con el shape del Formato A (errors como array de {code, params}). Clientes que hacen if (status === 400 && body.detail === "same_institution") no disparan en la practica — utilice if (status === 422 && body.errors?.[0]?.code === "same_institution_transfer").
Errores de integracion con el proveedor / DICT (HTTP 400)
Cuando el OnZ retorna HTTP 4xx sincronico (antes que PACS.008 llegue al BACEN), el backend clasifica el error via Fluxiq.UseCases.Pix.ReasonCodes.classify_provider_error/2 y retorna Formato A con HTTP 400 (error_stage: :integration):
Codigo (errors[0].code) | Significado | Accion recomendada |
|---|---|---|
dict_key_not_found | Clave PIX no localizada en el DICT/BACEN (OnZ HTTP 404) | Verifique con el pagador; la clave puede haber sido removida o nunca registrada |
dict_key_blocked | Clave bloqueada (ej: sospecha de fraude, OnZ HTTP 403) | Contacto con el titular de la clave |
dict_lookup_failed | Falla al consultar DICT (mensaje "consulta dict" en el body OnZ) | Retry en 5-30s |
dict_rate_limited | OnZ retorno 429 con mensaje "rate limit" o "limite de consultas dict" | Backoff exponencial antes de retry |
dict_bucket_exhausted | OnZ retorno body con mencion a "bucket" / "balde de fichas" | Retry en 60-120s; evite rafagas |
provider_rejected | OnZ rechazo con error 4xx generico no clasificado | Vea errors[0].params para contexto (HTTP original de OnZ); reabra caso con soporte Owem |
provider_schema_error | OnZ retorno HTTP 422 — PACS.008 con formato invalido (error interno) | Reporte inmediatamente — no intente rehacer, es bug del backend |
provider_unknown_error | Status fuera de 400..499 que entro en este camino | Log completo disponible en soporte |
HTTP es 400 (no 429)
Versiones anteriores de estos docs mostraban HTTP 429 para dict_rate_limited y dict_bucket_exhausted en el camino sincronico. El FallbackController.call/2 mapea %OutboundPayment.Result{error_stage: :integration} a HTTP 400 Bad Request — nunca 429. El unico camino que retorna HTTP 202 para rate-limit es la fila de retry automatico (vea seccion abajo).
Rate-limit con retry automatico (HTTP 202 queued)
Cuando el adaptador interno (no el OnZ) detecta que el limite fue alcanzado antes de enviar la solicitud al OnZ, el backend encola la transaccion para retry automatico. Ese camino es activado por el flag pix_out_retry_queue_enabled (ON en PRD desde session 155).
Dos escenarios disparan la fila:
| Origen | reason_code (webhook pix.payout.queued) | Causa |
|---|---|---|
ClientLimiter per-merchant | DICT_CLIENT_RATE_LIMITED | Merchant excedio la cuota de consultas DICT por minuto (default DICT_CLIENT_MAX_PER_MIN=120, configurable via env). Proteccion para evitar que un unico cliente monopolice el bucket BACEN compartido. Se aplica solo al camino cache-MISS — destinos recurrentes en cache no cuentan. |
DictBucket.Guard global | DICT_BUCKET_EXHAUSTED | Balde de fichas DICT del participante OnZ junto al BACEN agotado. Refill empirico DICT_BUCKET_REFILL_RATE=18/min (session 155 incidente R Torres), capacity 250 fichas (rating G, tabla §13.1 Manual DICT BACEN). |
Respuesta HTTP cuando encolado (generada 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
}Mecanica del retry:
- Worker Oban
Fluxiq.Workers.PixOutRetryWorkerre-intenta cada 3 segundos (alineado con el refill rate BACEN de ~3,3s por ficha). - TTL total: 7200 segundos (120 minutos). Despues de expirar, el worker hace void del TB pending y dispara
pix.payout.failedconreason_code: "DICT_QUEUE_TIMEOUT". - Max attempts: 50 (snooze del Oban no cuenta como attempt). Unique constraint por
request_idimpide jobs duplicados. - Webhook inmediato: al entrar en la fila, el backend dispara
pix.payout.queuedconreason_code(DICT_CLIENT_RATE_LIMITEDoDICT_BUCKET_EXHAUSTED) yreason_description. Este es el unico webhook emitido durante los 120 minutos — el proximo serapix.payout.confirmed(exito) opix.payout.failed(timeout).
DICT_CLIENT_MAX_PER_MIN ≠ DICT_BUCKET_REFILL_RATE
Son dos limites independientes con causas distintas:
DICT_CLIENT_MAX_PER_MIN=120: cuota por merchant, ventana deslizante de 60s, contabilizada en Redis antes de la consulta DICT. Cuando alcanzado → reason_codeDICT_CLIENT_RATE_LIMITED. Si el flag de retry queue esta ON (PRD actual), la respuesta es HTTP 202queued. Si OFF, la solicitud cae en el caminoResult.success(:accepted)y depende del StaleChecker para eventualmente voidar la transaccion — comportamiento legacy.DICT_BUCKET_REFILL_RATE=18(por minuto) + capacity 250: limite global del balde BACEN compartido por todo el ISPB OnZ. Reset de tokens cada ~3,3s. Cuando el bucket llega a cero → reason_codeDICT_BUCKET_EXHAUSTED, misma semantica de HTTP 202queued(si flag ON).
El flag pix_out_retry_queue_enabled esta ON en produccion desde session 155 (20/04/2026). Para clientes en homologacion, el comportamiento puede variar — siempre trate ambos status === "queued" y status === "accepted" como no-terminales.
Permiso y autenticacion (HTTP 401 / 403)
| HTTP | detail | Significado |
|---|---|---|
| 401 | Invalid HMAC signature | Firma HMAC no coincide. Confirme el orden alfabetico de los campos en el body serializado — vea HMAC-SHA512 |
| 401 | Invalid API Key | client_id:client_secret incorrecto |
| 403 | permission 'transfer:write' required | API Key sin permiso para PIX |
| 403 | IP not whitelisted | IP de origen fuera de la allowlist de la API Key |
Vocabulario de codigos — UPPERCASE × lowercase
Los codigos estructurados del cash-out vienen de dos diccionarios distintos, consistentes en el backend via Fluxiq.UseCases.Pix.ReasonCodes:
| Namespace | Convencion | Origen | Ejemplos |
|---|---|---|---|
| BACEN SPI | UPPERCASE | Rechazos asincronicos via PACS.002 RJCT (llegan despues del 202) — visibles en GET /transactions/:id y webhook pix.payout.rejected | AC03, AB03, ED05, DUPL, AM02, FF08, BE01 |
| Provider / Adapter | lowercase snake_case | Rechazos sincronicos del OnZ antes que el PACS.008 alcance el BACEN — usados en errors[0].code en este endpoint | dict_key_not_found, dict_rate_limited, same_institution_transfer, provider_schema_error |
| Fila de retry | UPPERCASE (prefijo DICT_) | Webhook pix.payout.queued / pix.payout.failed cuando hay retry automatico | DICT_CLIENT_RATE_LIMITED, DICT_BUCKET_EXHAUSTED, DICT_QUEUE_TIMEOUT |
Al hacer switch programatico de errores, normalice a uppercase o lowercase en su lado para evitar branches duplicados. No espere AM02 en respuestas sincronicas — BACEN codes solo aparecen en consultas GET pos-aceptacion.
Webhooks correspondientes
- Rechazos sincronicos (Formato A/B arriba) no disparan webhook — el cliente ya recibio el error en la respuesta HTTP.
- Encolamiento por rate-limit (HTTP 202
queued) disparapix.payout.queuedinmediatamente conreason_code+reason_description. - Rechazos asincronicos (PACS.002 RJCT despues de aceptacion 202) disparan
pix.payout.rejectedconreason_codeBACEN (AC03, AB03, ED05, DUPL etc.) yreason_descriptionen ingles. - Voids de orfanas (>30min sin PACS.002) disparan
pix.payout.failedconreason_code: "orphan_force_voided". - Expiracion de la fila de retry (120min) dispara
pix.payout.failedconreason_code: "DICT_QUEUE_TIMEOUT".
Tipos de Clave PIX
| Tipo | Formato | Ejemplo |
|---|---|---|
cpf | 11 digitos (sin puntuacion) | 12345678901 |
cnpj | 14 digitos (sin puntuacion) | 12345678000199 |
email | Direccion de e-mail | nome@empresa.com.br |
phone | DDD + numero (11 digitos) | 11999998888 |
evp | UUID v4 | a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d |
Claves de 11 digitos — Ambiguedad CPF vs Telefono
Claves con exactamente 11 digitos pueden ser tanto un CPF como un telefono celular (DDD + 9xxxx-xxxx). Cuando la clave es ambigua, la API rechaza con HTTP 400 y failure_reason: "ambiguous key".
Solucion recomendada:
- Use el endpoint Validacion CPF (
POST /api/external/cpf/validate) para verificar si los 11 digitos forman un CPF valido - Si
valid: true→ enviepix_key_type: "cpf"en el cash-out - Si
valid: false→ es un telefono, enviepix_key_type: "phone"(la API agrega automaticamente el prefijo+55)
// Ejemplo de flujo automatizado
async function resolveKeyType(key) {
if (key.length !== 11 || /\D/.test(key)) return null; // sin ambiguedad
const { data } = await api.post('/api/external/cpf/validate', { cpf: key });
return data.valid ? 'cpf' : 'phone';
}Tip: envie telefonos como 11 digitos puros (DDD + numero). La API agrega el prefijo +55 automaticamente. Evite enviar el +55 manualmente — puede causar falla en la validacion HMAC en algunos clientes.
Proximos Pasos
Despues de crear la transferencia, acompane el status via:
- Consultar por ID
- Consultar por E2E ID
- Consultar por Tag
- Consultar por External ID --
GET /api/external/transactions/ref/{external_id}
O reciba la confirmacion automaticamente via Webhook.