Paystack with a Headless CMS Checkout
In a headless CMS setup: 1) Fetch product data (price, name) from your CMS API (Sanity GROQ or Contentful REST/GraphQL). 2) Create a Next.js API route at /api/checkout that calls POST /transaction/initialize with the Paystack secret key server-side. 3) Redirect the customer to the Paystack authorization_url. 4) Handle charge.success webhook in /api/webhooks/paystack — verify HMAC and update your order DB.
Headless CMS + Paystack Architecture
The flow: Next.js frontend fetches product data from CMS → customer clicks Buy → Next.js API route initializes Paystack with price from CMS → customer is redirected to Paystack checkout → on success, Paystack fires a webhook to your Next.js API route → you update your database and fulfill the order.
Key rule: always fetch the price from your CMS server-side before passing to Paystack. Never pass a client-supplied price to Paystack — a customer could modify the request to pay less.
Next.js API Route for Paystack Checkout
// pages/api/checkout.js (or app/api/checkout/route.js in App Router)
import { createClient } from '@sanity/client';
var sanity = createClient({ projectId: process.env.SANITY_PROJECT_ID, dataset: 'production', useCdn: true, apiVersion: '2024-01-01' });
export default async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).end();
var { productId, email } = req.body;
// Fetch price from CMS — never trust client-supplied price
var product = await sanity.fetch('*[_id == $id][0]{ name, price }', { id: productId });
if (!product) return res.status(404).json({ error: 'Product not found' });
var reference = 'order_' + productId + '_' + Date.now();
var 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: Math.round(product.price * 100), // NGN to kobo
reference,
metadata: { productId, productName: product.name },
callback_url: process.env.NEXT_PUBLIC_BASE_URL + '/checkout/callback',
}),
});
var data = await response.json();
res.json({ url: data.data.authorization_url });
}
Webhook Handler in Next.js
// pages/api/webhooks/paystack.js
import crypto from 'crypto';
export const config = { api: { bodyParser: false } };
async function getRawBody(req) {
return new Promise((resolve, reject) => {
var chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
export default async function handler(req, res) {
var rawBody = await getRawBody(req);
var signature = req.headers['x-paystack-signature'];
var expected = crypto.createHmac('sha512', process.env.PAYSTACK_WEBHOOK_SECRET).update(rawBody).digest('hex');
if (expected !== signature) return res.status(401).end();
var event = JSON.parse(rawBody);
if (event.event === 'charge.success') {
var txn = event.data;
var { productId } = txn.metadata;
await db.orders.upsert({ reference: txn.reference }, { status: 'paid', productId, email: txn.customer.email });
}
res.sendStatus(200);
}
Learn More
See the Paystack integrations guide for platform-specific plugins.
Key Takeaways
- ✓Fetch product prices from Sanity/Contentful at runtime — never hardcode prices in frontend components.
- ✓The Paystack initialize call must happen in a server-side API route, not in the browser.
- ✓Pass the CMS product ID as Paystack metadata so your webhook knows what was purchased.
- ✓Verify amount server-side before passing to Paystack — the CMS price is the source of truth.
- ✓Webhooks work the same regardless of CMS — handle charge.success in your API route.
Frequently Asked Questions
- Can I use Contentful instead of Sanity?
- Yes. Replace the Sanity query with a Contentful REST API or GraphQL call to fetch the product price. The Paystack integration code is the same — only the CMS data fetch changes. The key principle is the same: fetch price server-side from CMS before initializing Paystack.
- What if my headless CMS does not store prices?
- If prices are in a separate system (e.g., a database or Airtable), fetch from there instead. The point is to not trust the client. Whatever is your source of truth for price, query it server-side in the API route before calling Paystack initialize.
- How do I handle the Paystack redirect in a Next.js App Router setup?
- In App Router, create app/api/checkout/route.ts with a POST handler that returns a Response.json() with the Paystack URL. On the client, use router.push(data.url) or window.location.href = data.url to redirect to Paystack checkout. Handle the callback in app/checkout/callback/page.tsx by calling your /api/verify?reference=... endpoint.
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