Bonaventure OgetoBy Bonaventure Ogeto|

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.

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.

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.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon