skills/paystack-testing/SKILL.md
Paystack Testing Guide — comprehensive testing strategies for Paystack integrations using test mode keys. Covers test card numbers, bank account test data, webhook testing, transaction simulation, transfer testing, and end-to-end test patterns. Use this skill whenever writing tests for Paystack payments, simulating transactions in test mode, testing webhook handlers, mocking Paystack API responses, validating payment flows before going live, or debugging failed test transactions. Also use when you see references to sk_test_, pk_test_, test card numbers, or Paystack test mode.
npx skillsauth add rexedge/paystack paystack-testingInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Complete guide for testing Paystack integrations in test mode.
Depends on: paystack-setup for the
paystackRequesthelper.
# .env.test (or .env.local for development)
PAYSTACK_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Never commit real keys. Use
sk_test_*/pk_test_*keys from your Paystack Dashboard → Settings → API Keys & Webhooks.
| Aspect | Test Mode | Live Mode |
| --- | --- | --- |
| Key prefix | sk_test_ / pk_test_ | sk_live_ / pk_live_ |
| Real charges | No | Yes |
| Webhooks | Sent to test webhook URL | Sent to live webhook URL |
| Card validation | Simulated | Real bank processing |
| Transfers | Simulated (no real payout) | Real bank transfers |
Use these with the Paystack Popup or Charge API in test mode:
| Card Number | Expiry | CVV | Behavior |
| --- | --- | --- | --- |
| 4084 0840 8408 4081 | Any future date | 408 | Successful transaction |
| 4084 0840 8408 4081 | Any future date | 408 | PIN auth → use 1234 |
| 5060 6666 6666 6666 666 | Any future date | 123 | Verve card — success |
| 5078 5078 5078 5078 12 | Any future date | 081 | Insufficient funds |
| 4000 0000 0000 0002 | Any future date | Any | Card declined |
| Auth | Test Value |
| --- | --- |
| PIN | 1234 |
| OTP | 123456 |
| Phone | 08012345678 |
| Birthday | 1999-12-31 |
import { describe, it, expect, beforeAll } from "vitest";
const BASE_URL = "https://api.paystack.co";
const SECRET_KEY = process.env.PAYSTACK_SECRET_KEY!;
async function paystackRequest<T = any>(
path: string,
options: RequestInit = {}
): Promise<{ status: boolean; message: string; data: T }> {
const res = await fetch(`${BASE_URL}${path}`, {
...options,
headers: {
Authorization: `Bearer ${SECRET_KEY}`,
"Content-Type": "application/json",
...options.headers,
},
});
return res.json();
}
describe("Paystack Transaction Flow", () => {
let authorizationUrl: string;
let reference: string;
it("should initialize a transaction", async () => {
const res = await paystackRequest<{
authorization_url: string;
access_code: string;
reference: string;
}>("/transaction/initialize", {
method: "POST",
body: JSON.stringify({
email: "[email protected]",
amount: 50000, // ₦500
callback_url: "https://example.com/callback",
}),
});
expect(res.status).toBe(true);
expect(res.data.authorization_url).toContain("paystack.com");
expect(res.data.reference).toBeTruthy();
authorizationUrl = res.data.authorization_url;
reference = res.data.reference;
});
it("should verify transaction (pending before payment)", async () => {
const res = await paystackRequest(`/transaction/verify/${reference}`);
// In test mode without completing payment, status may be "abandoned"
expect(res.status).toBe(true);
});
it("should list transactions", async () => {
const res = await paystackRequest("/transaction?perPage=5");
expect(res.status).toBe(true);
expect(Array.isArray(res.data)).toBe(true);
});
});
describe("Paystack Customers", () => {
let customerCode: string;
it("should create a customer", async () => {
const res = await paystackRequest<{
customer_code: string;
email: string;
}>("/customer", {
method: "POST",
body: JSON.stringify({
email: `test-${Date.now()}@example.com`,
first_name: "Test",
last_name: "User",
}),
});
expect(res.status).toBe(true);
expect(res.data.customer_code).toMatch(/^CUS_/);
customerCode = res.data.customer_code;
});
it("should fetch the customer", async () => {
const res = await paystackRequest(`/customer/${customerCode}`);
expect(res.status).toBe(true);
expect(res.data.customer_code).toBe(customerCode);
});
});
describe("Paystack Plans", () => {
let planCode: string;
it("should create a plan", async () => {
const res = await paystackRequest<{ plan_code: string }>("/plan", {
method: "POST",
body: JSON.stringify({
name: `Test Plan ${Date.now()}`,
interval: "monthly",
amount: 100000, // ₦1,000
}),
});
expect(res.status).toBe(true);
expect(res.data.plan_code).toMatch(/^PLN_/);
planCode = res.data.plan_code;
});
it("should list plans", async () => {
const res = await paystackRequest("/plan?perPage=5");
expect(res.status).toBe(true);
});
it("should fetch the plan", async () => {
const res = await paystackRequest(`/plan/${planCode}`);
expect(res.status).toBe(true);
expect(res.data.plan_code).toBe(planCode);
});
});
describe("Paystack Transfers", () => {
let recipientCode: string;
it("should create a transfer recipient", async () => {
const res = await paystackRequest<{ recipient_code: string }>(
"/transferrecipient",
{
method: "POST",
body: JSON.stringify({
type: "nuban",
name: "Test Recipient",
account_number: "0000000000",
bank_code: "058",
currency: "NGN",
}),
}
);
expect(res.status).toBe(true);
expect(res.data.recipient_code).toMatch(/^RCP_/);
recipientCode = res.data.recipient_code;
});
it("should initiate a transfer (test mode)", async () => {
const res = await paystackRequest("/transfer", {
method: "POST",
body: JSON.stringify({
source: "balance",
amount: 10000, // ₦100
recipient: recipientCode,
reason: "Test transfer",
}),
});
// In test mode, transfers are simulated
expect(res.status).toBe(true);
});
});
import { createHmac } from "crypto";
import { describe, it, expect } from "vitest";
function validateWebhookSignature(
body: string,
signature: string,
secret: string
): boolean {
const hash = createHmac("sha512", secret).update(body).digest("hex");
return hash === signature;
}
describe("Webhook Signature Validation", () => {
const secret = "sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
it("should validate a correct signature", () => {
const body = JSON.stringify({
event: "charge.success",
data: { reference: "test_ref_123" },
});
const signature = createHmac("sha512", secret).update(body).digest("hex");
expect(validateWebhookSignature(body, signature, secret)).toBe(true);
});
it("should reject an invalid signature", () => {
const body = JSON.stringify({ event: "charge.success" });
expect(validateWebhookSignature(body, "invalid_hash", secret)).toBe(false);
});
it("should reject a tampered body", () => {
const body = JSON.stringify({ event: "charge.success" });
const signature = createHmac("sha512", secret).update(body).digest("hex");
const tampered = JSON.stringify({ event: "charge.failed" });
expect(validateWebhookSignature(tampered, signature, secret)).toBe(false);
});
});
Use Paystack's test mode + a tunnel service:
# 1. Start your local server
npm run dev
# 2. Expose localhost via tunnel (pick one)
npx localtunnel --port 3000
# or
ngrok http 3000
# 3. Set the tunnel URL as your test webhook URL in Paystack Dashboard
# Dashboard → Settings → API Keys & Webhooks → Test Webhook URL
# 4. Trigger events by completing test transactions
# Paystack will send webhooks to your tunnel URL
import { describe, it, expect, vi } from "vitest";
import { createHmac } from "crypto";
// Simulates a POST to your webhook endpoint
async function simulateWebhook(
handler: (req: Request) => Promise<Response>,
event: string,
data: Record<string, unknown>,
secret: string
) {
const body = JSON.stringify({ event, data });
const signature = createHmac("sha512", secret).update(body).digest("hex");
return handler(
new Request("http://localhost/api/webhooks/paystack", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-paystack-signature": signature,
},
body,
})
);
}
describe("Webhook Handler", () => {
const secret = "sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
it("should process charge.success event", async () => {
const processPayment = vi.fn();
// Your actual webhook handler
async function webhookHandler(req: Request): Promise<Response> {
const body = await req.text();
const sig = req.headers.get("x-paystack-signature")!;
const hash = createHmac("sha512", secret).update(body).digest("hex");
if (hash !== sig) {
return new Response("Unauthorized", { status: 401 });
}
const { event, data } = JSON.parse(body);
if (event === "charge.success") {
processPayment(data);
}
return new Response("OK", { status: 200 });
}
const res = await simulateWebhook(
webhookHandler,
"charge.success",
{ reference: "test_123", amount: 50000, currency: "NGN" },
secret
);
expect(res.status).toBe(200);
expect(processPayment).toHaveBeenCalledWith(
expect.objectContaining({ reference: "test_123" })
);
});
});
describe("Bank Verification", () => {
it("should resolve a bank account", async () => {
const res = await paystackRequest(
"/bank/resolve?account_number=0000000000&bank_code=058"
);
// Test mode may return mock data
expect(res.status).toBe(true);
});
it("should list banks", async () => {
const res = await paystackRequest("/bank?country=nigeria&perPage=10");
expect(res.status).toBe(true);
expect(res.data.length).toBeGreaterThan(0);
expect(res.data[0]).toHaveProperty("code");
expect(res.data[0]).toHaveProperty("name");
});
});
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
testTimeout: 30_000, // API calls may take time
setupFiles: ["./tests/setup.ts"],
},
});
// tests/setup.ts
import { config } from "dotenv";
config({ path: ".env.test" });
if (!process.env.PAYSTACK_SECRET_KEY?.startsWith("sk_test_")) {
throw new Error(
"Tests must use test mode keys (sk_test_*). Never run tests with live keys."
);
}
sk_test_ keyssk_live_ / pk_live_ keys in production environmentdevelopment
Paystack webhook integration — signature validation with HMAC SHA512, event parsing, IP whitelisting, retry policy, and all supported event types. Use this skill whenever setting up a webhook endpoint for Paystack, validating x-paystack-signature headers, handling charge.success or transfer.success events, debugging webhook delivery failures, implementing idempotent event processing, or building any server-side Paystack event listener. Also use when encountering webhook timeout issues or needing the list of Paystack webhook IP addresses.
tools
Paystack Verification API — KYC verification tools for resolving bank accounts, validating account ownership, and looking up card BIN information. Use this skill whenever verifying bank account details before transfers, confirming account holder names, validating customer identity for compliance, looking up card brand/type/bank from BIN, or implementing KYC flows. Also use when you see references to /bank/resolve, /bank/validate, /decision/bin endpoints, or need to match account numbers to names.
development
Paystack Transfers API — send money to bank accounts and mobile wallets. Initiate single and bulk transfers, finalize OTP-verified transfers, list, fetch, and verify transfer status. Use this skill whenever implementing payouts, disbursements, vendor payments, withdrawal flows, or any feature that sends money from your Paystack balance to recipients. Also use when you see references to transfer_code, TRF_ prefixed codes, the /transfer endpoint, or need to handle transfer OTP verification.
development
Paystack Transfer Recipients API — create, list, fetch, update, and delete transfer recipients (beneficiaries) for payouts. Supports NUBAN (Nigeria), GHIPSS (Ghana), Mobile Money, BASA (South Africa), and authorization-based recipients. Use this skill whenever adding bank accounts or mobile wallets as payout destinations, creating transfer recipients before initiating transfers, managing beneficiary lists, or doing bulk recipient creation. Also use when you see references to recipient_code, RCP_ prefixed codes, or the /transferrecipient endpoint.