Skip to content

PIX Cash-Out by Key

Performs a PIX transfer using the recipient's PIX key.

Endpoint

POST /api/external/pix/cash-out

Headers

HeaderTypeRequiredDescription
AuthorizationStringYesApiKey {client_id}:{client_secret}
Content-TypeStringYesapplication/json
hmacStringYesHMAC-SHA512 signature of the body (hex)
Idempotency-KeyStringNoUnique 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: true and echoes the Idempotency-Key sent.
  • 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_id still guarantees idempotency at the BACEN/SPI layer, but may generate rejection with failure_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

FieldTypeRequiredDescription
amountIntegerYesAmount in centavos. R$ 30.00 = 3000
pix_keyStringYesRecipient's PIX key
pix_key_typeStringNoKey type: cpf, cnpj, email, phone, evp. If omitted, auto-detected from the key.
descriptionStringNoTransfer description (max 140 characters)
external_idStringNoYour 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_ispbStringNoISPB 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_idStringNoEnd-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.
purposeStringNoTransfer 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

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

Success Response -- 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: Transaction already settled (final: true, status: "settled").
  • HTTP 202: Transaction accepted for processing (final: false). Track the status via polling or webhook. status can 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).
FieldTypeDescription
workedBooleantrue indicates the request was accepted
finalBooleantrue when the transaction reached a terminal state (settled or rejected). false when still processing
transaction_idStringUnique transaction identifier
end_to_end_idStringEnd-to-End identifier in SPI/BACEN (format E{ISPB}...)
external_idStringYour identifier, returned as sent. null if not provided
amountIntegerTransfer amount in base units (÷ 10,000 for BRL). 300000 = R$ 30.00
fee_amountIntegerFee charged in base units (÷ 10,000 for BRL)
net_amountIntegerGross 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=350net_amount=300350 (R$ 30.035 debited from your account, R$ 30.00 credited to the recipient)
statusStringOne 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
detailStringDescriptive 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)

HTTPFormatField with codeMeaning
400Berrors.bad_request: "invalid or missing amount"amount missing, zero, negative, or non-integer
400Berrors.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
400Berrors.bad_request: "invalid pix_key"Key failed format rules (invalid CPF checksum, malformed email, etc.)
422Aerrors[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: []}]}
422Aerrors[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)MeaningRecommended action
dict_key_not_foundPIX key not located in DICT/BACEN (OnZ HTTP 404)Check with the payer; the key may have been removed or never registered
dict_key_blockedKey blocked (e.g., fraud suspicion, OnZ HTTP 403)Contact the key owner
dict_lookup_failedFailure querying DICT ("consulta dict" message in OnZ body)Retry in 5-30s
dict_rate_limitedOnZ returned 429 with "rate limit" or "limite de consultas dict" messageExponential backoff before retry
dict_bucket_exhaustedOnZ returned body mentioning "bucket" / "balde de fichas"Retry in 60-120s; avoid bursts
provider_rejectedOnZ rejected with unclassified generic 4xx errorCheck errors[0].params for context (original OnZ HTTP); reopen case with Owem support
provider_schema_errorOnZ returned HTTP 422 — PACS.008 with invalid format (internal error)Report immediately — do not retry, it's a backend bug
provider_unknown_errorStatus outside 400..499 that entered this pathFull 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:

Originreason_code (webhook pix.payout.queued)Cause
ClientLimiter per-merchantDICT_CLIENT_RATE_LIMITEDMerchant 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 globalDICT_BUCKET_EXHAUSTEDDICT 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}):

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
}

Retry mechanics:

  • Oban worker Fluxiq.Workers.PixOutRetryWorker retries 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.failed with reason_code: "DICT_QUEUE_TIMEOUT".
  • Max attempts: 50 (Oban snooze does not count as an attempt). Unique constraint by request_id prevents duplicate jobs.
  • Immediate webhook: when entering the queue, the backend dispatches pix.payout.queued with reason_code (DICT_CLIENT_RATE_LIMITED or DICT_BUCKET_EXHAUSTED) and reason_description. This is the only webhook emitted during the 120 minutes — the next will be pix.payout.confirmed (success) or pix.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_code DICT_CLIENT_RATE_LIMITED. If the retry queue flag is ON (current PRD), the response is HTTP 202 queued. If OFF, the request falls into the Result.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_code DICT_BUCKET_EXHAUSTED, same semantics of HTTP 202 queued (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)

HTTPdetailMeaning
401Invalid HMAC signatureHMAC signature does not match. Check the alphabetical order of fields in the serialized body — see HMAC-SHA512
401Invalid API KeyIncorrect client_id:client_secret
403permission 'transfer:write' requiredAPI Key lacks PIX permission
403IP not whitelistedSource 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:

NamespaceConventionOriginExamples
BACEN SPIUPPERCASEAsynchronous rejections via PACS.002 RJCT (arrive after the 202) — visible in GET /transactions/:id and webhook pix.payout.rejectedAC03, AB03, ED05, DUPL, AM02, FF08, BE01
Provider / Adapterlowercase snake_caseSynchronous OnZ rejections before PACS.008 reaches BACEN — used in errors[0].code in this endpointdict_key_not_found, dict_rate_limited, same_institution_transfer, provider_schema_error
Retry queueUPPERCASE (prefix DICT_)Webhook pix.payout.queued / pix.payout.failed when there is automatic retryDICT_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) dispatches pix.payout.queued immediately with reason_code + reason_description.
  • Asynchronous rejections (PACS.002 RJCT after 202 acceptance) dispatch pix.payout.rejected with BACEN reason_code (AC03, AB03, ED05, DUPL, etc.) and reason_description in English.
  • Orphan voids (>30min without PACS.002) dispatch pix.payout.failed with reason_code: "orphan_force_voided".
  • Retry queue expiration (120min) dispatches pix.payout.failed with reason_code: "DICT_QUEUE_TIMEOUT".

PIX Key Types

TypeFormatExample
cpf11 digits (no punctuation)12345678901
cnpj14 digits (no punctuation)12345678000199
emailEmail addressnome@empresa.com.br
phonearea code + number (11 digits)11999998888
evpUUID v4a1b2c3d4-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:

  1. Use the CPF Validation endpoint (POST /api/external/cpf/validate) to check if the 11 digits form a valid CPF
  2. If valid: true → send pix_key_type: "cpf" in cash-out
  3. If valid: false → it's a phone, send pix_key_type: "phone" (the API automatically adds the +55 prefix)
javascript
// 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:

Or receive confirmation automatically via Webhook.

Owem Pay Instituição de Pagamento — ISPB 37839059