PIX Lifecycle -- Definitive View
This is the authoritative page on the lifecycle of a PIX transaction at Owem Pay. 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.paidwithstatus: "paid" - PIX OUT (sending): webhook
pix.payout.confirmedwithstatus: "settled"(success) ORpix.payout.failedwithstatus: "rejected"(failure)
Anything else is intermediate. Do not credit or debit anything in your system before those events.
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 | Owem 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" |
Example of pix.charge.paid webhook (real production payload):
{
"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):
{
"event_type": "pix.payout.confirmed",
"status": "settled",
"account_id": 10011,
"amount": 500000,
"fee_amount": 250,
"description": "PIX Cash-Out",
"end_to_end_id": "E3783905920260411101530220db1672",
"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):
{
"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-OWEMPAY-1775664887942",
"end_to_end_id": "E3783905920260408161448ad70215f0",
"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"
}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 | OnZ or bucket 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: "requested" |
Webhook pix.refund.completed | Refund executed (full MED flow) | body status: "completed" |
Webhook pix.payout.returned | Sent PIX OUT was returned via PACS.004 (D-prefix) | body status: "returned" |
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). Query preventive blocks in List MED.
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/refundfor MED — the backend executes automatically. See 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 OnZ 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 Owem reserve operator (who checks OnZ MGMT + Planner cockpit + 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@owem.com.br |
| 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. |
This replaces the previous "force_void after 30min" behavior which caused financial loss risk (balance restored in the Owem system while BACEN actually transferred to the recipient's end).
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.
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:
- The
started_atis more than 30 min ago - No
pix.payout.confirmedorpix.payout.failedwebhook was received - 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).
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/:idbody:"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-Keyheader with a unique value from your system. 24h TTL. - For received webhooks: deduplicate by the
X-Owem-Event-Idheader. 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 Owem 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).
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 -- import directly into Postman
- Bruno Collection:
backend/bruno/external/in the repository - Payload examples: Webhook Payloads
- Authentication: API Key + HMAC
Summary in one sentence
For cash-out, only treat as final when you receive
pix.payout.confirmed(settled) orpix.payout.failed(rejected). For cash-in, only treat as final when you receivepix.charge.paid(paid). Nothing else is terminal.