Skip to content

PIX Cash-Out par clé

Effectue un virement PIX en utilisant la clé PIX du destinataire.

Endpoint

POST /api/external/pix/cash-out

Headers

HeaderTypeObligatoireDescription
AuthorizationStringOuiApiKey {client_id}:{client_secret}
Content-TypeStringOuiapplication/json
hmacStringOuiSignature HMAC-SHA512 du body (hex)
Idempotency-KeyStringNonClé 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: true et renvoie le Idempotency-Key envoyé.
  • 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_id déterministe garantit encore l'idempotence au niveau BACEN/SPI, mais peut générer un rejet avec failure_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

ChampTypeObligatoireDescription
amountIntegerOuiValeur en centavos. R$ 30,00 = 3000
pix_keyStringOuiClé PIX du destinataire
pix_key_typeStringNonType de la clé : cpf, cnpj, email, phone, evp. Si omis, détecté automatiquement à partir de la clé.
descriptionStringNonDescription du virement (max 140 caractères)
external_idStringNonIdentifiant 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_ispbStringNonISPB 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_idStringNonEnd-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é.
purposeStringNonFinalité 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

bash
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

json
{
  "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. status peut ê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é).
ChampTypeDescription
workedBooleantrue indique que la requête a été acceptée
finalBooleantrue quand la transaction a atteint un état terminal (liquidée ou rejetée). false quand encore en traitement
transaction_idStringIdentifiant unique de la transaction
end_to_end_idStringIdentifiant End-to-End dans le SPI/BACEN (format E{ISPB}...)
external_idStringVotre identifiant, retourné tel qu'envoyé. null s'il n'a pas été renseigné
amountIntegerValeur du virement en unités de base (÷ 10 000 pour reais). 300000 = R$ 30,00
fee_amountIntegerFrais prélevés en unités de base (÷ 10 000 pour reais)
net_amountIntegerValeur 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=350net_amount=300350 (R$ 30,035 débités de votre compte, R$ 30,00 crédités au destinataire)
statusStringUn 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
detailStringMessage 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)

HTTPFormatChamp avec codeSignification
400Berrors.bad_request: "invalid or missing amount"amount absent, zéro, négatif ou non-entier
400Berrors.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
400Berrors.bad_request: "invalid pix_key"La clé n'a pas passé les règles de format (checksum CPF invalide, email mal formé, etc.)
422Aerrors[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: []}]}
422Aerrors[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)SignificationAction recommandée
dict_key_not_foundClé 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_blockedClé 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_limitedOnZ a retourné 429 avec le message « rate limit » ou « limite de consultas dict »Backoff exponentiel avant retry
dict_bucket_exhaustedOnZ a retourné un body mentionnant « bucket » / « balde de fichas »Retry dans 60-120s ; évitez les rafales
provider_rejectedOnZ a rejeté avec une erreur 4xx générique non classéeVoir errors[0].params pour contexte (HTTP original d'OnZ) ; réouvrez un cas avec le support Owem
provider_schema_errorOnZ 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_errorStatus hors de 400..499 qui est entré dans ce cheminLog 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 :

Originereason_code (webhook pix.payout.queued)Cause
ClientLimiter per-merchantDICT_CLIENT_RATE_LIMITEDLe 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 globalDICT_BUCKET_EXHAUSTEDBalde 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}) :

json
{
  "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.PixOutRetryWorker ré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.failed avec reason_code: "DICT_QUEUE_TIMEOUT".
  • Max attempts : 50 (snooze d'Oban ne compte pas comme attempt). Unique constraint par request_id empêche les jobs dupliqués.
  • Webhook immédiat : à l'entrée dans la file, le backend déclenche pix.payout.queued avec reason_code (DICT_CLIENT_RATE_LIMITED ou DICT_BUCKET_EXHAUSTED) et reason_description. C'est le seul webhook émis pendant les 120 minutes — le suivant sera pix.payout.confirmed (succès) ou pix.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_code DICT_CLIENT_RATE_LIMITED. Si le flag retry queue est ON (PRD actuel), la réponse est HTTP 202 queued. Si OFF, la requête retombe dans le chemin Result.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_code DICT_BUCKET_EXHAUSTED, même sémantique HTTP 202 queued (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)

HTTPdetailSignification
401Invalid HMAC signatureLa signature HMAC ne correspond pas. Vérifiez l'ordre alphabétique des champs dans le body sérialisé — voir HMAC-SHA512
401Invalid API Keyclient_id:client_secret incorrect
403permission 'transfer:write' requiredAPI Key sans permission pour PIX
403IP not whitelistedIP 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 :

NamespaceConventionOrigineExemples
BACEN SPIUPPERCASERejets asynchrones via PACS.002 RJCT (arrivent après le 202) — visibles dans GET /transactions/:id et webhook pix.payout.rejectedAC03, AB03, ED05, DUPL, AM02, FF08, BE01
Provider / Adapterlowercase snake_caseRejets synchrones d'OnZ avant que PACS.008 n'atteigne le BACEN — utilisés dans errors[0].code sur cet endpointdict_key_not_found, dict_rate_limited, same_institution_transfer, provider_schema_error
File de retryUPPERCASE (préfixe DICT_)Webhook pix.payout.queued / pix.payout.failed quand il y a retry automatiqueDICT_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éclenche pix.payout.queued immédiatement avec reason_code + reason_description.
  • Les rejets asynchrones (PACS.002 RJCT après acceptation 202) déclenchent pix.payout.rejected avec reason_code BACEN (AC03, AB03, ED05, DUPL etc.) et reason_description en anglais.
  • Les voids d'orphelines (>30min sans PACS.002) déclenchent pix.payout.failed avec reason_code: "orphan_force_voided".
  • L'expiration de la file de retry (120min) déclenche pix.payout.failed avec reason_code: "DICT_QUEUE_TIMEOUT".

Types de clé PIX

TypeFormatExemple
cpf11 chiffres (sans ponctuation)12345678901
cnpj14 chiffres (sans ponctuation)12345678000199
emailAdresse emailnom@entreprise.com.br
phoneDDD + numéro (11 chiffres)11999998888
evpUUID v4a1b2c3d4-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 :

  1. Utilisez l'endpoint Validation CPF (POST /api/external/cpf/validate) pour vérifier si les 11 chiffres forment un CPF valide
  2. Si valid: true → envoyez pix_key_type: "cpf" dans le cash-out
  3. Si valid: false → c'est un téléphone, envoyez pix_key_type: "phone" (l'API ajoute automatiquement le préfixe +55)
javascript
// 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 :

Ou recevez la confirmation automatiquement via Webhook.

Owem Pay Instituição de Pagamento — ISPB 37839059