Bonaventure OgetoBy Bonaventure Ogeto|

Paystack Charge API for Custom Checkout Experiences

The Paystack Charge API sends payment details directly from your server to Paystack, bypassing the hosted checkout page. You collect card numbers, bank accounts, or USSD details on your own form, send them to the /charge endpoint, and handle the multi-step response flow (pending, send_otp, send_pin, open_url) until the charge resolves to success or failure.

Why Use the Charge API Instead of Initialize Transaction

Paystack gives you two paths to accept payments. Initialize Transaction creates a checkout session and hands you a URL. The customer goes to that URL (or you open it in a popup), and Paystack handles the payment form, card tokenization, OTP screens, and bank redirects. You write minimal code.

The Charge API is the other path. You build the entire payment form yourself. You collect the card number, expiry, CVV, or bank details on your own page, then send those details to Paystack from your server. Paystack processes the charge and returns instructions for the next step (collect OTP, collect PIN, redirect to bank page).

When does this make sense?

  • Brand consistency: You want the payment form to match your application exactly. No popup, no redirect, no Paystack branding.
  • Embedded flows: You are building a POS system, a kiosk, or a mobile app where a web popup is not practical.
  • Charge on file: You already have authorization codes from previous payments and want to charge stored cards without customer interaction.
  • Non-card channels: You want to initiate bank transfer or USSD charges server-side with full control over the user experience.

If none of those apply, stick with Initialize Transaction. The hosted checkout is maintained by Paystack, handles edge cases you have not thought of, and keeps card data off your servers entirely.

The Charge Endpoint: Request and Response Structure

Every Charge API flow starts with a POST to https://api.paystack.co/charge. The request body varies depending on the payment channel, but the response structure is consistent.

const chargeResponse = await fetch('https://api.paystack.co/charge', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    email: 'customer@example.com',
    amount: 500000, // 5,000 NGN in kobo
    // ...channel-specific fields
  }),
});

const result = await chargeResponse.json();
// result.data.status could be:
// "success"       - charge completed immediately
// "send_otp"      - customer needs to provide OTP
// "send_pin"      - customer needs to provide PIN
// "open_url"      - redirect customer to a URL for 3DS
// "pay_offline"   - customer dials a USSD code or makes a transfer
// "pending"       - charge is processing, check back later

The status field in the response tells you what to do next. This is the core of the Charge API: it is a conversation, not a single request-response. You send the initial charge, read the status, take the next action, and repeat until the status is success or failed.

Every response includes a reference field. Store this reference. You need it for every subsequent call in the flow and for final verification.

Charging a Card: The Full Multi-Step Flow

A card charge through the Charge API requires the card number, expiry month, expiry year, and CVV. Here is the initial request:

const response = await fetch('https://api.paystack.co/charge', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    email: 'customer@example.com',
    amount: 250000, // 2,500 NGN
    card: {
      number: '4084084084084081',
      cvv: '408',
      expiry_month: '12',
      expiry_year: '2028',
    },
    metadata: {
      order_id: 'order_778',
    },
  }),
});

const data = await response.json();
console.log(data.data.status);
// Likely "send_pin" or "send_otp" for Nigerian cards

After the initial charge, you typically get send_pin for Verve cards or send_otp for Visa/Mastercard cards. Here is how to handle each:

// If status is "send_pin"
const pinResponse = 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: '1234', // Customer's card PIN
    reference: data.data.reference,
  }),
});

const pinResult = await pinResponse.json();
// pinResult.data.status might now be "send_otp"
// If status is "send_otp" (or after PIN submission)
const otpResponse = 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: '123456', // OTP sent to customer's phone
    reference: data.data.reference,
  }),
});

const otpResult = await otpResponse.json();
// otpResult.data.status should now be "success" or "failed"

Some cards return open_url instead of send_otp. This means the bank requires 3D Secure verification on their own page. You redirect the customer to the URL in data.data.url, and they complete verification there. After verification, Paystack sends a webhook and the customer is redirected to your callback URL.

The key pattern: read the status, take the matching action, repeat. A single card charge can involve two to four round trips before it resolves.

Bank Transfer and USSD Charges

Non-card channels through the Charge API skip card data entirely. This simplifies compliance and works well for customers who prefer bank transfers or USSD.

Bank transfer charge:

const bankResponse = await fetch('https://api.paystack.co/charge', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    email: 'customer@example.com',
    amount: 1000000, // 10,000 NGN
    bank_transfer: {
      account_expires_at: '2026-07-23T00:00:00.000Z',
    },
  }),
});

const bankData = await bankResponse.json();
// bankData.data.status = "pay_offline"
// bankData.data.data.account_number = "1234567890"
// bankData.data.data.bank.name = "Wema Bank"
// Display the account number and bank name to the customer

After the customer transfers money to the generated account, Paystack matches the deposit and sends a charge.success webhook. You do not need to poll. The webhook is your notification that the transfer landed.

USSD charge:

const ussdResponse = await fetch('https://api.paystack.co/charge', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    email: 'customer@example.com',
    amount: 500000, // 5,000 NGN
    ussd: {
      type: '737', // GTBank USSD code
    },
  }),
});

const ussdData = await ussdResponse.json();
// ussdData.data.status = "pay_offline"
// ussdData.data.data.ussd_code = "*737*2*amount*ref#"
// Display the USSD code to the customer

For both bank transfer and USSD, the pay_offline status means the customer needs to complete the payment outside your application. Display the instructions (account number or USSD code), and wait for the webhook. Set a timeout on your UI so customers know how long the payment window stays open.

Charging Saved Cards with Authorization Codes

Once a customer has paid successfully and the authorization is reusable, you can charge their card again without collecting card details. This is the most common production use of the Charge API.

const recurringCharge = await fetch('https://api.paystack.co/transaction/charge_authorization', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    authorization_code: 'AUTH_abc123def456',
    email: 'customer@example.com',
    amount: 500000,
    reference: 'renewal_' + Date.now(),
  }),
});

const result = await recurringCharge.json();
if (result.data.status === 'success') {
  // Charge went through. Verify to confirm.
}

Authorization code charges are server-to-server. No UI, no OTP, no PIN. The bank has already authorized recurring charges for this card. This is how subscription renewals, wallet auto-debits, and saved-card one-click payments work.

Two things to watch for:

  • Check reusable first. When you store an authorization code from a previous payment, only store it if authorization.reusable === true. Some cards and some banks do not support recurring charges.
  • Handle failures gracefully. Authorization codes stop working when the card expires, the customer blocks the card, or the bank revokes recurring permission. When a charge_authorization call fails, do not retry indefinitely. Notify the customer and ask them to pay with a new card.

Note that charge_authorization uses the /transaction/charge_authorization endpoint, not the /charge endpoint. The naming is confusing, but they are different endpoints with different request bodies.

PCI Compliance When You Handle Card Data

The moment your server receives a card number, you enter PCI DSS scope. This is the biggest trade-off of the Charge API for card payments.

With Initialize Transaction or Paystack Inline JS, card data goes directly from the customer's browser to Paystack over HTTPS. Your server never touches it. You qualify for SAQ A, the simplest PCI compliance level.

With the Charge API, your server receives the card number, CVV, and expiry from your frontend, then forwards them to Paystack. Even though you do not store the card data, the fact that it passes through your server means you need SAQ D or an equivalent assessment. This involves:

  • Encryption in transit (HTTPS everywhere, including between your frontend and backend)
  • Never logging card data in application logs, error trackers, or analytics
  • Never storing card data in your database, even temporarily
  • Network segmentation and access controls on the servers that handle card data
  • Regular vulnerability scans and security assessments

If you only use the Charge API for bank transfers, USSD, or authorization code charges (saved cards), PCI scope is much smaller because no raw card data touches your server.

For most startups and small businesses, the compliance burden of handling card data through the Charge API is not worth the design flexibility. Use the hosted checkout for card payments and reserve the Charge API for non-card channels and saved-card charges.

Handling Errors and Timeouts in the Charge Flow

The multi-step nature of the Charge API means errors can happen at any step. Here is how to handle each scenario.

async function handleChargeFlow(initialResponse) {
  let current = initialResponse.data;
  const reference = current.reference;
  const maxAttempts = 10;
  let attempts = 0;

  while (current.status !== 'success' && current.status !== 'failed' && attempts < maxAttempts) {
    attempts++;

    if (current.status === 'send_pin') {
      const pin = await promptCustomerForPin();
      const res = await submitPin(reference, pin);
      current = res.data;
    } else if (current.status === 'send_otp') {
      const otp = await promptCustomerForOtp();
      const res = await submitOtp(reference, otp);
      current = res.data;
    } else if (current.status === 'open_url') {
      // Redirect customer to current.url
      // Wait for webhook or callback
      return { status: 'redirected', url: current.url, reference };
    } else if (current.status === 'pay_offline') {
      // Display payment instructions to customer
      return { status: 'awaiting_payment', data: current, reference };
    } else if (current.status === 'pending') {
      // Wait and check again
      await new Promise(resolve => setTimeout(resolve, 5000));
      const check = await verifyTransaction(reference);
      current = check.data;
    } else {
      // Unknown status, break and verify
      break;
    }
  }

  // Always verify final state
  const verification = await verifyTransaction(reference);
  return verification.data;
}

