Fonlok
Sandbox + Production API

Escrow as a service.
Built for Cameroon's platforms.

Integrate Fonlok into any marketplace or platform with a single REST API. Seller payouts go directly to their Mobile Money number. No Fonlok account required on either side.

Use the sandbox below to test every flow end-to-end before going live. No real money moves. When you're ready, a production key unlocks the full live API.

Open API explorerGet an API key
Sandbox only. This environment does not process real payments. No real MTN or Orange Money transactions will be initiated.
POST/v1/invoices

Pass seller name, email, phone and item details. Fonlok creates the escrow invoice and returns a payment URL.

POST/v1/payments/initiate

Send a MoMo USSD push to the buyer's phone. Returns a reference for polling or webhook confirmation.

POST/v1/payments/release

When the buyer confirms receipt, call this. Fonlok disburses the net amount to the seller's MoMo number instantly.

Ready to integrate the live API?

Create a Fonlok account, generate a production key from your dashboard, and follow the integration guide below.

Request live accessView full API reference
Live API

Live API reference

Complete reference for all production endpoints. The live API processes real Mobile Money payments in XAF. Every request must be authenticated with a sk_live_ key obtained from your Fonlok account. Keys are available to approved integration partners — contact support@fonlok.com to apply.

Base URL
https://fonlok-backend-production.up.railway.app
Authorization header — required on every request
Authorization: Bearer sk_live_a1b2c3d4...
Fees: Fonlok charges a 3% platform fee deducted at release. The POST /v1/payments/release response includes the exact fee and net amount. Currency: Only XAF (Central African Franc) is supported.
GET/v1/ping

Health check. Verifies that your live key is valid and the API is reachable. Returns a JSON object confirming the environment. Does not initiate any transaction.

curl https://fonlok-backend-production.up.railway.app/v1/ping \
  -H "Authorization: Bearer sk_live_..."

Response

JSON
{
  "object": "api_status",
  "status": "ok",
  "environment": "production",
  "key_label": "My Integration",
  "message": "Fonlok live API is operational. Real transactions will be processed.",
  "timestamp": "2025-01-15T10:30:00.000Z"
}

Invoices

Create and retrieve escrow invoices. Each invoice represents one transaction — funds are held by Fonlok until you call POST /v1/payments/release. No Fonlok account is required for the seller or buyer.

POST/v1/invoices

Create a new escrow invoice on behalf of any seller on your platform. Returns a payment_url you can redirect the buyer to, or use POST /v1/payments/initiate to trigger a direct MoMo push instead.

If you supply a reference that already exists for your API key, the request returns 409 duplicate_reference. The payment_url is always present and active — share it with the buyer to let them pay directly through the Fonlok payment page.

Request body

FieldTypeRequiredDescription
titlestringrequiredInvoice title, max 200 chars. Shown to buyer on the payment page.
amountnumberrequiredInvoice amount in XAF. Minimum 500 XAF.
currencystringoptionalCurrency code. Only "XAF" is supported. Defaults to "XAF".
seller_namestringrequiredSeller's full name, max 200 chars.
seller_emailstringrequiredSeller's email. Fonlok sends payout confirmation and PDF receipt here.
seller_phonestringrequiredSeller's MTN or Orange MoMo number in international format, e.g. 237670000001. This is where the payout is sent.
buyer_emailstringoptionalBuyer's email. Fonlok sends payment confirmation and PDF receipt here.
buyer_phonestringoptionalBuyer's MoMo number. Can also be supplied later when calling POST /v1/payments/initiate.
descriptionstringoptionalItem description, max 2000 chars.
referencestringoptionalYour internal order or reference ID, max 200 chars. Must be unique per API key. Returned as external_reference.
expires_atstringoptionalISO 8601 expiry date, e.g. "2026-12-31". After this date the payment URL becomes inactive.
curl https://fonlok-backend-production.up.railway.app/v1/invoices \
  -X POST \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "title": "iPhone 15 Pro",
    "amount": 850000,
    "seller_name": "Jean Fotso",
    "seller_email": "jean@example.com",
    "seller_phone": "237670000001",
    "buyer_email": "buyer@example.com",
    "description": "Brand new, sealed box",
    "reference": "order_789",
    "expires_at": "2026-12-31"
  }'

