Build an Online Store Checkout with Paystack
To build an online store checkout with Paystack, create a cart system that calculates totals in the smallest currency unit (kobo or cents), initialize transactions via the Paystack API with line-item metadata, verify payments through webhooks using HMAC signature validation, and update order status only after webhook confirmation. The entire flow can run on a Node.js backend with Express, a PostgreSQL database, and a simple frontend.
Architecture Overview
An online store checkout with Paystack follows a clean client-server pattern. The frontend handles the shopping experience (browsing, adding to cart, entering shipping info). The backend handles everything that involves money: calculating totals, initializing Paystack transactions, receiving webhooks, and updating order status.
Here is the high-level flow:
- Customer adds products to their cart and proceeds to checkout
- Your frontend sends the cart data and customer details to your backend
- Your backend creates an order record in the database with status "pending"
- Your backend calls the Paystack Initialize Transaction endpoint, passing the total amount, customer email, and metadata (order ID, line items)
- Paystack returns an authorization URL. Your backend sends this URL to the frontend
- The frontend redirects the customer to the Paystack checkout page (or opens it in a popup)
- The customer pays using their preferred method (card, M-Pesa, bank transfer)
- Paystack sends a webhook to your backend confirming the payment
- Your backend verifies the webhook signature, checks the amount matches, and updates the order to "paid"
- The customer is redirected back to your site with a success message
The critical thing to understand: step 8 and step 10 happen independently. The webhook might arrive before the customer is redirected back, or after. Your code must handle both sequences gracefully. Never update order status based on the redirect alone. Always wait for the webhook.
For the tech stack, you need a Node.js backend (Express or Fastify), a database (PostgreSQL works well for e-commerce), and any frontend framework you prefer. The Paystack-specific code lives entirely on the backend.
Data Model for Products, Cart, and Orders
Your database needs at minimum four tables: products, customers, orders, and order_items. Here is a simplified schema:
The products table stores your catalog. Each product has a name, description, price (stored in the smallest currency unit), stock quantity, and status (active/inactive). Storing prices in kobo or cents from the start means you never have to worry about floating-point math when calculating totals.
The orders table is the heart of the checkout system. Each order has:
id: Your internal order ID (UUID or auto-increment)customer_email: The email used for the Paystack transactiontotal_amount: The total in smallest currency unitcurrency: NGN, KES, GHS, or ZARstatus: pending, paid, shipped, delivered, refunded, or failedpaystack_reference: The reference string from Paystackpaystack_transaction_id: The numeric ID Paystack assigns after paymentshipping_address: JSON or a separate address tablecreated_atandupdated_at: Timestamps
The order_items table links products to orders. Each row has an order_id, product_id, quantity, and unit_price (captured at the time of order, since product prices can change later).
One important design decision: always capture the unit_price in order_items at checkout time. If you just reference the product price, a price change after the order will make your financial records incorrect. The price in order_items is the price the customer actually agreed to pay.
Initializing a Transaction with the Paystack API
When the customer clicks "Pay Now," your backend creates the order record and then calls the Paystack Initialize Transaction endpoint. Here is the code:
const axios = require("axios");
async function initializeTransaction(order) {
const response = await axios.post(
"https://api.paystack.co/transaction/initialize",
{
email: order.customer_email,
amount: order.total_amount, // Already in kobo/cents
currency: order.currency,
reference: "ORD-" + order.id + "-" + Date.now(),
callback_url: "https://yourstore.com/order/confirm",
metadata: {
order_id: order.id,
cart_items: order.items.map(function(item) {
return {
product_id: item.product_id,
name: item.name,
quantity: item.quantity,
unit_price: item.unit_price
};
}),
custom_fields: [
{
display_name: "Order Number",
variable_name: "order_number",
value: "ORD-" + order.id
}
]
}
},
{
headers: {
Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY,
"Content-Type": "application/json"
}
}
);
// Save the reference to your order record
await db.query(
"UPDATE orders SET paystack_reference = $1 WHERE id = $2",
[response.data.data.reference, order.id]
);
return response.data.data.authorization_url;
}
Key details in this code:
- The
referencemust be unique per transaction. Combining your order ID with a timestamp works. If you send the same reference twice, Paystack rejects the second call. - The
callback_urlis where Paystack redirects the customer after payment. This is NOT the webhook URL. This is just the page the customer sees. - The
metadataobject can hold any JSON you want. Paystack stores it and returns it in webhooks and on the dashboard. Put everything you need for order fulfillment here. - The
custom_fieldsarray inside metadata shows up in the Paystack dashboard transaction detail. Useful for your support team.
After this call, redirect the customer to the authorization_url. Paystack handles the rest of the payment collection.
Webhook Handling and Payment Verification
This is the most critical part of the entire checkout. When a payment succeeds (or fails), Paystack sends a POST request to the webhook URL you configured in your Paystack dashboard. You must verify that this request actually came from Paystack, not from someone trying to trick your system into marking fake orders as paid.
const crypto = require("crypto");
const express = require("express");
const router = express.Router();
router.post("/webhooks/paystack", express.raw({ type: "application/json" }), async function(req, res) {
// Step 1: Verify the signature
var hash = crypto
.createHmac("sha512", process.env.PAYSTACK_SECRET_KEY)
.update(req.body)
.digest("hex");
if (hash !== req.headers["x-paystack-signature"]) {
return res.status(401).send("Invalid signature");
}
var event = JSON.parse(req.body);
// Step 2: Handle the event
if (event.event === "charge.success") {
var data = event.data;
var reference = data.reference;
var amountPaid = data.amount;
// Step 3: Find the matching order
var order = await db.query(
"SELECT * FROM orders WHERE paystack_reference = $1",
[reference]
);
if (!order) {
console.log("No order found for reference: " + reference);
return res.status(200).send("OK");
}
// Step 4: Verify the amount matches
if (amountPaid !== order.total_amount) {
console.log("Amount mismatch for order " + order.id);
await db.query(
"UPDATE orders SET status = $1 WHERE id = $2",
["amount_mismatch", order.id]
);
return res.status(200).send("OK");
}
// Step 5: Update order status
await db.query(
"UPDATE orders SET status = $1, paystack_transaction_id = $2, updated_at = NOW() WHERE id = $3",
["paid", data.id, order.id]
);
// Step 6: Trigger fulfillment (send confirmation email, update inventory, etc.)
await fulfillOrder(order.id);
}
// Always return 200 to acknowledge receipt
res.status(200).send("OK");
});
Critical notes on webhook handling:
- Always return HTTP 200, even if you encounter an error processing the event. If you return an error code, Paystack will retry the webhook, potentially causing duplicate processing.
- Use
express.raw()to get the raw body for HMAC verification. If you parse the body as JSON first, the signature check will fail because the string representation might differ. - Make your webhook handler idempotent. If Paystack sends the same event twice (which can happen), processing it a second time should not create a second fulfillment. Check if the order is already marked as "paid" before doing anything.
- The amount verification in step 4 is a security measure. Someone could theoretically initialize a transaction for a small amount and try to use the reference for a larger order.
Handling the Customer Redirect
After payment, Paystack redirects the customer back to the callback_url you specified during initialization. The URL will include a reference query parameter. On this page, you should verify the transaction status with Paystack's Verify Transaction endpoint:
async function verifyTransaction(reference) {
var response = await axios.get(
"https://api.paystack.co/transaction/verify/" + encodeURIComponent(reference),
{
headers: {
Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
}
}
);
return response.data.data;
}
Use this verification on the redirect page to show the customer an appropriate message. If the transaction status is "success," show a thank-you page with order details. If it is "failed" or "abandoned," show a message and offer a retry link.
However, do not use this verification to update your order status. That is the webhook's job. The redirect verification is purely for displaying the right page to the customer. There is a timing gap where the customer might land on the redirect page before the webhook has been processed. In that case, your order might still show as "pending" in the database even though payment succeeded. Handle this by checking the Paystack verification response on the redirect page rather than checking your own database.
For a better user experience, you can also use Paystack Inline (the JavaScript popup) instead of a redirect. This keeps the customer on your site throughout the payment process. The Paystack Inline popup fires a callback function on success, which you can use to show a confirmation message immediately while your webhook processes in the background.
Inventory Management and Order Fulfillment
Once a payment is confirmed via webhook, you need to handle fulfillment. For a physical goods store, this means updating inventory, sending a confirmation email, and creating a shipping record. For digital goods, it might mean granting access immediately.
Inventory management has a concurrency challenge: what happens if two customers try to buy the last item at the same time? There are two common approaches:
Reserve-on-cart approach: When a customer adds an item to the cart, decrement the available stock immediately. If they do not complete checkout within a timeout period (say 15 minutes), release the reservation. This prevents overselling but requires a background job to clean up abandoned carts.
Check-on-payment approach: Only check and decrement stock when the webhook confirms payment. This is simpler but can lead to situations where a customer pays for an item that just sold out. You then have to refund them, which is a poor experience. For most African e-commerce stores with moderate traffic, this approach is fine because simultaneous purchases of the same last item are rare.
For the confirmation email, include the order number, items purchased, total paid, and estimated delivery time. If you are selling across multiple African countries, include the currency and any relevant tax information. In Kenya, you might need to reference KRA eTIMS requirements. In Nigeria, VAT considerations apply.
Build your fulfillment function to be re-runnable. If it fails halfway through (email sent but inventory not updated), you should be able to call it again without sending a duplicate email or double-decrementing stock. Use status flags on each step of the fulfillment process.
Multi-Currency and Regional Considerations
If your store serves multiple African markets, you need to handle multiple currencies. Paystack supports NGN (Nigeria), GHS (Ghana), ZAR (South Africa), and KES (Kenya). Each currency has its own supported payment methods.
In Kenya, M-Pesa through Paystack is a key payment method. Many customers prefer it over cards. Make sure your checkout flow accommodates mobile money by letting the customer enter their phone number for M-Pesa payments.
In Nigeria, customers use cards, bank transfers, and USSD. Paystack handles all of these through the same initialization endpoint. You do not need different code for different payment methods; just initialize the transaction and let Paystack's checkout page offer the available options.
A few practical tips for multi-currency stores:
- Store prices in the customer's local currency, not in a base currency with conversion. Exchange rate fluctuations will cause pricing inconsistencies if you convert at checkout time.
- Create separate Paystack sub-accounts or use the currency parameter in the transaction initialization to handle each currency.
- Display the final price (including any shipping or taxes) before the customer clicks "Pay." Surprises at the payment screen kill conversion rates.
- Consider network conditions. Many African customers are on mobile networks with variable latency. Keep your checkout page lightweight and ensure the Paystack redirect or popup loads quickly.
Deployment and Going Live
Before switching from Paystack test mode to live mode, run through this checklist:
- Webhook URL is publicly accessible: Paystack needs to reach your webhook endpoint over the internet. If you are deploying to Vercel, Railway, or Render, this is automatic. If you are on a VPS, make sure your firewall allows incoming POST requests on the webhook route.
- HTTPS is enabled: Paystack requires HTTPS for webhook delivery. All modern hosting platforms provide this. Do not try to receive webhooks over plain HTTP.
- Environment variables are set: Swap your test secret key for your live secret key. Update the webhook secret if it differs. Double-check that no test keys remain in your production environment.
- Error logging is in place: When a webhook fails to process, you need to know about it. Log webhook events, verification failures, and any database errors. Services like Sentry or LogTail work well for this.
- Test with a real small payment: After going live, make a real payment for a small amount (like 100 NGN or 10 KES) to verify the entire flow works with real money. Then refund it from the Paystack dashboard.
For hosting, a Node.js backend on Railway or Render with a managed PostgreSQL database is the simplest production setup. If you expect high traffic during sales events, consider adding a Redis queue between the webhook handler and the fulfillment logic. This lets you acknowledge the webhook quickly and process fulfillment asynchronously.
Set up uptime monitoring on your webhook endpoint. If your server goes down and Paystack cannot deliver webhooks, payments will succeed but orders will not be fulfilled. Paystack retries failed webhooks, but only for a limited time. Having monitoring in place means you can fix server issues before the retry window closes.
Handling Refunds and Disputes
Refunds are part of running a store. Paystack provides a Refund API that lets you issue full or partial refunds programmatically:
async function refundTransaction(transactionId, amountInKobo) {
var response = await axios.post(
"https://api.paystack.co/refund",
{
transaction: transactionId,
amount: amountInKobo // Omit for full refund
},
{
headers: {
Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
}
}
);
return response.data;
}
When you issue a refund, Paystack sends a refund.processed webhook event. Update your order status to "refunded" (or "partially_refunded") when you receive this event. Also update your inventory to add the returned items back to stock if applicable.
For disputes (chargebacks), Paystack sends a charge.dispute.create webhook. You need to respond to disputes within the timeframe Paystack specifies, providing evidence that the transaction was legitimate. Good order records, delivery confirmation, and customer communication logs are your best defense.
Build an admin interface that lets you view orders, issue refunds, and track dispute status. Even a simple page that lists recent orders with a "Refund" button will save you from having to log into the Paystack dashboard for every refund request.
Key Takeaways
- ✓Always send amounts to Paystack in the smallest currency unit. For NGN, multiply naira by 100 to get kobo. For KES, multiply shillings by 100 to get cents.
- ✓Never rely on client-side payment confirmation. Use Paystack webhooks to verify that money actually arrived before you mark an order as paid.
- ✓Store the Paystack transaction reference alongside your internal order ID. This makes reconciliation straightforward when you need to match payments to orders.
- ✓Include line items in the transaction metadata. This gives you a clear audit trail in the Paystack dashboard and helps with dispute resolution.
- ✓Use idempotency keys when initializing transactions to prevent duplicate charges if a user double-clicks the pay button or if a network retry fires.
- ✓Test the full flow in Paystack test mode before going live. Test mode uses fake card numbers and simulated M-Pesa prompts, so no real money moves.
Frequently Asked Questions
- Can I use Paystack Inline (popup) instead of redirecting to Paystack?
- Yes. Paystack Inline is a JavaScript library that opens the payment form as a popup on your site. You initialize it with the same parameters (email, amount, reference) and it fires a callback function on success. The webhook still arrives separately. Many stores prefer Inline because the customer never leaves the site.
- How do I handle abandoned carts where the customer started payment but never completed it?
- Paystack sends a charge.failed or charge.abandoned webhook event if the customer starts but does not complete the payment. You can listen for these events to update the order status to "abandoned" and release any inventory reservations. You can also build a follow-up email system that reminds customers about their abandoned cart after a set period.
- What happens if my webhook endpoint is down when Paystack sends a payment notification?
- Paystack retries failed webhook deliveries multiple times over a period of hours. If your server comes back online during the retry window, you will receive the event. As a safety net, you can also run a periodic job that calls the Paystack Verify Transaction endpoint for any orders stuck in "pending" status for more than an hour.
- Do I need to handle different code paths for card payments versus M-Pesa payments?
- No. The Paystack Initialize Transaction endpoint is payment-method agnostic. You send the same request regardless of how the customer will pay. Paystack handles showing the appropriate payment options on their checkout page. The webhook structure is the same for all payment methods too.
- How do I test the checkout flow without processing real payments?
- Use Paystack test mode. When you initialize transactions with your test secret key, Paystack provides test card numbers (like 4084 0840 8408 4081) and simulated M-Pesa flows. No real money moves. Webhooks still fire normally in test mode, so you can verify your entire flow end to end.
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