Premier paiement de test
L’API marchande de Futa crée un paiement ; le payeur l’approuve dans sa propre banque ou son portefeuille ; un webhook signé donne le résultat à votre serveur. Ici, toute la chaîne tourne dans le bac à sable de Futa avec des émetteurs synthétiques, sans qu’aucun argent ne bouge : hébergé pour les testeurs invités, qui lisent le résultat dans la console développeur, ou sur votre machine, où le webhook atteint votre propre serveur.
- Payeurs synthétiques uniquement
- Une création se réessaie sans risque
- CDF et USD
Réessayer sans risque veut dire renvoyer le corps identique : calculez expires_at une fois par commande, gardez-le avec la clé Idempotency-Key de la commande et renvoyez ce corps ; une expiration recalculée sous la même clé répond 409 IDEMPOTENCY_CONFLICT, et une nouvelle commande prend une nouvelle clé.
-
Obtenir une clé de test
Section intitulée « Obtenir une clé de test »Dans le bac à sable hébergé, que Futa ouvre aux testeurs qu’il invite, connectez-vous à la console développeur à
https://console.sandbox.futa-app.comavec le compte synthétique que Futa vous a remis, puis créez la clé de votre serveur : « Clés API », « Créer une clé restreinte », avecpayments:read,payments:write,webhooks:readetwebhooks:manage. La console s’ouvre aux membres d’un commerce de test : si votre compte n’est membre d’aucun, créez-en d’abord un dans le portail marchand,https://portal.sandbox.futa-app.com. Sur votre machine, c’est la clé d’amorçage du bac à sable qui émet la clé : voir le faire tourner en local.Le secret n’est montré qu’une fois ; il commence par
sk_test_. Les clés de test n’atteignent que le bac à sable et ne déplacent jamais d’argent. Gardez le secret sur votre serveur :export FUTA_SECRET_KEY="sk_test_…" -
Créer un paiement et envoyer le payeur au paiement
Section intitulée « Créer un paiement et envoyer le payeur au paiement »Appelez
POST /v1/paymentsdepuis votre serveur avecAuthorization: Bearer $FUTA_SECRET_KEY, uneIdempotency-Keyet le corps JSON des exemples de code, puis envoyez le payeur vers lecheckout_urlde la réponse. Le payeur saisit l’un des six numéros de test et le simulateur répond comme l’émetteur le ferait. Les exemples appellent le bac à sable hébergé,https://api.sandbox.futa-app.com, dont les payeurs paient surhttps://checkout.sandbox.futa-app.com; sur votre machine, appelez plutôthttp://127.0.0.1:18130, et les payeurs paient surhttp://127.0.0.1:18140.- Les montants sont des entiers en unités mineures, à deux décimales pour USD et CDF :
2500000avecCDFvaut 25 000,00 FC, et un montant en CDF est divisible par 100. expires_atest obligatoire, un instant UTC dans les 24 prochaines heures, commedescriptionetmerchant_reference.Idempotency-Keyest obligatoire à la création : la même clé et le même corps renvoient le paiement d’origine, jamais un second ; un autre corps sous la clé répond409 IDEMPOTENCY_CONFLICT. Une clé ou unemerchant_referencequi commence parsandbox-run-, quelle que soit la casse, répond400 INVALID_REQUEST: les exécutions en bac à sable de la console développeur réservent ce préfixe.- Mode direct : ajoutez
method: "simulator"et un numéro de test commemsisdnpour passer la page hébergée ; le paiement commence àPENDING_APPROVAL. Voir les paiements directs.
return_urlest une URLhttpsabsolue, d’au plus 2048 caractères, avec un hôte et sans identifiants dans l’adresse : la page de paiement l’offre comme « Retour à … » sur les pages de résultat qui ramènent à la boutique, telle quelle, sans rien y ajouter, et toute autre adresse répond400 INVALID_REQUEST.embeddingest facultatif : absent ounull, il garde la redirection hébergée, dont les pages ne s’affichent jamais dans un cadre ;{ "mode": "frame", "origin": "https://shop.example" }permet aux pages de cette seule origine de les afficher dans un cadre, et{ "mode": "app" }sert aux SDK mobiles de Futa, pas encore distribués. L’origine est enhttpsavec un nom d’hôte, telle que la donnewindow.location.origin, ouhttp://127.0.0.1:<port>ouhttp://localhost:<port>dans le bac à sable, hébergé ou sur votre machine ; l’Idempotency-Keyla lie aux autres conditions, et le mode direct la refuse.Associez l’
idde la réponse à la commande, à côté de son montant, en unités mineures entières, et de sa devise, et gardez chaque id de paiement donné à une commande, pas seulement le dernier : les webhooks désignent le paiement par cetid, jamais par votremerchant_reference, et un paiement plus ancien peut encore être approuvé après la création d’un plus récent.GET /v1/payments/{id}donne l’état qui fait autorité ; un passage parreturn_urlne prouve jamais un paiement. - Les montants sont des entiers en unités mineures, à deux décimales pour USD et CDF :
-
Confirmer avec un webhook signé
Section intitulée « Confirmer avec un webhook signé »Dans le bac à sable hébergé, Futa ne livre les webhooks que chez Futa, jamais à votre serveur. Ajoutez votre point de réception, dans la page Webhooks de la console développeur, sur le récepteur de test du bac à sable,
http://127.0.0.1:18161/sandbox/webhooks, qui répond204à chaque livraison et ne garde rien : le « Journal de livraison » liste alors chaque livraison, avec son événement, ses tentatives et le statut HTTP que chacune a reçu. Confirmez le paiement avecGET /v1/payments/{id}, son état qui fait autorité. Votre serveur reçoit lui-même les livraisons une fois que vous faites tourner le bac à sable en local et que le point de réception pointe vers votre récepteur ; vérifiez dans tous les cas votre code de signature avec les vecteurs signés.Vérifiez
Futa-Signature, un HMAC-SHA256 sur l’horodatage, un point et le corps brut, avecverifiedEventdu guide des webhooks, gardé dansverified-event.js, qui refuse aussi un horodatage à plus de cinq minutes de votre horloge. Surpayment.approved, trouvez la commande par l’iddu paiement,data.id(data.referenceest la référenceFT-propre à Futa, pas votre commande). Tant qu’aucune commande ne détient cetid, n’enregistrez rien et répondez503: Futa relance une livraison échouée pendant 72 heures. Des paiements que votre serveur n’a jamais créés arrivent aussi ici et ne correspondent jamais, comme les tentatives sur vos liens de paiement et les exécutions en bac à sable de la console développeur, dont lamerchant_referencecommence parsandbox-run-. Une fois les relances finies, parcourezGET /v1/payments?state=APPROVEDen passant lenext_cursorde chaque page commecursor, la liste bornée parfromau plus ancien jour encore en doute, et lisez chaque paiement approuvé qu’aucune commande ne détient avecGET /v1/payments/{id}: trouvez sa commande par samerchant_reference, mettez le paiement en attente de vérification et ne le remboursez jamais automatiquement.Ne marquez la commande payée que si elle attend son paiement et que
data.amountetdata.currencysont les siens ; sinon mettez le paiement en attente de vérification à côté de sa commande (sonid, son montant et sa devise), y compris pour un second paiement d’une commande déjà payée, et ne le remboursez jamais automatiquement. Un renvoi garde l’idde l’événement : dédupliquez dessus, dans la transaction qui modifie la commande. Dans cette transaction, marquez seulement la commande payée et mettez son expédition en file ; expédiez une fois la transaction validée, et chaque commande en file une seule fois, par son numéro, pour qu’aucune relance n’expédie deux fois. Répondez avec le statut que renvoiehandleWebhook, et500quand elle lève une erreur : Futa relance alors la livraison.import { verifiedEvent } from "./verified-event.js";// orders : votre stockage des commandes. Il associe à une commande chaque// id de paiement créé pour elle, pas seulement le dernier, et garde son// montant en unités mineures entières, sa devise et si elle attend son// paiement.export async function handleWebhook(signature, rawBody, orders) {const event = verifiedEvent(signature, rawBody, process.env.FUTA_WEBHOOK_SECRET);if (!event) return 400;if (event.type !== "payment.approved") return 200;return orders.transaction(async (store) => {// findByPaymentId verrouille la ligne de la commande jusqu’à la// validation (SELECT ... FOR UPDATE) : deux paiements d’une même// commande sont décidés l’un après l’autre.const order = await store.findByPaymentId(event.data.id);// Aucune commande ne détient encore ce paiement : n’enregistrez rien,// pour que Futa relance.if (!order) return 503;// recordEvent insère l’id de l’événement sous une clé unique (INSERT// ... ON CONFLICT DO NOTHING) et répond s’il l’a inséré : une nouvelle// livraison, même simultanée, ne change rien, et une tentative échouée// ne garde rien, si bien que la relance marque la commande payée.if (!(await store.recordEvent(event.id))) return 200;// BigInt compare aussi une colonne BIGINT que le pilote lit comme une chaîne.const matches =BigInt(order.amount) === BigInt(event.data.amount) && order.currency === event.data.currency;// Seulement des écritures en base à partir d’ici, une mise en attente// comprise : l’expédition lit la file une fois la transaction validée.if (!order.awaitingPayment || !matches) {await store.holdForReview(order, event.data);return 200;}await store.markPaid(order);await store.queueShipment(order);return 200;});}
Le faire tourner en local
Section intitulée « Le faire tourner en local »Pour faire tourner toute la chaîne sur votre machine, avec les webhooks vers votre propre serveur, démarrez le bac à
sable depuis Futa-platform : init, up, build, start, puis cdc.
init écrit MERCHANT_API_KEY, la clé d’amorçage du bac à sable, dans sandbox/.env. Utilisez-la une fois pour
émettre la clé de votre serveur avec POST /v1/keys, puis appelez http://127.0.0.1:18130, le second serveur du
contrat, à la place de l’adresse hébergée :
{ "label": "Order service", "permissions": ["payments:read", "payments:write", "webhooks:read", "webhooks:manage"]}Le point de réception du commerce du bac à sable démarre enregistré à http://127.0.0.1:18160/sandbox/webhooks,
le récepteur du service des canaux, si bien que POST /v1/webhooks/endpoint répond
409 WEBHOOK_ALREADY_CONFIGURED. Dirigez-le vers votre propre récepteur sur un port de 18161 à 18169, à son
propre chemin : lisez config_version avec GET /v1/webhooks/endpoint, envoyez
POST /v1/webhooks/endpoint/configuration avec une Idempotency-Key et ce corps, puis
POST /v1/webhooks/endpoint/rotate pour obtenir votre propre secret de signature, montré une seule fois.
{ "url": "http://127.0.0.1:18161/sandbox/webhooks", "events": ["payment.approved", "payment.declined", "payment.expired", "refund.completed"], "version": 1}Événements de webhook
Section intitulée « Événements de webhook »Les paiements, les remboursements et les encaissements envoient ces événements ; le guide des webhooks dit quand chacun arrive.
payment.createdpayment.pending_approvalpayment.uncertainpayment.approvedpayment.declinedpayment.expiredrefund.pending_approvalrefund.queuedrefund.processingrefund.uncertainrefund.completedrefund.failedrefund.expiredrefund.rejectedobligation.collected
Quatre ne sont envoyés que si votre point de réception les choisit : refund.pending_approval, refund.expired, refund.rejected et obligation.collected. Le tableau des événements marque ceux envoyés par défaut.
Erreurs à traiter
Section intitulée « Erreurs à traiter »Chaque refus répond avec le corps Error ; décidez selon code, qui est stable. Chaque réponse authentifiée porte
X-Request-Id : donnez-le quand vous posez une question sur un appel. La page des erreurs liste chaque
code par statut.
409 Conflit
{ "error": { "code": "IDEMPOTENCY_CONFLICT" }}Mode réel
Section intitulée « Mode réel »Les clés de production, sk_live_…, sont refusées. Le mode réel attend la production, et un dossier KYB approuvé ne
débloque rien à lui seul. Pour défaire un paiement jamais soumis, utilisez l’annulation de paiement ;
elle ne contre-passe pas un paiement déjà soumis.
/v1/paymentsEXPIRES_AT=$(date -u -v+1H +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '+1 hour' +%Y-%m-%dT%H:%M:%SZ)curl -X POST https://api.sandbox.futa-app.com/v1/payments \ -H "Authorization: Bearer $FUTA_SECRET_KEY" \ -H "Idempotency-Key: order-10482-attempt-1" \ -H "Content-Type: application/json" \ -d '{ "amount": 2500000, "currency": "CDF", "merchant_reference": "order-10482", "description": "Order 10482", "return_url": "https://shop.example/thanks", "expires_at": "'"$EXPIRES_AT"'" }'const response = await fetch("https://api.sandbox.futa-app.com/v1/payments", { method: "POST", headers: { Authorization: `Bearer ${process.env.FUTA_SECRET_KEY}`, "Idempotency-Key": "order-10482-attempt-1", "Content-Type": "application/json", }, body: JSON.stringify({ "amount": 2500000, "currency": "CDF", "merchant_reference": "order-10482", "description": "Order 10482", "return_url": "https://shop.example/thanks", "expires_at": new Date(Date.now() + 60 * 60 * 1000).toISOString() }),});const answer = await response.json();<?php$request = curl_init('https://api.sandbox.futa-app.com/v1/payments');curl_setopt_array($request, [ CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('FUTA_SECRET_KEY'), 'Idempotency-Key: order-10482-attempt-1', 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'amount' => 2500000, 'currency' => 'CDF', 'merchant_reference' => 'order-10482', 'description' => 'Order 10482', 'return_url' => 'https://shop.example/thanks', 'expires_at' => gmdate('Y-m-d\TH:i:s\Z', time() + 3600), ]),]);$answer = json_decode(curl_exec($request), true);import osfrom datetime import datetime, timedelta, timezone
import requests
response = requests.request( "POST", "https://api.sandbox.futa-app.com/v1/payments", headers={ "Authorization": f"Bearer {os.environ['FUTA_SECRET_KEY']}", "Idempotency-Key": "order-10482-attempt-1", }, json={ "amount": 2500000, "currency": "CDF", "merchant_reference": "order-10482", "description": "Order 10482", "return_url": "https://shop.example/thanks", "expires_at": (datetime.now(timezone.utc) + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ"), },)answer = response.json()Exemples HTTP : Futa ne publie encore aucune bibliothèque cliente ; le client TypeScript généré depuis le contrat reste à l’usage de Futa. Calculez expires_at une fois par commande et gardez-le avec la clé Idempotency-Key de la commande : une reprise renvoie ce corps exact.
201 CrééRéponse
{ "id": "5202ca3f-3215-42ad-8880-2de1f57ad097", "reference": "FT-5202CA3F321542AD88802DE1F57AD097", "merchant_reference": "order-10482", "branch_id": null, "branch_code": null, "branch_name": null, "merchant_name": "Sandbox merchant", "description": "Order 10482", "amount": 2500000, "currency": "CDF", "payer_fee": 0, "total": 2500000, "state": "CREATED", "reason": null, "created_at": "2026-10-20T09:12:00Z", "expires_at": "2026-10-20T10:12:00Z", "approval_deadline": null, "checkout_url": "https://checkout.sandbox.futa-app.com/pay/5202ca3f-3215-42ad-8880-2de1f57ad097?token=example-token-not-a-real-capability-0000000", "sandbox": true, "method": null, "version": 1, "escalated": false, "embedding": null}Payeurs de testBac à sable uniquement
Envoyez-en un comme msisdn avec la méthode simulator, ou saisissez-le au paiement, pour choisir le résultat. Ce ne sont pas de vrais abonnés.
| Numéro de test | Résultat |
|---|---|
+243810000001 | Succès |
+243810000002 | Refus |
+243810000003 | Expiration |
+243810000004 | Incertain |
+243810000005 | Succès tardif |
+243810000006 | Émetteur indisponible |