Response

JSON
HTTP 201 Created

{
  "object": "invoice",
  "id": "42-a1b2c3d4e5f6",
  "title": "iPhone 15 Pro",
  "description": "Brand new, sealed box",
  "amount": 850000,
  "currency": "XAF",
  "seller": {
    "name": "Jean Fotso",
    "email": "jean@example.com",
    "phone": "237670000001"
  },
  "buyer_email": "buyer@example.com",
  "buyer_phone": null,
  "status": "pending",
  "payment_url": "https://fonlok.com/pay/42-a1b2c3d4e5f6",
  "external_reference": "order_789",
  "expires_at": "2026-12-31T00:00:00.000Z",
  "created_at": "2025-01-15T10:30:00.000Z"
}
GET/v1/invoices/:invoice_id

Retrieve a single invoice by its ID (the invoicenumber returned on creation). Returns the complete invoice object including current status, payment URL, timestamps, and — when disputed — secure chat links for both parties.

Status values: pending paidcompleted (or disputed). Also possible: delivered, cancelled, refunded. The payment_url field is always returned regardless of status.
curl https://fonlok-backend-production.up.railway.app/v1/invoices/42-a1b2c3d4e5f6 \
  -H "Authorization: Bearer sk_live_..."

Response

JSON
{
  "object": "invoice",
  "id": "42-a1b2c3d4e5f6",
  "title": "iPhone 15 Pro",
  "description": "Brand new, sealed box",
  "amount": 850000,
  "currency": "XAF",
  "seller": {
    "name": "Jean Fotso",
    "email": "jean@example.com",
    "phone": "237670000001"
  },
  "buyer_email": "buyer@example.com",
  "buyer_phone": "237670000000",
  "status": "paid",
  "payment_url": "https://fonlok.com/pay/42-a1b2c3d4e5f6",
  "external_reference": "order_789",
  "expires_at": "2026-12-31T00:00:00.000Z",
  "created_at": "2025-01-15T10:30:00.000Z",
  "paid_at": "2025-01-15T10:33:00.000Z",
  "delivered_at": null

  // When status is "disputed", an extra field appears:
  // "chat_links": {
  //   "buyer":  "https://fonlok.com/chat/42-a1b2c3d4e5f6?token=abc123...&role=buyer",
  //   "seller": "https://fonlok.com/chat/42-a1b2c3d4e5f6?token=def456...&role=seller"
  // }
}

Payments

Initiate and settle payments. A confirmed payment puts funds in escrow. Call /v1/payments/release when the buyer is satisfied, or /v1/payments/dispute if there is a problem.

POST/v1/payments/initiate

Send a Mobile Money USSD push prompt to the buyer's phone via Campay. The buyer receives a pop-up on their handset and approves or declines the charge. Returns a reference UUID for polling status. Only invoices in 'pending' status can be initiated.

Number format: 12 digits starting with 237. MTN numbers start with 2376, Orange with 2372. The reference UUID in the response is used for polling status. Prefer webhooks over polling in production — the payment.confirmed event fires immediately when Campay confirms.

Request body

FieldTypeRequiredDescription
invoice_idstringrequiredInvoice ID returned when the invoice was created.
phone_numberstringrequiredBuyer's MTN or Orange MoMo number in international format, e.g. 237670000000. Fonlok auto-detects the network (6xx = MTN, 2xx = Orange).
buyer_emailstringoptionalBuyer email for confirmation emails. Overrides the buyer_email set on the invoice.
curl https://fonlok-backend-production.up.railway.app/v1/payments/initiate \
  -X POST \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "invoice_id": "42-a1b2c3d4e5f6",
    "phone_number": "237670000000",
    "buyer_email": "buyer@example.com"
  }'

Response

JSON
HTTP 201 Created

{
  "object": "payment",
  "reference": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "invoice_id": "42-a1b2c3d4e5f6",
  "amount": 850000,
  "currency": "XAF",
  "provider": "mtn",
  "phone_number": "237670000000",
  "status": "pending",
  "message": "A mtn Mobile Money prompt has been sent to 237670000000. The buyer must approve it on their phone.",
  "created_at": "2025-01-15T10:31:00.000Z"
}
GET/v1/payments/:reference/status

Poll the status of a payment using the reference UUID returned by POST /v1/payments/initiate. Returns the payment status and the associated invoice status.

Payment status: pending (awaiting buyer action), paid (confirmed, funds in escrow), failed (declined or timed out). Invoice status: mirrors the invoice lifecycle — pending, paid, completed, disputed, cancelled.
curl https://fonlok-backend-production.up.railway.app/v1/payments/a1b2c3d4-e5f6-7890-abcd-ef1234567890/status \
  -H "Authorization: Bearer sk_live_..."

Response

JSON
{
  "object": "payment_status",
  "reference": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "invoice_id": "42-a1b2c3d4e5f6",
  "amount": 850000,
  "currency": "XAF",
  "provider": "mtn",
  "status": "paid",
  "invoice_status": "paid",
  "created_at": "2025-01-15T10:31:00.000Z"
}
POST/v1/payments/release

Release escrowed funds to the seller after the buyer confirms receipt. Fonlok deducts a 3% platform fee and disburses the net amount directly to the seller's MoMo phone. Sends PDF receipt emails to both seller and buyer. Only 'paid' invoices created via the API can be released through this endpoint.

Idempotency: Concurrent calls for the same invoice are safe — only one will succeed. If the Campay payout fails, the invoice status is restored to paid so you can retry safely. This endpoint fires a payment.released webhook event.

Request body

FieldTypeRequiredDescription
invoice_idstringrequiredInvoice ID to release. Must be in 'paid' status and created via the API.
curl https://fonlok-backend-production.up.railway.app/v1/payments/release \
  -X POST \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"invoice_id": "42-a1b2c3d4e5f6"}'

Response

JSON
{
  "object": "release",
  "invoice_id": "42-a1b2c3d4e5f6",
  "status": "completed",
  "gross_amount": 850000,
  "platform_fee": 17000,
  "seller_receives": 833000,
  "currency": "XAF",
  "seller_phone": "237670000001",
  "message": "833,000 XAF dispatched to 237670000001 via Mobile Money.",
  "released_at": "2025-01-15T10:35:00.000Z"
}
POST/v1/payments/dispute

Flag a paid invoice as disputed when the buyer raises a complaint before funds are released. Fonlok freezes the funds, creates a moderated chat thread, and sends chat links to both parties by email. Returns secure chat links for buyer and seller. Only 'paid' API-created invoices can be disputed.

Chat links are sensitive. Each link is role-scoped — send the buyer link only to the buyer and the seller link only to the seller. You can retrieve both links again later via GET /v1/invoices/:invoice_id. Contact support@fonlok.com with the invoice ID to initiate resolution.

Request body

FieldTypeRequiredDescription
invoice_idstringrequiredInvoice ID to dispute. Must be in 'paid' status.
reasonstringrequiredConcise dispute reason visible to the seller and support team. Max 1,000 characters.
contextstringoptionalAdditional supporting detail for the support team only. Not shown directly to the seller. Max 10,000 characters. Include order history, screenshots descriptions, timestamps, etc.
curl https://fonlok-backend-production.up.railway.app/v1/payments/dispute \
  -X POST \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "invoice_id": "42-a1b2c3d4e5f6",
    "reason": "Item was not as described. Phone has a cracked screen.",
    "context": "Buyer reported this on 2025-01-15 at 14:30. Photos: ..."
  }'

Response

JSON
{
  "object": "dispute",
  "invoice_id": "42-a1b2c3d4e5f6",
  "status": "disputed",
  "reason": "Item was not as described. Phone has a cracked screen.",
  "message": "Invoice flagged as disputed. Funds are held. Share the chat links below with each party so they can communicate with Fonlok support.",
  "disputed_at": "2025-01-15T10:40:00.000Z",
  "chat_links": {
    "buyer":  "https://fonlok.com/chat/42-a1b2c3d4e5f6?token=abc123def456...&role=buyer",
    "seller": "https://fonlok.com/chat/42-a1b2c3d4e5f6?token=789ghi012jkl...&role=seller"
  }
}
POST/v1/wallet/pay

Pay an invoice directly from a pre-funded platform wallet — an alternative to the MoMo USSD push. The buyer's wallet balance is debited atomically and the invoice moves to 'paid' status immediately. The buyer receives a confirmation email with a release link. Use for marketplace users who have deposited funds in advance.

