Skip to content

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.

  1. In the hosted sandbox, which Futa opens to the testers it invites, sign in to the developer console at https://console.sandbox.futa-app.com with the synthetic account Futa gave you, then create your server’s key: « Clés API », « Créer une clé restreinte », with payments:read, payments:write, webhooks:read and webhooks: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_…"
  2. Create a payment and send the payer to checkout

    Section titled “Create a payment and send the payer to checkout”

    Call POST /v1/payments from your server with Authorization: Bearer $FUTA_SECRET_KEY, an Idempotency-Key and the JSON body in the code samples, then send the payer to the answer’s checkout_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 on https://checkout.sandbox.futa-app.com; on your machine, call http://127.0.0.1:18130 instead, and payers pay on http://127.0.0.1:18140.

    • Amounts are integers in minor units, at scale two for USD and CDF: 2500000 with CDF is 25,000.00 FC, and a CDF amount is divisible by 100.
    • expires_at is required, a UTC instant within the next 24 hours, as are description and merchant_reference.
    • Idempotency-Key is required on creation: the same key and body return the original payment, never a second one; another body under the key answers 409 IDEMPOTENCY_CONFLICT. A key or merchant_reference starting with sandbox-run-, in any letter case, answers 400 INVALID_REQUEST: the developer console’s sandbox runs reserve that prefix.
    • Direct mode: add method: "simulator" and a test number as msisdn to skip the hosted page; the payment starts at PENDING_APPROVAL. See direct payments.

    return_url is an absolute https URL, 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 answers 400 INVALID_REQUEST. embedding is optional: absent or null keeps 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 is https with a host name, as window.location.origin gives it, or http://127.0.0.1:<port> or http://localhost:<port> in the sandbox, hosted or on your machine; the Idempotency-Key binds it with the other terms, and direct mode refuses it.

    Map the answer’s id to 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 that id, never by your merchant_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 to return_url never proves a payment.

  3. 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 delivery 204 and keeps nothing: « Journal de livraison » then lists each delivery, with its event, its attempts and the HTTP status each received. Confirm the payment with GET /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, with verifiedEvent from the webhooks guide, kept in verified-event.js, which also refuses a timestamp more than five minutes from your clock. On payment.approved, find the order by the payment id, data.id (data.reference is Futa’s own FT- reference, not your order). While no order holds that id, record nothing and answer 503: 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, whose merchant_reference starts with sandbox-run-. Once the retries end, page through GET /v1/payments?state=APPROVED, passing each page’s next_cursor as cursor and bounding the list with from at the oldest day still in doubt, and read each approved payment no order holds with GET /v1/payments/{id}: find its order by its merchant_reference, hold the payment for review and never refund it automatically.

    Mark the order paid only when it awaits payment and data.amount and data.currency are its own; otherwise hold the payment for review beside its order (its id, amount and currency), a second payment of a paid order included, and never refund it automatically. A replay keeps the event’s id: 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 status handleWebhook returns, and 500 when 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;
    });
    }

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
}

Payments, refunds and collections send these events; the webhooks guide says when each one comes.

  • 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

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.

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

Open API reference

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

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 numberOutcome
+243810000001Success
+243810000002Decline
+243810000003Expiry
+243810000004Uncertain
+243810000005Late success
+243810000006Issuer unavailable