Server to server integration

It's possible to do a full on-ramp flow by using only merchant API. Here you can find how to send API requests correctly. The full workflow should look like this:

  1. Get a list of supported countries and their payment channels.

  2. Pick a country, payment channel, and blockchain asset. Get order limits using these values.

  3. Get the user's KYC status and check if they need to pass a KYC process. If they need a KYC, submit the document information and check the KYC status until it's accepted.

  4. Get the best offer , to get the quoteId, understand how much a user should pay, and what additional information is required from a user to create an order.

  5. Create an order using the user's email, desired amount, blockchain asset, country, payment channel, additional data required from a user, and quoteId. Some orders may require verifying an OTP code sent to a user.

Getting countries, payment channels, blockchain assets and order limits

Let's get a list of supported countries , the response would be like this:

[
  {
    "name": "Nigeria",
    "countryIsoCode": "NG",
    "currencyIsoCode": "NGN",
    "paymentChannels": [
      {
        "paymentChannel": "bank",
        "description": "Bank transfer",
        "requiresCarrier": false,
        "carriers": []
      },
      {
        "paymentChannel": "airtime",
        "description": "Airtime",
        "requiresCarrier": true,
        "carriers": [
          {
            "id": "618e43914f57e07d255ff357",
            "name": "Airtel Nigeria"
          },
          ...
        ]
      }
    ]
  },
  ...
]

We see that Nigeria is supported for on-ramp and has payment channels: bank and airtime.

Let's pick this country and bank payment channel. Let's get a list of supported blockchain assets:

[
  {
    "network": "POLYGON",
    "asset": "USDC",
    "canOfframp": true,
    "canOnramp": true
  },
  {
    "network": "ETHEREUM",
    "asset": "USDC",
    "canOfframp": true,
    "canOnramp": true
  },
  ...
]

We see that POLYGON USDC is supported for on-ramp; let's pick it.

Now, let's check order limits for Nigeria, bank payment channel and POLYGON USDC asset. Request query params:

?countryIsoCode=NG&paymentChannel=bank&network=POLYGON&asset=USDC

Response:

{
  "minUsd": 1,
  "maxUsd": 100,
  "minLocalCurrency": 1534,
  "maxLocalCurrency": 153376,
  "minCrypto": 1,
  "maxCrypto": 100
}

So, now we understand that a user can buy from 1 to 100 POLYGON USDC and can pay from 1534 to 153376 NGN.

KYC

Let's check a user's KYC status to determine if we need to sumbit a KYC documents. Request body:

{
    email: "[email protected]",
    countryIsoCode: "NG"
}

Response:

{
  "reachedKycLimit": false,
  "basicDocuments": [
    {
      "_id": "67da909b739fc481aa525c45",
      "type": "basic",
      "title": "BVN",
      "value": "BVN",
      "requiredFields": {
        "first_name": {
          "type": "string",
          "label": "First Name",
          "required": true
        },
        "last_name": {
          "type": "string",
          "label": "Last Name",
          "required": true
        },
        "dob": {
          "type": "date",
          "label": "Date of birth",
          "required": true
        },
        "email": {
          "type": "email",
          "label": "Email",
          "required": true
        },
        "id_number": {
          "type": "string",
          "label": "BVN Number",
          "required": true,
          "format": "00000000000",
          "regexp": "^[0-9]{11}$"
        }
      }
    },
    ...
  ],
  "advancedDocuments": [
    {
      "_id": "67da93c0dfd3a00f3380b857",
      "type": "advanced",
      "title": "Driving License",
      "value": "DRIVERS_LICENSE",
      "requiredFields": {
        "first_name": {
          "type": "string",
          "label": "First Name",
          "required": true
        },
        "last_name": {
          "type": "string",
          "label": "Last Name",
          "required": true
        },
        "dob": {
          "type": "date",
          "label": "Date of birth",
          "required": true
        },
        "email": {
          "type": "email",
          "label": "Email",
          "required": true
        },
        "images": {
          "type": "smile-identity-images",
          "label": "Verification images",
          "required": true
        }
      }
    },
    ...
  ],
  "kycRules": {
    "onramp": [
      { min: 0, max: 10, type: 'none' },
      { min: 10, max: 50, type: 'basic' },
      { min: 50, max: 100, type: 'advanced' },
    ],
    "offramp": [
      { min: 0, max: 7, type: 'none' },
      { min: 7, max: 35, type: 'basic' },
      { min: 35, max: 100, type: 'advanced' },
    ],
  },
}

