McTaba Labs logo
Software EngineeringThe WhiteboardProjectsEventsMasterclassesWebinarsLearn
Book Call

500+ developers already in the community

Get answers, find accountability partners, and stay ahead

Join Discord →
McTaba Labs logo

Educating 1 million students in tech by 2030.

Programs

  • HighSchool Graduates Launchpad
  • AI & Business Automation
  • Corporate Training
  • McTaba Story & Mission
  • Full-Stack Curriculum
  • Course Pricing & Plans
  • Frequently Asked Questions

Resources

  • Join Discord Community
  • Tech Events in Nairobi
  • Masterclasses
  • Free Resources
  • Learn
  • Campus
  • Africa Tech Talent Report
  • Partner With Us
  • Start a Project
  • Our Work

Contact Us

  • 124 Manyani East Road, Lavington, Nairobi
  • +254727795603
  • Book a Discovery Call

Office Hours:

Mon-Fri: 9am - 2pm EAT

Sat: 10am - 12pm EAT

© 2027 McTaba Labs - A product of Edtech Industries. All rights reserved.

Privacy PolicyTerms of ServiceRefund Policy
  1. Learn
  2. Paystack for African Developers
  3. Building a Fully Custom Checkout on the Paystack Charge API
Bonaventure OgetoBy Bonaventure Ogeto|Updated July 22, 2026

Building a Fully Custom Checkout on the Paystack Charge API

To build a fully custom checkout on the Paystack Charge API, you create your own payment form, collect payment details on your frontend, send them to your backend over HTTPS, call the Paystack /charge endpoint, and then handle the multi-step response loop (PIN, OTP, 3DS redirect) until the charge resolves. You own the UI and the flow, but you also own PCI compliance for card data.

In this article

  1. 1. When a Custom Checkout Makes Sense
  2. 2. Architecture: Frontend, Backend, and Paystack
  3. 3. Building the Payment Form
  4. 4. Server-Side Charge Flow in Node.js
  5. 5. Handling 3DS Redirects and Bank Pages
  6. 6. Testing Every Charge Path
  7. 7. Production Hardening Checklist
  8. 8. Stay Up to Date

When a Custom Checkout Makes Sense

The Paystack hosted checkout (popup or redirect) works for most applications. It handles card tokenization, OTP collection, 3DS redirects, and error messages. You write a few lines of code and get a battle-tested payment form.

But some products need more control:

  • Marketplace platforms where the checkout is deeply integrated into a multi-step booking or ordering flow, and a popup would break the user journey.
  • Point-of-sale applications running on tablets or terminals where browser popups are not available.
  • White-label products where no third-party branding can appear anywhere in the payment process.
  • Mobile apps where you want a native payment form rather than a WebView wrapping the Paystack checkout page.

If you are building one of these, a custom checkout on the Charge API is the right approach. If you are building a standard web application and just want to change the checkout colors, check whether Paystack Inline JS customization options cover your needs first.

Architecture: Frontend, Backend, and Paystack

A custom checkout has three layers that work together:

Frontend (your payment form)

An HTML form (or native mobile screen) that collects payment details. For card payments: card number, expiry month/year, and CVV. For bank transfers: the customer clicks a button and you display the generated account number. The frontend sends collected data to your backend over HTTPS.

Backend (your server)

Your Node.js, Django, or Laravel server receives the payment details, calls the Paystack Charge API, processes the multi-step response, and returns instructions to the frontend (show OTP input, show PIN input, redirect to URL). The backend holds your Paystack secret key and makes all API calls.

Paystack API

Processes the charge, communicates with banks and card networks, returns step-by-step instructions, and sends webhooks when the charge completes.

// The flow in pseudocode:
// 1. Customer fills in card details on your form
// 2. Frontend POSTs details to your backend: POST /api/pay
// 3. Backend calls Paystack: POST https://api.paystack.co/charge
// 4. Paystack returns status: "send_pin"
// 5. Backend tells frontend: "show PIN input"
// 6. Customer enters PIN, frontend POSTs to backend: POST /api/pay/pin
// 7. Backend calls Paystack: POST https://api.paystack.co/charge/submit_pin
// 8. Paystack returns status: "send_otp"
// 9. Backend tells frontend: "show OTP input"
// 10. Customer enters OTP, frontend POSTs to backend: POST /api/pay/otp
// 11. Backend calls Paystack: POST https://api.paystack.co/charge/submit_otp
// 12. Paystack returns status: "success"
// 13. Backend verifies transaction, grants value

