# PIX Lifecycle -- Definitive View

This is the authoritative page on the lifecycle of a PIX transaction at Monetarie. If you have doubts about what a status means, come back here.

## TL;DR -- the only thing you need to know

To know if a transaction is **FINAL**, wait for one of these events:

- **PIX IN (receiving):** webhook `pix.charge.paid` with `status: "paid"`
- **PIX OUT (sending):** webhook `pix.payout.confirmed` with `status: "settled"` **(success)** OR `pix.payout.failed` with `status: "rejected"` **(failure)**

Anything else is intermediate. **Do not credit or debit anything in your system before those events.**

::: danger Golden rule
The only vocabulary that matters is the webhook vocabulary. The GET API returns exactly the same values -- there is no translation between webhook and GET.
:::

---

## Status Matrix -- single source of truth

The same status values appear in three surfaces: the POST response body, the webhook body, and the GET `/transactions/:id` response body. There is no translation between them.

### PIX IN (Cash-In) -- receiving a PIX

| Surface | When | `status` returned |
|---------|------|-------------------|
| POST `/api/external/pix/cash-in` | You generate a QR code | `"active"` |
| Webhook `pix.charge.created` | Monetarie fires when the QR is created | body `status: "created"` |
| Webhook `pix.charge.paid` | PIX was settled in the account | body `status: "paid"` **← TERMINAL** |
| GET `/api/external/transactions/:id` (after payment) | Query by the QR `tx_id` or by `transaction_id` | `"settled"` ← same as `paid`, just a different surface |
| GET before payment | Query by `tx_id` of a QR not yet paid | `"pending"` / `"expired"` / `"cancelled"` |

::: info Opt-in receiving controls - no default impact
If the receiving account has enabled an **opt-in** control ("expected-payer verification" or "block receiving from CNPJ"), a PIX that does not meet the criterion is **rejected and returned to the payer** (BACEN return) instead of credited, rather than proceeding to `pix.charge.paid`. These controls are **OFF by default** - no existing integration is affected unless the account owner enables them. See [Account Controls](/en/account-controls).
:::

**Example of `pix.charge.paid` webhook (real production payload):**

```json
{
  "event_type": "pix.charge.paid",
  "status": "paid",
  "account_id": 10011,
  "amount": 100000,
  "fee_amount": 250,
  "counterparty_name": "Marcia Cristiane Ribeiro Barbosa",
  "end_to_end_id": "E165015552026041016069d8b4c6b2fc",
  "external_id": "T2604101306qtsfffH",
  "paid_at": "2026-04-10T16:07:08.158762Z",
  "payer_bank_name": "STONE IP S.A.",
  "payer_document": "20018216897",
  "payer_ispb": "16501555",
  "qr_code_id": "e9f3df72-031f-49bf-abc3-a9ce1d540726",
  "tx_id": "smyoka2zd5xowvqq2hea"
}
```

### PIX OUT (Cash-Out) -- sending a PIX

| Surface | When | `status` returned |
|---------|------|-------------------|
| POST `/api/external/pix/cash-out` (async, 99% of cases) | Request accepted, forwarded to SPI | HTTP 202 `"accepted"` |
| POST `/api/external/pix/cash-out` (fast-track, rare) | BACEN settled before the response returned | HTTP 200 `"settled"` |
| Webhook `pix.payout.processing` (optional, can skip) | While waiting for BACEN | body `status: "processing"` |
| Webhook `pix.payout.confirmed` | BACEN confirmed settlement | body `status: "settled"` **← TERMINAL SUCCESS** |
| Webhook `pix.payout.failed` | SPI rejected the transaction | body `status: "rejected"` **← TERMINAL FAILURE** |
| Webhook `pix.payout.returned` | Sent PIX was returned later | body `status: "returned"` |
| GET `/api/external/transactions/:id` (in flight) | While the transaction has not been settled | `"processing"` |
| GET `/api/external/transactions/:id` (settled) | After `pix.payout.confirmed` | `"settled"` |
| GET `/api/external/transactions/:id` (failed) | After `pix.payout.failed` | `"failed"` |

**Example of `pix.payout.confirmed` webhook (real production payload):**

