> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vibepay.mn/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors and declines

> Two error envelopes, ten decline codes, and which failures are worth retrying.

## There are two error shapes, not one

This is the single thing most integrations get wrong on day one.

<CodeGroup>
  ```json 422 — JSON, with a stable code theme={null}
  {
    "error": "insufficient funds",
    "code": "INSUFFICIENT_FUNDS"
  }
  ```

  ```text Every other status — plain text theme={null}
  insufficient funds
  ```
</CodeGroup>

A `422` is a **decline**: your request was fine, but the payment cannot proceed for a specific
business reason. It is the only status that returns `application/json` and the only one carrying a
machine-readable `code`.

Everything else — `400`, `401`, `403`, `404`, `409`, `500` — returns
`text/plain; charset=utf-8`: one line of English with a trailing newline, no JSON, no code.

<Warning>
  Calling `response.json()` on a `409` will throw. Check the status first, and only parse JSON on
  a `422`.
</Warning>

```python theme={null}
if r.status_code == 422:
    code = r.json()["code"]      # safe: 422 is always JSON
else:
    detail = r.text.strip()      # everything else is plain text
```

## Decline codes

All ten arrive as `422`. Branch on `code`; never on the English `error` text, which is written for
your logs and can be reworded.

### The customer's money

| `code`                 | What happened                                            | What to tell the customer                                                |
| ---------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------ |
| `INSUFFICIENT_FUNDS`   | Balance is lower than the amount                         | "Not enough balance — would you like to pay the difference another way?" |
| `DAILY_LIMIT_EXCEEDED` | Their employer's daily spending cap is used up for today | "Today's meal allowance is used up. It resets tomorrow."                 |

### The employer's spend rules

Employers decide when and how their benefit may be spent. These are not errors — they are the
policy working.

| `code`                | What happened                                      | What to tell the customer                      |
| --------------------- | -------------------------------------------------- | ---------------------------------------------- |
| `WEEKDAY_NOT_ALLOWED` | Their benefit is not usable today (often weekends) | "This card can't be used today."               |
| `TIME_NOT_ALLOWED`    | Outside the permitted hours (often lunch only)     | "This card can only be used during set hours." |

<Info>
  Days and times are evaluated in **Asia/Ulaanbaatar**, and the daily limit resets at Mongolian
  midnight — not UTC midnight and not your server's local midnight. A charge at 00:30 in
  Ulaanbaatar is on the new day's allowance.
</Info>

### The card or wallet

| `code`                 | What happened                                      | What to tell the customer                               |
| ---------------------- | -------------------------------------------------- | ------------------------------------------------------- |
| `WALLET_SUSPENDED`     | The employer has suspended this employee's benefit | "This card is inactive — please contact your employer." |
| `CARD_FROZEN`          | The card is temporarily frozen                     | Same as above.                                          |
| `CARD_CANCELLED`       | The card is permanently cancelled                  | Same as above.                                          |
| `CARD_WALLET_MISMATCH` | Internal inconsistency — should not occur          | Take another payment method and report it to us.        |

### The scanned code

| `code`          | What happened                                            | What to do                                                  |
| --------------- | -------------------------------------------------------- | ----------------------------------------------------------- |
| `TOKEN_EXPIRED` | The code is more than 24 hours old                       | Ask them to open their pass, then rescan                    |
| `TOKEN_INVALID` | Not a Vibepay code, or the string was altered in transit | Rescan; check your scanner isn't adding a prefix or newline |

Both are recoverable at the till. Offer a **Rescan** action rather than failing the sale.

## Plain-text errors

### 400 — your request is wrong

| Body                                  | Cause                                                                 |
| ------------------------------------- | --------------------------------------------------------------------- |
| `qrToken is required`                 | Field missing or empty                                                |
| `amountMNT must be positive`          | Zero, negative, **or above the 1,000,000 per-charge cap**             |
| `invalid request body`                | Malformed JSON, an unknown field (`amountMnt`!), or a body over 1 MiB |
| `Idempotency-Key must not be blank …` | Header present but empty — omit it instead                            |
| `Idempotency-Key too long`            | Over 255 characters                                                   |
| `invalid transaction id`              | The `{txID}` in a reversal is not a valid id                          |
| ``invalid `from` (expected RFC3339)`` | Bad list filter                                                       |

These are integration bugs. Retrying will not change the answer.

### 401 — credentials dead

Always the body `unauthorized`, whatever the cause. Stop the sale and re-pair the terminal. See
[Authentication](/authentication).

### 403 — not yours

`forbidden`. On a reversal this means either the transaction belongs to another merchant, or it is
not a charge. The two are deliberately indistinguishable.

### 404 — not found

`wallet not found` on a charge means the code verified but the card behind it no longer exists.
`transaction not found` on a reversal means the id is unknown — check you saved the `id` from the
charge response and not something else.

### 409 — already done

This is the one to handle carefully.

<Warning>
  A `409` almost always means **the thing you were trying to do already happened**. It is far more
  often a success you did not hear about than a failure.
</Warning>

| Body                           | Meaning                                                                                | What to do                                                                                              |
| ------------------------------ | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `qr token already used`        | The scanned code was already spent                                                     | Check `GET /v1/transactions` before recharging — you are probably looking at your own successful charge |
| `transaction already exists`   | Your idempotency key was reused for a *different* charge, or its original was reversed | Use a fresh key for a genuinely new sale                                                                |
| `transaction already reversed` | The refund already went through                                                        | Treat as success — mark it refunded locally                                                             |

### 429 and 5xx — outcome unknown

`{"message":"Too Many Requests"}` and `internal error` respectively. Neither tells you whether the
charge committed. Retry with the same `Idempotency-Key`, or reconcile against
`GET /v1/transactions`. See [Environments and limits](/environments).

## A decision table for your POS

```mermaid theme={null}
flowchart TD
    A[Response] --> B{Status}
    B -->|201| C[Complete the sale]
    B -->|422| D{code}
    D -->|TOKEN_EXPIRED<br/>TOKEN_INVALID| E[Offer Rescan]
    D -->|anything else| F[Show reason<br/>offer another payment method]
    B -->|409| G[Verify against<br/>GET /v1/transactions]
    B -->|400| H[Integration bug<br/>log it, do not retry]
    B -->|401| I[Re-pair the terminal]
    B -->|429 / 5xx / timeout| J[Retry with the SAME<br/>Idempotency-Key]
    J --> A
```

## Do not match on message text

The `error` string and the plain-text bodies are for humans reading logs. They may be reworded at
any time. The contract you can rely on is the **HTTP status** and, on a `422`, the **`code`**.

If you need Mongolian text for a cashier, map the `code` to your own strings — the codes are
stable and the English is not.