Each step is a separate HTTP request between your frontend and backend, and a separate call from your backend to Paystack. The reference ties them all together.

Get weekly developer tips

Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.

No spam. Unsubscribe anytime.

Building the Payment Form

Your payment form needs to handle card input securely and support multiple steps. Here is a minimal HTML form structure:

<form id="payment-form">
  <!-- Step 1: Card details -->
  <div id="step-card">
    <input type="text" name="card_number" placeholder="Card Number" autocomplete="cc-number" />
    <input type="text" name="expiry" placeholder="MM/YY" autocomplete="cc-exp" />
    <input type="text" name="cvv" placeholder="CVV" autocomplete="cc-csc" />
    <button type="submit">Pay NGN 5,000</button>
  </div>

  <!-- Step 2: PIN input (hidden initially) -->
  <div id="step-pin" style="display:none">
    <input type="password" name="pin" placeholder="Enter your card PIN" />
    <button type="button" id="submit-pin">Submit PIN</button>
  </div>

  <!-- Step 3: OTP input (hidden initially) -->
  <div id="step-otp" style="display:none">
    <p>An OTP has been sent to your phone</p>
    <input type="text" name="otp" placeholder="Enter OTP" />
    <button type="button" id="submit-otp">Submit OTP</button>
  </div>
</form>

Key rules for the form:

  • Serve over HTTPS only. No exceptions. Card data must never travel over an unencrypted connection.
  • Never log card details. Do not send card data to your error tracker, analytics tool, or console.log. Scrub it from all logging.
  • Clear card fields after submission. Once the card details are sent to your backend, clear them from the DOM. Do not keep them in memory longer than necessary.
  • Format as you go. Add spaces in the card number every 4 digits. Auto-format MM/YY. Limit CVV to 3 or 4 characters. These small touches reduce input errors.

Server-Side Charge Flow in Node.js

Your backend needs three endpoints: one for the initial charge, one for PIN submission, and one for OTP submission. Here is a complete Express.js implementation:

const express = require('express');
const app = express();
app.use(express.json());

const PAYSTACK_SECRET = process.env.PAYSTACK_SECRET_KEY;
const headers = {
  Authorization: 'Bearer ' + PAYSTACK_SECRET,
  'Content-Type': 'application/json',
};

// Store references in memory (use a database in production)
const pendingCharges = new Map();

app.post('/api/pay', async (req, res) => {
  const { email, amount, card_number, expiry_month, expiry_year, cvv } = req.body;

  const response = await fetch('https://api.paystack.co/charge', {
    method: 'POST',
    headers,
    body: JSON.stringify({
      email,
      amount: amount * 100, // Convert to kobo
      card: {
        number: card_number,
        cvv,
        expiry_month,
        expiry_year,
      },
    }),
  });

  const data = await response.json();
  pendingCharges.set(data.data.reference, { email, amount });

  res.json({
    status: data.data.status,
    reference: data.data.reference,
    message: data.data.display_text || 'Processing',
  });
});

app.post('/api/pay/pin', async (req, res) => {
  const { reference, pin } = req.body;

  const response = await fetch('https://api.paystack.co/charge/submit_pin', {
    method: 'POST',
    headers,
    body: JSON.stringify({ pin, reference }),
  });

  const data = await response.json();
  res.json({
    status: data.data.status,
    reference: data.data.reference,
    message: data.data.display_text || 'Processing',
  });
});

app.post('/api/pay/otp', async (req, res) => {
  const { reference, otp } = req.body;

  const response = await fetch('https://api.paystack.co/charge/submit_otp', {
    method: 'POST',
    headers,
    body: JSON.stringify({ otp, reference }),
  });

  const data = await response.json();

  if (data.data.status === 'success') {
    // Verify the transaction
    const verify = await fetch(
      'https://api.paystack.co/transaction/verify/' + reference,
      { headers }
    );
    const verified = await verify.json();
    const charge = pendingCharges.get(reference);

    if (
      verified.data.status === 'success' &&
      verified.data.amount === charge.amount * 100
    ) {
      // Grant value here
      pendingCharges.delete(reference);
    }
  }

  res.json({
    status: data.data.status,
    reference: data.data.reference,
    message: data.data.display_text || 'Processing',
  });
});

This is a simplified version. In production, add input validation, rate limiting, request timeouts, and proper error responses. Store pending charges in a database, not in memory.

Handling 3DS Redirects and Bank Pages

When the Charge API returns open_url, the customer must visit a URL hosted by their bank to complete 3D Secure verification. Your custom checkout needs to handle this transition smoothly.

Options for handling the redirect:

  • Full redirect: Send the customer to the 3DS URL. After verification, the bank redirects them to a URL Paystack controls, which then redirects to your callback URL. This is the simplest approach.
  • Iframe embed: Load the 3DS URL in an iframe on your checkout page. This keeps the customer on your page but may not work with all banks. Some banks set X-Frame-Options headers that prevent iframe embedding.
  • New tab: Open the 3DS URL in a new browser tab. The customer completes verification and returns to your original tab. This can confuse customers who do not notice the new tab.

The full redirect is the most reliable approach. After the redirect, you receive a webhook, and the customer lands on your callback URL with the reference:

// Callback route after 3DS redirect
app.get('/payment/callback', async (req, res) => {
  const reference = req.query.reference;

  const verify = await fetch(
    'https://api.paystack.co/transaction/verify/' + reference,
    { headers }
  );
  const data = await verify.json();

  if (data.data.status === 'success') {
    // Show success page
    res.redirect('/order/success?ref=' + reference);
  } else {
    // Show failure page with retry option
    res.redirect('/order/failed?ref=' + reference);
  }
});

Always verify the transaction in your callback handler. The redirect URL alone is not proof of payment.

Testing Every Charge Path

Paystack provides test card numbers that trigger specific charge flows. Use these to verify your custom checkout handles every scenario.

In test mode, use these card patterns:

  • Successful charge (no auth): Card numbers that resolve to success immediately.
  • PIN required: Card numbers that return send_pin status. Submit any 4-digit PIN in test mode.
  • OTP required: Card numbers that return send_otp status after PIN or directly. Submit any 6-digit OTP in test mode.
  • 3DS redirect: Card numbers that return open_url status. Follow the URL to a test 3DS page.
  • Failed charge: Card numbers that decline with specific gateway responses.

Check the Paystack documentation for the current test card numbers. They provide different cards for Visa, Mastercard, and Verve, each triggering different flows.

Your test checklist should cover:

  1. Successful card payment through PIN and OTP
  2. Successful card payment through 3DS redirect
  3. Failed payment (insufficient funds)
  4. Customer abandoning the flow after PIN submission but before OTP
  5. Bank transfer charge with successful deposit
  6. Timeout scenarios (what happens if the customer never submits OTP)

Run through every path in test mode. Then run through them again with real cards on a live integration before launching to customers. Edge cases that seem unlikely in testing happen daily in production.

Production Hardening Checklist

Before launching your custom checkout, verify these items:

  • HTTPS everywhere. Your form page, your API endpoints, and your callback URL must all use HTTPS. No exceptions.
  • No card data in logs. Audit every log statement, error reporter, and analytics call. Card numbers, CVVs, and PINs must never appear in logs.
  • Rate limiting. Limit charge attempts per IP and per email address to prevent abuse and card testing attacks.
  • Idempotent references. Generate a unique reference for each charge attempt. If the customer retries, generate a new reference. Do not reuse references.
  • Timeout handling. Set a maximum wait time for each step. If the customer does not submit OTP within 10 minutes, show a timeout message and let them retry.
  • Webhook handler. Even with a custom checkout, you need a webhook endpoint. The webhook is your backup notification channel when the redirect or polling fails.
  • Verify every successful charge. Call the Verify endpoint after every charge that resolves to success. Check the amount, currency, and status match your expectations before granting value.

For the complete guide on accepting payments with Paystack, including webhook setup and verification patterns, see the accept payments complete guide.

Stay Up to Date

Paystack updates the Charge API as they add new payment channels and tighten security requirements. We keep these guides current when things change.

Join the McTaba newsletter to get notified when we publish new Paystack engineering guides.

Sign up for the McTaba newsletter

Key Takeaways

  • ✓A custom checkout means you design the payment form, collect payment details, and manage the charge flow yourself. You control every pixel but take on engineering and compliance responsibilities that the hosted checkout handles for you.
  • ✓Your frontend collects card or bank details and sends them to your backend over HTTPS. Your backend calls the Paystack Charge API. Never call the Charge API from the frontend because your secret key would be exposed.
  • ✓The charge flow is stateful. After the initial charge, you may need to collect a PIN, then an OTP, then handle a 3DS redirect. Build your UI to support multiple steps in a single payment session.
  • ✓For card payments, your server handles raw card data, which puts you in PCI DSS scope. For bank transfers and USSD, no card data is involved, so compliance is simpler.
  • ✓Always verify the transaction with the Verify endpoint after the charge flow completes. The charge response tells you the next step; the verify response tells you the final result.
  • ✓Test every charge path in Paystack test mode before going live. Different card numbers trigger different flows (PIN, OTP, 3DS, failure).

Frequently Asked Questions

Is a custom checkout on the Paystack Charge API harder to build than using the popup?
Yes, significantly. The popup integration takes about 20 lines of code. A custom checkout requires building the payment form, three or more backend endpoints, a multi-step UI flow, PCI compliance (for card payments), and thorough testing of every charge path. Plan for at least a week of engineering work compared to an afternoon for the popup.
Can I build a custom checkout that only handles bank transfers (no cards)?
Yes, and this is a simpler option. Bank transfer charges through the Charge API do not involve card data, so you skip PCI compliance concerns. You call the charge endpoint with bank_transfer parameters, display the generated account number to the customer, and wait for the webhook. No PIN, OTP, or 3DS steps.
What happens if my server goes down during a Charge API flow?
The charge may still complete on the Paystack side if the customer had already authorized it (entered PIN and OTP). Paystack sends a webhook when the charge completes. When your server comes back up, process the webhook and verify the transaction. The customer may see an error on your page, but the payment may have succeeded.
Can I use the Paystack Charge API with React or Vue?
Your frontend framework does not matter. React, Vue, Angular, Svelte, or plain HTML can all collect payment details and send them to your backend. The Charge API calls happen on your backend (Node.js, Python, PHP), not in the browser. Your frontend is just a form that posts data to your server.
How do I handle customers who start a charge but never finish?
Set a timeout on your frontend. If the customer does not submit OTP within a set time (5 to 10 minutes), show a timeout message and offer to restart. On the backend, charges that are never completed stay in a pending or abandoned state on Paystack. They do not charge the customer and do not need cleanup on your end.

Learn Paystack Integration

KES 2,999

The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.

Start Paystack Course

Not ready? Create Free Account

Keep reading

Accepting Payments with Paystack: Complete Guide

Everything you need to accept your first payment with Paystack: choosing between Initialize Transaction and the Charge API, handling amounts in kobo, attaching metadata, setting callback URLs, and designing transaction references that keep your system clean.

Read more

Paystack Metadata: Passing Custom Data Through a Transaction

Paystack metadata lets you attach arbitrary JSON data to a transaction that flows through the entire lifecycle: from initialization to verification to webhooks. This guide shows you what to put in metadata, how to structure it for different use cases, and the limits you need to stay within.

Read more

Paystack Product API for Simple Storefronts

The Paystack Product API lets you create products with names, prices, and descriptions, then generate payment links for each one. For simple storefronts selling a handful of products, this is faster than building a full e-commerce backend. This guide covers creating products, linking them to payment pages, and knowing when to outgrow this approach.

Read more

Reconciling Dedicated Virtual Account Deposits

Dedicated Virtual Accounts (DVAs) let each customer have a permanent bank account number linked to your Paystack integration. When a customer transfers money to their DVA, Paystack notifies you. But matching those deposits to the right customer, handling partial payments, and reconciling against your records requires careful engineering. This guide walks through the reconciliation workflow.

Read more

Paystack Webhooks on AWS Lambda and API Gateway

A complete guide to handling Paystack webhooks on AWS Lambda behind API Gateway. Covers the critical binary media type configuration for raw body passthrough, Lambda handler structure, HMAC-SHA512 signature verification, and production patterns for reliable payment event processing.

Read more

Explore related topics

M-Pesa & African Stack

M-Pesa Daraja API tutorials, USSD development, and WhatsApp Business API guides. The African Stack that global bootcamps do not teach.

Learn to Code in Kenya

Everything a Kenyan needs to know before writing their first line of code. Career paths, real costs, hardware requirements, and honest answers about what it takes.

Learning to Code

Roadmaps, language guides, and practical advice for learning to code and launching a programming career.

Breaking Into Tech

Practical, no-nonsense guides for anyone looking to start or change their career in tech. From choosing your first language to acing interviews.

The African Stack: Skills for This Market

The skills that make Kenyan developers hard to replace: M-Pesa, USSD, WhatsApp, fintech, and building for the African market.

Bonaventure Ogeto

Bonaventure Ogeto

Founder, McTaba Labs

Bonaventure is the founder of McTaba Labs, a coding bootcamp in Nairobi, Kenya. He has spent years building products for the African market and mentoring the next generation of developers across the continent.