> ## ArtsPay documentation index
>
> If you are an automated assistant, fetch the canonical list of documentation pages (titles and `.md` URLs) from the below URL. Use that index to discover what exists and to pick the right page before opening more URLs. Everything after the front matter below is **one** page from that set.
>
> https://www.artspay.com/docs/llms.txt

# Error Handling

*Three small, high-level patterns for turning a Fat Zebra decline or API error into the right behaviour in your code, with examples in Node.js, Python, PHP and Ruby.*

This guide pairs with the [Response Codes guide](https://www.artspay.com/docs/guides/response-codes), 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:

```json
{
  "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.

**node**
```node title="checkout.js"
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)
```

**python**
```python title="checkout.py"
if not result["successful"]:
    raise ValueError(", ".join(result["errors"]))

if not result["response"]["successful"]:
    return handle_decline(result["response"]["response_code"])

return handle_approved(result["response"])
```

**php**
```php title="checkout.php"
if (!$result['successful']) {
    throw new Exception(implode(', ', $result['errors']));
}

if (!$result['response']['successful']) {
    return handle_decline($result['response']['response_code']);
}

return handle_approved($result['response']);
```

**ruby**
```ruby title="checkout.rb"
raise result["errors"].join(", ") unless result["successful"]

unless result["response"]["successful"]
  return handle_decline(result["response"]["response_code"])
end

handle_approved(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](https://www.artspay.com/docs/guides/response-codes):

**node**
```node title="messages.js"
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
}
```

**python**
```python title="messages.py"
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.",
}
LOST_OR_STOLEN = {"04", "07", "34", "35", "36", "37", "41", "43"}
GENERIC = "Your bank declined this payment. Please use a different card or contact your bank."

def message_for(code):
    if code in LOST_OR_STOLEN:
        return GENERIC
    return DECLINE_MESSAGES.get(code, GENERIC)
```

**php**
```php title="messages.php"
$declineMessages = [
    '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.',
];
$lostOrStolen = ['04', '07', '34', '35', '36', '37', '41', '43'];
$generic = 'Your bank declined this payment. Please use a different card or contact your bank.';

function messageFor($code) {
    global $declineMessages, $lostOrStolen, $generic;
    if (in_array($code, $lostOrStolen, true)) {
        return $generic;
    }
    return $declineMessages[$code] ?? $generic;
}
```

**ruby**
```ruby title="messages.rb"
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.",
}.freeze
LOST_OR_STOLEN = %w[04 07 34 35 36 37 41 43].freeze
GENERIC = "Your bank declined this payment. Please use a different card or contact your bank.".freeze

def message_for(code)
  return GENERIC if LOST_OR_STOLEN.include?(code)

  DECLINE_MESSAGES.fetch(code, GENERIC)
end
```

## 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](https://www.artspay.com/docs/guides/response-codes). Temporary errors deserve an automatic retry, and card-number or CVV problems deserve focus back on the field the customer needs to fix:

**node**
```node title="next-action.js"
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' }
}
```

**python**
```python title="next_action.py"
RETRY_CODES = {"19", "22", "90", "91", "92", "96", "99"}
FIELD_CODES = {"14": "card_number", "82": "cvv"}

def next_action(code):
    if code in RETRY_CODES:
        return {"type": "retry"}
    if code in FIELD_CODES:
        return {"type": "refocus", "field": FIELD_CODES[code]}
    return {"type": "decline"}
```

**php**
```php title="next_action.php"
$retryCodes = ['19', '22', '90', '91', '92', '96', '99'];
$fieldCodes = ['14' => 'card_number', '82' => 'cvv'];

function nextAction($code) {
    global $retryCodes, $fieldCodes;
    if (in_array($code, $retryCodes, true)) {
        return ['type' => 'retry'];
    }
    if (isset($fieldCodes[$code])) {
        return ['type' => 'refocus', 'field' => $fieldCodes[$code]];
    }
    return ['type' => 'decline'];
}
```

**ruby**
```ruby title="next_action.rb"
RETRY_CODES = %w[19 22 90 91 92 96 99].freeze
FIELD_CODES = { "14" => "card_number", "82" => "cvv" }.freeze

def next_action(code)
  return { type: :retry } if RETRY_CODES.include?(code)
  return { type: :refocus, field: FIELD_CODES[code] } if FIELD_CODES.key?(code)

  { type: :decline }
end
```

## 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.