First test payment
Futa’s merchant API creates a payment; the payer approves it in their own bank or wallet; a signed webhook tells your server the outcome. Here the whole chain runs in Futa’s sandbox against synthetic issuers, so no money moves: hosted for invited testers, who read the outcome in the developer console, or on your machine, where the webhook reaches your own server.
- Synthetic payers only
- Creation is safe to retry
- CDF and USD
Safe to retry means the identical body: compute expires_at once per order, keep it with the order’s Idempotency-Key and resend that body; an expiry computed again under the same key answers 409 IDEMPOTENCY_CONFLICT, so a new order takes a new key.
-
Get a test key
Section titled “Get a test key”In the hosted sandbox, which Futa opens to the testers it invites, sign in to the developer console at
https://console.sandbox.futa-app.comwith the synthetic account Futa gave you, then create your server’s key: « Clés API », « Créer une clé restreinte », withpayments:read,payments:write,webhooks:readandwebhooks:manage. The console opens to the members of a test business: if your account belongs to none, first create one in the merchant portal,https://portal.sandbox.futa-app.com. On your machine, the sandbox’s bootstrap key issues the key instead: see run it locally.The secret is shown once; it starts with
sk_test_. Test keys reach only the sandbox and never move money. Keep the secret on your server:export FUTA_SECRET_KEY="sk_test_…" -
Create a payment and send the payer to checkout
Section titled “Create a payment and send the payer to checkout”Call
POST /v1/paymentsfrom your server withAuthorization: Bearer $FUTA_SECRET_KEY, anIdempotency-Keyand the JSON body in the code samples, then send the payer to the answer’scheckout_url. The payer types one of the six test numbers and the simulator answers as the issuer would. The samples call the hosted sandbox,https://api.sandbox.futa-app.com, whose payers pay onhttps://checkout.sandbox.futa-app.com; on your machine, callhttp://127.0.0.1:18130instead, and payers pay onhttp://127.0.0.1:18140.- Amounts are integers in minor units, at scale two for USD and CDF:
2500000withCDFis 25,000.00 FC, and a CDF amount is divisible by 100. expires_atis required, a UTC instant within the next 24 hours, as aredescriptionandmerchant_reference.Idempotency-Keyis required on creation: the same key and body return the original payment, never a second one; another body under the key answers409 IDEMPOTENCY_CONFLICT. A key ormerchant_referencestarting withsandbox-run-, in any letter case, answers400 INVALID_REQUEST: the developer console’s sandbox runs reserve that prefix.- Direct mode: add
method: "simulator"and a test number asmsisdnto skip the hosted page; the payment starts atPENDING_APPROVAL. See direct payments.
return_urlis an absolutehttpsURL, at most 2048 characters, with a host and no userinfo: the checkout offers it as « Retour à … » on the outcome pages that lead back to the shop, exactly as given, with nothing appended, and any other address answers400 INVALID_REQUEST.embeddingis optional: absent ornullkeeps the hosted redirect, whose pages are never framed;{ "mode": "frame", "origin": "https://shop.example" }lets pages of that one origin frame them, and{ "mode": "app" }is for Futa’s mobile SDKs, not yet distributed. The origin ishttpswith a host name, aswindow.location.origingives it, orhttp://127.0.0.1:<port>orhttp://localhost:<port>in the sandbox, hosted or on your machine; theIdempotency-Keybinds it with the other terms, and direct mode refuses it.Map the answer’s
idto the order, beside the order’s amount, in integer minor units, and its currency, and keep every payment id an order is given, not only the latest: webhooks name the payment by thatid, never by yourmerchant_reference, and an older payment can still be approved after a newer one was created.GET /v1/payments/{id}gives the authoritative state; a visit toreturn_urlnever proves a payment. - Amounts are integers in minor units, at scale two for USD and CDF:
-
Confirm with a signed webhook
Section titled “Confirm with a signed webhook”In the hosted sandbox, Futa delivers webhooks only inside Futa, never to your server. Add your endpoint in the developer console’s Webhooks page on the sandbox’s test receiver,
http://127.0.0.1:18161/sandbox/webhooks, which answers each delivery204and keeps nothing: « Journal de livraison » then lists each delivery, with its event, its attempts and the HTTP status each received. Confirm the payment withGET /v1/payments/{id}, its authoritative state. Your server receives the deliveries itself once you run the sandbox locally and point the endpoint at your receiver; check your signature code against the signed vectors either way.Verify
Futa-Signature, an HMAC-SHA256 over the timestamp, a dot and the raw body, withverifiedEventfrom the webhooks guide, kept inverified-event.js, which also refuses a timestamp more than five minutes from your clock. Onpayment.approved, find the order by the paymentid,data.id(data.referenceis Futa’s ownFT-reference, not your order). While no order holds thatid, record nothing and answer503: Futa retries a failed delivery for 72 hours. Payments your server never created reach this endpoint too and never match, such as attempts on your payment links and the developer console’s sandbox runs, whosemerchant_referencestarts withsandbox-run-. Once the retries end, page throughGET /v1/payments?state=APPROVED, passing each page’snext_cursorascursorand bounding the list withfromat the oldest day still in doubt, and read each approved payment no order holds withGET /v1/payments/{id}: find its order by itsmerchant_reference, hold the payment for review and never refund it automatically.Mark the order paid only when it awaits payment and
data.amountanddata.currencyare its own; otherwise hold the payment for review beside its order (itsid, amount and currency), a second payment of a paid order included, and never refund it automatically. A replay keeps the event’sid: deduplicate on it, in the transaction that changes the order. In that transaction, only mark the order paid and queue its shipment; ship once it commits, and each queued order once, by its number, so no retry ships twice. Answer the statushandleWebhookreturns, and500when it throws: Futa then retries the delivery.import { verifiedEvent } from "./verified-event.js";// orders: your order store. It maps every payment id created for an order// to that order, not only the latest, and keeps the order's amount in// integer minor units, its currency and whether it awaits payment.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 locks the order's row until the commit (SELECT ...// FOR UPDATE), so two payments of one order are decided in turn.const order = await store.findByPaymentId(event.data.id);// No order holds this payment yet: record nothing, so Futa retries.if (!order) return 503;// recordEvent inserts the event id under a unique key (INSERT ... ON// CONFLICT DO NOTHING) and answers whether it inserted: a redelivery,// even an overlapping one, changes nothing, and a failed attempt keeps// nothing, so the retry marks the order paid.if (!(await store.recordEvent(event.id))) return 200;// BigInt also compares a BIGINT column the database driver reads as a string.const matches =BigInt(order.amount) === BigInt(event.data.amount) && order.currency === event.data.currency;// Only database writes from here on, a hold included: shipping reads the// queue once this commits.if (!order.awaitingPayment || !matches) {await store.holdForReview(order, event.data);return 200;}await store.markPaid(order);await store.queueShipment(order);return 200;});}
Run it locally
Section titled “Run it locally”To run the whole chain on your machine, with webhooks to your own server, start the sandbox
from Futa-platform: init, up, build, start, then cdc. init writes MERCHANT_API_KEY, the sandbox’s
bootstrap key, to sandbox/.env. Use it once to issue your server’s key with POST /v1/keys, then call
http://127.0.0.1:18130, the contract’s second server, in place of the hosted address:
{ "label": "Order service", "permissions": ["payments:read", "payments:write", "webhooks:read", "webhooks:manage"]}The sandbox merchant’s endpoint starts registered at http://127.0.0.1:18160/sandbox/webhooks, the channels
service’s sink, so POST /v1/webhooks/endpoint answers 409 WEBHOOK_ALREADY_CONFIGURED. Point it at your own
receiver on a port from 18161 to 18169, at its own path: read config_version with GET /v1/webhooks/endpoint, send
POST /v1/webhooks/endpoint/configuration with an Idempotency-Key and this body, then
POST /v1/webhooks/endpoint/rotate for a signing secret of your own, shown once.
{ "url": "http://127.0.0.1:18161/sandbox/webhooks", "events": ["payment.approved", "payment.declined", "payment.expired", "refund.completed"], "version": 1}Webhook events
Section titled “Webhook events”Payments, refunds and collections send these events; the webhooks guide says when each one comes.
payment.createdpayment.pending_approvalpayment.uncertainpayment.approvedpayment.declinedpayment.expiredrefund.pending_approvalrefund.queuedrefund.processingrefund.uncertainrefund.completedrefund.failedrefund.expiredrefund.rejectedobligation.collected
Four are sent only once your endpoint chooses them: refund.pending_approval, refund.expired, refund.rejected and obligation.collected. The table of events marks those sent by default.
Errors you can code against
Section titled “Errors you can code against”Every refusal answers the Error body; branch on code, which is stable. Every authenticated response carries
X-Request-Id: give it when you ask about a call. The errors page lists every code by status.
409 Conflict
{ "error": { "code": "IDEMPOTENCY_CONFLICT" }}Live mode
Section titled “Live mode”Live keys, sk_live_…, are refused. Live mode waits for production, and an approved KYB alone unlocks nothing. To
undo a payment that was never submitted, use payment cancellation; it does not reverse a
submitted payment.
/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()HTTP samples: Futa publishes no client library yet; the TypeScript client generated from the contract is kept for Futa's own use. Compute expires_at once per order and keep it with the order's Idempotency-Key: a retry resends this exact body.
201 CreatedResponse
{ "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}Test payersSandbox only
Send one as msisdn with the simulator method, or type it at checkout, to choose the outcome. They are not real subscribers.
| Test number | Outcome |
|---|---|
+243810000001 | Success |
+243810000002 | Decline |
+243810000003 | Expiry |
+243810000004 | Uncertain |
+243810000005 | Late success |
+243810000006 | Issuer unavailable |