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

# Charging a wallet

> The charge request field by field, the amount rules, and what the response tells you.

```http theme={null}
POST /v1/transactions/charge-by-token
```

## The request

```bash theme={null}
curl -u 'term_a7f3k9d2:vpt_…' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: order-84271-attempt-1' \
  -d '{"qrToken":"vqr_AXk9Lm0pQr2sTu4vWx6yZa8bCd0eFg2h.Ij4kLm6nOp8qRs0t","amountMNT":12500}' \
  https://api.vibepay.mn/v1/transactions/charge-by-token
```

<ParamField body="qrToken" type="string" required>
  The scanned code, byte for byte. See [The QR code](/qr-tokens).
</ParamField>

<ParamField body="amountMNT" type="integer" required>
  Whole tugrik. ₮12,500 is `12500`.
</ParamField>

<ParamField header="Idempotency-Key" type="string">
  Optional but strongly recommended. Up to 255 characters. See [Idempotency](/idempotency).
</ParamField>

That is the entire request. There is no merchant field, no terminal field, and no currency field —
merchant and terminal come from your credentials, and the currency is always MNT.

### Amount rules

| Rule               | Value                               |
| ------------------ | ----------------------------------- |
| Unit               | Whole tugrik. No minor units exist. |
| Minimum            | 1                                   |
| Maximum per charge | 1,000,000                           |

<Warning>
  An amount above the per-charge maximum is currently rejected as
  `400 amount must be positive`. The message is misleading — it means *positive and within the
  cap*. If you see it on a plainly positive number, check the amount against the ceiling.
</Warning>

### The request body is strict

Unknown fields are rejected, not ignored:

```json Rejected with 400 invalid request body theme={null}
{ "qrToken": "vqr_…", "amountMnt": 12500 }
```

`amountMnt` is not `amountMNT`. Other Vibepay surfaces spell it in lowercase, and this one
deliberately refuses to guess: silently ignoring the field would leave `amountMNT` at zero and
charge the customer nothing while telling you the sale succeeded. Failing loudly is the safer
answer.

The body is also capped at 1 MiB and must be valid JSON. All three failures return the same
`400 invalid request body`.

## The response

```json 201 Created theme={null}
{
  "id": "tx_01k2y7v9j0e8ra7cx3mbq4d5nf",
  "amountMNT": 12500,
  "status": "COMPLETED",
  "type": "CHARGE",
  "vatReceiptID": "",
  "vatStatus": "pending",
  "terminalID": "ter_01k2y7v8t5f3s9wq1mzd7b6cxa",
  "createdAt": "2026-08-18T09:14:22.481739Z"
}
```

<ResponseField name="id" type="string" required>
  The transaction identifier. **Store this.** It is the only way to refund the payment later.
</ResponseField>

<ResponseField name="amountMNT" type="integer" required>
  What was charged. Always positive, even after a reversal — `status` carries that instead.
</ResponseField>

<ResponseField name="status" type="string" required>
  `COMPLETED` on a fresh charge, `REVERSED` once refunded.
</ResponseField>

<ResponseField name="type" type="string" required>
  Always `CHARGE` for anything you create.
</ResponseField>

<ResponseField name="vatReceiptID" type="string" required>
  The VAT receipt number (ДДТД). **Always empty here** — the receipt is issued afterwards.
</ResponseField>

<ResponseField name="vatStatus" type="string">
  Always `pending` on a fresh charge. See [VAT receipts](/vat-receipts).
</ResponseField>

<ResponseField name="terminalID" type="string">
  The terminal that took the payment.
</ResponseField>

<ResponseField name="createdAt" type="string">
  RFC 3339 UTC timestamp of the commit.
</ResponseField>

<Note>
  The response carries no wallet, card, employer or customer identifier, and it never will. A till
  handled by cashiers should not hold data it has no use for. If you need a customer reference for
  your own records, use `id`.
</Note>

## Reading the outcome

Branch on the HTTP status. Do not pattern-match the message text.

| Status        | Meaning                                      | Cashier should                                |
| ------------- | -------------------------------------------- | --------------------------------------------- |
| `201`         | Approved. Money moved.                       | Complete the sale                             |
| `422`         | Declined for a specific reason — read `code` | Show the reason, offer another payment method |
| `409`         | Already spent, or a duplicate                | **Verify before recharging**                  |
| `400`         | Your request is malformed                    | Nothing — this is an integration bug          |
| `401`         | Credentials dead                             | Re-pair the terminal                          |
| `429` / `5xx` | Unknown outcome                              | Retry idempotently, or verify                 |

<Warning>
  Never treat a timeout or a `5xx` as a decline. The charge may have committed before the
  connection dropped. Retry with the same `Idempotency-Key` or check
  `GET /v1/transactions` — anything else risks charging the customer twice.
</Warning>

## A worked integration

```python theme={null}
import requests, uuid
from requests.auth import HTTPBasicAuth

BASE = "https://api.vibepay.mn"
AUTH = HTTPBasicAuth(TERMINAL_USER, TERMINAL_PASS)

def charge(qr_token: str, amount_mnt: int, idempotency_key: str):
    r = requests.post(
        f"{BASE}/v1/transactions/charge-by-token",
        json={"qrToken": qr_token, "amountMNT": amount_mnt},
        headers={"Idempotency-Key": idempotency_key},
        auth=AUTH,
        timeout=30,
    )

    if r.status_code == 201:
        return "approved", r.json()

    if r.status_code == 422:
        # The only status with a JSON body and a stable code.
        return "declined", r.json()["code"]

    if r.status_code == 409:
        # Already spent or a duplicate — never silently recharge.
        return "verify", r.text.strip()

    if r.status_code in (429, 500, 502, 503, 504):
        # Outcome unknown. Retry with the SAME key, or reconcile.
        return "unknown", r.text.strip()

    r.raise_for_status()

# One key per sale attempt, reused across every retry of that attempt.
status, detail = charge(scanned, 12500, f"order-84271-{uuid.uuid4()}")
```

<CardGroup cols={2}>
  <Card title="Every decline code" icon="triangle-exclamation" href="/errors">
    What each one means and what to tell the customer.
  </Card>

  <Card title="Safe retries" icon="fingerprint" href="/idempotency">
    How the same key collapses a retry into one payment.
  </Card>
</CardGroup>