Insufficient funds: If the wallet balance is below the invoice amount, the request returns 409 insufficient_funds with current_balance and invoice_amount in the error body. The debit is atomic — no partial charges occur.

Request body

FieldTypeRequiredDescription
invoice_idstringrequiredInvoice ID to fund. Must be in 'pending' status and created via the API.
user_refstringrequiredYour platform's unique reference for the wallet holder (e.g. your internal user ID). Must match a wallet registered for this API key.
curl https://fonlok-backend-production.up.railway.app/v1/wallet/pay \
  -X POST \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "invoice_id": "42-a1b2c3d4e5f6",
    "user_ref": "user_1234"
  }'

Response

JSON
{
  "invoice_id": "42-a1b2c3d4e5f6",
  "invoice_name": "iPhone 15 Pro",
  "amount_paid": 850000,
  "new_balance": 1500000,
  "currency": "XAF",
  "status": "funded",
  "release_code": "XKCD4891",
  "message": "Invoice funded from wallet and held in escrow. Buyer will receive a release link by email."
}

Wallet management

Manage pre-funded platform wallets. Wallets are created automatically on the first deposit. Each wallet is identified by a user_ref — your platform's own user ID or reference string.

POST/v1/wallet/deposit/initiate

Send a MoMo USSD push to a user's phone to top up their platform wallet. The user is charged amount + ceil(amount × 1.5%); their wallet is credited with the original amount on confirmation. Poll GET /v1/wallet/deposit/:reference/status to confirm.

The wallet is created automatically if it does not yet exist for this user_ref. The reference in the response is the Campay transaction reference — use it to poll status.

Request body

FieldTypeRequiredDescription
amountintegerrequiredAmount to credit to the wallet in XAF. Minimum 100 XAF. The user is charged this amount plus a 1.5% fee.
phonestringrequiredUser's MTN or Orange MoMo number in international format, e.g. 237670000000.
user_refstringrequiredYour platform's unique reference for this user (e.g. your internal user ID). Creates the wallet on first deposit.
descriptionstringoptionalOptional description stored on the transaction record.
curl https://fonlok-backend-production.up.railway.app/v1/wallet/deposit/initiate \
  -X POST \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 10000,
    "phone": "237670000000",
    "user_ref": "user_1234"
  }'

Response

JSON
HTTP 202 Accepted

{
  "transaction_id": 88,
  "reference": "campay-ref-abc123",
  "user_ref": "user_1234",
  "amount_requested": 10000,
  "amount_charged": 10150,
  "fee": 150,
  "currency": "XAF",
  "status": "pending",
  "message": "Payment prompt sent to user's phone. Poll GET /v1/wallet/deposit/:reference/status to confirm."
}
GET/v1/wallet/deposit/:reference/status

Poll Campay for the status of a pending deposit. On the first SUCCESSFUL response the wallet is credited atomically. Safe to call multiple times — idempotent.

curl https://fonlok-backend-production.up.railway.app/v1/wallet/deposit/campay-ref-abc123/status \
  -H "Authorization: Bearer sk_live_..."

Response

JSON
// Completed — wallet credited
{
  "reference": "campay-ref-abc123",
  "status": "completed",
  "amount_credited": 10000,
  "transaction_id": 88,
  "user_ref": "user_1234"
}

// Still pending
{
  "reference": "campay-ref-abc123",
  "status": "pending",
  "transaction_id": 88,
  "user_ref": "user_1234"
}

// Failed / declined
{
  "reference": "campay-ref-abc123",
  "status": "failed",
  "transaction_id": 88,
  "user_ref": "user_1234"
}
GET/v1/wallet/balance

Return the current wallet balance for a user_ref. Returns 0 if no wallet exists yet.

Pass user_ref as a query parameter. If no wallet exists for this user_ref, balance returns 0 rather than a 404.
curl "https://fonlok-backend-production.up.railway.app/v1/wallet/balance?user_ref=user_1234" \
  -H "Authorization: Bearer sk_live_..."

Response

JSON
{
  "user_ref": "user_1234",
  "balance": 10000,
  "currency": "XAF"
}
POST/v1/wallet/withdraw

