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.
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:
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.
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.
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:
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.
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:
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.
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:
success immediately.send_pin status. Submit any 4-digit PIN in test mode.send_otp status after PIN or directly. Submit any 6-digit OTP in test mode.open_url status. Follow the URL to a test 3DS page.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:
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.
Before launching your custom checkout, verify these items:
For the complete guide on accepting payments with Paystack, including webhook setup and verification patterns, see the accept payments complete guide.
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