charge.success: The Only Event Most Apps Actually Need
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.
When charge.success Fires
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:
- One-time payments through the hosted checkout (Paystack Popup or redirect)
- One-time payments through the Charge API
- Recurring charges on saved cards (charge with authorization)
- Subscription renewal charges
- Bank transfer payments to dedicated virtual accounts
- Bulk charge completions
- Mobile money payments
- USSD payments
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.
The Full Payload
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.
Key Fields Explained
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.
The Minimum Viable Handler
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:
- Verifies the HMAC signature to confirm the request came from Paystack.
- Returns 200 immediately so Paystack does not retry.
- Ignores events that are not
charge.success. - Looks up the order using the transaction reference.
- Checks if the order is already paid (idempotency).
- Verifies the amount and currency match what was expected.
- Updates the order status.
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.
Why You Must Verify the Amount
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.
Extracting Metadata
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:
- Keep it small. Paystack has limits on metadata size. Do not store entire objects or base64-encoded files.
- Store IDs, not data. Put your order ID in metadata and look up the order details from your database. Do not put the full order object in metadata.
- Custom fields (the
custom_fieldsarray) 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.
When You Need More Than charge.success
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.
Key Takeaways
- ✓charge.success fires when a payment is completed. It is the primary event for payment confirmation.
- ✓The amount field is in the smallest currency unit: kobo for NGN, pesewas for GHS, cents for KES and ZAR.
- ✓Always verify that the amount in the webhook matches the amount you expected for the order. Do not blindly trust the webhook amount.
- ✓The metadata field contains whatever custom data you passed when initializing the transaction. Use it to link the payment to your internal records.
- ✓The authorization object contains card/payment method details that you need for recurring charges.
- ✓There is no charge.failed event. Paystack only sends webhooks for successful charges.
- ✓If your app only accepts one-time payments and does not send money out, charge.success is the only event you need.
Frequently Asked Questions
- Is there a charge.failed webhook event in Paystack?
- No. Paystack does not send a webhook when a charge fails. You only get charge.success for completed payments. Failed charges are communicated to the customer through the checkout interface (error message in the popup or redirect page). If you need to track failed attempts, use the Verify Transaction API or the Paystack Dashboard.
- What unit is the amount field in charge.success?
- The amount is always in the smallest currency unit. For NGN, this is kobo (100 kobo = 1 Naira). For GHS, pesewas (100 pesewas = 1 Cedi). For KES, cents (100 cents = 1 Shilling). For ZAR, cents (100 cents = 1 Rand). Divide by 100 to get the human-readable amount. Always verify the amount matches what you expected.
- How do I link a charge.success webhook to my order?
- Use the data.reference field. When you initialize the transaction, you provide a reference (or Paystack generates one). Store this reference alongside your order in your database. When the webhook arrives, look up the order by reference. Alternatively, put your order ID in the metadata field when initializing the transaction and extract it from data.metadata in the webhook.
- Does charge.success fire for subscription renewal payments?
- Yes. Every successful charge triggers a charge.success event, including subscription renewals. For renewals, you will also get an invoice.update event. Be careful not to handle both in a way that grants value twice. Most developers use charge.success as the single source of truth for all payment confirmations.
- What should I do if the amount in the webhook does not match my expected amount?
- Do not grant value. Log the discrepancy with the reference, expected amount, and received amount. Flag it for manual review. This can happen if your frontend has a bug that sends the wrong amount to the Initialize Transaction endpoint, or if someone is trying to manipulate the payment flow. Never assume the webhook amount is what you intended to charge.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon