Skip to content

PIX Cash-Out por Clave

Realiza una transferencia PIX utilizando la clave PIX del destinatario.

Endpoint

POST /api/external/pix/cash-out

Headers

HeaderTipoObligatorioDescripcion
AuthorizationStringSiApiKey {client_id}:{client_secret}
Content-TypeStringSiapplication/json
hmacStringSiFirma HMAC-SHA512 del body (hex)
Idempotency-KeyStringNoClave 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: true y reproduce el Idempotency-Key enviado.
  • 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_id determinista aun garantiza idempotencia en la capa BACEN/SPI, pero puede generar rechazo con failure_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

CampoTipoObligatorioDescripcion
amountIntegerSiValor en centavos. R$ 30,00 = 3000
pix_keyStringSiClave PIX del destinatario
pix_key_typeStringNoTipo de clave: cpf, cnpj, email, phone, evp. Si se omite, se detecta automaticamente a partir de la clave.
descriptionStringNoDescripcion de la transferencia (max 140 caracteres)
external_idStringNoIdentificador 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_ispbStringNoISPB 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_idStringNoEnd-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.
purposeStringNoFinalidad 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

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"
  }'

Respuesta de Exito -- 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: Transaccion ya liquidada (final: true, status: "settled").
  • HTTP 202: Transaccion aceptada para procesamiento (final: false). Acompane el status via polling o webhook. status puede 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).
CampoTipoDescripcion
workedBooleantrue indica que la solicitud fue aceptada
finalBooleantrue cuando la transaccion alcanzo estado terminal (liquidada o rechazada). false cuando aun en procesamiento
transaction_idStringIdentificador unico de la transaccion
end_to_end_idStringIdentificador End-to-End en el SPI/BACEN (formato E{ISPB}...)
external_idStringSu identificador, retornado tal como fue enviado. null si no informado
amountIntegerValor de la transferencia en unidades base (÷ 10.000 para reales). 300000 = R$ 30,00
fee_amountIntegerTarifa cobrada en unidades base (÷ 10.000 para reales)
net_amountIntegerValor 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=350net_amount=300350 (R$ 30,035 debitados de su cuenta, R$ 30,00 acreditados al destinatario)
statusStringUno 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
detailStringMensaje 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)

HTTPFormatoCampo con codigoSignificado
400Berrors.bad_request: "invalid or missing amount"amount ausente, cero, negativo o no-entero
400Berrors.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
400Berrors.bad_request: "invalid pix_key"Clave no paso las reglas de formato (CPF checksum invalido, email malformado, etc.)
422Aerrors[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: []}]}
422Aerrors[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)SignificadoAccion recomendada
dict_key_not_foundClave 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_blockedClave bloqueada (ej: sospecha de fraude, OnZ HTTP 403)Contacto con el titular de la clave
dict_lookup_failedFalla al consultar DICT (mensaje "consulta dict" en el body OnZ)Retry en 5-30s
dict_rate_limitedOnZ retorno 429 con mensaje "rate limit" o "limite de consultas dict"Backoff exponencial antes de retry
dict_bucket_exhaustedOnZ retorno body con mencion a "bucket" / "balde de fichas"Retry en 60-120s; evite rafagas
provider_rejectedOnZ rechazo con error 4xx generico no clasificadoVea errors[0].params para contexto (HTTP original de OnZ); reabra caso con soporte Owem
provider_schema_errorOnZ retorno HTTP 422 — PACS.008 con formato invalido (error interno)Reporte inmediatamente — no intente rehacer, es bug del backend
provider_unknown_errorStatus fuera de 400..499 que entro en este caminoLog 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:

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

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
}

Mecanica del retry:

  • Worker Oban Fluxiq.Workers.PixOutRetryWorker re-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.failed con reason_code: "DICT_QUEUE_TIMEOUT".
  • Max attempts: 50 (snooze del Oban no cuenta como attempt). Unique constraint por request_id impide jobs duplicados.
  • Webhook inmediato: al entrar en la fila, el backend dispara pix.payout.queued con reason_code (DICT_CLIENT_RATE_LIMITED o DICT_BUCKET_EXHAUSTED) y reason_description. Este es el unico webhook emitido durante los 120 minutos — el proximo sera pix.payout.confirmed (exito) o pix.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_code DICT_CLIENT_RATE_LIMITED. Si el flag de retry queue esta ON (PRD actual), la respuesta es HTTP 202 queued. Si OFF, la solicitud cae en el camino Result.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_code DICT_BUCKET_EXHAUSTED, misma semantica de HTTP 202 queued (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)

HTTPdetailSignificado
401Invalid HMAC signatureFirma HMAC no coincide. Confirme el orden alfabetico de los campos en el body serializado — vea HMAC-SHA512
401Invalid API Keyclient_id:client_secret incorrecto
403permission 'transfer:write' requiredAPI Key sin permiso para PIX
403IP not whitelistedIP 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:

NamespaceConvencionOrigenEjemplos
BACEN SPIUPPERCASERechazos asincronicos via PACS.002 RJCT (llegan despues del 202) — visibles en GET /transactions/:id y webhook pix.payout.rejectedAC03, AB03, ED05, DUPL, AM02, FF08, BE01
Provider / Adapterlowercase snake_caseRechazos sincronicos del OnZ antes que el PACS.008 alcance el BACEN — usados en errors[0].code en este endpointdict_key_not_found, dict_rate_limited, same_institution_transfer, provider_schema_error
Fila de retryUPPERCASE (prefijo DICT_)Webhook pix.payout.queued / pix.payout.failed cuando hay retry automaticoDICT_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) dispara pix.payout.queued inmediatamente con reason_code + reason_description.
  • Rechazos asincronicos (PACS.002 RJCT despues de aceptacion 202) disparan pix.payout.rejected con reason_code BACEN (AC03, AB03, ED05, DUPL etc.) y reason_description en ingles.
  • Voids de orfanas (>30min sin PACS.002) disparan pix.payout.failed con reason_code: "orphan_force_voided".
  • Expiracion de la fila de retry (120min) dispara pix.payout.failed con reason_code: "DICT_QUEUE_TIMEOUT".

Tipos de Clave PIX

TipoFormatoEjemplo
cpf11 digitos (sin puntuacion)12345678901
cnpj14 digitos (sin puntuacion)12345678000199
emailDireccion de e-mailnome@empresa.com.br
phoneDDD + numero (11 digitos)11999998888
evpUUID v4a1b2c3d4-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:

  1. Use el endpoint Validacion CPF (POST /api/external/cpf/validate) para verificar si los 11 digitos forman un CPF valido
  2. Si valid: true → envie pix_key_type: "cpf" en el cash-out
  3. Si valid: false → es un telefono, envie pix_key_type: "phone" (la API agrega automaticamente el prefijo +55)
javascript
// 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:

O reciba la confirmacion automaticamente via Webhook.

Owem Pay Instituição de Pagamento — ISPB 37839059