skills/rexedge/paystack/paystack-testing

paystack-testing

SKILL.md

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

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

Test Card Numbers

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

PIN, OTP, and other test values

Auth Test Value
PIN 1234
OTP 123456
Phone 08012345678
Birthday 1999-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)
Weekly Installs
7
GitHub Stars
1
First Seen
8 days ago
Installed on
opencode7
github-copilot7
cursor7
gemini-cli6
codex6
amp6