In a Paystack Charge API custom checkout, the PIN step appears primarily for Verve cards (Nigerian debit cards). The OTP step appears for most card types after PIN submission or directly for Visa/Mastercard. You submit the PIN to /charge/submit_pin and the OTP to /charge/submit_otp, each time passing the transaction reference. The flow ends when Paystack returns a success or failed status.
The step that follows your initial charge request depends on the card type and the issuing bank's policy.
Verve cards (Nigerian domestic cards): Almost always require PIN first, then OTP. The Charge API returns send_pin after the initial charge. After you submit the PIN, it returns send_otp. After OTP, the charge resolves.
Visa and Mastercard (Nigerian banks): Typically require OTP directly (no PIN step). The Charge API returns send_otp after the initial charge. The bank sends an SMS to the customer, and you submit the OTP.
International Visa and Mastercard: Often require 3D Secure verification instead of OTP. The Charge API returns open_url with a URL where the customer completes verification on their bank's page.
Some bank cards: Require phone number verification before OTP. The Charge API returns send_phone, you submit the customer's phone number, and then the bank sends an OTP to that number.
You cannot predict in advance which flow a card will take. Your custom checkout must handle all of these statuses and present the right UI for each one. Build the UI as a state machine, not a fixed sequence.
When the Charge API returns send_pin, your frontend should show a PIN input field. The PIN is the customer's 4-digit card PIN. They know it. Your job is to collect it securely and send it to Paystack.
// Backend endpoint for PIN submission
app.post('/api/pay/pin', async (req, res) => {
const { reference, pin } = req.body;
// Validate input
if (!reference || !pin || pin.length !== 4) {
return res.status(400).json({ error: 'Valid 4-digit PIN required' });
}
const response = await fetch('https://api.paystack.co/charge/submit_pin', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ pin, reference }),
});
const data = await response.json();
// After PIN, status is usually "send_otp" or "success" or "failed"
res.json({
status: data.data.status,
reference: data.data.reference,
message: data.data.display_text || '',
});
});
Frontend rules for the PIN input:
type="password" so the digits are masked.autocomplete="off" on the PIN field.After PIN submission, the most common next status is send_otp. Transition your UI to the OTP step immediately.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
When the Charge API returns send_otp, the customer's bank has sent an OTP to their registered phone number. Your job is to show an input field and submit the OTP to Paystack.
// Backend endpoint for OTP submission
app.post('/api/pay/otp', async (req, res) => {
const { reference, otp } = req.body;
if (!reference || !otp) {
return res.status(400).json({ error: 'OTP is required' });
}
const response = await fetch('https://api.paystack.co/charge/submit_otp', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ otp, reference }),
});
const data = await response.json();
if (data.data.status === 'success') {
// Verify the transaction before granting value
const verify = await fetch(
'https://api.paystack.co/transaction/verify/' + reference,
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
}
);
const verified = await verify.json();
return res.json({
status: verified.data.status,
reference: verified.data.reference,
verified: true,
});
}
res.json({
status: data.data.status,
reference: data.data.reference,
message: data.data.display_text || '',
});
});
The OTP is typically 6 digits, but some banks send 4-digit or 8-digit OTPs. Do not enforce a strict digit count on the frontend. Let the customer type whatever their bank sent.
Show the display_text from the Charge API response to the customer. It often contains the bank's message, like "Please enter the OTP sent to 080****5678." This confirms to the customer that the right phone number received the code.
Your custom checkout UI should work as a state machine with clear transitions between steps. Here is the pattern in frontend JavaScript:
// Frontend state machine for the charge flow
let currentStep = 'card'; // card | pin | otp | redirect | processing | success | failed
let transactionRef = null;
async function handleCardSubmit(cardDetails) {
currentStep = 'processing';
renderStep();
const response = await fetch('/api/pay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cardDetails),
});
const data = await response.json();
transactionRef = data.reference;
handleChargeStatus(data.status, data.message);
}
function handleChargeStatus(status, message) {
if (status === 'send_pin') {
currentStep = 'pin';
} else if (status === 'send_otp') {
currentStep = 'otp';
// Show the bank message if available
document.getElementById('otp-message').textContent = message || 'Enter the OTP sent to your phone';
} else if (status === 'open_url') {
currentStep = 'redirect';
// Redirect customer to bank 3DS page
} else if (status === 'success') {
currentStep = 'success';
} else {
currentStep = 'failed';
}
renderStep();
}
function renderStep() {
// Hide all step containers
document.querySelectorAll('.step').forEach(function(el) { el.style.display = 'none'; });
// Show the current step
document.getElementById('step-' + currentStep).style.display = 'block';
}
Each step shows only the relevant input. The customer sees one thing at a time: card form, PIN input, OTP input, or a success/failure message. Transitions are immediate after each server response.
Add a "back" or "cancel" button on the PIN and OTP steps. If the customer entered the wrong card and realizes it during the PIN step, they should be able to start over. On cancel, generate a new reference and show the card form again.
OTPs from Nigerian banks typically expire within 5 to 10 minutes. If the customer does not enter the OTP in time, the charge fails. Your custom checkout should communicate this deadline clearly.
Options for handling the OTP timeout:
// Start a timeout when entering OTP step
let otpTimeout = null;
function startOtpTimer(durationSeconds) {
const display = document.getElementById('otp-timer');
let remaining = durationSeconds;
otpTimeout = setInterval(function() {
remaining--;
const minutes = Math.floor(remaining / 60);
const seconds = remaining % 60;
display.textContent = minutes + ':' + (seconds < 10 ? '0' : '') + seconds;
if (remaining <= 0) {
clearInterval(otpTimeout);
display.textContent = 'OTP expired';
// Show retry button
document.getElementById('retry-button').style.display = 'block';
}
}, 1000);
}
// Call when entering OTP step
startOtpTimer(300); // 5 minutes
When the customer retries after an OTP timeout, you need to start a new charge from scratch. The previous charge reference is no longer usable. Generate a new reference, collect card details again (or use a stored authorization code), and begin the flow over.
Some customers complain that they never received the OTP. This is a bank-side issue, not something you can fix. Show a message like "If you did not receive an OTP, check your SMS inbox or contact your bank. You can try paying again with a different card."
Some bank cards return send_phone instead of send_otp. This happens when the bank needs to confirm the customer's phone number before sending the OTP. The customer enters their phone number, and the bank sends the OTP to that number.
// Backend endpoint for phone submission
app.post('/api/pay/phone', async (req, res) => {
const { reference, phone } = req.body;
const response = await fetch('https://api.paystack.co/charge/submit_phone', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ phone, reference }),
});
const data = await response.json();
// After phone submission, status is usually "send_otp"
res.json({
status: data.data.status,
reference: data.data.reference,
message: data.data.display_text || '',
});
});
Add a phone number step to your UI state machine. When the status is send_phone, show an input field asking for the customer's phone number (the one registered with their bank). After submission, the flow typically moves to send_otp.
This step is less common than PIN and OTP, but ignoring it means some customers will hit a dead end in your checkout. Handle all statuses, even the rare ones.
PINs and OTPs are sensitive credentials. Even though they pass through your server briefly, you must handle them with care.
Your server is a pass-through for these credentials. The less you do with them, the safer your integration. Collect, forward, forget.
For the broader picture of custom checkout architecture, see Charge API for custom checkout experiences. For the full accept payments workflow, see the complete accept payments 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