Payments fail constantly and mostly for boring reasons — an expired card, a typo in the CVC, a balance a few dollars short. Handling that well is worth real money: a failure that becomes a retry is revenue, and one that becomes a vague error message is usually a lost customer.
Everything below was run against the Paydude sandbox on 28 September 2026, using the documented decline amounts, so the responses shown are the ones your code will actually receive.
A decline is a 201
Confirm a payment intent for $7,777.01 — the sandbox amount that simulates insufficient funds — and the API answers 201 Created. Nothing about the HTTP layer says the payment failed:
POST /v1/payment-intents/pi_ZoenfTTmphPf9YKvdCllY/confirm
{ "payment_method_id": "pm_…" }
-> 201
{
"id": "pi_ZoenfTTmphPf9YKvdCllY",
"status": "requires_payment_method",
"amount_received": 0,
"last_payment_error": {
"charge_id": "ch_4bFr2bq6qrDCtgRXtQjFG",
"code": "insufficient_funds",
"message": "The account has insufficient funds to complete the purchase."
},
"charges": [
{ "id": "ch_4bFr2bq6qrDCtgRXtQjFG", "status": "failed",
"failure_code": "insufficient_funds", "failure_message": "…" }
]
}- Read the status field, not the HTTP code.
succeededis success.requires_payment_methodafter a confirm is a decline. - The reason has two names.
last_payment_error.codeon the intent,failure_codeon the charge. A helper that reads one field from both objects will find nothing on one of them. - The error can disappear. The reference notes that
last_payment_erroris cleared if the payment intent is later updated for any reason, so copy the code into your own records when you see it.
Three kinds of failure, three code paths
| Kind | How it arrives | What to do |
|---|---|---|
| API error | 4xx or 5xx with { statusCode, message, error } | Fix the request or retry through your idempotency guard — see idempotency keys explained |
| Decline | 2xx, status: requires_payment_method, a reason in last_payment_error | Show the customer a useful message and let them try again |
| Late failure | 2xx with status: processing, then an event later | Wait for the payment_intent.succeeded or payment_intent.payment_failed webhook |
The third row is ACH. A bank debit does not decide at confirm time: in the sandbox an ACH payment reported processing, and its payment_intent.succeeded event arrived about fourteen and a half hours later. Whether it succeeds or fails, the answer comes by webhook, so fulfilment for bank payments has to wait for it — see handling webhooks reliably.
Retrying a one-off payment
A declined payment intent is not dead. Its status is requires_payment_method, and confirming it again with a different payment method works: in the sandbox the second attempt added a second charge to the same intent, and last_payment_error moved to point at the newer one.
What does not work is reusing the declined payment method. Confirming a fresh intent with it returns:
400 { "statusCode": 400,
"message": "The payment method (pm_HS7q0guldPp3OYaEKQ8Ru) is not chargeable. Please submit a different payment method.",
"error": "Bad Request" }An unattached payment method is single-use, and a decline uses it up just as a success does. So after a decline, clear the card fields and have the customer enter their details again — even if they insist it is the same card and you suspect they mistyped the CVC. Then confirm the same intent with the new payment method, so every attempt for one order sits on one payment intent.
async function pay() {
// Always tokenise on submit. A declined payment method cannot be reused,
// and an unattached one expires within minutes anyway.
const paymentMethod = await payments.createPaymentMethod({
type: "card",
billing_details: collectBillingDetails(),
});
// Confirm on the server so the result comes back in one shape.
const res = await fetch("/api/confirm", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ intentId, paymentMethodId: paymentMethod.id }),
});
const intent = await res.json();
if (intent.status === "succeeded") return showReceipt(intent);
if (intent.status === "processing") return showPending(intent); // ACH
// A decline: explain it, clear the fields, let them try again.
showDeclineMessage(intent.last_payment_error?.code);
resetCardFields();
}What to tell the customer
The failure code decides the message, and most codes fall into a handful of groups. The fraud-related ones are the exception worth getting right: the documentation advises against giving the customer specific details, and so does common sense — "your card was reported stolen" is information for the issuer to give, not you.
| Failure codes | Tell the customer | Retry? |
|---|---|---|
incorrect_cvc, invalid_expiry, invalid_number, avs_check_failed, invalid_address | Which detail to check, specifically | Yes, straight away, with corrected details |
expired_card, card_not_supported, account_closed, invalid_account | This card cannot be used; try another | Only with a different payment method |
insufficient_funds, limit_exceeded | The payment was declined for the amount; try another card or try later | Later, or with another method |
try_again_later, issuer_not_available, processing_error | Something went wrong on the bank's side; please try again | Yes, after a short wait |
generic_decline, do_not_honor, call_issuer | The bank declined without a reason; contact them or use another card | With another method |
fraudulent, pickup_card, lost_card, stolen_card, restricted_card | The card was declined; please contact your bank | No — and do not say why |
Subscriptions: there is no automatic retry
This is the part that surprises people coming from processors with built-in dunning. Per the Paydude subscriptions documentation, when a scheduled payment fails:
…the subscription status changes to past_due and no further payments are attempted until you Update the Payment Method and Retry the Subscription manually.
We ran it in the sandbox: a subscription priced at $7,777.01, anchored two minutes out. The renewal was attempted at the top of the next hour, eighteen minutes after the anchor, and produced three events in order — payment_intent.created, payment_intent.payment_failed, then subscription.updated with status: "past_due" and next_payment_at: null. The null is the documentation's point in data form: nothing further is scheduled.
A past_due subscription does nothing on its own. It will not retry tomorrow, it will not cancel itself, and it will not email the customer. If your code does not act, the customer keeps whatever access your app grants and you are never paid. The generated payment intent is left at requires_payment_method, and you can find every attempt for one subscription with GET /v1/payment-intents?subscription_id=sub_….
The recovery itself is two calls, both documented in the reference:
# 1. Put the customer's new card on the subscription (attach it to the
# customer first, so it is reusable).
PATCH /v1/subscriptions/sub_…
{ "payment_method_id": "pm_new…" }
# 2. Ask for the payment to be attempted again. next_payment_at is optional
# and defaults to today.
POST /v1/subscriptions/sub_…/retry
{ }Everything around those two calls — when to retry, how often, what to email, when to give up — is yours to build. A reasonable starting schedule:
// Open a dunning case when a subscription goes past_due. A failed renewal
// emits payment_intent.payment_failed (the intent carries subscription_id)
// and then subscription.updated with status past_due; either will do.
// Re-fetch the subscription and check its status yourself.
async function onPastDue(subscription) {
await db.dunning.insertIfAbsent({
subscription_id: subscription.id,
opened_at: new Date(),
attempts: 0,
});
await email.send(subscription.customer_id, "payment-failed-update-card");
}
// Daily job. Retries on days 3, 5 and 7, then cancels.
const RETRY_DAYS = [3, 5, 7];
async function runDunning() {
for (const c of await db.dunning.open()) {
const days = daysSince(c.opened_at);
const sub = await api.get(`/v1/subscriptions/${c.subscription_id}`);
if (sub.status === "active") { // recovered
await db.dunning.close(c.id, "recovered");
} else if (c.attempts < RETRY_DAYS.length && days >= RETRY_DAYS[c.attempts]) {
await api.post(`/v1/subscriptions/${sub.id}/retry`, {});
await db.dunning.update(c.id, { attempts: c.attempts + 1 });
} else if (c.attempts >= RETRY_DAYS.length) {
await api.post(`/v1/subscriptions/${sub.id}/cancel`, {});
await db.dunning.close(c.id, "canceled");
await email.send(sub.customer_id, "subscription-canceled");
}
}
}Retrying the same card only helps for temporary failures like insufficient_funds; an expired_card will fail every time. The update-card email is what recovers most of these, which is why it goes out on day zero rather than after the retries. For how much this is worth, the failed payment revenue calculator models the recovered revenue, and what is involuntary churn covers why it is usually a bigger leak than cancellations.
Testing each path
The sandbox triggers declines by amount, not by card number. Amounts from $7,777.01 to $7,777.16 produce specific failure codes — $7,777.01 insufficient funds, $7,777.05 expired card, $7,777.15 fraudulent — and any ordinary amount succeeds. Build a test for each group in the table above, plus one for ACH's delayed outcome and one for a subscription that goes past_due.
GOOD QUESTIONS
Frequently asked
How do I know if a card payment was declined?+
Check the payment intent's status after confirming it. A declined payment returns HTTP 201 with the status back at requires_payment_method and the reason in last_payment_error.code. Treating any 2xx as success is the most common bug in payment integrations.
Can I retry a declined payment with the same payment method?+
No. An unattached payment method is single-use and a decline uses it up — confirming again with it returns 400, "The payment method is not chargeable". Have the customer enter their card again and confirm the same payment intent with the new payment method.
Does Paydude retry failed subscription payments automatically?+
No. When a subscription payment fails the subscription moves to past_due and no further payments are attempted until you update the payment method and call the retry endpoint. Retry timing, customer emails and eventual cancellation are all up to your code.
How do I recover a past_due subscription?+
Attach the customer's new payment method, set it on the subscription with PATCH /v1/subscriptions/{id} and payment_method_id, then call POST /v1/subscriptions/{id}/retry. The retry happens today unless you pass a future next_payment_at.
What should I tell a customer when their card is declined?+
Be specific when the customer can fix it — a wrong CVC or expiry date — and generic when they cannot. For fraud-related codes such as fraudulent or stolen_card, say only that the card was declined and suggest they contact their bank.
How do I test declines in the sandbox?+
Use the documented trigger amounts. A payment intent for $7,777.01 declines with insufficient_funds, $7,777.05 with expired_card, and so on up to $7,777.16. Ordinary amounts, including small ones like $77.01, succeed.
