Aller au contenu

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é.

  1. 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.com avec 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 », avec payments:read, payments:write, webhooks:read et webhooks: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_…"
  2. 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/payments depuis votre serveur avec Authorization: Bearer $FUTA_SECRET_KEY, une Idempotency-Key et le corps JSON des exemples de code, puis envoyez le payeur vers le checkout_url de 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 sur https://checkout.sandbox.futa-app.com ; sur votre machine, appelez plutôt http://127.0.0.1:18130, et les payeurs paient sur http://127.0.0.1:18140.

    • Les montants sont des entiers en unités mineures, à deux décimales pour USD et CDF : 2500000 avec CDF vaut 25 000,00 FC, et un montant en CDF est divisible par 100.
    • expires_at est obligatoire, un instant UTC dans les 24 prochaines heures, comme description et merchant_reference.
    • Idempotency-Key est 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épond 409 IDEMPOTENCY_CONFLICT. Une clé ou une merchant_reference qui commence par sandbox-run-, quelle que soit la casse, répond 400 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 comme msisdn pour passer la page hébergée ; le paiement commence à PENDING_APPROVAL. Voir les paiements directs.

    return_url est une URL https absolue, 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épond 400 INVALID_REQUEST. embedding est facultatif : absent ou null, 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 en https avec un nom d’hôte, telle que la donne window.location.origin, ou http://127.0.0.1:<port> ou http://localhost:<port> dans le bac à sable, hébergé ou sur votre machine ; l’Idempotency-Key la lie aux autres conditions, et le mode direct la refuse.

    Associez l’id de 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 cet id, jamais par votre merchant_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 par return_url ne prouve jamais un paiement.

  3. 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épond 204 à 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 avec GET /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, avec verifiedEvent du guide des webhooks, gardé dans verified-event.js, qui refuse aussi un horodatage à plus de cinq minutes de votre horloge. Sur payment.approved, trouvez la commande par l’id du paiement, data.id (data.reference est la référence FT- propre à Futa, pas votre commande). Tant qu’aucune commande ne détient cet id, n’enregistrez rien et répondez 503 : 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 la merchant_reference commence par sandbox-run-. Une fois les relances finies, parcourez GET /v1/payments?state=APPROVED en passant le next_cursor de chaque page comme cursor, la liste bornée par from au plus ancien jour encore en doute, et lisez chaque paiement approuvé qu’aucune commande ne détient avec GET /v1/payments/{id} : trouvez sa commande par sa merchant_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.amount et data.currency sont les siens ; sinon mettez le paiement en attente de vérification à côté de sa commande (son id, 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’id de 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 renvoie handleWebhook, et 500 quand 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;
    });
    }

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
}

Les paiements, les remboursements et les encaissements envoient ces événements ; le guide des webhooks dit quand chacun arrive.

  • payment.created
  • payment.pending_approval
  • payment.uncertain
  • payment.approved
  • payment.declined
  • payment.expired
  • refund.pending_approval
  • refund.queued
  • refund.processing
  • refund.uncertain
  • refund.completed
  • refund.failed
  • refund.expired
  • refund.rejected
  • obligation.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.

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"
}
}

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.

Ouvrir la référence de l’API

POST/v1/payments
EXPIRES_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"'"
}'

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 testRésultat
+243810000001Succès
+243810000002Refus
+243810000003Expiration
+243810000004Incertain
+243810000005Succès tardif
+243810000006Émetteur indisponible