AgentSkillsCN

paystack-integration

在尼日利亚及非洲市场实施Paystack支付处理。适用于一次性支付(初始化、重定向/弹出窗口、验证)、订阅/定期账单(计划、客户、订阅),或Webhook处理的场景。涵盖使用TypeScript的Next.js与Express实现。可通过“支付集成”“Paystack设置”“接受尼日利亚支付”、“NGN支付”、“订阅账单”、“支付Webhook”、“Paystack API”、“kobo转换”等指令触发。

SKILL.md
--- frontmatter
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/)

  1. Agent Execution Spec ⚠️ READ FIRST
  2. Quick Reference
  3. Payment Flow Overview
  4. Core Implementation
  5. Webhook Essentials
  6. Framework Guides
  7. Deployment Checklist
  8. 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

bash
# Backend (NEVER expose these)
PAYSTACK_SECRET_KEY=sk_test_xxx   # or sk_live_xxx for production
PAYSTACK_WEBHOOK_SECRET=whsec_xxx # Optional: separate webhook secret

# Frontend (safe to expose)
PAYSTACK_PUBLIC_KEY=pk_test_xxx   # or pk_live_xxx for production

API Configuration

typescript
const PAYSTACK_BASE_URL = 'https://api.paystack.co';

const headers = {
  Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
  'Content-Type': 'application/json',
};

Reference Management (CRITICAL)

typescript
// ❌ WRONG: Generate reference locally, save to DB, then call Paystack
const reference = generateReference('PAY');
await db.orders.create({ reference, ... });  // Orphan if next line fails!
const transaction = await initializeTransaction({ ..., reference });

// ✅ CORRECT: Call Paystack first, then save with returned reference
const transaction = await initializeTransaction({
  email,
  amount: amountInKobo,
  // reference intentionally omitted — Paystack generates it
});
await db.orders.create({
  reference: transaction.reference,  // ← Use Paystack's reference
  ...
});

Flow:

  1. Call initializeTransaction() WITHOUT a reference parameter
  2. Paystack returns a unique reference in result.data.reference
  3. Store this reference in your database
  4. Return this reference to frontend
  5. 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)

typescript
// Paystack amounts are in smallest currency unit
// NGN: kobo (1 Naira = 100 kobo)
// GHS: pesewas (1 Cedi = 100 pesewas)

const amountInKobo = amountInNaira * 100;  // 5000 NGN = 500000 kobo
const amountInNaira = amountInKobo / 100;  // 500000 kobo = 5000 NGN

// Helper function
function toKobo(amount: number): number {
  return Math.round(amount * 100);
}

Supported Payment Channels

CurrencySmallest UnitMultiplier
NGNkobo100
GHSpesewas100
ZARcents100
KEScents100
USDcents100

Note: multiplier is 100 for all listed currencies; store and compare amounts in the smallest unit.

typescript
type PaystackChannel = 'card' | 'bank' | 'ussd' | 'qr' | 'mobile_money' | 'bank_transfer' | 'eft';

Payment Flow Overview

One-Time Payment Flow

code
┌─────────────┐     ┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   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

MethodUse WhenProsCons
RedirectSimple integration, server-rendered appsNo JS required, works everywhereUser leaves your site
PopupSPA, better UXUser stays on your site, smootherRequires Inline.js SDK

Subscription Flow

code
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

typescript
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; // in kobo
  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

typescript
interface InitializeParams {
  email: string;
  amount: number; // in kobo
  /**
   * Transaction reference. RECOMMENDED: Omit this parameter.
   * When omitted, Paystack generates a unique reference.
   * Use the returned reference for database storage.
   * Only provide a reference for retry scenarios (e.g., retrying after network timeout).
   */
  reference?: string;
  callback_url?: string;
  metadata?: Record<string, unknown>;
  channels?: PaystackChannel[];
  plan?: string; // plan_code for subscriptions
}

/**
 * Initialize a Paystack transaction.
 *
 * IMPORTANT: Omit the 'reference' parameter to let Paystack generate one.
 * Store the returned reference (result.data.reference) in your database.
 * This reference will appear in webhooks and verification responses.
 */
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)

