Charging a Saved Card with Paystack Authorization Codes
To charge a saved card on Paystack, call POST /transaction/charge_authorization with the customer's email, the amount in the smallest currency unit, and the authorization_code you stored from their first payment. Paystack processes the charge and returns a response immediately. Always generate a unique reference for each charge, verify the response status, and handle failures with a retry strategy.
The Basic Charge Flow
Charging a saved card is a single API call. Here is the minimal version:
var axios = require('axios');
async function chargeCard(email, amount, authorizationCode, reference) {
var response = await axios.post(
'https://api.paystack.co/transaction/charge_authorization',
{
email: email,
amount: amount,
authorization_code: authorizationCode,
reference: reference,
currency: 'NGN',
},
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
}
);
return response.data.data;
}
The parameters:
- email: The customer's email address. Must match the email associated with the authorization on Paystack.
- amount: The charge amount in the smallest currency unit. 500000 means 5,000 NGN in kobo. See amount handling for currency details.
- authorization_code: The token from the customer's first payment. Must be a reusable authorization.
- reference: Your unique identifier for this charge. If you do not provide one, Paystack generates one. Always provide your own for traceability.
- currency: The currency code. Must match the currency the authorization was created in.
The response comes back immediately for card charges. Unlike the Charge API (which can return send_otp or pending), charge_authorization returns either success or failed right away. The customer has already authenticated during their first payment, so no OTP or PIN is needed.
Generating Unique References
The reference field is your defense against double charges. If a network timeout causes your code to retry the API call, Paystack checks the reference. If that reference has already been charged, Paystack returns the original transaction instead of charging the customer again.
Build references that are unique, meaningful, and traceable:
var crypto = require('crypto');
function generateChargeReference(userId, purpose) {
var timestamp = Date.now().toString();
var random = crypto.randomBytes(4).toString('hex');
return 'CHG_' + userId + '_' + purpose + '_' + timestamp + '_' + random;
}
// Examples:
// CHG_user123_monthly_1721500000000_a1b2c3d4
// CHG_user456_overage_1721500000000_e5f6g7h8
// CHG_user789_instalment3_1721500000000_i9j0k1l2
Store the reference in your database before making the API call. This way, even if your application crashes between sending the request and receiving the response, you have a record of the attempt. You can later reconcile by calling GET /transaction/verify/:reference.
async function chargeWithAuditTrail(userId, email, amount, authorizationCode) {
var reference = generateChargeReference(userId, 'monthly');
// Step 1: Record the attempt before calling Paystack
await db.query(
'INSERT INTO charge_attempts (user_id, reference, amount, status, created_at) VALUES ($1, $2, $3, $4, NOW())',
[userId, reference, amount, 'pending']
);
try {
var result = await chargeCard(email, amount, authorizationCode, reference);
// Step 2: Update with the result
await db.query(
'UPDATE charge_attempts SET status = $1, gateway_response = $2, paystack_id = $3, updated_at = NOW() WHERE reference = $4',
[result.status, result.gateway_response, result.id, reference]
);
return result;
} catch (error) {
// Step 3: Record the error
await db.query(
'UPDATE charge_attempts SET status = $1, error_message = $2, updated_at = NOW() WHERE reference = $3',
['error', error.message, reference]
);
throw error;
}
}
Handling the Response
The charge_authorization response has a predictable structure. Here is what to look for:
async function processChargeResponse(result) {
if (result.status === 'success') {
// Charge went through
console.log('Charged successfully');
console.log('Amount:', result.amount);
console.log('Transaction ID:', result.id);
console.log('Reference:', result.reference);
// Grant access, extend subscription, mark invoice as paid
await grantAccess(result.customer.email, result.reference);
return { success: true, reference: result.reference };
}
if (result.status === 'failed') {
console.log('Charge failed');
console.log('Reason:', result.gateway_response);
// Different failure reasons need different handling
var reason = result.gateway_response;
if (reason === 'Insufficient Funds') {
// Temporary. Retry later.
await scheduleRetry(result.reference, 'insufficient_funds');
} else if (reason === 'Card Expired') {
// Permanent. Ask customer to update card.
await requestCardUpdate(result.customer.email);
} else if (reason === 'Do Not Honor') {
// Could be temporary or permanent. Retry once, then ask customer.
await scheduleRetry(result.reference, 'do_not_honor');
} else {
// Unknown reason. Log and investigate.
console.log('Unknown decline reason:', reason);
await flagForReview(result.reference, reason);
}
return { success: false, reason: reason };
}
}
Common gateway_response values and what they mean:
| Response | Meaning | Action |
|---|---|---|
| Approved | Charge succeeded | Grant access |
| Insufficient Funds | Card valid but account balance too low | Retry in a few hours or days |
| Card Expired | Card is past its expiry date | Ask customer to add new card |
| Do Not Honor | Bank declined without specific reason | Retry once, then contact customer |
| Invalid Transaction | The transaction parameters are wrong | Check your request data |
| Transaction Not Permitted | Card restrictions prevent this charge | Ask customer to contact their bank |
For a complete guide to handling each failure type and building a dunning flow around them, see handling failed recurring charges and dunning.
Verifying After the Charge
The charge_authorization response tells you the result. But as a best practice, verify the transaction independently, especially if you are acting on the result (granting access, shipping goods, extending a subscription).
async function verifyCharge(reference) {
var response = await axios.get(
'https://api.paystack.co/transaction/verify/' + reference,
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
}
);
var data = response.data.data;
if (data.status === 'success') {
// Double-check the amount matches what you intended to charge
if (data.amount !== expectedAmount) {
console.log('Amount mismatch. Expected: ' + expectedAmount + ', Got: ' + data.amount);
// Flag for review
}
return true;
}
return false;
}
Why verify when you already have the response? Two reasons. First, network issues can corrupt responses. You might get a garbled response that you parse incorrectly. Second, if your application crashes between receiving the response and processing it, the verification call lets you recover the state on restart.
A recovery function that runs on application startup:
async function reconcilePendingCharges() {
var pending = await db.query(
'SELECT reference FROM charge_attempts WHERE status = $1',
['pending']
);
for (var i = 0; i < pending.rows.length; i++) {
var ref = pending.rows[i].reference;
try {
var response = await axios.get(
'https://api.paystack.co/transaction/verify/' + ref,
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
}
);
var data = response.data.data;
await db.query(
'UPDATE charge_attempts SET status = $1, updated_at = NOW() WHERE reference = $2',
[data.status, ref]
);
if (data.status === 'success') {
await grantAccess(data.customer.email, ref);
}
} catch (error) {
console.log('Could not verify ' + ref + ': ' + error.message);
}
}
}
For the full verification pattern, see verify transaction.
A Production-Ready Charge Function
Putting it all together: a charge function that handles references, audit trails, error handling, and verification in one flow.
var axios = require('axios');
var crypto = require('crypto');
var PAYSTACK_BASE = 'https://api.paystack.co';
var headers = {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
};
async function chargeCustomer(options) {
var userId = options.userId;
var email = options.email;
var amount = options.amount;
var authorizationCode = options.authorizationCode;
var currency = options.currency || 'NGN';
var purpose = options.purpose || 'charge';
var metadata = options.metadata || {};
// 1. Validate inputs
if (!authorizationCode || !email || !amount) {
throw new Error('Missing required charge parameters');
}
if (amount < 100) {
throw new Error('Amount must be at least 100 (1 NGN in kobo)');
}
// 2. Generate reference and record attempt
var reference = 'CHG_' + userId + '_' + purpose + '_' + Date.now() + '_' + crypto.randomBytes(4).toString('hex');
await db.query(
'INSERT INTO charge_attempts (user_id, reference, amount, currency, purpose, status, created_at) VALUES ($1, $2, $3, $4, $5, $6, NOW())',
[userId, reference, amount, currency, purpose, 'pending']
);
// 3. Make the charge
try {
var response = await axios.post(
PAYSTACK_BASE + '/transaction/charge_authorization',
{
email: email,
amount: amount,
authorization_code: authorizationCode,
reference: reference,
currency: currency,
metadata: metadata,
},
{ headers: headers }
);
var result = response.data.data;
// 4. Record the outcome
await db.query(
'UPDATE charge_attempts SET status = $1, gateway_response = $2, paystack_id = $3, updated_at = NOW() WHERE reference = $4',
[result.status, result.gateway_response, result.id, reference]
);
return {
success: result.status === 'success',
reference: reference,
status: result.status,
gatewayResponse: result.gateway_response,
amount: result.amount,
paystackId: result.id,
};
} catch (error) {
var errorMessage = error.response
? error.response.data.message
: error.message;
await db.query(
'UPDATE charge_attempts SET status = $1, error_message = $2, updated_at = NOW() WHERE reference = $3',
['error', errorMessage, reference]
);
return {
success: false,
reference: reference,
status: 'error',
gatewayResponse: errorMessage,
amount: amount,
};
}
}
This function never throws. It returns a result object that your calling code can inspect. Failed charges are recorded, not lost. Network errors are caught and logged. The reference is generated before the API call, so even crashes leave an auditable record.
Common Mistakes and How to Avoid Them
These mistakes show up repeatedly in production Paystack integrations.
1. Using a non-reusable authorization
If the customer's first payment was via bank transfer or USSD, the authorization has reusable: false. Calling charge_authorization with it will fail. Always check the reusable field before storing an authorization for recurring use. See reusable vs non-reusable authorizations.
2. Not generating your own reference
If you let Paystack generate the reference, you lose idempotency protection. A network timeout followed by a retry will result in two separate charges. Always generate and store your reference before calling the API.
3. Retrying too aggressively
When a charge fails, some developers retry immediately in a loop. This is dangerous. If the failure is "Insufficient Funds," retrying 10 times in 10 seconds will not help. Space retries over hours or days. See building a dunning email sequence for timing guidance.
4. Ignoring the currency mismatch
An authorization created in NGN cannot be charged in KES. The currency must match. If you serve multiple countries, track which currency each customer's authorization was created in.
5. Not handling the email mismatch
The email you pass to charge_authorization must match the email Paystack has for that customer. If the customer changed their email in your system but not on Paystack, the charge can fail. Keep customer emails synchronized.
6. Charging without user consent
Just because you have an authorization code does not mean you can charge any amount at any time. Make sure your terms of service clearly state what amounts and frequencies the customer is agreeing to. Unexpected charges lead to chargebacks and disputes.
Using Metadata for Charge Tracking
The charge_authorization endpoint accepts a metadata field. Use it to attach business context to each charge so you can trace it later in the Paystack Dashboard, in webhook events, and in your own reports.
var metadata = {
custom_fields: [
{
display_name: 'Invoice Number',
variable_name: 'invoice_number',
value: 'INV-2026-0042',
},
{
display_name: 'Billing Period',
variable_name: 'billing_period',
value: 'July 2026',
},
{
display_name: 'Plan',
variable_name: 'plan',
value: 'Pro Monthly',
},
],
};
// Pass metadata in the charge call
var result = await chargeCustomer({
userId: 'user123',
email: 'customer@example.com',
amount: 500000,
authorizationCode: 'AUTH_xxxxxxxxxxxxx',
metadata: metadata,
});
The custom_fields array shows up in the Paystack Dashboard under the transaction details. This saves your support team from cross-referencing your internal database every time they need to look up a charge.
Metadata also appears in webhook payloads. When you process a charge.success event, the metadata is right there in the event data, letting your webhook handler route the charge to the correct business logic without additional API calls.
Key Takeaways
- ✓The charge_authorization endpoint lets you charge a customer's saved card without them re-entering card details. You need their email, the amount, and their authorization_code.
- ✓Always generate a unique reference for each charge. This prevents double charges if your request is retried due to network issues.
- ✓The charge response is synchronous for card payments. You get success or failed immediately, unlike the Charge API which can return pending.
- ✓Check the gateway_response field on failures to understand why a charge was declined. Different decline reasons need different handling.
- ✓Store every charge attempt in your database before calling the API. This gives you an audit trail and makes reconciliation possible.
- ✓Do not retry charges indefinitely. Three to five attempts over a week is reasonable. Excessive retries can get your Paystack account flagged.
- ✓The authorization_code must have reusable: true. Non-reusable authorizations from bank transfers or USSD will fail when you try to charge them.
Frequently Asked Questions
- Can I charge a different amount each time with charge_authorization?
- Yes. Unlike the Subscriptions API where the plan fixes the amount, charge_authorization lets you specify any amount on each call. This is what makes it suitable for usage-based billing, instalment plans, and other variable-amount scenarios.
- Does the customer get a notification when I charge their saved card?
- Paystack sends a payment receipt email to the customer by default when a charge succeeds. You can include metadata to make the receipt more informative. If you want to control all customer communication yourself, coordinate with Paystack support about notification settings.
- What happens if I charge the same reference twice?
- Paystack treats the reference as an idempotency key. If you send a second charge request with the same reference, Paystack returns the result of the original charge instead of creating a new one. This prevents accidental double charges.
- Can I charge an authorization code from a different Paystack account?
- No. Authorization codes are scoped to the merchant account (integration) that created them. You cannot use an authorization code generated on another merchant's Paystack account.
- Is there a minimum or maximum amount for charge_authorization?
- Paystack enforces minimum transaction amounts that vary by currency. For NGN, the minimum is typically 100 kobo (1 Naira). Maximum amounts depend on your Paystack account limits and the card's own transaction limits. Check the Paystack documentation for current limits in your currency.
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