# PIX Cash-Out by Key

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

## Endpoint

```
POST /api/external/pix/cash-out
```

## Headers

| 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) |

::: tip Authentication
See [Authentication](/en/auth-token). The HMAC signature must be generated as described in [HMAC-SHA512](/en/hmac).
:::

::: tip 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).
:::

::: danger 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](/en/auth-token#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 Monetarie's ISPB (`46026562`)** - 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). |
| `recipient_document` | String | No* | CPF (11 digits) or CNPJ (14 digits) of the recipient; formatted input (dots/dashes/slash) is accepted and normalized to digits. **Optional by default**; becomes **conditionally required** when the "block PIX-OUT to CNPJ" control is enabled on the paying account (see below). When the `pix_key` is itself a CPF/CNPJ, it is authoritative and this field is unnecessary. |

::: danger 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.**
:::

### Opt-in control: block PIX-OUT to CNPJ

::: info Opt-in control - no default impact
This is an **optional, per-account control that is OFF by default**. While it is disabled, `recipient_document` is ignored and nothing changes - existing integrations keep working with no new required field. The control only takes effect once your account team enables it on the paying account.
:::

When the control **is enabled** on the paying account:

- The payment must have a **determinable** recipient document. If the `pix_key` is itself a CPF/CNPJ, that key is authoritative. Otherwise (random/EVP key, phone, or email), you **must** send `recipient_document`.
- If no valid recipient document can be determined → the request is rejected with `cnpj_out_document_required` (HTTP 400/422): "recipient document (CPF/CNPJ) is required when CNPJ-out block is enabled".
- If the recipient is a CNPJ that is **not** on the account's authorized-CNPJ list → rejected with `cnpj_out_blocked`.
- A CPF recipient, or a CNPJ on the authorized list, is allowed.

The authorized-CNPJ list is managed by your account team. See [Account Controls](/en/account-controls).

## Example

```bash
curl -X POST https://api.monetarie.com/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": "E46026562202603091530abcdef01",
  "external_id": "order-9876",
  "amount": 300000,
  "fee_amount": 350,
  "net_amount": 300350,
  "status": "accepted",
  "detail": "PIX enviado para processamento"
}
```

::: tip 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).
:::

| 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, awaiting automatic reprocessing due to a DICT limit), `pending_approval` (HTTP 202, awaiting approval). See terminal statuses in [Query Cash-Out by ID -- Status field values](/en/pix-cashout-status#status-field-values) |
| `detail` | String | Descriptive message |

::: warning 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](/en/pix-cashout-status) or the `pix.payout.rejected` webhook.

::: tip Error response format
Synchronous cash-out rejections return in **two distinct formats** - pick the correct parser based on the error origin:

**Format A -- Validation or integration**: HTTP 400 or 422, body `{"status": "failed", "errors": [{"code": "<code>", "message": "<text>", "params": {...}}]}`. Common codes: `same_institution_transfer`, `insufficient_balance`, `dict_key_not_found`, `dict_rate_limited`, `dict_bucket_exhausted`.