Withdraw funds from a user's wallet directly to a MoMo number. Fonlok covers Campay's ~1% disbursement fee — no fee is charged to the user. The debit is atomic; the balance is restored if the Campay disbursement fails.

Insufficient funds returns 409 insufficient_funds with current_balance. The disbursement is synchronous — the MoMo transfer is initiated immediately and the response includes the Campay reference.

Request body

FieldTypeRequiredDescription
amountintegerrequiredAmount to withdraw in XAF. Minimum 100 XAF.
phonestringrequiredDestination MoMo number in international format, e.g. 237670000000.
user_refstringrequiredYour platform reference for the wallet holder to debit.
descriptionstringoptionalOptional description stored on the transaction record.
curl https://fonlok-backend-production.up.railway.app/v1/wallet/withdraw \
  -X POST \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 5000,
    "phone": "237670000000",
    "user_ref": "user_1234"
  }'

Response

JSON
{
  "transaction_id": 91,
  "reference": "campay-wdr-ref-xyz789",
  "user_ref": "user_1234",
  "amount_withdrawn": 5000,
  "amount_sent_to_campay": 5050,
  "campay_fee_covered": 50,
  "new_balance": 5000,
  "currency": "XAF",
  "status": "success"
}

Webhooks

Register URLs to receive real-time signed notifications for invoice and payment events. Using webhooks is strongly preferred over polling — they fire as soon as an event occurs.

POST/v1/webhooks/register

Register a URL to receive Fonlok webhook event payloads. The response contains a signing secret (whsec_...) shown exactly once — store it immediately. You can register up to 5 active endpoints per API key.

The secret is shown once only. Copy it immediately and store it in a secure environment variable (e.g. FONLOK_WEBHOOK_SECRET). If lost, deactivate this endpoint and register a new one.

Request body

FieldTypeRequiredDescription
urlstringrequiredYour webhook endpoint URL. Must be a valid HTTPS URL. Fonlok POSTs signed JSON to this URL for each event.
labelstringoptionalA human-readable label for this endpoint, max 80 chars. e.g. "Production webhook".
curl https://fonlok-backend-production.up.railway.app/v1/webhooks/register \
  -X POST \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/webhooks/fonlok",
    "label": "Production webhook"
  }'

Response

JSON
HTTP 201 Created

{
  "object": "webhook",
  "id": 12,
  "url": "https://yourapp.com/webhooks/fonlok",
  "label": "Production webhook",
  "secret": "whsec_a1b2c3d4e5f6789012345678901234567890abcdef0123456789",
  "created_at": "2025-01-15T10:30:00.000Z",
  "_note": "Store the secret securely. It will not be shown again."
}
GET/v1/webhooks

List all registered webhook endpoints for your API key, including active status and the last time an event was delivered. Secrets are never returned after initial registration.

curl https://fonlok-backend-production.up.railway.app/v1/webhooks \
  -H "Authorization: Bearer sk_live_..."

Response

JSON
{
  "object": "list",
  "data": [
    {
      "id": 12,
      "url": "https://yourapp.com/webhooks/fonlok",
      "label": "Production webhook",
      "active": true,
      "created_at": "2025-01-15T10:30:00.000Z",
      "last_triggered_at": "2025-01-15T10:35:00.000Z"
    }
  ]
}
DELETE/v1/webhooks/:id

Deactivate a registered webhook by its numeric ID. The endpoint is marked inactive and will no longer receive deliveries. The ID is returned from GET /v1/webhooks.

curl https://fonlok-backend-production.up.railway.app/v1/webhooks/12 \
  -X DELETE \
  -H "Authorization: Bearer sk_live_..."

Response

JSON
{
  "object": "webhook",
  "id": 12,
  "active": false,
  "deleted": true
}

Webhook events

Fonlok POSTs a signed JSON payload to your registered endpoint each time one of these events occurs. Always verify the X-Fonlok-Signature header before processing. Events: payment.initiated · payment.confirmed · payment.disputed · payment.dispute_resolved · payment.released · payout.completed

Verifying webhook signatures

Every event delivery includes an X-Fonlok-Signature header containing an HMAC-SHA256 hex digest of the raw request body, signed with your webhook secret. Verify it before trusting any payload.

Node.js — Express example
import crypto from "crypto";

