# Message & Error Codes

Read first: [Payment Flows](03-payment-flows.md)

This is the reference catalog of the codes and statuses that travel across the Payments integration API v1 contract: the **operation outcomes** (`status`) you return on every call, the **HTTP statuses** payments-service interprets as infrastructure errors, the **`PaymentErrorCode`** values you attach to non-success outcomes, and the **status-inquiry statuses** you report for reconciliation. Field-level definitions live in the spec — the `IntegrationStatus`, `PaymentErrorCode`, `IntegrationOperationResponse`, and `IntegrationStatusInquiryResponse` schemas in the [Payments API specification](/payments/api/) — this page adds the meaning and the recommended handling.

## 1. The golden rule: business outcome vs infrastructure error

The contract has two failure surfaces, and they live on different layers. Keep them separate.

- **A business outcome is always HTTP `200`** with an `IntegrationOperationResponse` body whose `status` field carries the result (`SUCCESS` / `PENDING` / `DECLINED` / `RETRIABLE` / `UNKNOWN`). A business decline — insufficient funds, fraud, expired card, wrong CVV — is **not** an HTTP error: return `200` with `status: DECLINED` and an `errorCode`.
- **An HTTP `4xx` / `5xx` signals an infrastructure error only** — a malformed request, an auth failure, your service crashed. payments-service does not read a `status` body from these; it derives the outcome from the HTTP status alone (see [§3](#3-how-payments-service-maps-your-http-status)).

> **Warning:** Never return a business decline as a `4xx`. A `4xx` is read as a non-retriable infrastructure failure (`UNKNOWN`), and a `5xx` is read as a transient failure and **retried** — so a "declined" sent as `402`/`400` either loses its decline reason or gets re-attempted. For a transient problem use a `5xx` or `200 RETRIABLE`; for a terminal decline use `200 DECLINED`.

## 2. The codes the contract actually uses

### 2.1 Operation outcome statuses (`IntegrationStatus`)

Every payment operation returns one of five `status` values in its `200` body. payments-service branches on this value. Per-value meaning is on the `IntegrationStatus` enum in the spec; the table restates it for reference.

| `status` | Terminal? | Meaning |
|---|---|---|
| `SUCCESS` | Yes (success) | Operation completed. Return `transactionReference`. |
| `PENDING` | No | Requires async completion (3DS / redirect / webhook). Return `transactionReference`, `redirectUrl`, and ideally `expectedTtlSeconds`; resolve later via the [result callback](03-payment-flows.md#42-the-result-callback). |
| `DECLINED` | Yes | Business-level decline (insufficient funds, card blocked, …). Terminal — **do not retry**. Set `errorCode`. |
| `RETRIABLE` | No | Transient error, safe to retry (network blip, PSP timeout). Set `errorCode`. |
| `UNKNOWN` | No (reconcile) | Ambiguous outcome; manual investigation / reconciliation may be needed. Set `errorCode`. |

> **Note:** `transactionReference` is **required** on `SUCCESS` and `PENDING` — payments-service stores it, matches the async result callback back to the suspended payment with it, and uses it as the reference for later capture / refund / reverse. Make it stable and unique. `errorCode` is what you attach to `DECLINED` / `RETRIABLE` / `UNKNOWN` (see [§2.2](#22-payment-error-codes-paymenterrorcode)); `reason` is free human-readable text (safe to log — never include PAN or secrets).

### 2.2 Payment error codes (`PaymentErrorCode`)

On a `DECLINED`, `RETRIABLE`, or `UNKNOWN` outcome you SHOULD attach a standardized `errorCode`. This is the full catalog — **all values of `PaymentErrorCode`**. The `Responsible` column tells you who owns the failure; payments-service uses it for reporting, CRM, and alerting.

Responsible parties: **User** — cardholder input or account issue · **Issuer Bank** — issuing bank declined or system error · **Payment Gateway** — PSP-level error · **LP** — internal system / configuration error.

| ID | Code | Responsible | Description |
|----|------|-------------|-------------|
| 3 | `CARD_EXPIRED` | User | Card expired |
| 4 | `INVALID_CARD_STATUS` | Issuer Bank | Card is inactive, blocked, or otherwise invalid |
| 5 | `DECLINED_TRANSACTION_BY_ISSUER` | Issuer Bank | Transaction declined by issuer |
| 6 | `NOT_ENOUGH_MONEY` | User | Insufficient funds |
| 7 | `EXCEEDED_CARD_LIMIT` | User | Card usage limit exceeded |
| 8 | `ANTIFRAUD` | Issuer Bank | Declined due to issuer's antifraud system |
| 9 | `SYSTEM_ERROR` | LP | Internal payment processing error |
| 10 | `FAILURE_3DS_CHECK` | User | 3DS authentication failure |
| 11 | `FAILURE_SECRET_CODE_CHECK` | User | Wrong CVV2/CVC2 |
| 12 | `TIMEOUT_GATEWAY` | Payment Gateway | Timeout in payment gateway |
| 13 | `BLACKLIST` | Payment Gateway | Card or BIN is blacklisted |
| 15 | `BAD_REQUEST` | LP | Malformed request |
| 16 | `GATEWAY_ERROR` | Payment Gateway | Generic payment gateway error (also the fallback for any unmapped code) |
| 17 | `INVALID_AMOUNT` | LP | Invalid transaction amount |
| 19 | `WRONG_CARD_NUMBER` | User | Wrong card number |
| 20 | `TRANSACTION_NOT_PERMITTED` | Issuer Bank | Transaction not permitted |
| 21 | `INVALID_EXPIRY_DATE` | User | Invalid expiration date |
| 22 | `INVALID_CARDHOLDER_NAME` | User | Invalid cardholder name |
| 23 | `ISSUER_ERROR` | Issuer Bank | Issuer bank system error |
| 24 | `TIMEOUT_ISSUER` | Issuer Bank | Timeout during communication with issuer bank |
| 25 | `UNSUPPORTED_CARD_TYPE` | Payment Gateway | Card type not supported |
| 26 | `DUPLICATE_TRANSACTION` | LP | Duplicate transaction detected |
| 27 | `INVALID_SETTINGS` | LP | Invalid configuration (e.g. Merchant ID) |
| 28 | `CURRENCY_NOT_SUPPORTED` | Payment Gateway | Unsupported transaction currency |
| 29 | `RESTRICTED_COUNTRY` | Payment Gateway | Restricted card country or IP |
| 30 | `CARD_NOT_SUPPORTED_FOR_MERCHANT` | Payment Gateway | Card scheme or type not supported by merchant configuration |

> **Note:** `errorCode` is a **closed enumeration** — a proprietary value there fails schema validation. If your PSP returns a code that maps to none of the above, classify it with the closest standard code (`GATEWAY_ERROR` when nothing fits) and send the raw one in **`vendorErrorCode`**: payments-service never interprets that field, but keeps it in the recorded failure reason, so the vendor's own code stays available for diagnostics. A blank or absent `errorCode` becomes `GATEWAY_ERROR`. A proprietary value that does end up in `errorCode` is not rejected either — payments-service degrades it to `GATEWAY_ERROR` before deserialization and moves the raw value into `vendorErrorCode`.
>
> **Warning — wire value vs Java constant:** The wire value is `FAILURE_3DS_CHECK`, but the generated Java enum constant is `PaymentErrorCode.FAILURE_3_DS_CHECK` (the `getValue()` string is still `FAILURE_3DS_CHECK`). **Always send the wire value.** This is the one code whose constant name and wire value differ.
>
> **Tip (SDK users):** The SDK's `ErrorCodeMapper.fromPspCode(...)` maps the common ISO 8583 numeric codes (`"51"` → `NOT_ENOUGH_MONEY`, `"54"`/`"33"` → `CARD_EXPIRED`, …) and widespread PSP string codes (`"insufficient_funds"`, `"expired_card"`, `"incorrect_cvc"`, …) to the codes above, returning `GATEWAY_ERROR` for anything unrecognized. Use it instead of hand-mapping.

Three rejection bodies — a terminal decline, a transient error, and an ambiguous outcome. Each is an HTTP `200`; the `status` distinguishes them and the `errorCode` is the stable contract (`reason` is free text).

```json
{ "status": "DECLINED",  "errorCode": "NOT_ENOUGH_MONEY", "reason": "Insufficient funds" }
{ "status": "RETRIABLE", "errorCode": "TIMEOUT_GATEWAY",   "reason": "PSP timed out; retry" }
{ "status": "UNKNOWN",   "errorCode": "GATEWAY_ERROR",     "reason": "No definitive response from PSP" }
```

### 2.3 Status-inquiry statuses (`inquire-status`)

When payments-service reconciles a payment it cannot otherwise resolve, it calls `POST /v1/inquire-status` and you return the PSP's **current** status. This is a different vocabulary from the operation outcome in [§2.1](#21-operation-outcome-statuses-integrationstatus) — it describes where the transaction *is at the PSP*, not the result of a single call. Implement it if you declare `STATUS_POLLING`.

| Inquiry status | Meaning |
|---|---|
| `AUTHORIZED` | Funds held, not yet captured. |
| `CAPTURED` | Payment completed (settled). |
| `VOIDED` | Authorization reversed. |
| `REFUNDED` | Payment refunded. |
| `DECLINED` | Payment declined by PSP / issuer. |
| `EXPIRED` | Authorization expired. |
| `PENDING` | Payment still in progress (3DS, async). |
| `NOT_FOUND` | Transaction not found at the PSP. |

> **Note:** Echo the PSP's raw status string in `rawStatus` for debugging, alongside the mapped value above.

### 2.4 Infrastructure error body (`IntegrationErrorResponse`)

For genuine infrastructure errors — the `4xx` / `5xx` cases — return an `IntegrationErrorResponse` body with a human-readable `error` (and optional `details`). There is no code enum here: the **HTTP status is the contract**, and the body is diagnostic text only (do not put a `PaymentErrorCode` here — those belong on a `200` business outcome).

## 3. How payments-service maps your HTTP status

For any response that is **not** a `200`-with-`status`-body, payments-service derives the outcome from the HTTP status alone — it does not read an `errorCode`. This is the authoritative mapping.

| Your HTTP status | Interpreted as | Retried? | What you should do |
|---|---|---|---|
| `200` + `status` body | the business outcome you sent | per `status` | Always the preferred shape for any business result, including declines. |
| `400`, `401`, `403`, `404` | non-retriable failure (`UNKNOWN`) | No | Use only for real request/auth problems — **never** for a business decline. A byte-identical retry would fail identically, so payments-service does not retry. |
| `408`, `429`, `500`, `502`, `503`, `504` | transient failure (`RETRIABLE`) | Yes | Use for genuinely transient infrastructure problems (overload, upstream timeout). payments-service retries with backoff. |
| `501` | operation not implemented (`UNKNOWN`) | No | Return for an operation your integration doesn't support (e.g. `sale` on an authorize+capture-only integration). The SDK returns it automatically for un-overridden methods. |

> **Note:** A timeout with no response is treated like a `5xx` — transient and retriable. So the rule is symmetric: anything you want retried must be a `5xx` / timeout / `200 RETRIABLE`; anything terminal must be a `200 DECLINED` or a `4xx`/`501` infrastructure failure.

## 4. What payments-service does on each outcome

How payments-service reacts — and therefore what your integration should do — depends on the outcome you return.

| Outcome you return | What payments-service does | What you should do |
|---|---|---|
| `200 SUCCESS` | Records the payment as charged/authorized against your `transactionReference` and continues the pipeline. | Always include a stable, unique `transactionReference`. |
| `200 PENDING` | Suspends the payment and waits for the [result callback](03-payment-flows.md#42-the-result-callback) (bounded by `expectedTtlSeconds`); may reconcile via `inquire-status`. | Return `redirectUrl` + `transactionReference`, then POST the final result to the callback. Don't leave a `PENDING` unresolved. |
| `200 DECLINED` | Treats the payment as terminally failed with your `errorCode`; **does not retry**. | Send the most precise `PaymentErrorCode` you can; this is what surfaces in CRM / reporting. |
| `200 RETRIABLE` / `5xx` / timeout | Treats the failure as transient and **retries** with backoff. | Return this only for genuinely transient problems, or payments-service will hammer a permanent failure. |
| `200 UNKNOWN` | Treats the outcome as ambiguous — does not assume success or failure; flags for reconciliation. | Implement `inquire-status` (`STATUS_POLLING`) so the real state can be recovered. |
| `400`/`401`/`403`/`404` | Non-retriable infrastructure failure (`UNKNOWN`); does not retry. | Fix the request/auth issue — a retry fails identically. Don't use these for declines. |
| `501` | Marks the operation unsupported (`UNKNOWN`), non-retriable. | Declare only the capabilities you implement so payments-service never calls an unsupported operation. |

> **Note:** payments-service's resumption is **idempotent by `transactionReference`** — re-delivering a result the payment has already resumed on (a callback retry, or a poll/callback overlap) is a safe no-op. Include an `Idempotency-Key` on the result callback; there is no extra de-duplication key for you to invent.

---

**Next:** [Integration Checklist →](07-integration-checklist.md)