```json
{
  "event_type": "pix.payout.confirmed",
  "status": "settled",
  "account_id": 10011,
  "amount": 500000,
  "fee_amount": 250,
  "description": "PIX Cash-Out",
  "end_to_end_id": "E4602656220260411101530220db1672",
  "external_id": "T2604110715qx55o7E",
  "pix_key": "08389612747",
  "initiated_at": "2026-04-11T10:15:31.141953Z",
  "recipient": {
    "name": "Claudio Portugal Wanderley",
    "document": "08389612747",
    "account": "67469312",
    "agency": "1",
    "ispb": "18236120",
    "institution_name": "NU PAGAMENTOS - IP"
  },
  "transaction_id": "PIXOUT8813809cc536884c83056900088b"
}
```

**Example of `pix.payout.failed` webhook (real production payload):**

```json
{
  "event_type": "pix.payout.failed",
  "status": "rejected",
  "account_id": 10016,
  "amount": 80000000,
  "fee_amount": 350,
  "reason": "rejected",
  "reason_code": "AC03",
  "reason_description": "Invalid creditor account number",
  "description": "tx-MONETARIE-1775664887942",
  "end_to_end_id": "E4602656220260408161448ad70215f0",
  "pix_key": "23bb00c0-9b4a-48f5-b62a-03546beb858f",
  "recipient": {
    "name": null,
    "document": null,
    "ispb": null,
    "institution_name": null
  },
  "initiated_at": "2026-04-08T16:14:48.213978Z",
  "transaction_id": "PIXOUT00350c7c85c0b54e83056900e009"
}
```

::: tip Structured reason_code (BACEN UPPERCASE vs provider snake_case)
The `reason_code` and `reason_description` fields have **two conventions** coexisting - this is not inconsistency, it reflects the source of the error:

| Error origin | `reason_code` | Example | When it happens |
|---|---|---|---|
| **BACEN/SPI** (via PACS.002 RJCT) | UPPERCASE 4 chars | `AC03`, `ED05`, `AM02`, `BE01`, `DUPL` | BACEN rejection after our PACS.008 is sent |
| **Provider** (pre-BACEN) | snake_case lowercase | `dict_key_not_found`, `dict_bucket_exhausted`, `dict_client_rate_limited`, `provider_schema_error` | provider or DICT limit failure before reaching BACEN |
| **Others** | CamelCase_SNAKE when mixed | `DICT_CLIENT_RATE_LIMITED`, `DICT_BUCKET_EXHAUSTED`, `DICT_RATE_LIMITED` | Specific retry queue cases (`pix.payout.queued`) |

`reason_description` comes in **English** by default (e.g., `"Invalid creditor account number"` for `AC03`). To classify retries: exact code match + `direction=outbound` + deterministic retry table. Do not case-insensitive match between BACEN and provider - the two conventions are distinct by design.

The legacy `reason` field (string) only appears when the backend **cannot** extract a structured BACEN code; when `reason_code` is filled, `reason` is omitted (mutually exclusive, session 141+163).
:::

### Refund (returns)

There are two distinct return scenarios. Pay attention to the direction.

**Scenario A -- you RECEIVED a return (inbound refund)**

Another institution returned a PIX that you **had received** (e.g., the payer sent too much PIX, requested a partial/total return, and you, as the original receiver, get this return back as a credit).

| Surface | When | `status` returned |
|---------|------|-------------------|
| Webhook `pix.return.received` | You received a PIX back (credit into your account) | body `status: "settled"` |

**Scenario B -- you SENT a return (outbound refund)**

You initiated a return via `POST /api/external/pix/refund` (typically MED) OR received a PIX and returned it via PACS.004 manually.

| Surface | When | `status` returned |
|---------|------|-------------------|
| POST `/api/external/pix/refund` (async) | Request accepted | HTTP 202 `"accepted"` |
| POST `/api/external/pix/refund` (fast-track) | Synchronously settled | HTTP 200 `"settled"` |
| Webhook `pix.refund.requested` | MED preventive block created (dispute initiated) | body `status: "pending"` |
| Webhook `pix.refund.completed` | Refund executed (full MED flow) | body `status: "settled"` |
| Webhook `pix.payout.returned` | Sent PIX OUT was returned via PACS.004 (D-prefix) | body `status: "returned"` |

