PIX Lifecycle -- Vue autoritative
Cette page est la référence autoritative sur le cycle de vie d'une transaction PIX chez Owem Pay. En cas de doute sur la signification d'un status, revenez ici.
TL;DR -- la seule chose que vous devez savoir
Pour savoir si une transaction est FINALE, attendez l'un de ces événements :
- PIX IN (réception) : webhook
pix.charge.paidavecstatus: "paid" - PIX OUT (envoi) : webhook
pix.payout.confirmedavecstatus: "settled"(succès) oupix.payout.failedavecstatus: "rejected"(échec)
Tout autre état est intermédiaire. Ne créditez ni ne débitez rien dans votre système avant ces événements.
Règle d'or
Le seul vocabulaire qui compte est celui du webhook. L'API GET retourne exactement les mêmes valeurs — il n'y a pas de traduction entre le webhook et le GET.
Matrice de Status -- source unique de vérité
Les mêmes valeurs de status apparaissent sur trois surfaces : le body de la réponse POST, le body du webhook, et le body de la réponse GET /transactions/:id. Il n'y a pas de traduction entre elles.
PIX IN (Cash-In) -- recevoir un PIX
| Surface | Quand | status retourné |
|---|---|---|
POST /api/external/pix/cash-in | Vous générez le QR code | "active" |
Webhook pix.charge.created | Owem déclenche à la création du QR | body status: "created" |
Webhook pix.charge.paid | PIX liquidé sur le compte | body status: "paid" ← TERMINAL |
GET /api/external/transactions/:id (après paiement) | Consultation du tx_id du QR ou du transaction_id | "settled" ← même chose que paid, seule la surface change |
| GET avant le paiement | Consultation par tx_id de QR non encore payé | "pending" / "expired" / "cancelled" |
Exemple de webhook pix.charge.paid (payload réel de production) :
{
"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) -- envoyer un PIX
| Surface | Quand | status retourné |
|---|---|---|
POST /api/external/pix/cash-out (async, presque toujours) | Request accepté, envoyé au SPI | HTTP 202 "accepted" |
POST /api/external/pix/cash-out (fast-track, rare) | BACEN a liquidé avant le retour de la réponse | HTTP 200 "settled" |
Webhook pix.payout.processing (optionnel, peut être sauté) | En attente du BACEN | body status: "processing" |
Webhook pix.payout.confirmed | BACEN a confirmé la liquidation | body status: "settled" ← TERMINAL SUCCÈS |
Webhook pix.payout.failed | SPI a rejeté la transaction | body status: "rejected" ← TERMINAL ÉCHEC |
Webhook pix.payout.returned | PIX envoyé et ensuite remboursé | body status: "returned" |
GET /api/external/transactions/:id (transaction en vol) | Pendant que la transaction n'est pas liquidée | "processing" |
GET /api/external/transactions/:id (liquidée) | Après pix.payout.confirmed | "settled" |
GET /api/external/transactions/:id (échouée) | Après pix.payout.failed | "failed" |
Exemple de webhook pix.payout.confirmed (payload réel de production) :
{
"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"
}Exemple de webhook pix.payout.failed (payload réel de production) :
{
"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"
}reason_code structuré (BACEN UPPERCASE vs provider snake_case)
Les champs reason_code et reason_description ont deux conventions coexistantes — ce n'est pas une incohérence, c'est le reflet de la source de l'erreur :
| Origine de l'erreur | reason_code | Exemple | Quand ça arrive |
|---|---|---|---|
| BACEN/SPI (via PACS.002 RJCT) | UPPERCASE 4 caractères | AC03, ED05, AM02, BE01, DUPL | Rejet du BACEN après envoi de notre PACS.008 |
| Provider (pré-BACEN) | snake_case lowercase | dict_key_not_found, dict_bucket_exhausted, dict_client_rate_limited, provider_schema_error | Échec chez OnZ ou dans le bucket avant d'atteindre le BACEN |
| Autres | CamelCase_SNAKE quand mixte | DICT_CLIENT_RATE_LIMITED, DICT_BUCKET_EXHAUSTED, DICT_RATE_LIMITED | Cas spécifiques de retry queue (pix.payout.queued) |
reason_description vient en anglais par défaut (ex. : "Invalid creditor account number" pour AC03). Pour classer les retry : match exact sur code + direction=outbound + table de retry déterministe. Ne pas faire de match case-insensitive entre BACEN et provider — les deux conventions sont distinctes par design.
Le champ legacy reason (string) n'apparaît que quand le backend ne réussit pas à extraire un code BACEN structuré ; quand reason_code est renseigné, reason est omis (exclusion mutuelle, session 141+163).
Refund (remboursements)
Il existe deux scénarios distincts de remboursement. Attention à la direction.
Scénario A -- vous AVEZ REÇU un remboursement (inbound refund)
Une autre institution a remboursé un PIX que vous aviez reçu (ex. : le payeur a envoyé un PIX en trop, a demandé un remboursement partiel/total, et vous, en tant que bénéficiaire original, recevez ce remboursement de retour comme un crédit).
| Surface | Quand | status retourné |
|---|---|---|
Webhook pix.return.received | Vous avez reçu un PIX en retour (crédit sur votre compte) | body status: "settled" |
Scénario B -- vous AVEZ ENVOYÉ un remboursement (outbound refund)
Vous avez initié un remboursement via POST /api/external/pix/refund (généralement MED) OU vous avez reçu un PIX et l'avez remboursé manuellement via PACS.004.
| Surface | Quand | status retourné |
|---|---|---|
POST /api/external/pix/refund (async) | Request accepté | HTTP 202 "accepted" |
POST /api/external/pix/refund (fast-track) | Liquidé de façon synchrone | HTTP 200 "settled" |
Webhook pix.refund.requested | Blocage conservatoire MED créé (dispute initiée) | body status: "requested" |
Webhook pix.refund.completed | Remboursement effectué (flux MED complet) | body status: "completed" |
Webhook pix.payout.returned | PIX OUT envoyé a été remboursé via PACS.004 (D-prefix) | body status: "returned" |
Événements de refund déclenchés automatiquement
Les événements pix.refund.requested et pix.refund.completed SONT déclenchés automatiquement par le backend depuis avril/2026. pix.refund.requested se déclenche à la création du blocage conservatoire ; pix.refund.completed se déclenche à l'effectuation du remboursement. Le polling sur GET /med/:id continue à fonctionner comme alternative.
Flux complet d'infraction → blocage → remboursement dans Infractions (flux). Consultation des blocages conservatoires dans Liste MED.
Refund volontaire vs MED
- POST /api/external/pix/refund (ce flux Scénario B) : remboursement volontaire initié par vous. L'E2E du remboursement a un préfixe
D. - MED (Mécanisme Spécial de Restitution) : remboursement réglementaire exécuté par le système quand une infraction BACEN est acceptée (
analysis_result=AGREED). N'appelez pas/pix/refundpour MED — le backend exécute automatiquement. Voir Infractions.
Organigrammes
PIX IN -- réception
POST /api/external/pix/cash-in
│
│ Réponse : status "active", transaction_id (tx_id du QR)
▼
[Webhook] pix.charge.created ← status "created"
│
│ Temps indéterminé (jusqu'à ce que le payeur paie le QR)
▼
[Payeur paie le QR en externe]
│
│ BACEN liquide (<2s)
▼
[Webhook] pix.charge.paid ← status "paid" (TERMINAL)
│
▼
GET /api/external/transactions/:id retourne status "settled"PIX OUT -- envoi
POST /api/external/pix/cash-out
│
├─ Chemin A (99% des cas) : HTTP 202 + status "accepted"
│ │
│ │ Provider OnZ a envoyé PACS.008 au SPI/BACEN
│ ▼
│ [Webhook] pix.payout.processing (optionnel, peut être sauté)
│ │ ← body status "processing"
│ │ BACEN répond (1,6-2s typique)
│ │
│ ├─ Succès → [Webhook] pix.payout.confirmed
│ │ ← body status "settled" (TERMINAL SUCCÈS)
│ │ ← GET retourne "settled"
│ │
│ └─ Échec → [Webhook] pix.payout.failed
│ ← body status "rejected" (TERMINAL ÉCHEC)
│ ← GET retourne "failed"
│
└─ Chemin B (rare, fast-track) : HTTP 200 + status "settled" (déjà terminal)Quarantaine (opérations sans réponse BACEN)
Si un PIX OUT reste >30min en état processing sans confirmation/rejet du BACEN, le système déplace l'opération en quarantaine (stage=5) au lieu de forcer le void automatique. Le solde du client reste bloqué jusqu'à la décision manuelle du pilote de la réserve Owem (qui vérifie MGMT OnZ + cabine Planner + compte de liquidation).
| Aspect | Comportement |
|---|---|
| Durée | Indéterminée — peut être des minutes, heures ou D+1 |
| Escalation | Emails automatiques à 6h/24h/48h sans décision vers compliance@owem.com.br |
| Résolution automatique | Si le BACEN répond tardivement (PACS.002 via long-polling), l'opération est résolue sans intervention manuelle — le pilote est notifié de la résolution rétroactive |
| Visibilité pour le client | Le webhook correspondant (pix.payout.confirmed ou pix.payout.failed) n'est déclenché qu'après la décision finale — il n'y a pas de webhook intermédiaire pour quarantaine |
| Status intermédiaire | Reste "processing" dans GET /transactions/:id et GET /transactions/ref/:external_id (shape 2) pendant la quarantaine |
| Solde | Reste en pending (déduit de available, préservé dans balance). Voir Solde. |
Ceci remplace le comportement antérieur de « force_void après 30min » qui causait un risque de perte financière (solde restitué dans le système Owem alors que le BACEN effectuait le transfert côté destinataire).
Quarantaine vs incident
Si vous voyez un PIX OUT en processing pendant plus de 30min, ce n'est pas une erreur du système — c'est la quarantaine en attente de validation manuelle. Le webhook final sera déclenché à la résolution. Contactez le support uniquement si 48h passent sans résolution ou si vous avez identifié que le paiement est passé au BACEN mais que vous n'avez pas reçu de webhook.
Comment détecter la quarantaine via API
Dans les endpoints de consultation (GET /transactions/:id, GET /transactions/ref/:external_id shape 2, GET /statement avec status=processing), il n'y a pas de champ spécifique qui différencie « quarantaine » de « traitement normal » — les deux apparaissent avec status="processing" et payment_status="processing". Pour identifier la quarantaine :
- Le
started_atest d'il y a plus de 30 min - Aucun webhook
pix.payout.confirmedoupix.payout.failedn'a été reçu - La transaction reste dans cet état pendant >1h
Si ces 3 signaux se confirment, il s'agit très probablement de quarantaine — attendez ou contactez le support. Ne renvoyez jamais la même requête (génère des duplications quand l'original liquide rétroactivement).
Retry d'un PIX OUT en quarantaine
NE renvoyez JAMAIS un PIX OUT qui est en quarantaine. La transaction originale peut liquider à tout moment. La renvoyer génère des duplications. Attendez la résolution manuelle ou automatique — vous serez notifié via webhook à la résolution.
FAQ
Pourquoi le POST retourne accepted mais le webhook retourne settled ?
Ce sont des phases distinctes. POST = « nous avons reçu votre demande et l'avons mise en file ». Webhook confirmed = « BACEN a confirmé la liquidation ». Au Brésil, la liquidation est rapide (~1,6 seconde), mais reste asynchrone — le client HTTP qui a appelé le POST a déjà reçu la réponse avant que le BACEN ne réponde.
accepted signifie-t-il que l'argent a été débité définitivement ?
Non. Cela signifie que l'argent est en hold — réservé, mais peut encore être libéré si le SPI rejette. L'argent ne sort définitivement que lorsque vous recevez pix.payout.confirmed (ou GET retourne settled).
Quelle est la différence entre failed et rejected ?
Aucune. C'est le même état vu depuis des surfaces différentes :
- Webhook body :
"rejected" - GET
/transactions/:idbody :"failed"
Les deux signifient : la transaction a été rejetée par le SPI, le hold a été libéré, et le solde a été restauré. Ne créditez pas le paiement.
L'ancien document disait completed. Existe-t-il encore ?
En pratique, non. Le mot completed apparaissait dans des exemples anciens et était de la documentation fictive — corrigée en 2026-04-12. Pour toutes les transactions en production, le champ retourne "settled".
Il existe encore un fallback théorique dans le code (helpers.ex:127) : si une row de transactions a status=1 (approved) ET payment_status IS NULL, le backend renvoie "completed". En pratique, le pipeline TbFirst remplit toujours payment_status dans les PIX IN/OUT réussis — donc ce fallback n'est jamais déclenché en trafic réel. Si malgré tout votre intégration observe status="completed", traitez-le comme équivalent à settled (rollback sûr) et signalez au support pour investiguer la row divergente.
Que faire quand je reçois pix.payout.processing ?
Rien. C'est juste un avis. Le solde est en hold. Attendez le prochain événement : pix.payout.confirmed (succès) ou pix.payout.failed (échec).
Et si j'ai besoin d'une approbation avant d'envoyer ?
L'endpoint /pix/cash-out/approve n'existe pas aujourd'hui. Le flux d'envoi est un unique appel POST /pix/cash-out qui va immédiatement au SPI. Si vous voulez une approbation manuelle, implémentez-la de votre côté avant d'appeler l'API.
Comment garantir l'idempotence ?
- Pour POST cash-out/cash-in/refund : utilisez le header
Idempotency-Keyavec une valeur unique de votre système. TTL de 24h. - Pour les webhooks reçus : dédupliquez par le header
X-Owem-Event-Id. Le même événement peut être redistribué jusqu'à 8 fois en cas d'échec HTTP sur votre endpoint.
Et l'external_id ?
Champ optionnel (max 128 caractères) que vous définissez à chaque POST. Il est retourné dans toutes les réponses et webhooks, permettant une consultation inverse via GET /api/external/transactions/ref/:external_id. Utilisez-le pour corréler les commandes de votre système avec les transactions d'Owem.
Un PIX que j'ai reçu est devenu « contesté » — qu'est-ce que c'est ?
Une infraction PIX a été ouverte par l'institution du payeur (via BACEN DICT). Selon la valeur et l'E2E, elle peut générer un blocage conservatoire du solde jusqu'à la résolution. Voir le flux complet dans Infractions (flux).
Puis-je recevoir un remboursement sans avoir envoyé ? (inbound refund)
Oui. Si vous avez reçu un PIX, la contrepartie peut initier un remboursement unilatéral (PACS.004 D-prefix) — vous recevez l'événement pix.return.received avec status="settled" et un crédit de retour. C'est différent d'une infraction (qui est une contestation formelle BACEN, pas un remboursement direct).
Intégrations rapides
- Postman Collection v2.1 : Télécharger -- importez directement dans Postman
- Bruno Collection :
backend/bruno/external/dans le dépôt - Exemples de payload : Webhook Payloads
- Authentification : API Key + HMAC
Résumé en une phrase
Pour cash-out, ne traitez comme final que lorsque vous recevez
pix.payout.confirmed(settled) oupix.payout.failed(rejected). Pour cash-in, ne traitez comme final que lorsque vous recevezpix.charge.paid(paid). Rien d'autre n'est terminal.