Paystack Transaction Status Values and What Each Means
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.
The Two Status Fields That Trip Everyone Up
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.
Status: success
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.
Status: failed
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:
- "Declined" or "Transaction Declined": The card issuer declined the charge. This could be insufficient funds, a blocked card, or the bank's fraud detection flagging the transaction.
- "Insufficient Funds": The account does not have enough money.
- "Card Expired": The card has passed its expiration date.
- "Invalid Card Number": The card number does not pass validation checks.
- "Do Not Honour": The bank refused the transaction without giving a specific reason.
// 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.
Status: abandoned
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:
- Update your order status so you know the customer did not pay.
- Send a follow-up email with a link to retry the payment. Many customers abandon because of connectivity issues, especially on mobile networks in Africa.
- Track abandonment rates by channel and time of day. If abandonment spikes on USSD, your USSD flow might be confusing. If it spikes on mobile_money, there might be a gateway issue.
- Do not delete the order. The customer might come back. Keep it in "abandoned" status so they can pick up where they left off.
For strategies on recovering abandoned checkouts, see the abandoned checkouts guide.
Status: reversed
The "reversed" status means a previously successful transaction was reversed. The money was taken back from you. This can happen for several reasons:
- Bank reversal: The customer's bank reversed the charge, often because of a fraud claim or account dispute.
- Paystack reversal: Paystack may reverse transactions that fail certain post-processing checks.
- Duplicate charge reversal: If a customer was charged twice for the same transaction, one charge may be reversed.
// 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.
Status: queued and ongoing
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.
Building a Status Router
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.
Status Values in Webhook Events
Paystack sends different webhook events for different status transitions. The main ones related to transaction status are:
charge.success: Transaction completed successfully. Thedata.statuswill 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.
Mapping Paystack Statuses to Your Application States
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.
Testing Each Status in Development
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:
- Use the standard test card to simulate a successful payment.
- Use specific test card numbers that trigger declines to simulate failures.
- Start a transaction and close the browser tab without completing it to simulate abandonment. Check the status after a few minutes.
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.
Key Takeaways
- ✓Paystack transactions have distinct status values: success, failed, abandoned, reversed, and queued. Each requires different handling in your application.
- ✓The "success" status is the only one that means money moved. Never grant value for any other status.
- ✓The "abandoned" status means the customer opened the checkout page but never entered their payment details or closed the tab before completing.
- ✓The "failed" status covers card declines, insufficient funds, and bank rejections. The gateway_response field tells you the specific reason.
- ✓Always check data.status (the string) not the top-level status (the boolean). The boolean only tells you the API call succeeded, not whether the payment went through.
- ✓Build a status handler function that routes each status to the correct business logic. Do not scatter status checks across your codebase.
- ✓Log every status transition with the full Paystack response for debugging and dispute resolution.
Frequently Asked Questions
- Can a transaction status change from "success" to "failed"?
- No. Once a transaction reaches "success", it will not change to "failed". However, it can be reversed (status "reversed") or disputed through a chargeback. A successful transaction means money moved. Reversals and chargebacks are separate events that undo that movement.
- How long does a transaction stay in "abandoned" before Paystack marks it?
- Paystack typically marks a transaction as abandoned after the checkout session expires, which is usually 24 hours. If the customer never completes the payment within that window, the status moves to "abandoned". You can check by calling the verify endpoint periodically.
- Should I send different webhook responses for different transaction statuses?
- No. Always return HTTP 200 from your webhook handler regardless of the transaction status. The 200 response tells Paystack you received the webhook. Returning a non-200 for a failed transaction causes unnecessary retries. Handle the status in your application logic, not in the HTTP response.
- Is there a "pending" status?
- Paystack uses "queued" and "ongoing" for transactions that have not reached a final state. You will not see a field literally set to "pending" from the Paystack API. In your application, you might use "pending" as your initial order status before any Paystack interaction happens.
- What status does a refunded transaction show?
- A refunded transaction still shows status "success" because the original charge did succeed. The refund is a separate event with its own webhook (refund.processed). Your ledger should record both the original charge and the refund as separate entries.
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