| name | paystack-integration |
| description | Implement Paystack payment processing for Nigerian and African markets. Use when integrating one-time payments (initialize, redirect/popup, verify), subscriptions/recurring billing (plans, customers, subscriptions), or webhook handling. Covers Next.js and Express implementations with TypeScript. Triggers: payment integration, Paystack setup, accept payments Nigeria, NGN payments, subscription billing, payment webhooks, Paystack API, kobo conversion.
|
Paystack Integration
Paystack is Africa's leading payment gateway, primarily serving Nigeria, Ghana, Kenya, and South Africa. This skill covers complete payment integration patterns.
Table of Contents (all contained in .references/)
- Agent Execution Spec ⚠️ READ FIRST
- Quick Reference
- Payment Flow Overview
- Core Implementation
- Webhook Essentials
- Framework Guides
- Deployment Checklist
- Quick Troubleshooting
File Selection Decision Tree (quick)
⚠️ For Coding Agents: Start Here
Before reading any examples or framework guides, you MUST read:
Agent Execution Spec
This file defines the payment safety contract and execution order that must be followed to prevent fraud and incorrect implementations.
Condensed Payment Safety Rules (summary — see execution spec for full invariants):
- Call Paystack
initializeTransaction FIRST, then store the returned reference in DB.
- Always convert and store amounts in smallest currency unit (kobo/pesewa/cents) and compare those values.
- Never trust client-side success callbacks; always verify on backend using the reference.
- Webhook handlers must verify
x-paystack-signature using the raw request body before parsing.
- Verify that the verified amount exactly matches the expected DB amount before fulfilling.
- Ensure idempotency: if order.status == 'paid', exit immediately.
Examples in this SKILL are references. The execution spec is the protocol.
Frontend and Backend Integration Bridge
In most implementations, frontend and backend are separate or separated by folders.
After a successful popup or redirect, the frontend MUST send the transaction reference to the backend for verification.
Recommended pattern:
Frontend → POST /api/verify-payment { reference }
The backend then performs Paystack verification and marks the order as paid.
Reference consistency is critical: The reference returned from initializeTransaction is stored in your database and sent to the frontend. This same reference appears in webhooks and must be used for verification.
Quick Reference
Environment Variables
PAYSTACK_SECRET_KEY=sk_test_xxx
PAYSTACK_WEBHOOK_SECRET=whsec_xxx
PAYSTACK_PUBLIC_KEY=pk_test_xxx
API Configuration
const PAYSTACK_BASE_URL = 'https://api.paystack.co';
const headers = {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
};
Reference Management (CRITICAL)
const reference = generateReference('PAY');
await db.orders.create({ reference, ... });
const transaction = await initializeTransaction({ ..., reference });
const transaction = await initializeTransaction({
email,
amount: amountInKobo,
});
await db.orders.create({
reference: transaction.reference,
...
});
Flow:
- Call
initializeTransaction() WITHOUT a reference parameter
- Paystack returns a unique reference in
result.data.reference
- Store this reference in your database
- Return this reference to frontend
- Webhook events will contain this same reference
⚠️ WHY: If you save to DB before calling Paystack and the API fails, you create orphan records. By calling Paystack first, you only persist successful initializations.
Currency: Kobo Conversion (CRITICAL)
const amountInKobo = amountInNaira * 100;
const amountInNaira = amountInKobo / 100;
function toKobo(amount: number): number {
return Math.round(amount * 100);
}
Supported Payment Channels
| Currency | Smallest Unit | Multiplier |
|---|
| NGN | kobo | 100 |
| GHS | pesewas | 100 |
| ZAR | cents | 100 |
| KES | cents | 100 |
| USD | cents | 100 |
Note: multiplier is 100 for all listed currencies; store and compare amounts in the smallest unit.
type PaystackChannel = 'card' | 'bank' | 'ussd' | 'qr' | 'mobile_money' | 'bank_transfer' | 'eft';
Payment Flow Overview
One-Time Payment Flow
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Client │────▶│ Backend │────▶│ Paystack │────▶│ Backend │
│ (initiate) │ │ (initialize)│ │ (payment) │ │ (verify) │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│ │ │ │
│ 1. Request │ 2. POST │ 3. Pay via │ 4. GET
│ payment │ /initialize │ popup/redirect│ /verify/:ref
│ │ │ │
│ │ Returns: │ Returns: │ Returns:
│ │ authorization_url│ reference │ status: success
│ │ access_code │ │ amount verified
│ │ reference │ │
Decision: Redirect vs Popup
| Method | Use When | Pros | Cons |
|---|
| Redirect | Simple integration, server-rendered apps | No JS required, works everywhere | User leaves your site |
| Popup | SPA, better UX | User stays on your site, smoother | Requires Inline.js SDK |
Subscription Flow
1. Create Plan (Dashboard or API) → plan_code
2. Create/Fetch Customer → customer_code
3. Initialize with plan parameter OR Create subscription directly
4. Handle webhook events for lifecycle management
See references/one-time-payments.md for detailed one-time payment implementation.
See references/subscriptions.md for subscription/recurring billing.
Core Implementation
TypeScript Interfaces
interface PaystackResponse<T> {
status: boolean;
message: string;
data: T;
}
interface InitializeTransactionData {
authorization_url: string;
access_code: string;
reference: string;
}
interface VerifyTransactionData {
id: number;
status: 'success' | 'failed' | 'abandoned';
reference: string;
amount: number;
currency: string;
channel: string;
customer: {
id: number;
email: string;
customer_code: string;
};
authorization?: {
authorization_code: string;
card_type: string;
last4: string;
exp_month: string;
exp_year: string;
reusable: boolean;
};
metadata?: Record<string, unknown>;
}
interface WebhookEvent {
event: string;
data: Record<string, unknown>;
}
Initialize Transaction
interface InitializeParams {
email: string;
amount: number;
reference?: string;
callback_url?: string;
metadata?: Record<string, unknown>;
channels?: PaystackChannel[];
plan?: string;
}
async function initializeTransaction(params: InitializeParams): Promise<InitializeTransactionData> {
const response = await fetch(`${PAYSTACK_BASE_URL}/transaction/initialize`, {
method: 'POST',
headers,
body: JSON.stringify(params),
});
const result: PaystackResponse<InitializeTransactionData> = await response.json();
if (!result.status) {
throw new Error(result.message);
}
return result.data;
}
Verify Transaction (CRITICAL: Always Verify Amount)
async function verifyTransaction(reference: string, expectedAmount: number): Promise<VerifyTransactionData> {
const response = await fetch(
`${PAYSTACK_BASE_URL}/transaction/verify/${encodeURIComponent(reference)}`,
{ headers }
);
const result: PaystackResponse<VerifyTransactionData> = await response.json();
if (!result.status) {
throw new Error(result.message);
}
if (result.data.status !== 'success') {
throw new Error(`Payment not successful: ${result.data.status}`);
}
if (result.data.amount !== expectedAmount) {
throw new Error(`Amount mismatch: expected ${expectedAmount}, got ${result.data.amount}`);
}
return result.data;
}
Webhook Essentials
Webhooks notify your server of payment events. Signature verification is mandatory for security.
Signature Verification (SECURITY CRITICAL)
import crypto from 'crypto';
function verifyPaystackWebhook(
payload: string | Buffer,
signature: string,
secretKey: string
): boolean {
const payloadString = typeof payload === 'string' ? payload : payload.toString();
const hash = crypto
.createHmac('sha512', secretKey)
.update(payloadString)
.digest('hex');
try {
return crypto.timingSafeEqual(
Buffer.from(hash, 'hex'),
Buffer.from(signature.toLowerCase(), 'hex')
);
} catch {
return false;
}
}
function handleWebhook(req: Request): Response {
const signature = req.headers.get('x-paystack-signature');
if (!signature) {
return new Response('Missing signature', { status: 401 });
}
const rawBody = await req.text();
const isValid = verifyPaystackWebhook(
rawBody,
signature,
process.env.PAYSTACK_SECRET_KEY!
);
if (!isValid) {
console.error('Invalid webhook signature');
return new Response('Invalid signature', { status: 401 });
}
const event: WebhookEvent = JSON.parse(rawBody);
await processWebhookEvent(event);
return new Response('OK', { status: 200 });
}
Key Webhook Events
| Event | When | Action |
|---|
charge.success | Payment completed | Fulfill order, update database |
charge.failed | Payment failed | Notify user, log for analysis |
subscription.create | New subscription | Activate subscription |
subscription.disable | Subscription cancelled | Revoke access |
subscription.not_renew | Won't renew (user cancelled) | Send retention email |
invoice.create | 3 days before charge | Send reminder email |
invoice.payment_failed | Subscription charge failed | Notify user, retry logic |
invoice.update | Invoice status changed | Update subscription status |
Idempotency
async function processWebhookEvent(event: WebhookEvent): Promise<void> {
const eventId = event.data.id as number;
const processed = await db.webhookEvents.findUnique({ where: { eventId } });
if (processed) {
console.log(`Event ${eventId} already processed, skipping`);
return;
}
switch (event.event) {
case 'charge.success':
await handleChargeSuccess(event.data);
break;
case 'subscription.create':
await handleSubscriptionCreate(event.data);
break;
}
await db.webhookEvents.create({ data: { eventId, event: event.event } });
}
See references/webhooks.md for complete event handling patterns.
Framework Guides
Next.js (App Router)
Recommended file structure:
app/api/paystack/
├── initialize/route.ts # POST - Initialize payment
├── verify/route.ts # GET - Verify payment
└── webhook/route.ts # POST - Handle webhooks
components/
└── PaystackButton.tsx # Client component with Inline.js
lib/
└── paystack.ts # Utility functions
See references/nextjs-implementation.md for complete implementation.
Express.js
Recommended structure:
routes/
└── paystack.routes.ts
controllers/
└── paystack.controller.ts
middleware/
└── paystack.middleware.ts # Signature verification
services/
└── paystack.service.ts # API calls
See references/express-implementation.md for complete implementation.
Deployment Checklist
Before Going Live
Test Cards
| Card Number | Scenario |
|---|
4084084084084081 | Successful payment |
5078575078575078 | Insufficient funds |
4084080000005408 | Declined |
CVV: Any 3 digits | Expiry: Any future date | PIN: 1234 | OTP: 123456
Quick Troubleshooting
Common Errors
| Error | Cause | Solution |
|---|
Invalid key | Wrong API key | Check env var, test vs live key |
Amount is required | Missing/zero amount | Ensure amount in kobo (multiply by 100) |
Invalid signature | Webhook verification failed | Use raw body, verify secret key |
Transaction reference exists | Duplicate reference | Generate unique reference per transaction |
Customer not found | Invalid customer_code | Create customer first or use email |
Debug Checklist
-
API calls failing?
- Check Authorization header format:
Bearer sk_xxx
- Verify you're using the correct base URL
- Check Paystack status page for outages
-
Webhook not received?
- Verify URL is publicly accessible (HTTPS)
- Check Paystack Dashboard → Webhooks for delivery logs
- Ensure you return 200 OK quickly
-
Amount issues?
- Remember: amounts in kobo (multiply by 100)
- Verify amount in callback matches expected
See references/troubleshooting.md for comprehensive debugging guide.
Official Documentation