We see that there's no passedKycType field which means that the user haven't completed a KYC process in our system. Moreover, we see a list of supported documents for basic and advanced KYC.

The kycRules field indicates that we don't need a KYC for orders below $10, need a basic KYC for the $10-50(not including) range, and need an advanced KYC for the $50-100 range.

For demonstration purposes, let's at first complete the basic KYC and then the advanced one. Let's pick a basic document to submit:

{
      "_id": "67da909b739fc481aa525c45",
      "type": "basic",
      "title": "BVN",
      "value": "BVN",
      "requiredFields": {
        "first_name": {
          "type": "string",
          "label": "First Name",
          "required": true
        },
        "last_name": {
          "type": "string",
          "label": "Last Name",
          "required": true
        },
        "dob": {
          "type": "date",
          "label": "Date of birth",
          "required": true
        },
        "email": {
          "type": "email",
          "label": "Email",
          "required": true
        },
        "id_number": {
          "type": "string",
          "label": "BVN Number",
          "required": true,
          "format": "00000000000",
          "regexp": "^[0-9]{11}$"
        }
      }
    }

We need to build an object with keys described under "requiredFields", like this:

{
    "first_name": "Joe",
    "last_name": "Doe",
    "dob": "2000-01-01T00:00:00.000Z",
    "email": "[email protected]",
    "id_number": "00000000000"
}

Then we submit it using the user's email, countryIsoCode, document ID, and these fields.

Request body:

{
      "email": "[email protected]",
      "countryIsoCode": "NG",
      "documentId": "67da909b739fc481aa525c45",
      "userFields": {
        "first_name": "Joe",
        "last_name": "Doe",
        "dob": "2000-01-01T00:00:00.000Z",
        "email": "[email protected]",
        "id_number": "00000000012",
      },
}

Let's check a user's KYC status again:

{
    "passedKycType": "basic",
    "kycStatus": "approved",
    "kycStatusDescription": "Partial Match",
    ...
}

We see that a user passed the KYC and now has passedKycType = basic. If KYC check was still pending the response would be like the following:

{
    "kycStatus": "initiated",
}

Failed KYC would look like the following:

{
    "kycStatus": "rejected",
    "kycStatusDescription": "Unable to verify ID - Result Not Found",
}

In case of rejected KYC you can try to submit a new one untill reachedKycLimit = true, thereafter, you need to contact our support team.

Now pick a document for an advanced KYC :

{
      "_id": "67da93c0dfd3a00f3380b857",
      "type": "advanced",
      "title": "Driving License",
      "value": "DRIVERS_LICENSE",
      "requiredFields": {
        "first_name": {
          "type": "string",
          "label": "First Name",
          "required": true
        },
        "last_name": {
          "type": "string",
          "label": "Last Name",
          "required": true
        },
        "dob": {
          "type": "date",
          "label": "Date of birth",
          "required": true
        },
        "email": {
          "type": "email",
          "label": "Email",
          "required": true
        },
        "images": {
          "type": "smile-identity-images",
          "label": "Verification images",
          "required": true
        }
      }
    }

Everything is the same except the "images" field. It requires you to submit a photos of both sides of user's document and a user's selfie. Let's imagine that you took these photos and uploaded to the file storage under these URLs: https://cdn.com/selfie.jpg, https://cdn.com/front.jpg, https://cdn.com/back.jpg, the request to submit the KYC would look like the following:

{
      "email": "[email protected]",
      "countryIsoCode": "NG",
      "documentId": "67da909b739fc481aa525c45",
      "userFields": {
        "first_name": "Joe",
        "last_name": "Doe",
        "dob": "2000-01-01T00:00:00.000Z",
        "email": "[email protected]",
        "images": [
          {
            "image_type_id": 0,
            "image": "https://cdn.com/selfie.jpg" 
          }, 
          {
            "image_type_id": 1,
            "image": "https://cdn.com/front.jpg" 
          }, 
          {
            "image_type_id": 5,
            "image": "https://cdn.com/back.jpg" 
          }
        ]
      },
}

The rest of the logic is the same

Creating an order

Let's get the best offer using parameters picked in previous steps, the order will be for 5 POLYGON USDC.

Request query:

?countryIsoCode=NG&paymentChannel=bank&network=POLYGON&asset=USDC&amount=5&currency=crypto&includeRequiredFields=true

Response:

{
  "quoteId": "687a45186848159c27269e38",
  "offer": {
    "countryIsoCode": "NG",
    "currencyIsoCode": "NGN",
    "paymentChannel": "bank",
    "exchangeRate": 1532.89,
    "cryptoExchangeRate": 1532.89
  },
  "cashout": {
    "localCurrencyAmount": 7664,
    "totalAmountUsd": 5,
    "totalAmountCrypto": 5,
    "withdrawAmountUsd": 5,
    "withdrawAmountCrypto": 5,
    "feePercent": 0,
    "feeAmountUsd": 0,
    "feeAmountLocalCurrency": 0,
    "feeAmountCrypto": 0,
    "feePercentFonbnk": 0,
    "feeAmountUsdFonbnk": 0,
    "feeAmountLocalCurrencyFonbnk": 0,
    "feeAmountCryptoFonbnk": 0,
    "feePercentPartner": 0,
    "feeAmountUsdPartner": 0,
    "feeAmountLocalCurrencyPartner": 0,
    "feeAmountCryptoPartner": 0,
    "gasAmountUsd": 0.00112894,
    "gasAmountCrypto": 0,
    "gasAmountLocalCurrency": 2
  },
  "requiredFields": {
    "buyerFirstName": {
      "label": "Your bank account first name",
      "type": "string",
      "required": true,
      "sellerLabel": "Buyer first name"
    },
    "buyerLastName": {
      "label": "Your bank account last name",
      "type": "string",
      "required": true,
      "sellerLabel": "Buyer last name"
    },
    "buyerEmail": {
      "label": "Your email",
      "type": "email",
      "required": true,
      "sellerLabel": "Buyer email"
    },
    "bankCode": {
      "required": true,
      "type": "enum",
      "label": "Bank name",
      "sellerLabel": "Bank name",
      "options": [
        {
          "value": "120001",
          "label": "9mobile 9Payment Service Bank"
        },
        {
          "value": "50871",
          "label": "Unical MFB"
        },
        ...
      ]
    },
    "phoneNumber": {
      "label": "Your phone number",
      "sellerLabel": "Buyer's phone number",
      "type": "phone",
      "required": true
    }
  }
}

From the response, we understand that a user should pay 7664 NGN to receive 5 POLYGON USDC, also we see that the next additional data is required from a user: buyerFirstName, buyerLastName, buyerEmail, bankCode, phoneNumber.

Let's create an order using all this data.

Request body:

{
  "quoteId": "687a45186848159c27269e38",
  "email": "[email protected]",
  "network": "POLYGON",
  "asset": "USDC",
  "amount": 5,
  "currency": "crypto",
  "address": "0x91b0a33dbcb10f8331eD3627B94e5a9B1591269f",
  "userIp": "145.234.234.55",
  "redirectUrl": "https://your-website.con/fonbnk-success-page",
  "extraFields": {
    "buyerFirstName": "John",
    "buyerLastName": "Doe",
    "buyerEmail": "[email protected]",
    "bankCode": "120001",
    "phoneNumber": "234567890123",
  },
}

Response:

{
  "_id": "687a48eab6d730f80856e1ca",
  "status": "swap_initiated",
  "date": "2025-07-18T13:15:23.289Z",
  "orderId": "687a48eab6d730f80856e1ca",
  "email": "[email protected]",
  "localCurrencyAmount": 7664,
  "currencyIsoCode": "NGN",
  "countryIsoCode": "NG",
  "paymentChannel": "bank",
  "amount": 5,
  "amountCrypto": 5,
  "network": "POLYGON",
  "asset": "USDC",
  "address": "0x91b0a33dbcb10f8331eD3627B94e5a9B1591269f",
  "memo": null,
  "orderParams": null,
  "resumeUrl": "https://sandbox-pay.fonbnk.com/ussd/687a48eab6d730f80856e1ca",
  "carrierId": "618e43914f57e07d255ff351",
  "feePercent": 0,
  "feePercentFonbnk": 0,
  "feePercentPartner": 0,
  "feeAmountUsd": 0,
  "feeAmountLocalCurrency": 0,
  "feeAmountUsdFonbnk": 0,
  "feeAmountLocalCurrencyFonbnk": 0,
  "feeAmountUsdPartner": 0,
  "feeAmountLocalCurrencyPartner": 0,
  "gasAmountUsd": 0.00112894,
  "transferInstructions": {
    "type": "manual",
    "instructionsText": "It is a sandbox offer. If you are using test account, please confirm the transfer from your side and seller will automatically confirm the transfer from his side within 1 minute.",
    "warningText": "Created orders from non-test accounts will be automatically canceled after 5 minutes.",
    "transferDetails": {
      "bankAccountNumber": {
        "label": "Agent's bank account number",
        "value": "5158809613"
      },
      "bankName": {
        "label": "Agent's bank name",
        "value": "Fidelity Bank"
      },
      "bankAccountHolderName": {
        "label": "Agent's bank account holder name",
        "value": "SANDY BOXERRITTO"
      },
      "narration": {
        "label": "Narration",
        "description": "Transfer without narration will be ignored by the system.",
        "value": "6JTQAC"
      }
    }
  },
  "gasAmountLocalCurrency": 1
}

