Error Handling
This guide pairs with the Response Codes guide, which covers what each response_code means and how to map it to a safe message. This one is about where that check actually lives in your code: three small patterns every integration needs, shown in Node.js, Python, PHP and Ruby.
A decline isn't an exception, it's a normal response your code has to branch on every time a card is charged. Handle it once, in one place, and every checkout page in your app behaves the same way. Handle it ad hoc on each page, and you end up with some pages showing raw bank text to customers and others swallowing declines silently.
The response shape
Every Purchase, Refund and Auth/Capture call returns the same envelope. The top-level successful means the request was valid; response.successful means the transaction was approved. A malformed request and a declined card look different at this level, so check both:
{ "successful": true, "response": { "successful": false, "response_code": "05", "message": "Declined" }, "errors": []}The examples below assume result is already the parsed JSON body of that call, however you're making it (raw HTTP, or your own wrapper). Nothing here depends on a particular SDK.
Pattern 1: Check both levels of successful
A 400-style problem (bad data, a permissions error) and a declined card both come back as JSON, not as a thrown error, so check the request-level and transaction-level successful separately before doing anything else.
if (!result.successful) { // The request itself was invalid, not a decline throw new Error(result.errors.join(', '))}if (!result.response.successful) { return handleDecline(result.response.response_code)}return handleApproved(result.response)Pattern 2: Map the code to a safe message
Once you know it's a decline, look response_code up in a small message map instead of showing the bank's own wording. This mirrors the bucket table in Step 1 of the Response Codes guide:
const DECLINE_MESSAGES = { '51': 'This card was declined. Please use a different card or payment method.', '33': 'This card has expired. Please use a different card.', '54': 'This card has expired. Please use a different card.',}const LOST_OR_STOLEN = ['04', '07', '34', '35', '36', '37', '41', '43']const GENERIC = 'Your bank declined this payment. Please use a different card or contact your bank.'function messageFor(code) { if (LOST_OR_STOLEN.includes(code)) return GENERIC return DECLINE_MESSAGES[code] ?? GENERIC}Pattern 3: Retry or refocus, don't just re-show the decline
A handful of codes need different behaviour, not just different wording; see Step 2 of the Response Codes guide. Temporary errors deserve an automatic retry, and card-number or CVV problems deserve focus back on the field the customer needs to fix:
const RETRY_CODES = ['19', '22', '90', '91', '92', '96', '99']const FIELD_CODES = { '14': 'cardNumber', '82': 'cvv' }function nextAction(code) { if (RETRY_CODES.includes(code)) return { type: 'retry' } if (FIELD_CODES[code]) return { type: 'refocus', field: FIELD_CODES[code] } return { type: 'decline' }}FAQ
Does this apply to Refunds and Auth/Capture too, not just Purchase?
Yes. All three share the same envelope shape, so the same checks work unchanged.
What about the top-level errors array?
That covers request-level problems: bad JSON, a missing field, invalid credentials. Treat those as a bug in your integration to fix, not a payment outcome to show a customer.
Do I need to retry indefinitely on the temporary error codes?
No. Retry once or twice with a short delay. If it's still failing after that, fall through to the generic decline message rather than looping.