Key principles for the charge flow loop:

  • Set a maximum attempt count. Never loop forever. If the flow has not resolved after 10 steps, something is wrong. Verify the transaction and show the customer an appropriate message.
  • Handle "pending" with a delay. When the status is pending, the charge is still processing. Wait a few seconds and verify the transaction. Do not flood Paystack with verify calls.
  • Always verify at the end. Regardless of how the loop exits, call the Verify endpoint to get the definitive transaction status. The charge flow response is not your source of truth.

Charge API vs Initialize Transaction: Decision Framework

Here is a straightforward decision framework:

Use Initialize Transaction when:

  • You want the fastest integration path
  • The popup or redirect checkout works for your UX
  • You do not want to handle PCI compliance for card payments
  • You are building a web application where Paystack Inline JS can run

Use the Charge API when:

  • You need full control over the payment UI (kiosks, POS, custom mobile flows)
  • You are charging saved cards using authorization codes
  • You want to initiate bank transfer or USSD charges server-side
  • The hosted checkout does not work for your platform (terminal devices, background jobs)

Use both when:

  • First-time payments go through Initialize Transaction (customer enters card on Paystack's checkout, you avoid PCI scope)
  • Recurring charges go through the Charge API using the saved authorization code (no UI needed)

This hybrid approach is the most common production pattern. You get the security and simplicity of hosted checkout for initial payments, and the flexibility of server-to-server charges for renewals and saved-card payments.

For a detailed comparison of the two endpoints, see Initialize Transaction vs Charge API. For the full accept payments workflow, see the complete accept payments guide.

Stay Up to Date

The Charge API evolves as Paystack adds new payment channels and updates security requirements. We keep these guides current when the API changes.

Join the McTaba newsletter to get notified when we publish new Paystack engineering guides or spot breaking changes.

Sign up for the McTaba newsletter

Key Takeaways

  • The Charge API lets you own the entire checkout UI. You collect payment details on your form and submit them to Paystack server-to-server. This gives you full design control but adds PCI compliance responsibility for card payments.
  • Every Charge API call starts a multi-step conversation. The first response may ask for OTP, PIN, phone number, or a URL redirect. You keep calling /charge/submit_pin, /charge/submit_otp, or /charge/submit_phone until Paystack returns a final status.
  • Card charges require you to send the card number, expiry, and CVV in the request body. This means your server handles raw card data, which triggers PCI DSS compliance requirements.
  • Bank and USSD charges through the Charge API do not involve card data, so PCI scope is smaller. Bank charges generate a transfer destination; USSD charges generate a dial code.
  • Always verify the final transaction status with the Verify endpoint after the charge flow completes. The Charge API response alone is not your source of truth.
  • The Charge API is the right choice when the hosted checkout page does not fit your UX. If the popup or redirect works for your product, use Initialize Transaction instead and skip the compliance burden.

Frequently Asked Questions

Do I need PCI compliance to use the Paystack Charge API?
Only if you handle raw card data. If you use the Charge API to send card numbers, expiry dates, and CVVs from your server to Paystack, your server is in PCI scope. If you only use the Charge API for bank transfers, USSD, or authorization code charges (saved cards), PCI scope does not apply because no card data touches your server.
How many steps does a Charge API card payment take?
Typically two to four steps. You send the initial charge, then handle PIN submission (for Verve cards), OTP submission, or 3DS redirect. Some cards resolve in two steps (charge then OTP). Others require three (charge, PIN, then OTP). International cards may require a 3DS redirect instead of OTP.
Can I use the Charge API for mobile money payments?
Yes. For mobile money (available in Ghana), you send the charge request with the mobile money provider details. The customer receives an authorization prompt on their phone. Paystack sends a webhook when the charge completes. The flow is similar to USSD: you initiate the charge and wait for confirmation.
What happens if the customer closes the browser during a Charge API flow?
The transaction stays in a pending or abandoned state. If the customer completed the bank-side authorization (entered PIN and OTP) before closing, the charge may still succeed and you will receive a webhook. If they closed before completing authorization, the charge fails after the bank timeout. Always verify the transaction status to know the final result.
Can I mix Initialize Transaction and Charge API in the same application?
Yes. Many production applications use Initialize Transaction for first-time card payments (keeping card data off their servers) and the Charge API for recurring charges using saved authorization codes. This is the recommended pattern for most businesses.

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