function verifyFonlokWebhook(rawBody, signatureHeader, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)           // raw Buffer or string — before JSON.parse
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(signatureHeader, "hex"),
    Buffer.from(expected, "hex")
  );
}

app.post(
  "/webhooks/fonlok",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.headers["x-fonlok-signature"];
    if (!verifyFonlokWebhook(req.body, sig, process.env.FONLOK_WEBHOOK_SECRET)) {
      return res.status(400).send("Invalid signature");
    }
    const event = JSON.parse(req.body.toString());
    switch (event.type) {
      case "payment.confirmed":         /* mark order as paid */        break;
      case "payment.disputed":          /* flag order for review */     break;
      case "payment.dispute_resolved":  /* act on event.decision */     break;
      case "payment.released":          /* notify seller */             break;
      case "payout.completed":          /* alternate release path */    break;
    }
    res.sendStatus(200);
  }
);
payment.initiated

Fired when POST /v1/payments/initiate succeeds. The MoMo prompt has been sent to the buyer but not yet approved.

Payload
{
  "object": "event",
  "type": "payment.initiated",
  "invoice_id": "42-a1b2c3d4e5f6",
  "reference": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "amount": 850000,
  "currency": "XAF",
  "provider": "mtn",
  "phone_number": "237670000000",
  "status": "pending",
  "timestamp": "2025-01-15T10:31:00.000Z"
}
payment.confirmed

Fired when the buyer approves the MoMo prompt and Campay confirms the charge. Funds are now held in escrow and the invoice status is 'paid'.

Payload
{
  "object": "event",
  "type": "payment.confirmed",
  "invoice_id": "42-a1b2c3d4e5f6",
  "reference": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "amount": 850000,
  "currency": "XAF",
  "provider": "mtn",
  "timestamp": "2025-01-15T10:33:00.000Z"
}
payment.disputed

Fired when POST /v1/payments/dispute is called. Funds are frozen pending resolution. Includes chat_links for buyer and seller, and the optional context field if one was supplied.

Payload
{
  "object": "event",
  "type": "payment.disputed",
  "invoice_id": "42-a1b2c3d4e5f6",
  "amount": 850000,
  "currency": "XAF",
  "reason": "Item was not as described.",
  "context": "Buyer contacted seller on 2025-01-14...",  // present only if supplied
  "timestamp": "2025-01-15T10:40:00.000Z",
  "chat_links": {
    "buyer":  "https://fonlok.com/chat/42-a1b2c3d4e5f6?token=abc123...&role=buyer",
    "seller": "https://fonlok.com/chat/42-a1b2c3d4e5f6?token=def456...&role=seller"
  }
}
payment.dispute_resolved

Fired by the Fonlok admin when a dispute is closed. The decision field is either "seller" (funds released to seller) or "buyer" (buyer refunded). Only fired for API-created invoices.

Payload
// Decision: seller wins — funds disbursed to seller
{
  "object": "event",
  "type": "payment.dispute_resolved",
  "invoice_id": "42-a1b2c3d4e5f6",
  "decision": "seller",
  "amount": 850000,
  "amount_disbursed": 833000,
  "currency": "XAF",
  "status": "completed",
  "timestamp": "2025-01-17T09:00:00.000Z"
}

// Decision: buyer wins — refund sent to buyer
{
  "object": "event",
  "type": "payment.dispute_resolved",
  "invoice_id": "42-a1b2c3d4e5f6",
  "decision": "buyer",
  "amount": 850000,
  "amount_disbursed": 833000,
  "currency": "XAF",
  "status": "refunded",
  "timestamp": "2025-01-17T09:00:00.000Z"
}
payment.released

Fired when POST /v1/payments/release succeeds. The invoice is now 'completed' and the seller has received their MoMo payout.

Payload
{
  "object": "event",
  "type": "payment.released",
  "invoice_id": "42-a1b2c3d4e5f6",
  "invoice_name": "iPhone 15 Pro",
  "buyer_email": "buyer@example.com",
  "seller_phone": "237670000001",
  "seller_email": "jean@example.com",
  "gross_amount": 850000,
  "platform_fee": 17000,
  "seller_receives": 833000,
  "currency": "XAF",
  "timestamp": "2025-01-15T10:35:00.000Z"
}
payout.completed

Fired when the buyer releases funds via the confirmation email link (the non-API release flow). Equivalent to payment.released but triggered by the buyer's manual confirmation rather than a direct API call.

Payload
{
  "object": "event",
  "type": "payout.completed",
  "invoice_id": "42-a1b2c3d4e5f6",
  "invoice_name": "iPhone 15 Pro",
  "buyer_email": "buyer@example.com",
  "seller_phone": "237670000001",
  "seller_email": "jean@example.com",
  "gross_amount": 850000,
  "platform_fee": 17000,
  "seller_receives": 833000,
  "currency": "XAF",
  "released_at": "2025-01-15T10:35:00.000Z"
}

Error responses

All errors use a consistent shape. The error field is machine-readable; message is human-readable. Error handling you write against the sandbox works identically in production.

JSON
// 400  Validation failed
{ "error": "validation_error",        "message": "amount must be at least 500 XAF." }

// 401  Missing or invalid API key
{ "error": "unauthorized",            "message": "Invalid or missing API key." }

// 404  Resource not found
{ "error": "not_found",               "message": "No invoice found with id '42-xyz' on your account." }

// 409  Wrong status for the operation
{ "error": "invalid_invoice_status",  "message": "Cannot release an invoice with status 'pending'. Only 'paid' invoices can be released." }

// 409  Duplicate external reference
{ "error": "duplicate_reference",     "message": "An invoice with reference 'order_789' already exists." }

// 409  Insufficient wallet balance
{ "error": "insufficient_funds",      "message": "Wallet balance (200000 XAF) is less than invoice amount (850000 XAF).",
  "current_balance": 200000, "invoice_amount": 850000 }

// 429  Rate limited
{ "error": "rate_limit_exceeded",     "message": "Too many requests. Please slow down." }

// 500  Unexpected server error
{ "error": "server_error",            "message": "An unexpected error occurred. Please try again." }

How the sandbox works

Everything you need to know before writing your first API call.

Completely isolated

Sandbox invoices, payments, and transactions are stored in a dedicated environment, fully separated from Fonlok's live platform. Nothing you do in the sandbox can affect real users or real money.

Deterministic test flows

Payments stay in a pending state until you explicitly confirm or fail them. This means you can test the happy path (successful payment), the failure path (declined), and edge cases — one at a time, as many times as you need.

Full API fidelity

The sandbox uses exactly the same request and response format as the live Fonlok API. When you are ready to go live, you only need to replace your sandbox key with a live key — your code stays the same.

Authenticated with scoped keys

Each sandbox key (sk_test_*) is tied to your Fonlok account and can be revoked at any time. The full key is shown once at creation — copy it immediately, as it cannot be retrieved afterwards.

Consistent error responses

Validation errors, not-found responses, and other error types use the same structure across all endpoints. Error handling you build during testing will work correctly when you go live.

Authentication

Every sandbox request must include your API key. You pass it as a Bearer token in the Authorization HTTP header on every request. This is a standard pattern used by most APIs — it tells the server which account the request belongs to, without embedding credentials in the URL.

HTTP header
Authorization: Bearer sk_test_a1b2c3d4...

Key format

sk_test_ + 32 hex chars (40 chars total)

Key security

Shown once at creation — cannot be retrieved afterwards

Key scope

Sandbox-only, tied to your account, independently revocable

Quick start

Run a complete payment lifecycle in under five minutes.

  1. 01

    Create a sandbox key

    Scroll down to the "API keys" section, sign in if prompted, and click "Generate key". Give it a descriptive label so you can identify it later.

  2. 02

    Verify your key

    Paste your key into the explorer below and send a GET /sandbox/ping request. If you receive back {"status": "ok"}, your key is working correctly and you are ready to continue.

  3. 03

    Create a test invoice

    Call POST /sandbox/invoices with a title, amount, and seller email. The response includes an invoice_id (it looks like inv_test_abc123) — copy it, you will need it in the next step.

  4. 04

    Simulate a payment

    Call POST /sandbox/payments/initiate, passing the invoice_id from step 3 and a Cameroonian mobile number (format: 237XXXXXXXXX). The response includes a payment reference — a unique ID for this payment attempt.

  5. 05

    Confirm or fail the payment

    Call POST /sandbox/payments/{reference}/confirm to simulate the customer approving the payment, or POST /sandbox/payments/{reference}/fail to simulate a rejection. The invoice status updates automatically to reflect the outcome.

