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.
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?
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.
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.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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.
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.
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:
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.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.
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:
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.
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:
pending, the charge is still processing. Wait a few seconds and verify the transaction. Do not flood Paystack with verify calls.Here is a straightforward decision framework:
Use Initialize Transaction when:
Use the Charge API when:
Use both when:
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.
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