typescript
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);
  }

  // CRITICAL: Verify payment status
  if (result.data.status !== 'success') {
    throw new Error(`Payment not successful: ${result.data.status}`);
  }

  // CRITICAL: Verify amount matches expected (prevents underpayment fraud)
  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)

typescript
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');

  // Use timing-safe comparison to prevent timing attacks
  try {
    return crypto.timingSafeEqual(
      Buffer.from(hash, 'hex'),
      Buffer.from(signature.toLowerCase(), 'hex')
    );
  } catch {
    return false; // Lengths don't match
  }
}

// Usage in webhook handler
function handleWebhook(req: Request): Response {
  const signature = req.headers.get('x-paystack-signature');

  if (!signature) {
    return new Response('Missing signature', { status: 401 });
  }

  // IMPORTANT: Use raw body, not parsed JSON
  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);

  // Process event (see below)
  await processWebhookEvent(event);

  // MUST return 200 within 30 seconds
  return new Response('OK', { status: 200 });
}

Key Webhook Events

EventWhenAction
charge.successPayment completedFulfill order, update database
charge.failedPayment failedNotify user, log for analysis
subscription.createNew subscriptionActivate subscription
subscription.disableSubscription cancelledRevoke access
subscription.not_renewWon't renew (user cancelled)Send retention email
invoice.create3 days before chargeSend reminder email
invoice.payment_failedSubscription charge failedNotify user, retry logic
invoice.updateInvoice status changedUpdate subscription status

Idempotency

typescript
async function processWebhookEvent(event: WebhookEvent): Promise<void> {
  // Extract unique event identifier
  const eventId = event.data.id as number;

  // Check if already processed (use your database)
  const processed = await db.webhookEvents.findUnique({ where: { eventId } });
  if (processed) {
    console.log(`Event ${eventId} already processed, skipping`);
    return;
  }

  // Process based on event type
  switch (event.event) {
    case 'charge.success':
      await handleChargeSuccess(event.data);
      break;
    case 'subscription.create':
      await handleSubscriptionCreate(event.data);
      break;
    // ... other events
  }

  // Mark as processed
  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:

code
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:

code
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

  • Environment Variables

    • PAYSTACK_SECRET_KEY set (sk_live_xxx for production)
    • PAYSTACK_PUBLIC_KEY set (pk_live_xxx for production)
    • Keys not committed to version control
  • Webhook Configuration

    • Webhook URL configured in Paystack Dashboard (Settings → API Keys & Webhooks)
    • HTTPS endpoint (required for production)
    • Signature verification implemented and tested
    • Return 200 OK within 30 seconds
  • Payment Verification

    • Amount verification implemented (prevent underpayment)
    • Status check implemented (only process success)
    • Idempotency for webhook handling
  • Testing

    • Test with Paystack test cards
    • Test webhook with ngrok (local) or staging URL
    • Test subscription lifecycle (create → charge → cancel)
    • Test failed payment scenarios
  • Monitoring

    • Webhook delivery logs in Paystack Dashboard
    • Application logging for payment events
    • Alerting for webhook failures

Test Cards

Card NumberScenario
4084084084084081Successful payment
5078575078575078Insufficient funds
4084080000005408Declined

CVV: Any 3 digits | Expiry: Any future date | PIN: 1234 | OTP: 123456


Quick Troubleshooting

Common Errors

ErrorCauseSolution
Invalid keyWrong API keyCheck env var, test vs live key
Amount is requiredMissing/zero amountEnsure amount in kobo (multiply by 100)
Invalid signatureWebhook verification failedUse raw body, verify secret key
Transaction reference existsDuplicate referenceGenerate unique reference per transaction
Customer not foundInvalid customer_codeCreate customer first or use email

Debug Checklist

  1. API calls failing?

    • Check Authorization header format: Bearer sk_xxx
    • Verify you're using the correct base URL
    • Check Paystack status page for outages
  2. Webhook not received?

    • Verify URL is publicly accessible (HTTPS)
    • Check Paystack Dashboard → Webhooks for delivery logs
    • Ensure you return 200 OK quickly
  3. 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