API reference

Select an endpoint, fill in the parameters, and send a live request directly from this page.

Sandbox
Sandbox key
GET/sandbox/ping

Verifies that the sandbox is reachable and your API key is valid. Use this as a first step to confirm your integration is set up correctly.

curl -X GET \
  "https://api.fonlok.com/sandbox/ping" \
  -H "Authorization: Bearer sk_test_your_key_here"
Response
Click “Send request” to see the response here.
The example below shows a sample response.

Sample response

{
  "object": "sandbox_status",
  "status": "ok",
  "environment": "sandbox",
  "key_label": "Local development",
  "message": "The Fonlok sandbox is live. No real transactions will be processed.",
  "timestamp": "2026-07-01T12:00:00.000Z",
  "_sandbox": true
}

API keys

Sandbox keys are prefixed with sk_test_ and only work in the sandbox environment. They cannot be used to access live payments, real payouts, or any user data on Fonlok's live platform.

Loading…
Live API

Live API keys

Live keys are prefixed with sk_live_ and process real payments. Submit an application below — your key is generated immediately but requires admin approval before it becomes active. You will be emailed once it is activated.

Loading...

Common questions

Can sandbox activity affect the live Fonlok platform?

No. The sandbox runs in a completely isolated environment. Anything you do during testing — including errors — has no effect on real Fonlok users, live transactions, or real money.

Can I use a sandbox key to make real payments?

No. Keys prefixed with sk_test_ are only accepted in the sandbox. They are automatically rejected by Fonlok's live platform. Processing real payments requires a separate live API key, available to approved integration partners.

Do sandbox transactions expire or get cleaned up?

Sandbox data is kept for as long as your key remains active. When you revoke a key, all test invoices and transactions associated with it are permanently removed.

Can I test webhooks in the sandbox?

Yes. Use the POST /sandbox/momo/webhook/simulate endpoint to fire a real HTTP POST to any callback URL you provide. This lets you verify that your server handles incoming Fonlok payment notifications correctly before going live.

Can I use the sandbox in my CI/CD pipeline?

Yes. Create a key labelled for your pipeline (e.g. "GitHub Actions") and store it as a secret in your CI environment. The sandbox is designed to handle automated test suites without issues.

How do I report a bug in the sandbox API?

Reach us at support@fonlok.com with the subject line "Sandbox API issue". Include the endpoint, your request body, and the response you received.

How do I get a live (production) API key?

Scroll up to the "Live API keys" section, sign in, and click "Apply for a live key". Fill in your company name, website URL, and a brief description of your use case. Your key is generated immediately but starts as pending — a Fonlok admin reviews all applications and you will be emailed once it is activated.

What happens when a buyer opens a dispute?

Call POST /v1/payments/dispute with the invoice_id, a reason (max 1,000 chars), and an optional context field (max 10,000 chars — paste conversation history or evidence here). Fonlok freezes the funds, creates a shared chat thread, emails both parties their role-scoped chat links, and returns those links in the API response. Fonlok's dispute lifecycle is binary: open → resolved. You receive a payment.disputed webhook immediately and a payment.dispute_resolved webhook (with a decision field of "seller" or "buyer") when the admin closes the case. The typical resolution SLA is 24–48 hours.

How do I verify that a webhook payload came from Fonlok?

Every delivery includes an X-Fonlok-Signature header containing an HMAC-SHA256 hex digest of the raw request body, signed with your webhook secret. Compute the same digest on your server and compare using a constant-time comparison function (e.g. crypto.timingSafeEqual in Node.js). Reject any request where the signatures do not match.

What is the platform fee and who pays it?

Fonlok deducts a 3% platform fee from the gross amount at the time of release. The seller receives the remainder directly to their Mobile Money number. There are no charges to create invoices or initiate payments — the fee is only deducted when funds are released.

Can a buyer pay from a wallet instead of MoMo?

Yes. If your platform pre-funds buyer wallets, use POST /v1/wallet/pay with the buyer's user_ref. Funds are debited atomically from the wallet and the invoice moves to 'paid' immediately — no MoMo prompt is needed.