Bonaventure OgetoBy Bonaventure Ogeto|

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.

Sign up for the McTaba newsletter

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