**Format B -- Payload validation error**: HTTP 400, body `{"errors": {"bad_request": "message"}}`. Examples: `invalid or missing amount`, `ambiguous key`.

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 |
| **422** | **A** | `errors[0].code: "pix_key_ambiguous"` | 11-digit key without `pix_key_type` - could be CPF or phone. Resolve via [CPF Validation](/en/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 Monetarie's own ISPB (`46026562`). 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)`) |
| 422 | A | `errors[0].code: "funds_under_med_hold"` | Available balance is held by a regulatory special-return mechanism (MED). The funds cannot be used for this transfer until the precautionary hold is resolved. Distinct from `balance_corrupted` (which signals a ledger divergence) |
| 422 | A | `errors[0].code: "ceiling_exceeded"` | `amount` exceeds the configured per-transaction limit. `errors[0].message` = "PIX out limit reached: amount R$ X exceeds the R$ Y per-transaction limit"; `errors[0].params.ceiling` is the limit in subcents |
| 400/422 | A | `errors[0].code: "cnpj_out_document_required"` | **Opt-in** "block PIX-OUT to CNPJ" control enabled and no recipient document could be determined (random/EVP/phone/email key without `recipient_document`). `errors[0].message`: "recipient document (CPF/CNPJ) is required when CNPJ-out block is enabled". Without the control enabled, this error never occurs |
| 400/422 | A | `errors[0].code: "cnpj_out_blocked"` | **Opt-in** "block PIX-OUT to CNPJ" control enabled and the recipient is a CNPJ that is not on the account's authorized-CNPJ list. Without the control enabled, this error never occurs |

::: danger 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 the provider returns a synchronous error before BACEN confirmation, the API returns **Format A with HTTP 400**:

| Code (`errors[0].code`) | Meaning | Recommended action |
|---|---|---|
| `dict_key_not_found` | PIX key not located in DICT/BACEN | Check with the payer; the key may have been removed or never registered |
| `dict_key_blocked` | Key blocked, for example due to fraud suspicion | Contact the key owner |
| `dict_key_invalid` | Invalid PIX key format (rejected by the DICT) | Fix the key before resubmitting; retrying with the same key never succeeds |
| `dict_lookup_failed` | Failure querying DICT | Retry in 5-30s |
| `dict_rate_limited` | Temporary DICT lookup limit | Wait for automatic reprocessing or apply backoff before a new request |
| `dict_bucket_exhausted` | DICT limit temporarily unavailable | Wait for automatic reprocessing; avoid bursts |
| `provider_rejected` | Provider rejected with an unclassified generic error | Check `errors[0].params` for context and reopen the case with Monetarie support |
| `provider_schema_error` | Integration payload incompatible | Report immediately and do not retry without Monetarie guidance |
| `provider_unknown_error` | Status outside 400..499 that entered this path | Full log available via support |

::: warning 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 current contract is: synchronous integration errors return HTTP 400; DICT limits with automatic reprocessing return HTTP 202 `queued`.
:::

### Rate-limit with automatic retry (HTTP 202 `queued`)

When Monetarie detects a DICT lookup limit before sending the transaction to the provider, the PIX OUT remains in processing and enters automatic retry. This path avoids another DICT call while capacity is unavailable.

Two scenarios trigger the queue:

| Origin | `reason_code` (webhook `pix.payout.queued`) | Cause |
|---|---|---|
| Per-client quota | `DICT_CLIENT_RATE_LIMITED` | Client DICT lookup volume exceeded Monetarie protection policy. |
| Provider DICT limit | `DICT_BUCKET_EXHAUSTED` | Operational DICT lookup capacity is temporarily unavailable. |

**HTTP response when queued**:

```json
{
  "status": "queued",
  "type": "pix",
  "transaction_id": "PIXOUT20260309a1b2c3d4e5f6",
  "end_to_end_id": "E46026562202603091530abcdef01",
  "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**:

- Monetarie retries automatically while the retry window is active.
- Total TTL: **7200 seconds (120 minutes)**. After expiration, the client receives `pix.payout.failed` with `reason_code: "DICT_QUEUE_TIMEOUT"`.
- **Immediate webhook**: when entering the queue, Monetarie dispatches `pix.payout.queued` with `reason_code` (`DICT_CLIENT_RATE_LIMITED` or `DICT_BUCKET_EXHAUSTED`) and `reason_description`. The next event will be `pix.payout.confirmed` or `pix.payout.failed`.

::: tip Non-terminal status
Always treat both `queued` and `accepted` as non-terminal states. Track the final result by webhook or by querying the transaction.
:::

### 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](/en/hmac) |
| 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 distinct vocabularies:

| Namespace | Convention | Origin | Examples |
|---|---|---|---|
| **BACEN SPI** | UPPERCASE | Asynchronous rejections via PACS.002 RJCT, visible in status queries and webhook `pix.payout.rejected` | `AC03`, `AB03`, `ED05`, `DUPL`, `AM02`, `FF08`, `BE01` |
| **Provider / DICT** | lowercase snake_case | Synchronous rejections before BACEN confirmation | `dict_key_not_found`, `dict_rate_limited`, `same_institution_transfer`, `provider_schema_error` |
| **Automatic reprocessing** | UPPERCASE (prefix `DICT_`) | Webhook `pix.payout.queued` / `pix.payout.failed` when there is automatic reprocessing | `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`) 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"`.
- Automatic reprocessing expiration dispatches `pix.payout.failed` with `reason_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` |

::: danger 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](/en/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:

- [Query by ID](/en/pix-cashout-status)
- [Query by E2E ID](/en/pix-cashout-e2e)
- [Query by Tag](/en/pix-cashout-tag)
- [Query by External ID](/en/pix-query-external-id) -- `GET /api/external/transactions/ref/{external_id}`

Or receive confirmation automatically via [Webhook](/en/webhooks).