::: tip Automatically dispatched refund events
The events `pix.refund.requested` and `pix.refund.completed` **ARE dispatched automatically** by the backend as of April 2026. `pix.refund.requested` fires when a preventive block is created; `pix.refund.completed` fires when the refund is completed. Polling on `GET /med/:id` continues to work as an alternative.

Full infraction → block → refund flow in [Infractions (flow)](/en/infractions). Query preventive blocks in [List MED](/en/med-list).
:::

::: warning Voluntary refund vs MED
- **POST /api/external/pix/refund** (this Scenario B flow): **voluntary** refund initiated by you. The return E2E has prefix `D`.
- **MED (Special Refund Mechanism)**: **regulatory** refund executed by the system when a BACEN infraction is accepted (`analysis_result=AGREED`). **Do not call `/pix/refund` for MED** - the backend executes automatically. See [Infractions](/en/infractions).
:::

---

## Flowcharts

### PIX IN -- receiving

```
POST /api/external/pix/cash-in
       │
       │ Response: status "active", transaction_id (QR tx_id)
       ▼
[Webhook] pix.charge.created       ← status "created"
       │
       │ Indeterminate time (until the payer pays the QR)
       ▼
[Payer pays the QR externally]
       │
       │ BACEN settles (<2s)
       ▼
[Webhook] pix.charge.paid          ← status "paid" (TERMINAL)
       │
       ▼
GET /api/external/transactions/:id returns status "settled"
```

### PIX OUT -- sending

```
POST /api/external/pix/cash-out
       │
       ├─ Path A (99% of cases): HTTP 202 + status "accepted"
       │        │
       │        │ Provider forwarded PACS.008 to SPI/BACEN
       │        ▼
       │   [Webhook] pix.payout.processing (optional, may skip)
       │        │          ← body status "processing"
       │        │ BACEN responds (1.6-2s typical)
       │        │
       │        ├─ Success → [Webhook] pix.payout.confirmed
       │        │                     ← body status "settled" (TERMINAL SUCCESS)
       │        │                     ← GET returns "settled"
       │        │
       │        └─ Failure → [Webhook] pix.payout.failed
       │                              ← body status "rejected" (TERMINAL FAILURE)
       │                              ← GET returns "failed"
       │
       └─ Path B (rare, fast-track): HTTP 200 + status "settled" (already terminal)
```

---

## Quarantine (operations without BACEN response)

If a PIX OUT stays >30min in `processing` state without BACEN confirmation/rejection, the system moves the operation to **quarantine** (stage=5) instead of forcing an automatic void. The client's balance **remains blocked** until manual decision by the Monetarie reserve operator (who checks Monetarie operational desk and settlement account).

| Aspect | Behavior |
|---|---|
| **Duration** | Indefinite - may be minutes, hours, or D+1 |
| **Escalation** | Automatic emails at 6h/24h/48h without a decision to compliance@monetarie.com |
| **Automatic resolution** | If BACEN responds late (PACS.002 via long-polling), the operation is resolved without manual intervention - the operator is notified of the retroactive resolution |
| **Client visibility** | The corresponding webhook (`pix.payout.confirmed` or `pix.payout.failed`) is dispatched **only after the final decision** - there is no intermediate webhook for quarantine |
| **Intermediate status** | Remains `"processing"` in `GET /transactions/:id` and `GET /transactions/ref/:external_id` (shape 2) during quarantine |
| **Balance** | Remains `pending` (deducted from `available`, preserved in `balance`). See [Balance](/en/bank-balance). |

This replaces the previous "force_void after 30min" behavior which caused financial loss risk (balance restored in the Monetarie system while BACEN actually transferred to the recipient's end).

::: info Quarantine vs incident
If you see a PIX OUT in `processing` for more than 30min, **it is not a system error** - it is quarantine awaiting manual validation. The final webhook will fire when resolved. Contact support only if 48h pass without resolution or if you confirmed the payment hit BACEN but you did not receive the webhook.
:::

::: warning How to detect quarantine via API
In the query endpoints (`GET /transactions/:id`, `GET /transactions/ref/:external_id` shape 2, `GET /statement` with `status=processing`), **there is no specific field** that differentiates "quarantine" from "normal processing" - both appear with `status="processing"` and `payment_status="processing"`. To identify quarantine:

1. The `started_at` is more than 30 min ago
2. No `pix.payout.confirmed` or `pix.payout.failed` webhook was received
3. The transaction remains in that state for >1h

If these 3 signals hold, quarantine is very likely - wait or contact support. **Never resend** the same request (it generates duplication when the original settles retroactively).
:::

::: warning Retry of a PIX OUT in quarantine
**NEVER resend a PIX OUT that is in quarantine.** The original transaction may settle at any moment. Resending generates duplication. Wait for manual or automatic resolution - you will be notified via webhook when resolved.
:::

---

## FAQ

### Why does POST return `accepted` but the webhook returns `settled`?

These are distinct phases. POST = "we received your request and queued it". Webhook `confirmed` = "BACEN confirmed the settlement". In Brazil settlement is fast (~1.6 seconds), but still asynchronous -- the HTTP client that made the POST already got the response before BACEN answered.

### Does `accepted` mean the money has been debited for good?

**No.** It means the money is **on hold** -- reserved, but it can still be released if SPI rejects. Money only leaves for good when you receive `pix.payout.confirmed` (or GET returns `settled`).

### What is the difference between `failed` and `rejected`?

None. They are the same state seen from different surfaces:

- Webhook body: `"rejected"`
- GET `/transactions/:id` body: `"failed"`

Both mean: the transaction was rejected by SPI, the hold was released, and the balance was restored. **Do not credit the payment.**

### The old documentation said `completed`. Does that still exist?

In practice, no. The word `completed` appeared in old examples and was fictitious documentation - fixed on 2026-04-12. For all production transactions, the field returns `"settled"`.

There is still a theoretical fallback in the code (`helpers.ex:127`): if a row in `transactions` has `status=1` (approved) **AND** `payment_status IS NULL`, the backend returns `"completed"`. In practice, the TbFirst pipeline **always** fills `payment_status` on successful PIX IN/OUT - therefore this fallback is never triggered in real traffic. If your integration nonetheless observes `status="completed"`, treat it as equivalent to `settled` (safe rollback) and report to support to investigate the divergent row.

### What should I do when I receive `pix.payout.processing`?

**Nothing.** It is just a notice. Balance is on hold. Wait for the next event: `pix.payout.confirmed` (success) or `pix.payout.failed` (failure).

### What if I need approval before sending?

The `/pix/cash-out/approve` endpoint does not exist today. The send flow is a single POST `/pix/cash-out` call that immediately forwards to SPI. If you need manual approval, implement that on your side before calling the API.

### How to guarantee idempotency?

- **For POST cash-out/cash-in/refund:** use the `Idempotency-Key` header with a unique value from your system. 24h TTL.
- **For received webhooks:** deduplicate by the `X-Monetarie-Event-Id` header. The same event can be redelivered up to 8 times in case of HTTP failure on your endpoint.

### What about `external_id`?

Optional field (max 128 chars) you set on each POST. Returned in all responses and webhooks, allowing reverse query via `GET /api/external/transactions/ref/:external_id`. Use it to correlate orders in your system with Monetarie transactions.

### A PIX I received was "disputed" - what is that?

A **PIX infraction** was opened by the payer's institution (via BACEN DICT). Depending on the amount and the E2E, it may generate a preventive block on the balance until resolution. See the full flow at [Infractions (flow)](/en/infractions).

### Can I receive a refund without having sent one? (inbound refund)

Yes. If you received a PIX, the counterparty can initiate a unilateral return (PACS.004 D-prefix) - you receive the `pix.return.received` event with `status="settled"` and a credit back. This is different from an infraction (which is a formal BACEN **dispute**, not a direct return).

---

## Quick integrations

- **Postman Collection v2.1:** [Download](/monetarie-postman-collection.json) -- import directly into Postman
- **Payload examples:** [Webhook Payloads](/en/webhooks-payloads)
- **Authentication:** [API Key + HMAC](/en/auth-token)

---

## Summary in one sentence

> For cash-out, only treat as final when you receive **`pix.payout.confirmed`** (`settled`) or **`pix.payout.failed`** (`rejected`). For cash-in, only treat as final when you receive **`pix.charge.paid`** (`paid`). Nothing else is terminal.
