Bonaventure OgetoBy Bonaventure Ogeto|

Paystack OTP and PIN Flows in Custom Checkout

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.

When Does Paystack Ask for PIN vs OTP?

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.

Collecting and Submitting the PIN

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:

  • Use type="password" so the digits are masked.
  • Limit to 4 characters.
  • Do not autocomplete. Set autocomplete="off" on the PIN field.
  • Clear the field from memory after submission. Set the input value to an empty string.
  • Never log the PIN value. Not in console.log, not in error tracking, not anywhere.

After PIN submission, the most common next status is send_otp. Transition your UI to the OTP step immediately.

Collecting and Submitting the OTP

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.

Building a Step-Based UI

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.

Handling OTP Expiry and Timeouts

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:

  • Show a countdown timer. Start a 5-minute countdown when the OTP step appears. When it reaches zero, show a message like "OTP expired. Please try again." and reset the checkout flow.
  • Show a static message. "Enter the OTP within 5 minutes. If it expires, you can restart the payment." Less precise but simpler to implement.
  • Handle the expired OTP error. If the customer submits an expired OTP, Paystack returns an error. Catch this and show a clear message: "That OTP has expired. Would you like to try again?"
// 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."

The Phone Number Step

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.

Security Rules for PIN and OTP Handling

PINs and OTPs are sensitive credentials. Even though they pass through your server briefly, you must handle them with care.

  • Never store PINs or OTPs. Not in your database, not in Redis, not in session storage. They are single-use values that your server forwards to Paystack and forgets.
  • Never log PINs or OTPs. Audit your logging middleware. If you log request bodies, exclude the /api/pay/pin and /api/pay/otp routes or redact the sensitive fields.
  • Use HTTPS for every request. Between the customer's browser and your server, and between your server and Paystack. No plaintext connections anywhere in the chain.
  • Rate limit OTP and PIN submission. If someone is brute-forcing OTPs (trying all 6-digit combinations), block them after 3 to 5 failed attempts per reference.
  • Clear sensitive fields on the frontend. After the customer submits their PIN or OTP, clear the input field value in the DOM. Do not leave sensitive data sitting in form fields.

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.

Stay Up to Date

Bank OTP and PIN requirements change as banks update their security policies. We keep these guides current when Paystack updates the Charge API flow.

Join the McTaba newsletter for notifications when we publish new Paystack engineering guides.

Sign up for the McTaba newsletter

Key Takeaways

  • PIN entry is required primarily for Verve cards. The Charge API returns send_pin status when the issuing bank requires the card PIN before proceeding. Visa and Mastercard cards typically skip PIN and go straight to OTP or 3DS.
  • OTP is sent by the customer's bank via SMS. Your application does not generate the OTP. You show an input field, the customer enters the code from their phone, and you submit it to Paystack.
  • The PIN and OTP are submitted to different endpoints: /charge/submit_pin for PIN and /charge/submit_otp for OTP. Both require the transaction reference from the initial charge response.
  • Some cards require PIN then OTP (two steps). Some require only OTP. Some require a 3DS redirect instead of OTP. Your checkout UI must handle all three paths.
  • OTPs expire. Most Nigerian bank OTPs expire within 5 to 10 minutes. If the customer takes too long, the OTP becomes invalid and the charge fails. Show a countdown or a clear message about the time limit.
  • Never store PINs or OTPs in your database or logs. These are single-use credentials that should pass through your server and be discarded immediately.
  • Always verify the transaction after the OTP step completes. A successful OTP submission does not guarantee the charge succeeded. Verify with the /transaction/verify endpoint.

Frequently Asked Questions

Why do some cards require PIN and OTP while others only require OTP?
It depends on the card type and issuing bank. Verve cards (Nigerian domestic cards) typically require PIN then OTP because PIN is part of the Verve authentication protocol. Visa and Mastercard cards issued by Nigerian banks usually skip PIN and go straight to OTP or 3D Secure verification. International cards often use 3DS redirect instead of OTP. The Charge API response tells you which step is needed for each specific card.
What happens if the customer enters the wrong PIN?
Paystack forwards the PIN to the issuing bank. If the PIN is wrong, the bank declines the authorization and Paystack returns a failed status with a gateway response like "Incorrect PIN." The customer can retry with the correct PIN, but you need to start a new charge (new reference). Most banks lock the card after 3 incorrect PIN attempts.
Can I request a new OTP if the customer did not receive one?
The Paystack Charge API does not have a "resend OTP" endpoint. The OTP is sent by the customer's bank, not by Paystack. If the customer did not receive the OTP, they need to start a new charge. Show a "Try again" button that restarts the checkout flow with a new reference.
How long does a Paystack OTP stay valid?
OTP validity depends on the issuing bank, not Paystack. Most Nigerian bank OTPs expire within 5 to 10 minutes. Some banks use shorter windows (2 to 3 minutes). Since you cannot know the exact expiry time, show a conservative countdown (5 minutes) and let the customer retry if the OTP expires.
Do authorization code charges (saved cards) require PIN or OTP?
No. When you charge a saved authorization code using /transaction/charge_authorization, the charge happens server-to-server without any customer interaction. No PIN, no OTP, no 3DS redirect. The customer already authorized recurring charges when they first saved their card. This is why saved-card charges are simpler than initial card charges.

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