Accepting Payments with Paystack: Complete Guide
To accept payments with Paystack, create a server-side POST request to the Initialize Transaction endpoint with the customer email, amount in the smallest currency unit (kobo for NGN, pesewas for GHS), and a callback URL. Paystack returns an authorization URL. Redirect the customer there or open it in a popup. After payment, verify the transaction server-side before granting value.
The Two Ways to Accept Payments
Paystack gives you two distinct API paths for collecting money from customers. Every feature, every channel, and every payment type flows through one of these two endpoints. Understanding the difference before you write any code saves you from rebuilding later.
Path 1: Initialize Transaction
You POST to /transaction/initialize with the customer's email, amount, and (optionally) a callback URL. Paystack returns an authorization_url. You send the customer there. Paystack handles the entire checkout experience: collecting card details, running 3DS verification, showing bank transfer instructions, or prompting for a USSD code. When the customer finishes, Paystack redirects them back to your callback URL with a reference you can verify.
This is what most integrations use. It is fast to set up, PCI-compliant by default (card details never touch your server), and Paystack keeps updating the checkout UI without you changing a line of code.
Path 2: The Charge API
You POST to /charge with the customer's payment details (card token, bank account, mobile money number, or USSD code) directly. You build the entire checkout UI yourself. You handle OTP prompts, PIN collection, and 3DS redirects by responding to Paystack's status codes and following up with /charge/submit_otp, /charge/submit_pin, or /charge/submit_phone.
The Charge API exists for apps that need full control over the payment experience: custom mobile UIs, background charges on saved cards, or payment flows where redirecting to an external page breaks the user experience. It requires more work and more careful security handling.
Which one should you pick?
- If you are building a web app and this is your first Paystack integration, use Initialize Transaction. You will be collecting payments within an hour.
- If you need a custom mobile checkout, are charging saved authorization codes, or want to accept mobile money without a redirect, use the Charge API.
- If you are unsure, start with Initialize Transaction. You can always add Charge API flows later for specific use cases. The two paths are not mutually exclusive.
The rest of this guide covers both paths. We start with Initialize Transaction because it is the faster path to a working integration, then cover the Charge API for when you need more control.
Initialize Transaction Flow
The Initialize Transaction flow has four steps: initialize on your server, redirect or popup, customer pays, you verify. Here is each step with working code.
Step 1: Initialize the transaction from your server
Your backend sends a POST request to Paystack with the customer's email and the amount. This must happen server-side because the request includes your secret key.
// Node.js + Express example
const express = require('express');
const app = express();
app.use(express.json());
app.post('/api/pay', async (req, res) => {
const { email, amountInNaira } = req.body;
const response = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
amount: amountInNaira * 100, // Convert to kobo
callback_url: 'https://yoursite.com/payment/callback',
reference: `order_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
}),
});
const data = await response.json();
if (data.status) {
// data.data.authorization_url is where the customer pays
// data.data.access_code is used for inline/popup checkout
// data.data.reference is the transaction reference
res.json({
authorization_url: data.data.authorization_url,
access_code: data.data.access_code,
reference: data.data.reference,
});
} else {
res.status(400).json({ error: data.message });
}
});
Paystack responds with three important values:
authorization_url: A full URL (likehttps://checkout.paystack.com/abc123) where the customer completes paymentaccess_code: A short code you can pass to Paystack's inline JavaScript library for popup checkoutreference: The transaction reference (either the one you provided or one Paystack generated)
Step 2: Send the customer to pay
You have two options here. The first is a full-page redirect:
// Frontend: redirect to Paystack checkout
window.location.href = authorizationUrl;
The second is the inline popup, which keeps the customer on your page:
<!-- Load Paystack inline JS -->
<script src="https://js.paystack.co/v2/inline.js"></script>
<script>
const popup = new PaystackPop();
popup.checkout({
accessCode: 'ACCESS_CODE_FROM_YOUR_SERVER',
onSuccess: (transaction) => {
// transaction.reference contains the reference
// Verify on your server before granting value
verifyOnServer(transaction.reference);
},
onCancel: () => {
console.log('Customer closed the popup');
},
});
</script>
Both options lead to the same Paystack checkout page. The redirect version takes the customer away from your site entirely. The popup version opens Paystack's checkout in an overlay on top of your page. Choose redirect if you want simplicity; choose popup if you want the customer to stay on your page.
Step 3: Customer completes payment
On the Paystack checkout page, the customer picks a payment method and completes the transaction. Paystack handles card validation, 3DS challenges, bank transfer instructions, and everything else. You do not need to write any code for this step.
After payment, two things happen:
- Paystack redirects the customer to your
callback_urlwith?trxref=REFERENCE&reference=REFERENCEappended as query parameters - Paystack sends a
charge.successwebhook to your webhook URL (if configured in your Paystack dashboard)
Step 4: Verify the transaction
This is the step most tutorials gloss over and the one that matters most. Before you grant any value (deliver a product, credit an account, activate a feature), you must verify the transaction server-side.
app.get('/payment/callback', async (req, res) => {
const reference = req.query.reference;
const response = await fetch(
`https://api.paystack.co/transaction/verify/${reference}`,
{
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
},
}
);
const data = await response.json();
if (
data.status &&
data.data.status === 'success' &&
data.data.amount === expectedAmountInKobo &&
data.data.currency === 'NGN'
) {
// Payment confirmed. Grant value.
// Mark this reference as fulfilled in your database
// to prevent double-granting if the webhook also fires.
} else {
// Payment failed or amounts do not match.
// Show an error page.
}
});
Three checks are non-negotiable in the verify step: the transaction status must be "success", the amount must match what you expected, and the currency must be correct. Skipping any of these opens you up to underpayment attacks or currency mismatch exploits. Read more in our verification and reconciliation guide.
The Charge API
The Charge API gives you direct control over the payment flow. Instead of redirecting to Paystack's checkout, you collect payment details in your own UI and send them to /charge. This is useful for mobile apps, background charges on saved cards, and payment flows where a redirect would break the experience.
Charging a saved card (authorization code)
After a customer pays successfully through Initialize Transaction, the verify response includes an authorization object with an authorization_code. You can store this code and use it to charge the customer again without them re-entering their card details.
const response = 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, // 5,000 NGN in kobo
reference: `sub_renewal_${customerId}_${Date.now()}`,
}),
});
const data = await response.json();
// Check data.data.status === 'success'
Charging with the Charge endpoint
The /charge endpoint handles multiple payment types. The flow is not always one request and done. Depending on the payment method and the bank's requirements, Paystack may ask you to submit additional information (OTP, PIN, phone number, birthday) before the charge completes.
// Mobile money charge example
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: 10000, // 100 GHS in pesewas
currency: 'GHS',
mobile_money: {
phone: '0551234567',
provider: 'MTN', // MTN, VOD (Vodafone), TGO (AirtelTigo)
},
}),
});
const data = await response.json();
if (data.data.status === 'send_otp') {
// Prompt customer for OTP, then submit it
// POST /charge/submit_otp with { otp, reference }
}
if (data.data.status === 'success') {
// Charge completed immediately
}
The Charge API returns a status field that tells you what to do next:
success: Charge completed. Verify and grant value.send_otp: The bank sent an OTP to the customer. Collect it and POST to/charge/submit_otp.send_pin: The customer needs to enter their card PIN. Collect it and POST to/charge/submit_pin.send_phone: Collect the phone number linked to the card and POST to/charge/submit_phone.send_birthday: Collect the cardholder's birthday and POST to/charge/submit_birthday.open_url: Redirect the customer to the URL indata.data.urlfor 3DS verification.pending: The transaction is being processed. Poll/charge/{reference}or wait for a webhook.
Building a full custom checkout on the Charge API requires handling all of these statuses. For a complete walkthrough, see building a fully custom checkout on the Charge API.
Payment Channels
Paystack supports multiple payment channels, and new ones get added as Paystack expands across Africa. Here is what is available and when each one makes sense.
Card
Visa, Mastercard, and Verve cards. This is the default channel and the one most customers in Nigeria use for online payments. Cards go through 3DS verification for security. Paystack handles the entire card flow in their checkout UI, so you do not touch card numbers.
Bank Transfer
Paystack generates a temporary bank account number. The customer transfers the exact amount to that account from their banking app. Once the transfer hits, Paystack confirms the payment. This works well for customers who do not have cards or prefer not to enter card details online. The generated account number typically expires after a set period (usually 30 minutes to a few hours, depending on configuration).
USSD
The customer dials a USSD code (like *737*amount*ref# for GTBank) from their phone to authorize the payment. USSD payments do not require a smartphone or internet connection on the customer's end. This channel works well in areas with limited connectivity.
Mobile Money
Available in Ghana (MTN, Vodafone, AirtelTigo) and other markets where mobile money is the primary payment method. The customer enters their mobile money number and confirms on their phone. Mobile money is the dominant payment channel in Ghana and is growing across East and West Africa.
QR Code
Paystack generates a QR code that the customer scans with their banking app to authorize payment. This is useful for in-person payments or kiosk scenarios where typing card details is impractical.
Apple Pay
Available for customers with Apple devices that support Apple Pay. Requires domain verification setup on your end. Provides a fast checkout experience for iOS and Safari users.
Restricting channels
By default, Paystack shows all available channels to the customer. You can restrict which channels appear by passing a channels array when initializing:
const response = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'customer@example.com',
amount: 100000, // 1,000 NGN
channels: ['card', 'bank_transfer'], // Only show card and bank transfer
}),
});
Valid channel values include: card, bank, ussd, mobile_money, bank_transfer, qr, apple_pay. For a detailed look at channel selection strategy, see restricting payment channels at checkout.
Amount Handling: Kobo, Pesewas, and Cents
This trips up nearly every developer on their first Paystack integration: amounts are in the smallest currency unit, not the main unit.
- Nigerian Naira (NGN): amount is in kobo. 1 Naira = 100 kobo. To charge 5,000 NGN, send
amount: 500000. - Ghanaian Cedi (GHS): amount is in pesewas. 1 Cedi = 100 pesewas. To charge 50 GHS, send
amount: 5000. - South African Rand (ZAR): amount is in cents. 1 Rand = 100 cents. To charge 200 ZAR, send
amount: 20000. - Kenyan Shilling (KES): amount is in cents. 1 Shilling = 100 cents. To charge 1,000 KES, send
amount: 100000. - US Dollar (USD): amount is in cents. 1 Dollar = 100 cents. To charge $10, send
amount: 1000.
The formula is always the same: amount = display_price * 100.
If you forget to multiply by 100, a customer trying to pay 5,000 NGN gets charged 50 NGN instead. If you multiply twice, they get charged 500,000 NGN. Both are bad. Build a helper function and use it everywhere:
// Convert human-readable amount to Paystack amount
function toSmallestUnit(amount) {
return Math.round(amount * 100);
}
// Convert Paystack amount back to human-readable
function fromSmallestUnit(amount) {
return amount / 100;
}
// Usage
const paystackAmount = toSmallestUnit(5000); // 500000
const displayAmount = fromSmallestUnit(500000); // 5000
Use Math.round() in the conversion to avoid floating-point issues. JavaScript floating-point arithmetic can produce results like 19.99 * 100 = 1998.9999999999998, which would cause the charge to fail because Paystack expects an integer.
When verifying a transaction, the amount in the verify response is also in the smallest unit. Compare it directly against the amount you initialized with. Do not convert and compare display values, because floating-point rounding can cause false mismatches.
For the full breakdown of currency handling across all Paystack-supported currencies, see kobo, pesewa, and cents explained and currency handling in Paystack.
Metadata and Custom Fields
Metadata lets you attach extra information to a transaction that Paystack stores and returns in verify responses and webhooks. This is how you link a Paystack transaction to your internal records without building a separate mapping table.
const response = await fetch('https://api.paystack.co/transaction/initialize', {
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
metadata: {
order_id: 'ORD-2026-00142',
product_name: 'Annual Subscription',
user_id: 'usr_8f3k2n',
cancel_action: 'https://yoursite.com/payment/cancelled',
},
}),
});
The metadata field accepts any JSON object. Whatever you put in it comes back in the verify response at data.metadata and in webhook payloads at data.metadata. Use it to store order IDs, user IDs, cart contents, or anything else your fulfillment logic needs.
Custom fields
Custom fields are a special metadata structure that Paystack displays on the checkout page and in the transaction details on your dashboard. Use them when you need the customer to see specific information during checkout, or when your support team needs to quickly identify what a transaction was for.
metadata: {
custom_fields: [
{
display_name: 'Order Number',
variable_name: 'order_number',
value: 'ORD-2026-00142',
},
{
display_name: 'Delivery Address',
variable_name: 'delivery_address',
value: '15 Adeola Odeku, Victoria Island, Lagos',
},
],
}
Each custom field needs three properties: display_name (what the customer and your team see), variable_name (the machine-readable key), and value (the actual data). Custom fields appear on the Paystack checkout page below the payment form, so they are useful for order confirmation details.
You can combine regular metadata and custom fields in the same request. Regular metadata is for your backend logic; custom fields are for human-readable display. For advanced patterns, see passing custom data through a transaction and collecting extra checkout information.
Transaction References
Every Paystack transaction has a reference: a unique string that identifies it. If you do not provide one when initializing, Paystack generates a random one for you. You should always generate your own. Here is why.
References prevent duplicate charges
If your frontend sends the initialize request twice (user double-clicks the pay button, network retry, browser refresh), and you are generating references yourself, both requests use the same reference. Paystack treats the second request as a duplicate and returns the same transaction instead of creating a new one. If you let Paystack generate random references, each request creates a separate transaction, and the customer might get charged twice.
Designing good references
A good reference is unique, deterministic (the same inputs always produce the same reference), and readable when you are debugging at 2 AM. Here are patterns that work:
// Pattern 1: Entity-based (best for orders)
const reference = `order_${orderId}_${Date.now()}`;
// Example: order_142_1721472000000
// Pattern 2: User + action (best for subscriptions or top-ups)
const reference = `sub_${userId}_${billingPeriod}`;
// Example: sub_usr8f3k_2026-07
// Pattern 3: Idempotency key style
const reference = `pay_${orderId}`;
// Example: pay_ORD-2026-00142
// Same order always gets the same reference
Pattern 3 is the strongest for preventing duplicate charges. If order ORD-2026-00142 should only be paid once, using pay_ORD-2026-00142 as the reference means any second attempt to initialize payment for that order returns the existing transaction instead of creating a new one.
Reference constraints
- Must be unique across your Paystack account (not globally, just your account)
- Should be alphanumeric with hyphens and underscores. Avoid special characters, spaces, or very long strings
- Maximum length is not officially documented, but keep it under 100 characters to be safe
Store the reference in your database alongside the order or payment record. When the webhook or callback arrives, look up the reference to find which order to fulfill. For deeper patterns on reference design and idempotency, see transaction reference design patterns and idempotency in payment flows.
Callback URLs and Redirect Handling
After a customer completes payment on Paystack's checkout page, Paystack redirects them back to your site. Where they land depends on how you configured the callback URL.
Per-transaction callback URL
You can set a callback_url in the initialize request. This overrides the default callback URL in your Paystack dashboard for this specific transaction:
body: JSON.stringify({
email: 'customer@example.com',
amount: 100000,
callback_url: 'https://yoursite.com/payment/callback',
})
After payment, Paystack redirects to: https://yoursite.com/payment/callback?trxref=REF123&reference=REF123
Dashboard default callback URL
In your Paystack dashboard under Settings, you can set a default callback URL. Any transaction that does not include a callback_url in the initialize request uses this default. Set it to a generic payment-complete page on your site.
Handling the redirect
When the customer arrives at your callback URL, extract the reference query parameter and verify the transaction immediately:
// Express route handling the callback redirect
app.get('/payment/callback', async (req, res) => {
const { reference } = req.query;
if (!reference) {
return res.status(400).send('Missing reference');
}
// Verify the transaction
const verifyResponse = await fetch(
`https://api.paystack.co/transaction/verify/${reference}`,
{
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
},
}
);
const data = await verifyResponse.json();
if (data.data.status === 'success') {
// Look up the order associated with this reference
// Mark it as paid (if not already marked by webhook)
// Show the customer a success page
res.redirect('/order/success?ref=' + reference);
} else {
res.redirect('/order/failed?ref=' + reference);
}
});
Important: the redirect is not confirmation
The fact that a customer landed on your callback URL does not mean they paid. Anyone can type that URL into a browser with a fake reference. The verify call is what confirms payment. Never grant value based on the redirect alone.
Also, the redirect and the webhook can arrive in any order. Sometimes the webhook arrives before the customer is redirected. Sometimes the redirect happens first. Your code must handle both scenarios without double-granting value. The standard approach: use a database flag (like order.paid = true) and check it in both the webhook handler and the callback handler. Whichever fires first sets the flag; whichever fires second sees the flag is already set and skips fulfillment.
For a full comparison of per-transaction vs dashboard callback URLs and the edge cases each creates, see callback URL configuration patterns.
What This Cluster Covers
This guide is the hub for everything about accepting payments with Paystack. Each topic mentioned above has a dedicated deep-dive article with full code examples, edge cases, and production patterns. Here is how the articles in this cluster are organized:
Core concepts
- How Paystack accept payments actually works under the hood: The full request lifecycle from initialize to settlement
- Initialize Transaction vs Charge API: A decision guide for choosing the right endpoint
- Why you must never call the Paystack API from your frontend: Security fundamentals for payment integrations
Checkout
- Paystack Popup / Inline.js explained line by line
- Redirect checkout vs inline checkout
- Access codes: what they are and how long they live
- Migrating from Inline.js v1 to v2
Payment channels
- Payment channels overview
- Mobile money payments
- Bank transfer payments
- USSD payments
- QR code payments
- Apple Pay setup
Amounts, currencies, and data
- Kobo, pesewa, and cents explained
- Currency handling across markets
- Metadata and custom data
- Custom fields for checkout
References, callbacks, and idempotency
Advanced payment types
- Card preauthorization
- Bulk charging
- Dedicated virtual accounts
- Payment pages (no-code)
- Charge API for custom checkout
After payment
Once payment is collected, your next steps are setting up webhooks to get real-time notifications and verifying and reconciling transactions to make sure every payment is accounted for. For the full picture of how all of these fit together, see the Paystack engineering guide.
Key Takeaways
- ✓Paystack gives you two paths to accept payments: Initialize Transaction (quick, hosted checkout) and the Charge API (full control, you build the UI). Most integrations should start with Initialize Transaction.
- ✓Amounts are always in the smallest currency unit. For Nigerian Naira, 1000 means 10 Naira, not 1000 Naira. Getting this wrong is the most common first-integration bug.
- ✓Every transaction must be verified server-side after payment. Never trust the redirect callback alone. Call GET /transaction/verify/{reference} and check that the amount, currency, and status all match what you expect.
- ✓Transaction references should be unique, deterministic, and meaningful. A good pattern is {prefix}_{entity_id}_{timestamp}. This prevents duplicate charges and makes debugging straightforward.
- ✓Paystack supports cards, bank transfers, USSD, mobile money, QR codes, and Apple Pay. You can restrict which channels appear at checkout by passing a channels array when initializing.
Frequently Asked Questions
- What currencies does Paystack support for accepting payments?
- Paystack supports Nigerian Naira (NGN), Ghanaian Cedi (GHS), South African Rand (ZAR), Kenyan Shilling (KES), and US Dollars (USD). The currencies available to you depend on which country your Paystack business is registered in. You can accept payments in multiple currencies if your account is enabled for them.
- Do I need PCI compliance to accept card payments with Paystack?
- If you use Initialize Transaction (redirect or popup checkout), Paystack handles all card data on their PCI-compliant servers, and your application never touches card numbers. You do not need your own PCI certification. If you use the Charge API to collect card details directly, you take on more responsibility and should consult Paystack's compliance documentation for your specific setup.
- How long does it take to receive settlements from Paystack?
- Settlement timelines depend on your country and account type. In Nigeria, the standard settlement cycle is T+1 (next business day) for most businesses. [TODO: verify] settlement timelines for GHS, ZAR, and KES markets. You can check your specific settlement schedule in the Paystack dashboard under Settings.
- Can I test Paystack payments without real money?
- Yes. Paystack provides test secret keys (starting with sk_test_) and test public keys (starting with pk_test_). Transactions made with test keys use simulated payment flows and no real money moves. Paystack provides test card numbers in their documentation for simulating successful and failed payments.
- What happens if the customer closes the browser during payment?
- The transaction stays in a "pending" or "abandoned" state on Paystack's end. If the customer had already authorized the charge (entered their PIN or OTP) before closing, the payment may still complete and you will receive a webhook. Always rely on webhooks as the primary confirmation mechanism, not the redirect callback. You can query abandoned transactions through the Paystack API or dashboard.
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