PIX Cash-Out by Key
Performs a PIX transfer using the recipient's PIX key.
Endpoint
POST /api/external/pix/cash-outHeaders
| Header | Type | Required | Description |
|---|---|---|---|
Authorization | String | Yes | ApiKey {client_id}:{client_secret} |
Content-Type | String | Yes | application/json |
hmac | String | Yes | HMAC-SHA512 signature of the body (hex) |
Idempotency-Key | String | No | Unique key to prevent duplicate processing (max 256 chars) |
Authentication
See Authentication. The HMAC signature must be generated as described in HMAC-SHA512.
Idempotency-Key — replay behavior
When present, the API stores the response (only on 2xx) for 24 hours and returns the cached response for any new POST with the same (method, path, Idempotency-Key) combination. The cache is scoped by endpoint (the same key on /cash-out and /refund does not collide).
- On the replay response, the API includes the header
X-Idempotent-Replay: trueand echoes theIdempotency-Keysent. - Keys longer than 256 characters return
400 Bad Request. - The key is optional. If you don't send it, the API processes each POST as a new transaction (the deterministic
end_to_end_idstill guarantees idempotency at the BACEN/SPI layer, but may generate rejection withfailure_reason: "DUPL"if the first attempt has already been settled).
Required permission
The API Key must have the transfer:write permission to send PIX. Without it, the request returns 403 Forbidden. See how to configure permissions.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
amount | Integer | Yes | Amount in centavos. R$ 30.00 = 3000 |
pix_key | String | Yes | Recipient's PIX key |
pix_key_type | String | No | Key type: cpf, cnpj, email, phone, evp. If omitted, auto-detected from the key. |
description | String | No | Transfer description (max 140 characters) |
external_id | String | No | Your system identifier for tracking. Max 128 chars after trim. Only a-zA-Z0-9._:- characters. Returned in responses and webhooks. Invalid values (disallowed chars, > 128 chars, empty after trim) are silently discarded — the transaction proceeds with external_id: null. Validate on your side before sending if you need to guarantee persistence. |
recipient_ispb | String | No | ISPB of the recipient institution for manual routing (8 digits). When provided, directs the payment to the specified PSP. Do not send Owem's ISPB (37839059) — intra-institutional requests return same_institution error (internal PIX not supported). |
end_to_end_id | String | No | End-to-End ID in BACEN format (E{ISPB}{YYYYMMDDHHmm}{entropy}). Recommended to omit — the backend generates a deterministic E2E on each attempt (same amount + pix_key + merchant_id → same E2E). This determinism guarantees idempotency at SPI/BACEN even without Idempotency-Key. Only send manually in coordinated reprocessing scenarios. |
purpose | String | No | Transfer purpose (free-form field for internal use and compliance). |
Monetary values
Request values are in centavos (R$ 1.00 = 100). Response values are in base units (R$ 1.00 = 10000). To convert the response to BRL, divide by 10,000. Never use floating point.
Example
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"
}'Success Response -- 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: Transaction already settled (
final: true,status: "settled"). - HTTP 202: Transaction accepted for processing (
final: false). Track the status via polling or webhook.statuscan be"accepted"(normal flow),"queued"(rate-limit applied — automatic retry every 3s for up to 120min) or"pending_approval"(awaiting approval via dual-control workflow, when enabled).
| Field | Type | Description |
|---|---|---|
worked | Boolean | true indicates the request was accepted |
final | Boolean | true when the transaction reached a terminal state (settled or rejected). false when still processing |
transaction_id | String | Unique transaction identifier |
end_to_end_id | String | End-to-End identifier in SPI/BACEN (format E{ISPB}...) |
external_id | String | Your identifier, returned as sent. null if not provided |
amount | Integer | Transfer amount in base units (÷ 10,000 for BRL). 300000 = R$ 30.00 |
fee_amount | Integer | Fee charged in base units (÷ 10,000 for BRL) |
net_amount | Integer | Gross amount debited from the paying account, in base units. Computed as amount + fee_amount (total debit includes the fee). Not what the recipient receives — they receive only amount. Example: amount=300000 + fee_amount=350 → net_amount=300350 (R$ 30.035 debited from your account, R$ 30.00 credited to the recipient) |
status | String | One of: accepted (HTTP 202, normal synchronous processing), settled (HTTP 200, immediate settlement — rare in fast-track), queued (HTTP 202, entered automatic retry queue due to DICT rate-limit — session 155), pending_approval (HTTP 202, awaiting approval). See terminal statuses in Query Cash-Out by ID -- Status field values |
detail | String | Descriptive message |
Meaning of net_amount in cash-out differs from cash-in
In cash-out, net_amount = amount + fee_amount (gross debit on the paying account). In cash-in (paid QR Code), the backend treats net_amount as the net value credited after the fee is deducted. This asymmetry is historical — always treat net_amount as "actual movement in your account in that direction". For accounting reconciliation, prefer working with the amount and fee_amount fields separately.
Rejection Codes
The API may reject a cash-out by input validation (before sending to the SPI), by integration error with provider / DICT (during synchronous sending), or by rate-limit with automatic retry in queue. BACEN rejections via PACS.002 RJCT arrive asynchronously and appear only via status query or the pix.payout.rejected webhook.
Error response format
Synchronous cash-out rejections return in two distinct formats — pick the correct parser based on the error origin:
Format A — Orchestrator validation or integration (codes same_institution_transfer, insufficient_balance, dict_key_not_found, dict_rate_limited, dict_bucket_exhausted coming from the synchronous OnZ → Orchestrator path): HTTP 400 or 422, body {"status": "failed", "errors": [{"code": "<code>", "params": [...]}]}. Generated by FallbackController from %OutboundPayment.Result{error_stage: :validation | :integration}.
Format B — Pre-Orchestrator controller error (e.g., invalid or missing amount, ambiguous key from Helpers.validate_amount / KeySanitizer): HTTP 400, body {"errors": {"bad_request": "message"}}.
Route via data.status === "failed" (Format A) vs data.errors.bad_request (Format B).
Validation errors (HTTP 400 / 422)
| HTTP | Format | Field with code | Meaning |
|---|---|---|---|
| 400 | B | errors.bad_request: "invalid or missing amount" | amount missing, zero, negative, or non-integer |
| 400 | B | errors.bad_request: "ambiguous key" | 11-digit key without pix_key_type — could be CPF or phone. Resolve via CPF Validation and pass pix_key_type explicitly |
| 400 | B | errors.bad_request: "invalid pix_key" | Key failed format rules (invalid CPF checksum, malformed email, etc.) |
| 422 | A | errors[0].code: "same_institution_transfer" | recipient_ispb is Owem's own ISPB (37839059). Intra-institutional PIX is not supported — use internal TEF. Note: this validation returns HTTP 422 (not 400) with the structure {status: "failed", errors: [{code: "same_institution_transfer", params: []}]} |
| 422 | A | errors[0].code: "insufficient_balance" | Available balance less than amount + fee_amount. Considers active holds (gotcha min(TB, PG)) |
Shape change for same_institution
Earlier versions of these docs stated HTTP 400 with detail: "same_institution". The actual behavior is HTTP 422 with the Format A shape (errors as array of {code, params}). Clients doing if (status === 400 && body.detail === "same_institution") do not match in practice — use if (status === 422 && body.errors?.[0]?.code === "same_institution_transfer").
Integration errors with provider / DICT (HTTP 400)
When OnZ returns synchronous HTTP 4xx (before PACS.008 reaches BACEN), the backend classifies the error via Fluxiq.UseCases.Pix.ReasonCodes.classify_provider_error/2 and returns Format A with HTTP 400 (error_stage: :integration):
Code (errors[0].code) | Meaning | Recommended action |
|---|---|---|
dict_key_not_found | PIX key not located in DICT/BACEN (OnZ HTTP 404) | Check with the payer; the key may have been removed or never registered |
dict_key_blocked | Key blocked (e.g., fraud suspicion, OnZ HTTP 403) | Contact the key owner |
dict_lookup_failed | Failure querying DICT ("consulta dict" message in OnZ body) | Retry in 5-30s |
dict_rate_limited | OnZ returned 429 with "rate limit" or "limite de consultas dict" message | Exponential backoff before retry |
dict_bucket_exhausted | OnZ returned body mentioning "bucket" / "balde de fichas" | Retry in 60-120s; avoid bursts |
provider_rejected | OnZ rejected with unclassified generic 4xx error | Check errors[0].params for context (original OnZ HTTP); reopen case with Owem support |
provider_schema_error | OnZ returned HTTP 422 — PACS.008 with invalid format (internal error) | Report immediately — do not retry, it's a backend bug |
provider_unknown_error | Status outside 400..499 that entered this path | Full log available via support |
HTTP is 400 (not 429)
Earlier versions of these docs showed HTTP 429 for dict_rate_limited and dict_bucket_exhausted in the synchronous path. The FallbackController.call/2 maps %OutboundPayment.Result{error_stage: :integration} to HTTP 400 Bad Request — never 429. The only path that returns HTTP 202 for rate-limit is the automatic retry queue (see section below).
Rate-limit with automatic retry (HTTP 202 queued)
When the internal adapter (not OnZ) detects the limit has been reached before sending the request to OnZ, the backend enqueues the transaction for automatic retry. This path is activated by the pix_out_retry_queue_enabled flag (ON in PRD since session 155).
Two scenarios trigger the queue:
| Origin | reason_code (webhook pix.payout.queued) | Cause |
|---|---|---|
ClientLimiter per-merchant | DICT_CLIENT_RATE_LIMITED | Merchant exceeded the DICT lookup quota per minute (default DICT_CLIENT_MAX_PER_MIN=120, configurable via env). Protection to prevent a single client from monopolizing the shared BACEN bucket. Applies only to the cache-MISS path — recurring destinations in the cache do not count. |
DictBucket.Guard global | DICT_BUCKET_EXHAUSTED | DICT token bucket of the OnZ participant at BACEN exhausted. Empirical refill DICT_BUCKET_REFILL_RATE=18/min (session 155 R Torres incident), capacity 250 tokens (rating G, §13.1 BACEN DICT Manual). |
HTTP response when queued (generated by FallbackController from %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
}Retry mechanics:
- Oban worker
Fluxiq.Workers.PixOutRetryWorkerretries every 3 seconds (aligned with the BACEN refill rate of ~3.3s per token). - Total TTL: 7200 seconds (120 minutes). After expiration, the worker voids the TB pending and dispatches
pix.payout.failedwithreason_code: "DICT_QUEUE_TIMEOUT". - Max attempts: 50 (Oban snooze does not count as an attempt). Unique constraint by
request_idprevents duplicate jobs. - Immediate webhook: when entering the queue, the backend dispatches
pix.payout.queuedwithreason_code(DICT_CLIENT_RATE_LIMITEDorDICT_BUCKET_EXHAUSTED) andreason_description. This is the only webhook emitted during the 120 minutes — the next will bepix.payout.confirmed(success) orpix.payout.failed(timeout).
DICT_CLIENT_MAX_PER_MIN ≠ DICT_BUCKET_REFILL_RATE
These are two independent limits with distinct causes:
DICT_CLIENT_MAX_PER_MIN=120: quota per merchant, 60s sliding window, counted in Redis before the DICT lookup. When reached → reason_codeDICT_CLIENT_RATE_LIMITED. If the retry queue flag is ON (current PRD), the response is HTTP 202queued. If OFF, the request falls into theResult.success(:accepted)path and depends on the StaleChecker to eventually void the transaction — legacy behavior.DICT_BUCKET_REFILL_RATE=18(per minute) + capacity 250: global BACEN bucket limit shared by the entire OnZ ISPB. Token reset every ~3.3s. When the bucket hits zero → reason_codeDICT_BUCKET_EXHAUSTED, same semantics of HTTP 202queued(if flag ON).
The pix_out_retry_queue_enabled flag is ON in production since session 155 (2026-04-20). For clients in homologation, the behavior may vary — always treat both status === "queued" and status === "accepted" as non-terminal.
Permission and authentication (HTTP 401 / 403)
| HTTP | detail | Meaning |
|---|---|---|
| 401 | Invalid HMAC signature | HMAC signature does not match. Check the alphabetical order of fields in the serialized body — see HMAC-SHA512 |
| 401 | Invalid API Key | Incorrect client_id:client_secret |
| 403 | permission 'transfer:write' required | API Key lacks PIX permission |
| 403 | IP not whitelisted | Source IP outside the API Key allowlist |
Code vocabulary — UPPERCASE × lowercase
The structured cash-out codes come from two distinct dictionaries, consistent in the backend via Fluxiq.UseCases.Pix.ReasonCodes:
| Namespace | Convention | Origin | Examples |
|---|---|---|---|
| BACEN SPI | UPPERCASE | Asynchronous rejections via PACS.002 RJCT (arrive after the 202) — visible in GET /transactions/:id and webhook pix.payout.rejected | AC03, AB03, ED05, DUPL, AM02, FF08, BE01 |
| Provider / Adapter | lowercase snake_case | Synchronous OnZ rejections before PACS.008 reaches BACEN — used in errors[0].code in this endpoint | dict_key_not_found, dict_rate_limited, same_institution_transfer, provider_schema_error |
| Retry queue | UPPERCASE (prefix DICT_) | Webhook pix.payout.queued / pix.payout.failed when there is automatic retry | DICT_CLIENT_RATE_LIMITED, DICT_BUCKET_EXHAUSTED, DICT_QUEUE_TIMEOUT |
When switching on errors programmatically, normalize to uppercase or lowercase on your side to avoid duplicate branches. Do not expect AM02 in synchronous responses — BACEN codes only appear in GET queries after acceptance.
Corresponding webhooks
- Synchronous rejections (Formats A/B above) do not fire a webhook — the client has already received the error in the HTTP response.
- Queueing due to rate-limit (HTTP 202
queued) dispatchespix.payout.queuedimmediately withreason_code+reason_description. - Asynchronous rejections (PACS.002 RJCT after 202 acceptance) dispatch
pix.payout.rejectedwith BACENreason_code(AC03, AB03, ED05, DUPL, etc.) andreason_descriptionin English. - Orphan voids (>30min without PACS.002) dispatch
pix.payout.failedwithreason_code: "orphan_force_voided". - Retry queue expiration (120min) dispatches
pix.payout.failedwithreason_code: "DICT_QUEUE_TIMEOUT".
PIX Key Types
| Type | Format | Example |
|---|---|---|
cpf | 11 digits (no punctuation) | 12345678901 |
cnpj | 14 digits (no punctuation) | 12345678000199 |
email | Email address | nome@empresa.com.br |
phone | area code + number (11 digits) | 11999998888 |
evp | UUID v4 | a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d |
11-digit keys — CPF vs Phone ambiguity
Keys with exactly 11 digits may be either a CPF or a mobile phone (area code + 9xxxx-xxxx). When the key is ambiguous, the API rejects with HTTP 400 and failure_reason: "ambiguous key".
Recommended solution:
- Use the CPF Validation endpoint (
POST /api/external/cpf/validate) to check if the 11 digits form a valid CPF - If
valid: true→ sendpix_key_type: "cpf"in cash-out - If
valid: false→ it's a phone, sendpix_key_type: "phone"(the API automatically adds the+55prefix)
// Example automated flow
async function resolveKeyType(key) {
if (key.length !== 11 || /\D/.test(key)) return null; // no ambiguity
const { data } = await api.post('/api/external/cpf/validate', { cpf: key });
return data.valid ? 'cpf' : 'phone';
}Tip: send phones as 11 raw digits (area code + number). The API adds the +55 prefix automatically. Avoid sending +55 manually — it may cause HMAC validation failures in some clients.
Next Steps
After creating the transfer, track the status via:
- Query by ID
- Query by E2E ID
- Query by Tag
- Query by External ID --
GET /api/external/transactions/ref/{external_id}
Or receive confirmation automatically via Webhook.