Skip to content

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.paid avec status: "paid"
  • PIX OUT (envoi) : webhook pix.payout.confirmed avec status: "settled" (succès) ou pix.payout.failed avec status: "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

SurfaceQuandstatus retourné
POST /api/external/pix/cash-inVous générez le QR code"active"
Webhook pix.charge.createdOwem déclenche à la création du QRbody status: "created"
Webhook pix.charge.paidPIX liquidé sur le comptebody 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 paiementConsultation par tx_id de QR non encore payé"pending" / "expired" / "cancelled"

Exemple de webhook pix.charge.paid (payload réel de production) :

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) -- envoyer un PIX

SurfaceQuandstatus retourné
POST /api/external/pix/cash-out (async, presque toujours)Request accepté, envoyé au SPIHTTP 202 "accepted"
POST /api/external/pix/cash-out (fast-track, rare)BACEN a liquidé avant le retour de la réponseHTTP 200 "settled"
Webhook pix.payout.processing (optionnel, peut être sauté)En attente du BACENbody status: "processing"
Webhook pix.payout.confirmedBACEN a confirmé la liquidationbody status: "settled" ← TERMINAL SUCCÈS
Webhook pix.payout.failedSPI a rejeté la transactionbody status: "rejected" ← TERMINAL ÉCHEC
Webhook pix.payout.returnedPIX 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) :

json
{
  "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) :

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-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'erreurreason_codeExempleQuand ça arrive
BACEN/SPI (via PACS.002 RJCT)UPPERCASE 4 caractèresAC03, ED05, AM02, BE01, DUPLRejet du BACEN après envoi de notre PACS.008
Provider (pré-BACEN)snake_case lowercasedict_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
AutresCamelCase_SNAKE quand mixteDICT_CLIENT_RATE_LIMITED, DICT_BUCKET_EXHAUSTED, DICT_RATE_LIMITEDCas 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).

SurfaceQuandstatus retourné
Webhook pix.return.receivedVous 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.

SurfaceQuandstatus retourné
POST /api/external/pix/refund (async)Request acceptéHTTP 202 "accepted"
POST /api/external/pix/refund (fast-track)Liquidé de façon synchroneHTTP 200 "settled"
Webhook pix.refund.requestedBlocage conservatoire MED créé (dispute initiée)body status: "requested"
Webhook pix.refund.completedRemboursement effectué (flux MED complet)body status: "completed"
Webhook pix.payout.returnedPIX 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/refund pour 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).

AspectComportement
DuréeIndéterminée — peut être des minutes, heures ou D+1
EscalationEmails automatiques à 6h/24h/48h sans décision vers compliance@owem.com.br
Résolution automatiqueSi 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 clientLe 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édiaireReste "processing" dans GET /transactions/:id et GET /transactions/ref/:external_id (shape 2) pendant la quarantaine
SoldeReste 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 :

  1. Le started_at est d'il y a plus de 30 min
  2. Aucun webhook pix.payout.confirmed ou pix.payout.failed n'a été reçu
  3. 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/:id body : "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-Key avec 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


Résumé en une phrase

Pour cash-out, ne traitez comme final que lorsque vous recevez pix.payout.confirmed (settled) ou pix.payout.failed (rejected). Pour cash-in, ne traitez comme final que lorsque vous recevez pix.charge.paid (paid). Rien d'autre n'est terminal.

Owem Pay Instituição de Pagamento — ISPB 37839059