PIX Cash-Out par clé
Effectue un virement PIX en utilisant la clé PIX du destinataire.
Endpoint
POST /api/external/pix/cash-outHeaders
| Header | Type | Obligatoire | Description |
|---|---|---|---|
Authorization | String | Oui | ApiKey {client_id}:{client_secret} |
Content-Type | String | Oui | application/json |
hmac | String | Oui | Signature HMAC-SHA512 du body (hex) |
Idempotency-Key | String | Non | Clé unique pour éviter le traitement en double (max 256 caractères) |
Authentification
Voir Authentification. La signature HMAC doit être générée comme décrit dans HMAC-SHA512.
Idempotency-Key — comportement de replay
Quand présent, l'API stocke la réponse (seulement en 2xx) pendant 24 heures et retourne la réponse en cache pour tout nouveau POST avec la même combinaison (méthode, path, Idempotency-Key). Le cache est limité par endpoint (même clé sur /cash-out et /refund n'entrent pas en collision).
- Dans la réponse replay, l'API inclut l'en-tête
X-Idempotent-Replay: trueet renvoie leIdempotency-Keyenvoyé. - Les clés de plus de 256 caractères retournent
400 Bad Request. - La clé est optionnelle. Si vous ne l'envoyez pas, l'API traite chaque POST comme une nouvelle transaction (le
end_to_end_iddéterministe garantit encore l'idempotence au niveau BACEN/SPI, mais peut générer un rejet avecfailure_reason: "DUPL"si la première tentative a déjà été liquidée).
Permission obligatoire
L'API Key doit avoir la permission transfer:write pour envoyer du PIX. Sans elle, la requête retourne 403 Forbidden. Voir comment configurer les permissions.
Request Body
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
amount | Integer | Oui | Valeur en centavos. R$ 30,00 = 3000 |
pix_key | String | Oui | Clé PIX du destinataire |
pix_key_type | String | Non | Type de la clé : cpf, cnpj, email, phone, evp. Si omis, détecté automatiquement à partir de la clé. |
description | String | Non | Description du virement (max 140 caractères) |
external_id | String | Non | Identifiant de votre système pour le suivi. Max 128 caractères après trim. Uniquement caractères a-zA-Z0-9._:-. Retourné dans les réponses et webhooks. Les valeurs invalides (caractères non autorisés, > 128 caractères, vide après trim) sont silencieusement écartées — la transaction continue avec external_id: null. Validez de votre côté avant d'envoyer si vous avez besoin de garantir la persistance. |
recipient_ispb | String | Non | ISPB de l'institution du destinataire pour le routage manuel (8 chiffres). Quand fourni, dirige le paiement vers le PSP spécifié. N'envoyez pas l'ISPB d'Owem (37839059) — les requêtes intra-institutionnelles retournent l'erreur same_institution (PIX interne non supporté). |
end_to_end_id | String | Non | End-to-End ID au format BACEN (E{ISPB}{YYYYMMDDHHmm}{entropy}). Recommandé à omettre — le backend génère un E2E déterministe à chaque tentative (mêmes amount + pix_key + merchant_id → même E2E). Ce déterminisme garantit l'idempotence dans le SPI/BACEN même sans Idempotency-Key. N'envoyez manuellement que dans des scénarios de reprocessement coordonné. |
purpose | String | Non | Finalité du virement (champ libre pour usage interne et compliance). |
Valeurs monétaires
Les valeurs d'entrée sont en centavos (R$ 1,00 = 100). Les valeurs de réponse sont en unités de base (R$ 1,00 = 10000). Pour convertir la réponse en reais, divisez par 10 000. N'utilisez jamais de virgule flottante.
Exemple
curl -X POST https://api.owem.com.br/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": "Paiement fournisseur",
"external_id": "order-9876"
}'Réponse de succès -- 200 / 202
{
"worked": true,
"final": false,
"transaction_id": "PIXOUT20260309a1b2c3d4e5f6",
"end_to_end_id": "E37839059202603091530abcdef01",
"external_id": "order-9876",
"amount": 300000,
"fee_amount": 350,
"net_amount": 300350,
"status": "accepted",
"detail": "PIX enviado para processamento"
}HTTP 200 vs 202
- HTTP 200 : Transaction déjà liquidée (
final: true,status: "settled"). - HTTP 202 : Transaction acceptée pour traitement (
final: false). Suivez le status via polling ou webhook.statuspeut être"accepted"(flux normal),"queued"(rate-limit appliqué — retry automatique toutes les 3s pendant jusqu'à 120 min) ou"pending_approval"(en attente d'approbation via workflow de double autorisation, quand activé).
| Champ | Type | Description |
|---|---|---|
worked | Boolean | true indique que la requête a été acceptée |
final | Boolean | true quand la transaction a atteint un état terminal (liquidée ou rejetée). false quand encore en traitement |
transaction_id | String | Identifiant unique de la transaction |
end_to_end_id | String | Identifiant End-to-End dans le SPI/BACEN (format E{ISPB}...) |
external_id | String | Votre identifiant, retourné tel qu'envoyé. null s'il n'a pas été renseigné |
amount | Integer | Valeur du virement en unités de base (÷ 10 000 pour reais). 300000 = R$ 30,00 |
fee_amount | Integer | Frais prélevés en unités de base (÷ 10 000 pour reais) |
net_amount | Integer | Valeur brute débitée sur le compte payeur, en unités de base. Calculée comme amount + fee_amount (le débit total inclut les frais). Ce n'est pas la valeur que le destinataire reçoit — il ne reçoit que amount. Exemple : amount=300000 + fee_amount=350 → net_amount=300350 (R$ 30,035 débités de votre compte, R$ 30,00 crédités au destinataire) |
status | String | Un de : accepted (HTTP 202, traitement synchrone normal), settled (HTTP 200, liquidation immédiate — rare en fast-track), queued (HTTP 202, entré dans la file de retry automatique pour rate-limit DICT — session 155), pending_approval (HTTP 202, en attente d'approbation). Voir les status terminaux dans Consulter Cash-Out par ID -- Valeurs du champ status |
detail | String | Message descriptif |
Sens de net_amount en cash-out diffère de cash-in
En cash-out, net_amount = amount + fee_amount (débit brut sur le compte payeur). En cash-in (QR Code payé), le backend traite net_amount comme valeur nette créditée après déduction des frais. Cette asymétrie est historique — traitez net_amount toujours comme « mouvement effectif sur votre compte dans cette direction ». Pour la conciliation comptable, préférez opérer avec les champs amount et fee_amount séparément.
Codes de rejet
L'API peut rejeter un cash-out par validation d'entrée (avant envoi au SPI), par erreur d'intégration avec le provider / DICT (pendant l'envoi synchrone), ou par rate-limit avec retry automatique en file d'attente. Les rejets BACEN via PACS.002 RJCT arrivent de façon asynchrone et n'apparaissent que via consultation de status ou webhook pix.payout.rejected.
Format de la réponse d'erreur
Les rejets synchrones du cash-out retournent en deux formats distincts — choisissez le parser correct selon l'origine de l'erreur :
Format A — Validation ou intégration de l'Orchestrator (codes same_institution_transfer, insufficient_balance, dict_key_not_found, dict_rate_limited, dict_bucket_exhausted venant du chemin synchrone OnZ → Orchestrator) : HTTP 400 ou 422, body {"status": "failed", "errors": [{"code": "<code>", "params": [...]}]}. Généré par le FallbackController à partir de %OutboundPayment.Result{error_stage: :validation | :integration}.
Format B — Erreur de controller pré-Orchestrator (ex. : invalid or missing amount, ambiguous key de Helpers.validate_amount / KeySanitizer) : HTTP 400, body {"errors": {"bad_request": "message"}}.
Routez via data.status === "failed" (Format A) vs data.errors.bad_request (Format B).
Erreurs de validation (HTTP 400 / 422)
| HTTP | Format | Champ avec code | Signification |
|---|---|---|---|
| 400 | B | errors.bad_request: "invalid or missing amount" | amount absent, zéro, négatif ou non-entier |
| 400 | B | errors.bad_request: "ambiguous key" | Clé de 11 chiffres sans pix_key_type — peut être CPF ou téléphone. Résolvez via Validation CPF et fournissez pix_key_type explicitement |
| 400 | B | errors.bad_request: "invalid pix_key" | La clé n'a pas passé les règles de format (checksum CPF invalide, email mal formé, etc.) |
| 422 | A | errors[0].code: "same_institution_transfer" | recipient_ispb est l'ISPB d'Owem (37839059). PIX intra-institutionnel non supporté — utilisez TEF interne. Note : cette validation retourne HTTP 422 (pas 400) avec la structure {status: "failed", errors: [{code: "same_institution_transfer", params: []}]} |
| 422 | A | errors[0].code: "insufficient_balance" | Solde disponible inférieur à amount + fee_amount. Tient compte du hold actif (gotcha min(TB, PG)) |
Changement de shape pour same_institution
Les versions précédentes de ces docs affirmaient HTTP 400 avec detail: "same_institution". Le comportement réel est HTTP 422 avec le shape du Format A (errors comme tableau de {code, params}). Les clients qui font if (status === 400 && body.detail === "same_institution") ne se déclenchent pas en pratique — utilisez if (status === 422 && body.errors?.[0]?.code === "same_institution_transfer").
Erreurs d'intégration avec le provider / DICT (HTTP 400)
Quand OnZ retourne HTTP 4xx synchrone (avant que PACS.008 n'atteigne le BACEN), le backend classe l'erreur via Fluxiq.UseCases.Pix.ReasonCodes.classify_provider_error/2 et retourne le Format A avec HTTP 400 (error_stage: :integration) :
Code (errors[0].code) | Signification | Action recommandée |
|---|---|---|
dict_key_not_found | Clé PIX non localisée dans le DICT/BACEN (OnZ HTTP 404) | Vérifiez avec le payeur ; la clé peut avoir été supprimée ou jamais enregistrée |
dict_key_blocked | Clé bloquée (ex. : suspicion de fraude, OnZ HTTP 403) | Contact avec le titulaire de la clé |
dict_lookup_failed | Échec de consultation du DICT (message « consulta dict » dans le body OnZ) | Retry dans 5-30s |
dict_rate_limited | OnZ a retourné 429 avec le message « rate limit » ou « limite de consultas dict » | Backoff exponentiel avant retry |
dict_bucket_exhausted | OnZ a retourné un body mentionnant « bucket » / « balde de fichas » | Retry dans 60-120s ; évitez les rafales |
provider_rejected | OnZ a rejeté avec une erreur 4xx générique non classée | Voir errors[0].params pour contexte (HTTP original d'OnZ) ; réouvrez un cas avec le support Owem |
provider_schema_error | OnZ a retourné HTTP 422 — PACS.008 avec format invalide (erreur interne) | Signalez immédiatement — n'essayez pas de refaire, c'est un bug du backend |
provider_unknown_error | Status hors de 400..499 qui est entré dans ce chemin | Log complet disponible auprès du support |
HTTP est 400 (pas 429)
Les versions précédentes de ces docs montraient HTTP 429 pour dict_rate_limited et dict_bucket_exhausted dans le chemin synchrone. Le FallbackController.call/2 mappe %OutboundPayment.Result{error_stage: :integration} vers HTTP 400 Bad Request — jamais 429. Le seul chemin qui retourne HTTP 202 pour rate-limit est la file de retry automatique (voir section ci-dessous).
Rate-limit avec retry automatique (HTTP 202 queued)
Quand l'adaptateur interne (pas OnZ) détecte que la limite a été atteinte avant d'envoyer la requête à OnZ, le backend met la transaction en file d'attente pour retry automatique. Ce chemin est activé par le flag pix_out_retry_queue_enabled (ON en PRD depuis session 155).
Deux scénarios déclenchent la file :
| Origine | reason_code (webhook pix.payout.queued) | Cause |
|---|---|---|
ClientLimiter per-merchant | DICT_CLIENT_RATE_LIMITED | Le merchant a dépassé le quota de consultations DICT par minute (default DICT_CLIENT_MAX_PER_MIN=120, configurable via env). Protection pour éviter qu'un client unique ne monopolise le bucket BACEN partagé. S'applique uniquement au chemin cache-MISS — les destinations récurrentes en cache ne comptent pas. |
DictBucket.Guard global | DICT_BUCKET_EXHAUSTED | Balde de fichas DICT du participant OnZ auprès du BACEN épuisé. Refill empirique DICT_BUCKET_REFILL_RATE=18/min (incident session 155 R Torres), capacity 250 fichas (rating G, tableau §13.1 Manual DICT BACEN). |
Réponse HTTP quand mise en file (générée par FallbackController à partir de %Result{status: :queued}) :
{
"status": "queued",
"type": "pix",
"transaction_id": "PIXOUT20260309a1b2c3d4e5f6",
"end_to_end_id": "E37839059202603091530abcdef01",
"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
}Mécanique du retry :
- Le worker Oban
Fluxiq.Workers.PixOutRetryWorkerréessaie toutes les 3 secondes (aligné avec le refill rate BACEN de ~3,3s par ficha). - TTL total : 7200 secondes (120 minutes). Après expiration, le worker fait void du TB pending et déclenche
pix.payout.failedavecreason_code: "DICT_QUEUE_TIMEOUT". - Max attempts : 50 (snooze d'Oban ne compte pas comme attempt). Unique constraint par
request_idempêche les jobs dupliqués. - Webhook immédiat : à l'entrée dans la file, le backend déclenche
pix.payout.queuedavecreason_code(DICT_CLIENT_RATE_LIMITEDouDICT_BUCKET_EXHAUSTED) etreason_description. C'est le seul webhook émis pendant les 120 minutes — le suivant serapix.payout.confirmed(succès) oupix.payout.failed(timeout).
DICT_CLIENT_MAX_PER_MIN ≠ DICT_BUCKET_REFILL_RATE
Ce sont deux limites indépendantes avec des causes distinctes :
DICT_CLIENT_MAX_PER_MIN=120: quota par merchant, fenêtre glissante de 60s, comptabilisée en Redis avant la consultation DICT. Quand atteint → reason_codeDICT_CLIENT_RATE_LIMITED. Si le flag retry queue est ON (PRD actuel), la réponse est HTTP 202queued. Si OFF, la requête retombe dans le cheminResult.success(:accepted)et dépend du StaleChecker pour éventuellement voider la transaction — comportement legacy.DICT_BUCKET_REFILL_RATE=18(par minute) + capacity 250 : limite globale du bucket BACEN partagée par tout l'ISPB OnZ. Reset de tokens toutes les ~3,3s. Quand le bucket arrive à zéro → reason_codeDICT_BUCKET_EXHAUSTED, même sémantique HTTP 202queued(si flag ON).
Le flag pix_out_retry_queue_enabled est ON en production depuis session 155 (20/04/2026). Pour les clients en homologation, le comportement peut varier — traitez toujours status === "queued" et status === "accepted" comme non-terminaux.
Permission et authentification (HTTP 401 / 403)
| HTTP | detail | Signification |
|---|---|---|
| 401 | Invalid HMAC signature | La signature HMAC ne correspond pas. Vérifiez l'ordre alphabétique des champs dans le body sérialisé — voir HMAC-SHA512 |
| 401 | Invalid API Key | client_id:client_secret incorrect |
| 403 | permission 'transfer:write' required | API Key sans permission pour PIX |
| 403 | IP not whitelisted | IP d'origine hors de l'allowlist de l'API Key |
Vocabulaire de codes — UPPERCASE × lowercase
Les codes structurés du cash-out viennent de deux dictionnaires distincts, cohérents dans le backend via Fluxiq.UseCases.Pix.ReasonCodes :
| Namespace | Convention | Origine | Exemples |
|---|---|---|---|
| BACEN SPI | UPPERCASE | Rejets asynchrones via PACS.002 RJCT (arrivent après le 202) — visibles dans GET /transactions/:id et webhook pix.payout.rejected | AC03, AB03, ED05, DUPL, AM02, FF08, BE01 |
| Provider / Adapter | lowercase snake_case | Rejets synchrones d'OnZ avant que PACS.008 n'atteigne le BACEN — utilisés dans errors[0].code sur cet endpoint | dict_key_not_found, dict_rate_limited, same_institution_transfer, provider_schema_error |
| File de retry | UPPERCASE (préfixe DICT_) | Webhook pix.payout.queued / pix.payout.failed quand il y a retry automatique | DICT_CLIENT_RATE_LIMITED, DICT_BUCKET_EXHAUSTED, DICT_QUEUE_TIMEOUT |
En faisant un switch programmatique d'erreurs, normalisez en uppercase ou lowercase de votre côté pour éviter les branches dupliquées. N'attendez pas AM02 dans les réponses synchrones — les codes BACEN n'apparaissent que dans les consultations GET post-acceptation.
Webhooks correspondants
- Les rejets synchrones (Format A/B ci-dessus) ne déclenchent pas de webhook — le client a déjà reçu l'erreur dans la réponse HTTP.
- La mise en file par rate-limit (HTTP 202
queued) déclenchepix.payout.queuedimmédiatement avecreason_code+reason_description. - Les rejets asynchrones (PACS.002 RJCT après acceptation 202) déclenchent
pix.payout.rejectedavecreason_codeBACEN (AC03, AB03, ED05, DUPL etc.) etreason_descriptionen anglais. - Les voids d'orphelines (>30min sans PACS.002) déclenchent
pix.payout.failedavecreason_code: "orphan_force_voided". - L'expiration de la file de retry (120min) déclenche
pix.payout.failedavecreason_code: "DICT_QUEUE_TIMEOUT".
Types de clé PIX
| Type | Format | Exemple |
|---|---|---|
cpf | 11 chiffres (sans ponctuation) | 12345678901 |
cnpj | 14 chiffres (sans ponctuation) | 12345678000199 |
email | Adresse email | nom@entreprise.com.br |
phone | DDD + numéro (11 chiffres) | 11999998888 |
evp | UUID v4 | a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d |
Clés de 11 chiffres — Ambiguïté CPF vs Téléphone
Les clés de exactement 11 chiffres peuvent être soit un CPF soit un téléphone mobile (DDD + 9xxxx-xxxx). Quand la clé est ambiguë, l'API rejette avec HTTP 400 et failure_reason: "ambiguous key".
Solution recommandée :
- Utilisez l'endpoint Validation CPF (
POST /api/external/cpf/validate) pour vérifier si les 11 chiffres forment un CPF valide - Si
valid: true→ envoyezpix_key_type: "cpf"dans le cash-out - Si
valid: false→ c'est un téléphone, envoyezpix_key_type: "phone"(l'API ajoute automatiquement le préfixe+55)
// Exemple de flux automatisé
async function resolveKeyType(key) {
if (key.length !== 11 || /\D/.test(key)) return null; // sans ambiguïté
const { data } = await api.post('/api/external/cpf/validate', { cpf: key });
return data.valid ? 'cpf' : 'phone';
}Astuce : envoyez les téléphones sous forme de 11 chiffres purs (DDD + numéro). L'API ajoute le préfixe +55 automatiquement. Évitez d'envoyer le +55 manuellement — cela peut causer un échec de la validation HMAC dans certains clients.
Étapes suivantes
Après avoir créé le virement, suivez le status via :
- Consulter par ID
- Consulter par E2E ID
- Consulter par Tag
- Consulter par External ID --
GET /api/external/transactions/ref/{external_id}
Ou recevez la confirmation automatiquement via Webhook.