skills/paystack-webhooks/SKILL.md
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.
npx skillsauth add rexedge/paystack paystack-webhooksInstall 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.
Webhooks let Paystack push real-time event notifications to your server. They are the recommended way to confirm payment status — more reliable than client-side callbacks or polling.
Depends on: paystack-setup for environment configuration.
Customer pays → Paystack processes → Paystack POSTs event JSON to your webhook URL
→ Your server validates signature
→ Returns 200 OK immediately
→ Then processes the event asynchronously
Your webhook URL is a POST endpoint you create on your server. Register it on the Paystack Dashboard under Settings → API Keys & Webhooks.
Every webhook request includes an x-paystack-signature header containing an HMAC SHA512 hash of the request body, signed with your secret key. Always validate this before processing.
// app/api/webhooks/paystack/route.ts
import crypto from "crypto";
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get("x-paystack-signature");
const hash = crypto
.createHmac("sha512", process.env.PAYSTACK_SECRET_KEY!)
.update(body)
.digest("hex");
if (hash !== signature) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
// Return 200 immediately — process event asynchronously
const event = JSON.parse(body);
// Handle event based on type
switch (event.event) {
case "charge.success":
await handleChargeSuccess(event.data);
break;
case "transfer.success":
await handleTransferSuccess(event.data);
break;
case "transfer.failed":
await handleTransferFailed(event.data);
break;
// ... handle other events
}
return NextResponse.json({ received: true }, { status: 200 });
}
async function handleChargeSuccess(data: any) {
const { reference, amount, customer, metadata } = data;
// Verify the transaction server-side as an extra check
// Update your database, fulfill the order, etc.
}
async function handleTransferSuccess(data: any) {
const { reference, amount, recipient } = data;
// Mark transfer as completed in your database
}
async function handleTransferFailed(data: any) {
const { reference, amount } = data;
// Mark transfer as failed, notify admin, retry if needed
}
import crypto from "crypto";
import express from "express";
const app = express();
app.use(express.json());
app.post("/webhooks/paystack", (req, res) => {
const hash = crypto
.createHmac("sha512", process.env.PAYSTACK_SECRET_KEY!)
.update(JSON.stringify(req.body))
.digest("hex");
if (hash !== req.headers["x-paystack-signature"]) {
return res.status(401).send("Invalid signature");
}
// Return 200 immediately
res.sendStatus(200);
// Process event asynchronously
const event = req.body;
processEvent(event).catch(console.error);
});
As an additional security layer, only allow requests from Paystack's IP addresses:
52.31.139.75
52.49.173.169
52.214.14.220
These IPs apply to both test and live environments.
const PAYSTACK_IPS = ["52.31.139.75", "52.49.173.169", "52.214.14.220"];
function isPaystackIP(ip: string): boolean {
// Handle x-forwarded-for if behind a proxy/load balancer
const clientIP = ip.split(",")[0].trim();
return PAYSTACK_IPS.includes(clientIP);
}
If your webhook endpoint doesn't return a 200 OK status, Paystack retries:
| Mode | Retry Schedule | Duration | | --- | --- | --- | | Live | Every 3 minutes for first 4 tries, then hourly | Up to 72 hours | | Test | Hourly | Up to 10 hours |
Request timeout is 30 seconds in test mode. Return 200 OK immediately and process events asynchronously to avoid timeouts.
Webhook events may be sent more than once. Make your handler idempotent:
async function handleChargeSuccess(data: any) {
const { reference } = data;
// Check if already processed
const existing = await db.transaction.findUnique({ where: { reference } });
if (existing?.status === "completed") {
return; // Already processed, skip
}
// Process and mark as completed atomically
await db.transaction.upsert({
where: { reference },
update: { status: "completed", paidAt: new Date() },
create: { reference, status: "completed", amount: data.amount, paidAt: new Date() },
});
}
| Event | Description |
| --- | --- |
| charge.success | A successful charge/payment was made |
| charge.dispute.create | A dispute was logged against your business |
| charge.dispute.remind | A logged dispute hasn't been resolved |
| charge.dispute.resolve | A dispute has been resolved |
| customeridentification.failed | Customer ID validation failed |
| customeridentification.success | Customer ID validation succeeded |
| dedicatedaccount.assign.failed | DVA couldn't be created/assigned |
| dedicatedaccount.assign.success | DVA successfully created/assigned |
| invoice.create | Invoice created for a subscription (3 days before due) |
| invoice.payment_failed | Invoice payment failed |
| invoice.update | Invoice updated (usually after successful charge) |
| paymentrequest.pending | Payment request sent to customer |
| paymentrequest.success | Payment request paid |
| refund.failed | Refund failed — account credited with refund amount |
| refund.pending | Refund initiated, awaiting processor |
| refund.processed | Refund successfully processed |
| refund.processing | Refund received by processor |
| subscription.create | Subscription created |
| subscription.disable | Subscription disabled |
| subscription.expiring_cards | Monthly notice of subscriptions with expiring cards |
| subscription.not_renew | Subscription set to non-renewing |
| transfer.success | Transfer completed successfully |
| transfer.failed | Transfer failed |
| transfer.reversed | Transfer reversed |
Every webhook event follows this structure:
{
"event": "charge.success",
"data": {
"id": 4099260516,
"domain": "live",
"status": "success",
"reference": "re4lyvq3s3",
"amount": 50000,
"currency": "NGN",
"channel": "card",
"customer": {
"id": 82796315,
"email": "[email protected]",
"customer_code": "CUS_xxxxx"
},
"authorization": {
"authorization_code": "AUTH_xxxxx",
"card_type": "visa",
"last4": "4081",
"reusable": true
},
"metadata": {}
}
}
.htaccess, add a trailing / to the URLx-paystack-signature200 OK immediately before processing long-running taskstools
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.
development
Paystack Transactions API — initialize payments, verify transactions, list/fetch transaction history, charge saved authorizations, view timelines, get totals, export data, and perform partial debits. Use this skill whenever building a checkout flow, verifying payment status, recharging a returning customer's saved card, pulling transaction reports or analytics, exporting transaction CSVs, or handling any transaction-related Paystack endpoint. Also use when you see references to /transaction/initialize, /transaction/verify, authorization_url, access_code, or charge_authorization in Paystack integrations.