Payment errors
When a customer's payment fails, the error sits on the Payment Intent's lastPaymentError field. The shape, the adviceCode, and how to split copy between your customer and your team.
This page covers the asynchronous failure surface. When a customer's payment fails (card declined, processor timeout, expired card, etc.), the error doesn't come back as an HTTP response to your server. It lives on the Payment Intent's lastPaymentError field for the rest of its life.
For synchronous errors returned by your own API calls, see API errors.
Where the error lives
A failed payment leaves three distinct breadcrumbs:
paymentIntent.lastPaymentError: the full structured detail. Read this for everything you need to show in your ops UI, decide retry strategy, or build customer-facing copy.charge.failureCodeandcharge.failureMessage: the same headline error, attached to the specific Charge that failed. Useful when you key fulfillment off the Charge.- The
charge.failedwebhook: fires the moment a charge attempt fails. Subscribe to it if you need to react to failures in real time.
The most complete data is on lastPaymentError. If your handler receives charge.failed, fetch the Payment Intent to get the structured fields described below.
The lastPaymentError shape
{
"type": "card_error",
"code": "card_declined",
"declineCode": "insufficient_funds",
"networkDeclineCode": "51",
"message": "Your card has insufficient funds.",
"merchantMessage": "Declined: insufficient funds. Customer should use a different payment method.",
"adviceCode": "try_again_later",
"docUrl": "https://docs.xpay.app/integrate/errors/decline-codes#insufficient_funds",
"param": null,
"chargeId": "ch_test_AbC123",
"paymentMethodType": "card",
"paymentMethod": { "card": { "brand": "mastercard", "last4": "0008" } },
"processorCode": "...",
"processorMessage": "..."
}| Field | Meaning |
|---|---|
type | Broad category: card_error, payment_method_error (BNPL, wallet, kiosk, bank transfer), processor_error, api_error. |
code | The specific error reason. From the Payment error codes list. |
declineCode | For card declines, the issuer's reason. From the Decline codes list. null for non-decline failures. |
networkDeclineCode | The 2-4 digit code returned by the card network (e.g. 51 for insufficient funds in ISO 8583). The meaning depends on the card brand. null when the network didn't provide one. |
message | Short message safe to surface to the customer. |
merchantMessage | Detailed message for your support team, includes context the customer shouldn't see. |
adviceCode | What to do next: confirm_card_data, try_again_later, or do_not_try_again. The single most actionable field. |
docUrl | Deep link to the matching row in the codes reference. |
param | The request body field that failed, if applicable. Usually null on this surface. |
chargeId | The ch_* ID of the Charge that failed. |
paymentMethodType | The kind of payment method (card, valu, fawry, etc.). |
paymentMethod | Snapshot of the payment method at the time of failure (brand, last4 for cards). |
processorCode, processorMessage | Raw codes and messages from the processor, untranslated. For deep debugging only. |
code is always set. declineCode and networkDeclineCode are set on card declines; the rest fill in based on what XPay can extract from the processor response.
The three code spaces
lastPaymentError carries up to three different codes for the same failure. Each is the source of truth for a different question:
| Field | Question it answers | Where to find descriptions |
|---|---|---|
code | What kind of failure was this? | Payment error codes |
declineCode | Why did the issuer decline? (cards only) | Decline codes |
networkDeclineCode | What raw code did the card network return? | Brand-specific; pass through to support if needed |
For most handlers, branch on adviceCode first, surface merchantMessage in your ops UI, and use code or declineCode to pick customer-facing copy.
The handler pattern
The action you take is decided by adviceCode, not by code. The code list grows; the three advice values don't.
adviceCode | What it means | What to do |
|---|---|---|
confirm_card_data | The card data was probably wrong (CVV mismatch, incorrect number, postal code mismatch). | Tell the customer to re-enter their card. Don't suggest a different card; the same card with corrected details is likely to work. |
try_again_later | Transient: insufficient funds, processor timeout, network glitch. | Tell the customer to try again, or surface a "try a different payment method" option. Safe to retry once. |
do_not_try_again | Hard reject: stolen card, lost card, fraud, expired card. | Don't retry the same card. Show the customer a "use a different payment method" prompt. Notify your team if the rate spikes. |
function handlePaymentFailure(error: LastPaymentError) {
// Always log first. The merchantMessage is what your support team needs.
log.error("payment failed", {
chargeId: error.chargeId,
type: error.type,
code: error.code,
declineCode: error.declineCode,
networkDeclineCode: error.networkDeclineCode,
merchantMessage: error.merchantMessage,
docUrl: error.docUrl,
});
// Decide UX from adviceCode.
switch (error.adviceCode) {
case "confirm_card_data":
return showRetryWithCard(error.message);
case "try_again_later":
return showRetryWithFallback(error.message);
case "do_not_try_again":
return showUseDifferentMethod(error.message);
default:
// Missing adviceCode is rare. Treat as do_not_try_again to be safe.
return showUseDifferentMethod(error.message);
}
}Customer copy vs merchant copy
XPay returns two messages on every failure. They're not interchangeable.
| Field | Audience | Example |
|---|---|---|
message | Your customer. Already filtered for what's safe to display. | "Your card has insufficient funds." |
merchantMessage | Your team (support, ops dashboards). Includes context that's useful internally but inappropriate for customers. | "Declined: insufficient funds. Customer should use a different payment method." |
The dashboard's transaction tooltip uses merchantMessage. Build your own ops UI the same way. For the customer-facing surface (your checkout page, your transactional emails), use message or your own copy keyed off code / declineCode.
Don't show merchantMessage to customers. It can leak internal context. Don't show processorCode or processorMessage to customers either; those are raw processor passthrough, untranslated.
Reading lastPaymentError in webhooks
The charge.failed webhook carries a Charge in event.data.object. The Charge has the headline failure (failureCode, failureMessage) but not the full structured detail. To read the full lastPaymentError, fetch the parent Payment Intent:
async function onChargeFailed(event: { data: { object: Charge } }) {
const charge = event.data.object;
// Quick action: the Charge alone has the headline.
log.warn("charge failed", {
chargeId: charge.id,
failureCode: charge.failureCode,
failureMessage: charge.failureMessage,
});
// Full detail: fetch the PI.
const pi = await getPaymentIntent(charge.paymentIntentId);
if (pi.lastPaymentError) {
handlePaymentFailure(pi.lastPaymentError);
}
}lastPaymentError is set on the PI for the lifetime of the resource. It records the most recent failed attempt; subsequent retries on the same PI overwrite it.
What not to do
- Don't branch on
codealone. The code list grows over time. Branch onadviceCodefor what to do, then usecodefor narrow copy decisions. - Don't show
merchantMessageto your customers. It can include details that are inappropriate for the consumer surface. - Don't ignore
adviceCode: "do_not_try_again". Retrying after a stolen-card or fraud decline doesn't help and can flag your account to the issuer. - Don't blindly retry on
try_again_later. Retry once, then move the customer to a different method or end the flow. Looping on ado_not_honordecline produces the same result every time. - Don't use
networkDeclineCodeas your primary branch. It's a raw passthrough that depends on the card brand. UsedeclineCode(XPay's normalized form) instead.
Where to next
Payment error codes
Every value of lastPaymentError.code, with the customer-safe and merchant-facing copy XPay returns.
Decline codes
The issuer's reason for the decline (lastPaymentError.declineCode). The most specific code XPay carries.
API errors
The other surface: synchronous errors returned in your API call's HTTP response.
Events panel
See every charge.failed event in the dashboard. Resend, drill into the attempt, jump to the Payment Intent.
Logs panel
The original API call that produced the failed Charge. Useful for cross-referencing.
Errors introduction
Back to the top. The two-surfaces map.