Accepting card payments is one of those features that looks simple from the outside but quickly becomes complex once banks, OTPs, and 3-D Secure flows enter the picture. If you're building a product in Nigeria, you'll also need a payment provider that understands local cards, local banks, and the realities of failed redirects and dropped sessions.
Monnify is one of the most popular payment platforms for Nigerian businesses, and it offers two distinct ways to accept card payments: a hosted checkout that abstracts away most of the complexity, and a set of APIs that allow you to charge cards directly and handle OTP and 3-D Secure flows yourself.
In this article, we'll walk through both approaches. You'll learn when to use Monnify's Checkout SDK, when a direct card charge integration makes sense, and how to correctly handle OTP and 3-D Secure authorization without breaking the user experience.
TL;DR (how to choose)
- Use the Checkout SDK when you want the fastest integration, lower PCI burden, and a hosted checkout/modal that handles card entry, 3DS, and callbacks for you.
- Use Charge Card + OTP / 3DS when you need a fully branded flow, card tokenization for recurring charges, or advanced control — but be prepared for PCI responsibilities and more server logic.
Prerequisites (both flows)
- A Monnify Merchant Account (get API key & secret, contract code).
- Set your Transaction completion Webhook URL in the Monnify dashboard. A webhook is strongly recommended for reliable asynchronous confirmation.
- Always call Monnify API from your server (never expose secret key on the client). For added security, we only honour requests from your whitelisted IP address.
1) Checkout SDK — Quick Integration
Your server generates a transaction using the Initialize Transaction API. Monnify returns a checkoutUrl in the response object (or you can use the inline SDK). To restrict the payment method to cards only, the paymentMethods array in the request body should contain only CARDS.
1import express from "express";
2import fetch from "node-fetch";
3const router = express.Router();
4
5const MONNIFY_BASE = "https://sandbox.monnify.com";
6const API_KEY = process.env.MONNIFY_API_KEY;
7const SECRET = process.env.MONNIFY_SECRET;
8const CONTRACT_CODE = process.env.MONNIFY_CONTRACT_CODE;
9
10async function getAccessToken() {
11 const basic = Buffer.from(`${API_KEY}:${SECRET}`).toString("base64");
12 const res = await fetch(`${MONNIFY_BASE}/api/v1/auth/login`, {
13 method: "POST",
14 headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/json" },
15 });
16 const body = await res.json();
17 return body.responseBody.accessToken;
18}
19
20router.post("/init", async (req, res) => {
21 const { amount, customerName, customerEmail } = req.body;
22 const token = await getAccessToken();
23 const payload = {
24 amount,
25 customerName,
26 customerEmail,
27 paymentReference: `order_${Date.now()}`,
28 contractCode: CONTRACT_CODE,
29 redirectUrl: "https://yourdomain.com/monnify/callback",
30 paymentMethods: ["CARD"],
31 };
32 const r = await fetch(`${MONNIFY_BASE}/api/v1/merchant/transactions/init-transaction`, {
33 method: "POST",
34 headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
35 body: JSON.stringify(payload),
36 });
37 const data = await r.json();
38 res.json(data.responseBody);
39});
40
41export default router;Notes
- Checkout URL expires (e.g., 40 minutes) — initialize per attempt.
- The SDK also has an inline MonnifySDK.initialize() option that opens a modal — see Monnify SDK docs.
2) Direct Integration (Charge Card → OTP / 3DS flows)
Your server generates a transaction using the Initialize Transaction API, then sends the raw card details to Monnify (including the transactionReference from step 1).
Warning: Ensure you are PCI-DSS compliant before handling raw card numbers on your server.
1router.post("/direct-charge", async (req, res) => {
2 const token = await getAccessToken();
3 const payload = {
4 transactionReference: req.body.transactionReference,
5 card: {
6 number: req.body.cardNumber,
7 expiryMonth: req.body.expiryMonth,
8 expiryYear: req.body.expiryYear,
9 pin: req.body.pin,
10 cvv: req.body.cvv,
11 }
12 };
13
14 const response = await fetch(`${MONNIFY_BASE}/api/v1/merchant/cards/charge`, {
15 method: "POST",
16 headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
17 body: JSON.stringify(payload),
18 });
19
20 res.json(await response.json());
21});Handle the Response Logic
The status returned by the API determines your next step:
- SUCCESS: Payment complete.
- OTP_REQUIRED: Show an input field for the user to enter the code sent to their phone. Then call the authorize-otp endpoint.
1// POST /api/monnify/authorize-otp
2fetch(`${MONNIFY_BASE}/api/v1/merchant/cards/otp/authorize`, {
3 method: "POST",
4 headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
5 body: JSON.stringify({
6 transactionReference: "<reference-from-charge-response>",
7 tokenId: "<reference-from-charge-response>",
8 otp: "<user-typed-otp>"
9 })
10});- BANK_AUTHORIZATION_REQUIRED: This triggers 3D Secure (3DS). If the secure3dData.method is POST, do not use fetch to call the acsUrl. You must perform a full browser redirect to that URL.
1async function handleChargeResponse(data) {
2 const { status, secure3dData, transactionReference } = data.responseBody;
3
4 if (status === "BANK_AUTHORIZATION_REQUIRED") {
5 // Redirect browser to bank's 3DS page
6 window.location.href = secure3dData.acsUrl;
7 } else if (status === "OTP_REQUIRED") {
8 // Open your custom OTP modal
9 promptUserForOTP(transactionReference);
10 } else if (status === "SUCCESS") {
11 // verify server-side and show success
12 }
13}Verification & Webhooks
Never trust the frontend redirect as proof of payment. A user could manually navigate to your /success page.
- Transaction Status API: Always verify the status server-side using the transaction status endpoint.
- Webhooks: Configure a Webhook URL in your Monnify Dashboard. Monnify will send a POST request to your server when a payment is successful.
- Security: Verify the monnify-signature header to ensure the request actually came from Monnify.
- Idempotency: Ensure that if you receive the same webhook twice, you don't credit the user twice.
Best Practices Checklist
- Use Environment Variables: Never hardcode your SECRET_KEY or CONTRACT_CODE.
- Handle Sandbox vs. Live: Use https://sandbox.monnify.com for testing and https://api.monnify.com for production.
- PCI Compliance: If you aren't PCI certified, stick to the Checkout SDK to minimize your liability.
- Graceful Failures: Banks in Nigeria occasionally have downtime. Always provide clear error messages to the user (e.g., 'Insufficient Funds' or 'Bank System Timeout').