Order is created, now a user must pay to the agent using the "transferInstructions" details.

Here are possible types of transfer instructions:

Manual, a user pays manually to the provided details:

{
  "transferInstructions": {
    "type": "manual",
    "transferDetails": {
      "bankAccountNumber": {
        "label": "Agent's bank account number",
        "value": "9637959770"
      },
      "bankName": {
        "label": "Agent's bank name",
        "value": "PROVIDUS BANK"
      },
      "bankAccountHolderName": {
        "label": "Agent's bank account holder name",
        "value": "Start Button Limited(Checkout)"
      },
      "narration": {
        "label": "Narration",
        "description": "TRANSFER WITHOUT NARRATION WILL BE IGNORED BY THE SYSTEM.",
        "value": "shc-0x6on2w5js"
      }
    },
    "instructionsText": "Transfer the NGN to the agent's bank account.",
    "warningText": "Important: Only transfer funds from a bank account you specified previously. Send the exact NGN amount. Use the displayed account for this transaction only."
  }
}

STK Push, user receives a mobile carrier popup and confirms a transfer:

{
  "transferInstructions": {
    "type": "stk_push",
    "instructionsText": "You’ll be prompted with a USSD dialog to proceed the transfer. If the transfer is unsuccessful or you don’t receive the USSD dialog, please retry the transfer"
  }
}

Redirect, user must open a specidied redirect URL and complete the transfer there. After the success they will be redirected to the URL specified during order creation.

{
  "transferInstructions": {
    "type": "redirect",
    "instructionsText": "You’ll be redirected to Flutterwave checkout. Enter the OTP code received to initiate the transaction. If you have any issues, please retry the transfer",
    "paymentUrl": "https://checkout.flutterwave.com/captcha/verify/lang-en/9704816:565eee314972431349bd77403e57a741"
  }
}

USSD, in that case, a user must execute the provided USSD code to complete the transfer. USSD code may include a {pin} placeholder; in that case you must ask the user to provide a pin code and replace the placeholder with it before execution.

{
  "transferInstructions": {
    "type": "ussd",
    "ussdCode": "*321*0701232567*1587*{pin}#",
    "transferDetails": {
      "phoneNumber": {
        "label": "Agent's phone number",
        "value": "254701232567"
      }
    },
    "instructionsText": "Dial the USSD code and follow the instructions to complete the transfer. You have to replace {pin} placeholder with your PIN code if you dial USSD code manually.",
    "warningText": "Important: Transfer airtime NGN to the agent from the phone number you specified during order creation."
  }
}

STK Push with OTP, in that case user receives an sms with an OTP code which must be sent to the confirm OTP endpoint , after that a user receives the STK Push and pays for the order

{
  "transferInstructions": {
    "type": "otp_stk_push",
    "transferDetails": {},
    "isOtpRequired": true,
    "instructionsText": "Enter the OTP code received to initiate the transaction and you’ll be prompted with a USSD dialog to proceed the transfer. If the transfer is unsuccessful or you don’t receive the USSD dialog, please retry the transfer",
    "actionButtonText": "Verify OTP code"
  }
}

After a user sends funds to the agent, you must confirm the order.

Request body:

{
  "orderId": "687a48eab6d730f80856e1ca"
}

The order flow is finished; now you need to wait for crypto to be sent to the user's wallet. You can do that either by waiting for a webhook or by fetching the order.

Last updated