The charge.success webhook event fires when a payment completes. The payload contains the transaction reference, amount (in smallest currency unit), currency, payment channel, customer details, metadata, and authorization info. Your handler should verify the signature, check the amount and reference against your records, update the order status, and return 200. For most applications that just accept payments, this is the only event worth handling.
charge.success fires when money has successfully moved from a customer's payment method (card, bank account, mobile money, USSD) to your Paystack balance. By the time you receive this event, the payment is done. The money is in your Paystack account.
This event fires for:
It does not fire for failed charges, abandoned checkouts, or pending bank transfers that have not been completed. There is no charge.failed webhook event. If a charge fails, the customer sees an error in the checkout interface, and you can check the status through the Verify Transaction API.
Here is a realistic charge.success payload for a card payment in Nigeria:
{
"event": "charge.success",
"data": {
"id": 1234567890,
"domain": "live",
"status": "success",
"reference": "ORD-2026-07-20-abc123",
"amount": 750000,
"currency": "NGN",
"channel": "card",
"ip_address": "102.89.45.123",
"fees": 11250,
"fees_split": null,
"customer": {
"id": 98765432,
"first_name": "Amina",
"last_name": "Okafor",
"email": "amina@example.com",
"customer_code": "CUS_abc123def456",
"phone": "+2348012345678",
"metadata": null,
"risk_action": "default"
},
"authorization": {
"authorization_code": "AUTH_abc123",
"bin": "408408",
"last4": "4081",
"exp_month": "12",
"exp_year": "2028",
"channel": "card",
"card_type": "visa",
"bank": "TEST BANK",
"country_code": "NG",
"brand": "visa",
"reusable": true,
"signature": "SIG_abc123",
"account_name": null
},
"metadata": {
"order_id": "ORD-789",
"plan": "premium",
"custom_fields": [
{
"display_name": "Delivery Address",
"variable_name": "delivery_address",
"value": "12 Admiralty Way, Lekki Phase 1, Lagos"
}
]
},
"paid_at": "2026-07-20T14:30:00.000Z",
"created_at": "2026-07-20T14:29:45.000Z",
"transaction_date": "2026-07-20T14:29:45.000Z",
"gateway_response": "Successful",
"message": null,
"plan": {},
"paidAt": "2026-07-20T14:30:00.000Z",
"createdAt": "2026-07-20T14:29:45.000Z",
"requested_amount": 750000
}
}
That is a lot of fields. Most handlers only need a few of them. Let us go through the important ones.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
data.reference - The transaction reference you set when initializing the transaction. This is your primary key for linking the webhook to your internal records. If you used ORD-2026-07-20-abc123 as the reference when calling Initialize Transaction, this is where it shows up.
data.amount - The amount paid, in the smallest currency unit. For NGN, this is kobo (750000 kobo = 7,500 Naira). For GHS, pesewas. For KES and ZAR, cents. Divide by 100 to get the human-readable amount. Always verify this matches the amount you expected for the order.
data.currency - The three-letter currency code (NGN, GHS, KES, ZAR, USD). Check this if you accept payments in multiple currencies.
data.channel - How the customer paid: "card", "bank", "ussd", "mobile_money", "bank_transfer", or "qr". Useful for analytics and for channel-specific processing (e.g., bank transfers may take longer to settle).
data.customer - Object containing the customer's email, name, phone, and Paystack customer code. The email is always present because it is required by Paystack. Name and phone may be null if the customer did not provide them.
data.metadata - Whatever custom data you attached when initializing the transaction. This is where you stash your internal order ID, user ID, plan name, or any other context your handler needs. If you use custom fields, they appear in data.metadata.custom_fields.
data.authorization - Details about the payment method used. For card payments, this includes the card brand, last four digits, expiry, and an authorization_code. If reusable is true and the customer consents, you can charge this card again using the authorization code without the customer going through checkout. See metadata guide for passing custom data.
data.fees - The Paystack fees deducted from this transaction, in the smallest currency unit. The amount you receive in settlement is data.amount - data.fees.
data.paid_at - ISO 8601 timestamp of when the payment was completed. Use this for your records rather than the time you received the webhook, since webhooks can be delayed or retried.
Here is the smallest charge.success handler that is production-ready:
const crypto = require('crypto');
const express = require('express');
const app = express();
app.post(
'/webhooks/paystack',
express.raw({ type: 'application/json' }),
async (req, res) => {
// 1. Verify signature
const secret = process.env.PAYSTACK_SECRET_KEY;
const signature = req.headers['x-paystack-signature'];
const hash = crypto
.createHmac('sha512', secret)
.update(req.body)
.digest('hex');
if (hash !== signature) {
return res.status(400).send('Invalid signature');
}
// 2. Return 200 immediately
res.status(200).send('OK');
// 3. Parse and process
const payload = JSON.parse(req.body.toString());
if (payload.event !== 'charge.success') {
// Log other events but do not process them
console.log('Ignoring event: ' + payload.event);
return;
}
const { reference, amount, currency } = payload.data;
// 4. Look up the order
const order = await db.query(
'SELECT id, expected_amount, currency, status FROM orders WHERE payment_reference = $1',
[reference]
);
if (order.rows.length === 0) {
console.error('No order found for reference: ' + reference);
return;
}
const row = order.rows[0];
// 5. Check idempotency
if (row.status === 'paid') {
console.log('Order already paid for reference: ' + reference);
return;
}
// 6. Verify the amount
if (amount !== row.expected_amount || currency !== row.currency) {
console.error(
'Amount mismatch for reference ' + reference +
': expected ' + row.expected_amount + ' ' + row.currency +
', got ' + amount + ' ' + currency
);
return;
}
// 7. Update the order
await db.query(
'UPDATE orders SET status = $1, paid_at = $2 WHERE id = $3 AND status = $4',
['paid', payload.data.paid_at, row.id, 'pending']
);
console.log('Order ' + row.id + ' marked as paid');
}
);
This handler does seven things:
charge.success.Steps 5 and 6 are the ones most tutorials skip and most production outages result from. Without step 5, duplicate webhooks create double credits. Without step 6, a partial payment or a modified amount could slip through.
The webhook tells you a charge.success happened. It does not tell you that the customer paid the correct amount for their order. You must check.
Scenario: your product costs 5,000 Naira (500000 kobo). A customer initializes a transaction for 500000, but through some manipulation (a modified frontend, a race condition, or a bug in your initialization code), the transaction was actually created for 100 kobo. The charge succeeds. You get a charge.success webhook with amount: 100. If you mark the order as paid without checking the amount, the customer got a 5,000 Naira product for 1 Naira.
The fix is straightforward:
// When you initialize the transaction, store the expected amount
await db.query(
'INSERT INTO orders (payment_reference, expected_amount, currency, status) VALUES ($1, $2, $3, $4)',
[reference, 500000, 'NGN', 'pending']
);
// In your webhook handler, verify the amount
if (webhookAmount !== order.expected_amount) {
console.error(
'Amount mismatch: expected ' + order.expected_amount +
', received ' + webhookAmount
);
// Flag for manual review, do not grant value
return;
}
This is especially important if you let the frontend specify the amount or if you use the metadata to determine what to fulfill. The webhook amount is authoritative (it reflects what Paystack actually charged), but it must match what you intended to charge.
The metadata field is your lifeline for connecting the payment to your business logic. Whatever you passed as metadata when initializing the transaction comes back in the webhook.
// When initializing the transaction
const response = await axios.post(
'https://api.paystack.co/transaction/initialize',
{
email: 'customer@example.com',
amount: 500000,
currency: 'NGN',
reference: 'ORD-2026-001',
metadata: {
order_id: 'ORD-789',
user_id: 'USR-456',
plan: 'premium',
items: ['widget-a', 'widget-b'],
},
},
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
}
);
// In your webhook handler, extract it
const metadata = payload.data.metadata;
if (metadata) {
const orderId = metadata.order_id;
const userId = metadata.user_id;
const plan = metadata.plan;
console.log(
'Processing payment for user ' + userId +
', order ' + orderId +
', plan ' + plan
);
}
Tips for metadata:
custom_fields array) are visible on the Paystack Dashboard. Regular metadata fields are not. Use custom fields for information you want your team to see when reviewing transactions in the Dashboard.For more on metadata patterns, see Paystack metadata: passing custom data through a transaction.
charge.success is enough if your app only accepts payments and never sends money out. You need other events when:
You send payouts. If your app pays vendors, freelancers, or users (like a marketplace or a withdrawal feature), you need transfer.success, transfer.failed, and transfer.reversed. You cannot rely on the Transfer API response alone because transfers are asynchronous. The API returns "pending" and the webhook tells you the final result. See handling transfer events.
You have subscriptions. If customers subscribe to monthly or annual plans, you need subscription.create, subscription.disable, invoice.create, and invoice.payment_failed. You will still get charge.success for each renewal payment, but the subscription events tell you about lifecycle changes (new subscriber, cancellation, upcoming charge, failed renewal). See handling subscription events.
You process refunds. If you refund customers through the API, refund.processed and refund.failed tell you the outcome. See handling refund events.
You use dedicated virtual accounts. dedicatedaccount.assign.success tells you when an account is ready. Payments to the account still trigger charge.success. See handling dedicated account events.
You need to handle disputes. Every production app should handle dispute.create at minimum. Disputes have deadlines. Missing one costs you money and credibility. See handling dispute events.
For the full catalog, see every Paystack webhook event explained.
This article is part of the Paystack webhooks engineering guide.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course