Paystack transactions can have these status values: "success" means the payment completed and funds will be settled. "failed" means the payment was declined or could not be processed. "abandoned" means the customer started checkout but never completed it. "reversed" means a successful transaction was reversed. Always check data.status from the verify endpoint, not the top-level status boolean.
Every Paystack API response has a top-level status field. It is a boolean. It tells you whether the API call itself worked. A value of true means Paystack received your request and processed it. A value of false means something went wrong with the request itself (bad auth, invalid parameters, server error).
Inside the data object, there is another status field. This one is a string. It tells you what happened with the actual payment. These are two completely different things, and confusing them is the most common Paystack integration bug.
{
"status": true,
"message": "Verification successful",
"data": {
"status": "abandoned",
"reference": "order_abc123",
"amount": 500000,
"currency": "NGN"
}
}
In this response, the top-level "status": true means the API call worked fine. But "data.status": "abandoned" means the customer never completed the payment. If your code only checks the top-level boolean, you would treat this as a successful payment and grant value to someone who never paid.
For the full verification pipeline that handles these status values correctly, see the complete verification and reconciliation guide.
The "success" status means the payment went through. Money left the customer's account or card. Paystack will settle the funds to you according to your settlement schedule.
This is the only status that should trigger value granting in your application. When you see data.status === "success", you can mark the order as paid, add credits, unlock access, or ship the product. But only after you also verify the amount and currency match what you expected.
// Handling a successful transaction
async function handleSuccess(txn, order) {
// Even with status "success", verify amount and currency
if (txn.amount !== order.expected_amount) {
console.error('Amount mismatch on success: expected '
+ order.expected_amount + ', got ' + txn.amount);
return { granted: false, reason: 'amount_mismatch' };
}
if (txn.currency !== order.expected_currency) {
console.error('Currency mismatch on success: expected '
+ order.expected_currency + ', got ' + txn.currency);
return { granted: false, reason: 'currency_mismatch' };
}
// Grant value
await db.query(
'UPDATE orders SET status = $1, paid_at = $2, paystack_fees = $3, channel = $4 WHERE id = $5',
['paid', txn.paid_at, txn.fees, txn.channel, order.id]
);
return { granted: true };
}
Key fields available on a successful transaction:
paid_at: ISO timestamp of when the payment completed.fees: The processing fee Paystack deducted, in the smallest currency unit.channel: How the customer paid ("card", "bank", "ussd", "mobile_money", "bank_transfer").authorization: Card details (last4, card type, bank) that you can store for recurring charges.Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
The "failed" status means the payment was attempted but did not go through. The customer entered their card details (or selected their bank), but the transaction was declined. No money moved.
The gateway_response field tells you why. Common values include:
// Handling a failed transaction
async function handleFailed(txn, order) {
// Update the order with failure details
await db.query(
'UPDATE orders SET status = $1, failure_reason = $2 WHERE id = $3',
['failed', txn.gateway_response, order.id]
);
// Log for monitoring
console.log(JSON.stringify({
event: 'payment_failed',
reference: txn.reference,
reason: txn.gateway_response,
channel: txn.channel,
timestamp: new Date().toISOString(),
}));
return {
granted: false,
reason: txn.gateway_response,
retryable: isRetryableFailure(txn.gateway_response),
};
}
function isRetryableFailure(gatewayResponse) {
var nonRetryable = ['Card Expired', 'Invalid Card Number'];
return nonRetryable.indexOf(gatewayResponse) === -1;
}
Some failures are retryable (insufficient funds, temporary bank issues) and some are not (expired card, invalid number). Your UI should guide the customer accordingly. Tell them to try a different card or try again later, depending on the reason.
The "abandoned" status means the customer started the checkout process but never completed it. They might have closed the browser tab, navigated away, or simply decided not to pay. No payment details were submitted, or the customer cancelled before the charge was attempted.
Abandoned transactions are extremely common. Depending on your industry, 40-70% of initiated transactions may end up abandoned. This is normal and not a bug in your integration.
// Handling an abandoned transaction
async function handleAbandoned(txn, order) {
await db.query(
'UPDATE orders SET status = $1 WHERE id = $2 AND status = $3',
['abandoned', order.id, 'pending']
);
// Optionally send a reminder email after a delay
await scheduleAbandonedCartEmail(order.id, txn.customer.email);
return { granted: false, reason: 'abandoned' };
}
What to do with abandoned transactions:
For strategies on recovering abandoned checkouts, see the abandoned checkouts guide.
The "reversed" status means a previously successful transaction was reversed. The money was taken back from you. This can happen for several reasons:
// Handling a reversed transaction
async function handleReversal(reference) {
// Find the original order
var order = await db.query(
'SELECT id, status, user_id FROM orders WHERE paystack_reference = $1',
[reference]
);
if (order.rows.length === 0) return;
var row = order.rows[0];
if (row.status !== 'paid') {
// Order was never fulfilled, nothing to reverse
return;
}
// Revoke the value that was granted
await db.query('BEGIN');
await db.query(
'UPDATE orders SET status = $1, reversed_at = NOW() WHERE id = $2',
['reversed', row.id]
);
// Reverse credits, revoke access, etc.
await revokeAccess(row.user_id, row.id);
// Record in ledger
await db.query(
'INSERT INTO payment_ledger (reference, event_type, gross_amount, currency, order_id) VALUES ($1, $2, $3, $4, $5)',
[reference, 'reversal', 0, 'NGN', row.id]
);
await db.query('COMMIT');
// Alert the team
await sendAlert('Transaction reversed', {
reference: reference,
order_id: row.id,
});
}
Reversals are rare but serious. When one happens, you need to undo whatever value you granted. If you shipped a physical product, you have a problem that requires manual intervention. Build alerting for reversals so your team can respond quickly.
Some payment channels do not complete instantly. Bank transfers, USSD payments, and mobile money can take seconds to minutes to process. During this window, the transaction may show status values that indicate it is still in progress.
The "queued" status means Paystack has received the payment instruction and is waiting for confirmation from the payment provider. You might see this on bank transfer transactions where the customer has initiated the transfer but the bank has not confirmed receipt yet.
The "ongoing" status appears during multi-step payment flows, particularly with USSD payments where the customer needs to complete additional steps on their phone.
// Handling pending/in-progress statuses
async function handleInProgress(txn, order) {
// Do NOT grant value
// Do NOT mark as failed
// Just update the status so you can track it
await db.query(
'UPDATE orders SET paystack_status = $1, last_checked = NOW() WHERE id = $2',
[txn.status, order.id]
);
return {
granted: false,
reason: 'in_progress',
message: 'Payment is being processed. Please wait.',
};
}
The critical rule for in-progress statuses: do not grant value, and do not mark the transaction as failed. Wait. Paystack will send a webhook when the transaction reaches a final state (success, failed, or abandoned). Your pending transactions handler should poll or wait for the webhook to arrive.
Instead of scattering status checks across your codebase, build a single function that routes each status to the correct handler. This makes your code easier to test and harder to break:
// A central status router for Paystack transactions
async function handleTransactionStatus(txn, order) {
var statusHandlers = {
success: handleSuccess,
failed: handleFailed,
abandoned: handleAbandoned,
reversed: handleReversal,
};
var handler = statusHandlers[txn.status];
if (!handler) {
// Unknown or in-progress status
console.log('Unhandled transaction status: ' + txn.status
+ ' for reference: ' + txn.reference);
return handleInProgress(txn, order);
}
// Log every status transition
console.log(JSON.stringify({
event: 'transaction_status',
reference: txn.reference,
status: txn.status,
amount: txn.amount,
channel: txn.channel,
timestamp: new Date().toISOString(),
}));
return handler(txn, order);
}
// Use it in your verification endpoint
app.get('/api/verify-payment', async function(req, res) {
var reference = req.query.reference;
// ... look up order, call Paystack verify ...
var result = await handleTransactionStatus(txn, order);
return res.json(result);
});
This pattern has three advantages. First, adding a new status handler is one function and one line in the routing object. Second, you can test each handler independently. Third, the logging in the router captures every status for every transaction, giving you a complete audit trail.
Paystack sends different webhook events for different status transitions. The main ones related to transaction status are:
charge.success: Transaction completed successfully. The data.status will be "success".charge.failed: Transaction failed. Paystack does not always send this event for all failure types.transfer.reversed: A transfer (payout) was reversed. Not the same as a charge reversal, but similar handling applies.// Webhook handler with status awareness
app.post('/webhooks/paystack', async function(req, res) {
// Validate HMAC signature (not shown)
var event = req.body;
var eventHandlers = {
'charge.success': async function(data) {
// Verify with Paystack API before trusting
var verification = await verifyTransaction(data.reference);
if (verification.data.status === 'success') {
var order = await findOrder(data.reference);
if (order) {
await handleSuccess(verification.data, order);
}
}
},
'refund.processed': async function(data) {
await handleRefund(data);
},
'charge.dispute.create': async function(data) {
await handleDispute(data);
},
};
var handler = eventHandlers[event.event];
if (handler) {
await handler(event.data);
}
// Always return 200 to acknowledge receipt
return res.sendStatus(200);
});
Always return 200 from your webhook handler, even if you do not recognize the event. Returning a non-200 status causes Paystack to retry, which wastes resources if the event type is simply one you do not handle.
Your application probably has its own order statuses that do not map one-to-one to Paystack statuses. A common mapping:
// Mapping Paystack status to application order status
var statusMap = {
// Paystack status -> Your application status
'success': 'paid',
'failed': 'payment_failed',
'abandoned': 'abandoned',
'reversed': 'reversed',
'queued': 'processing',
'ongoing': 'processing',
};
function mapPaystackStatus(paystackStatus) {
return statusMap[paystackStatus] || 'unknown';
}
Store both your application status and the raw Paystack status. Your application status drives your business logic (what the customer sees, whether to ship, whether to grant access). The Paystack status is for debugging and reconciliation.
-- Order table with both statuses
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- Your application status: pending, paid, payment_failed, abandoned, reversed
paystack_status VARCHAR(20),
-- Raw Paystack status: success, failed, abandoned, reversed, queued
paystack_reference VARCHAR(100) UNIQUE,
expected_amount BIGINT NOT NULL,
expected_currency VARCHAR(3) NOT NULL,
paid_at TIMESTAMPTZ,
failure_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
This separation matters during reconciliation. If your application status says "paid" but the Paystack status says "reversed", you have a problem that needs investigation. The daily reconciliation job can flag these mismatches automatically.
Paystack test mode lets you simulate different outcomes using specific test card numbers. Check the Paystack documentation for the current list of test cards. The general pattern is:
For automated testing, mock the Paystack API response and test your status router with every possible status value:
// test/status-handling.test.js
var assert = require('assert');
var statuses = ['success', 'failed', 'abandoned', 'reversed', 'queued', 'ongoing'];
for (var i = 0; i < statuses.length; i++) {
(function(status) {
it('should handle status: ' + status, async function() {
var mockTxn = {
status: status,
reference: 'test_' + status + '_' + Date.now(),
amount: 500000,
currency: 'NGN',
gateway_response: status === 'failed' ? 'Declined' : 'Approved',
channel: 'card',
paid_at: status === 'success' ? new Date().toISOString() : null,
fees: status === 'success' ? 7500 : 0,
};
var mockOrder = {
id: 1,
expected_amount: 500000,
expected_currency: 'NGN',
status: 'pending',
};
var result = await handleTransactionStatus(mockTxn, mockOrder);
if (status === 'success') {
assert.strictEqual(result.granted, true);
} else {
assert.strictEqual(result.granted, false);
}
});
})(statuses[i]);
}
Run these tests as part of your CI pipeline. Every deployment should confirm that all status values are handled correctly. For more test scenarios, see the reconciliation failure modes 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