Implementing Card Payments with Monnify — A Practical Guide (Checkout SDK and Direct Charge + OTP / 3DS)

By Chidubem Agulue 7th Jan, 2026

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

  1. Use the Checkout SDK when you want fastest integration, lower PCI burden, and a hosted checkout/modal that handles card entry, 3DS, and callbacks for you.
  2. 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)

  1. A Monnify Merchant Account (get API key & secret, contract code).
  2. Set your Transaction completion Webhook URL in the Monnify dashboard. A webhook is strongly recommended for reliable asynchronous confirmation.
  3. 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) CheckoutUrl / Web SDK — quick integration: How it works (overview)

  1. 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). Note that to restrict the payment method to cards only, the paymentMethods array in the request body should contain only CARDS.
  2. You redirect/open the checkoutUrl from the user’s browser. The modal/page handles card entry, 3DS, bank flows, authorization, etc.
  3. On completion Monnify redirects the user to your callback URL provided in the redirectUrl of your request body and emits webhook events. You can also verify final status via the transaction status endpoint.
Server: initialize transaction
1import express from "express";
2import fetch from "node-fetch";
3const router = express.Router();
4
5const MONNIFY_BASE = "https://sandbox.monnify.com"; // change to live on production
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  };
31  const r = await fetch(`${MONNIFY_BASE}/api/v1/merchant/transactions/init-transaction`, {
32    method: "POST",
33    headers: {
34      Authorization: `Bearer ${token}`,
35      "Content-Type": "application/json"
36    },
37    body: JSON.stringify(payload),
38  });
39  const data = await r.json();
40  // data.responseBody.checkoutUrl holds the URL
41  res.json(data.responseBody);
42});
43
44export default router;
45            

Notes

  1. Checkout URL expires (e.g., 40 minutes) — initialize per attempt.
  2. SDK also has an inline MonnifySDK.initialize() option that opens a modal — see Monnify SDK docs.

2) Direct integration (Charge Card → OTP / 3DS flows)

  1. Your server generates a transaction using the Initialize Transaction API.
  2. Initiate Charge (Server): You send the raw card details to Monnify, including the transactionReference that was generated from step 1.
    Charge a Card
    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});
  3. Handle the Response Logic (Client)
    The status returned by the API determines your next step:
    1. SUCCESS: Payment complete.
    2. OTP_REQUIRED: Show an input field for the user to enter the code sent to their phone. Then call the authorize-otp endpoint.
      Authorize OTP
      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});
    3. BANK_AUTHORIZATION_REQUIRED: This triggers 3D Secure (3DS).
      Handling charge card response
      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}
  4. For 3DS flows some integrations use Authorize 3DS Card endpoint to finalize server-side when required.

Verification & Webhooks

Never trust the frontend redirect as proof of payment. A user could manually navigate to your /success page.
  1. 1. Transaction Status API: Always verify the status server-side using the transaction status endpoint.
  2. 2. Webhooks: Configure a Webhook URL in your Monnify Dashboard. Monnify will send a POST request to your server when a payment is successful.
    1. Security: Verify the monnify-signature header to ensure the request actually came from Monnify.
    2. Idempotency: Ensure that if you receive the same webhook twice, you don't credit the user twice.

Best Practices Checklist

  1. Use Environment Variables: Never hardcode your SECRET_KEY or CONTRACT_CODE.
  2. Handle Sandbox vs. Live: Use `https://sandbox.monnify.com` for testing and `https://api.monnify.com` for production.
  3. PCI Compliance: If you aren't PCI certified, stick to the Checkout SDK to minimize your liability.
  4. Graceful Failures: Banks in Nigeria occasionally have downtime. Always provide clear error messages to the user (e.g., "Insufficient Funds" or "Bank System Timeout").
Copyright © 2026 Monnify
instagramfacebookicon