paystack-testing

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.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "paystack-testing" with this command: npx skills add rexedge/paystack/rexedge-paystack-paystack-testing

Paystack Testing

Complete guide for testing Paystack integrations in test mode.

Depends on: paystack-setup for the paystackRequest helper.

Test Environment Setup

Environment Variables

# .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.

Test vs Live Mode

AspectTest ModeLive Mode
Key prefixsk_test_ / pk_test_sk_live_ / pk_live_
Real chargesNoYes
WebhooksSent to test webhook URLSent to live webhook URL
Card validationSimulatedReal bank processing
TransfersSimulated (no real payout)Real bank transfers

Test Card Numbers

Use these with the Paystack Popup or Charge API in test mode:

Card NumberExpiryCVVBehavior
4084 0840 8408 4081Any future date408Successful transaction
4084 0840 8408 4081Any future date408PIN auth → use 1234
5060 6666 6666 6666 666Any future date123Verve card — success
5078 5078 5078 5078 12Any future date081Insufficient funds
4000 0000 0000 0002Any future dateAnyCard declined

PIN, OTP, and other test values

AuthTest Value
PIN1234
OTP123456
Phone08012345678
Birthday1999-12-31

Integration Tests with TypeScript

Transaction Flow Test

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: "test@example.com",
        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);
  });
});

Customer API Tests

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

Plan + Subscription Tests

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

Transfer Recipient + Transfer Tests

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

Webhook Testing

Unit Test for Webhook Signature Validation

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

Testing Webhooks Locally

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

Mock Webhook Handler Test

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

Bank Account Resolution Test

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 Configuration

// 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."
  );
}

Checklist Before Going Live

  • All tests pass with sk_test_ keys
  • Webhook signature validation tested (valid + invalid + tampered)
  • Transaction initialize → verify flow tested
  • Error responses handled (invalid amount, missing fields, etc.)
  • Idempotency: duplicate references handled gracefully
  • Switch to sk_live_ / pk_live_ keys in production environment
  • Live webhook URL configured in Dashboard
  • IP whitelist verified for webhook source (52.31.139.75, 52.49.173.169, 52.214.14.220)

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

paystack-charges

No summary provided by upstream source.

Repository SourceNeeds Review
General

paystack-webhooks

No summary provided by upstream source.

Repository SourceNeeds Review
General

paystack-transfers

No summary provided by upstream source.

Repository SourceNeeds Review