docs
IntegrateErrors

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.failureCode and charge.failureMessage: the same headline error, attached to the specific Charge that failed. Useful when you key fulfillment off the Charge.
  • The charge.failed webhook: 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": "..."
}
FieldMeaning
typeBroad category: card_error, payment_method_error (BNPL, wallet, kiosk, bank transfer), processor_error, api_error.
codeThe specific error reason. From the Payment error codes list.
declineCodeFor card declines, the issuer's reason. From the Decline codes list. null for non-decline failures.
networkDeclineCodeThe 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.
messageShort message safe to surface to the customer.
merchantMessageDetailed message for your support team, includes context the customer shouldn't see.
adviceCodeWhat to do next: confirm_card_data, try_again_later, or do_not_try_again. The single most actionable field.
docUrlDeep link to the matching row in the codes reference.
paramThe request body field that failed, if applicable. Usually null on this surface.
chargeIdThe ch_* ID of the Charge that failed.
paymentMethodTypeThe kind of payment method (card, valu, fawry, etc.).
paymentMethodSnapshot of the payment method at the time of failure (brand, last4 for cards).
processorCode, processorMessageRaw 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:

FieldQuestion it answersWhere to find descriptions
codeWhat kind of failure was this?Payment error codes
declineCodeWhy did the issuer decline? (cards only)Decline codes
networkDeclineCodeWhat 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.

adviceCodeWhat it meansWhat to do
confirm_card_dataThe 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_laterTransient: 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_againHard 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.

FieldAudienceExample
messageYour customer. Already filtered for what's safe to display."Your card has insufficient funds."
merchantMessageYour 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 code alone. The code list grows over time. Branch on adviceCode for what to do, then use code for narrow copy decisions.
  • Don't show merchantMessage to 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 a do_not_honor decline produces the same result every time.
  • Don't use networkDeclineCode as your primary branch. It's a raw passthrough that depends on the card brand. Use declineCode (XPay's normalized form) instead.

Where to next

On this page