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

# Charge a wallet by scanned QR token

> Debits the cardholder's wallet by `amountMNT` and records the transaction. The scanned token's nonce is rotated in the same database transaction as the debit, so the token you just charged is dead the instant the charge commits — a second attempt with the same token returns `409`.

The merchant and terminal are taken from the authenticated credential and are never read from the request body. Send the barcode payload exactly as scanned.

The response always reports `vatStatus: "pending"`: the VAT receipt is minted out-of-band and attached to the transaction later. Poll `GET /v1/transactions` to learn the receipt number.



## OpenAPI

````yaml openapi.json POST /v1/transactions/charge-by-token
openapi: 3.1.0
info:
  title: Vibepay Charge API
  version: 1.0.0
  description: >-
    The public API a merchant point-of-sale charges a Vibepay meal-benefit
    wallet against.


    A cardholder's card lives in Apple or Google Wallet as a pass that displays
    a rotating QR token. Your terminal scans that token, posts it here with an
    amount in tugrik, and the wallet is debited atomically. Every card charge
    also produces a Mongolian VAT receipt (ДДТД), issued asynchronously.


    This surface is authenticated per terminal with HTTP Basic and is intended
    for server-to-server or native POS callers. It sends no CORS headers, so a
    browser page cannot call it cross-origin.
  contact:
    name: Vibepay integrations
    email: info@vibepay.mn
    url: https://vibepay.mn
servers:
  - url: https://api.vibepay.mn
    description: >-
      Production. There is no separate sandbox — see the Environments guide for
      the small-amount charge-and-reverse loop used to verify an integration.
security:
  - terminalBasic: []
tags:
  - name: Transactions
    description: Charge a wallet, reverse a charge, and read this terminal's own history.
  - name: Service
    description: Unauthenticated liveness and readiness probes.
paths:
  /v1/transactions/charge-by-token:
    post:
      tags:
        - Transactions
      summary: Charge a wallet by scanned QR token
      description: >-
        Debits the cardholder's wallet by `amountMNT` and records the
        transaction. The scanned token's nonce is rotated in the same database
        transaction as the debit, so the token you just charged is dead the
        instant the charge commits — a second attempt with the same token
        returns `409`.


        The merchant and terminal are taken from the authenticated credential
        and are never read from the request body. Send the barcode payload
        exactly as scanned.


        The response always reports `vatStatus: "pending"`: the VAT receipt is
        minted out-of-band and attached to the transaction later. Poll `GET
        /v1/transactions` to learn the receipt number.
      operationId: chargeByToken
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          description: >-
            A unique key for this checkout attempt, reused across every network
            retry of that same attempt so a timeout cannot double-charge.
            Omitting the header is allowed and turns retry collapsing off.
            Sending a blank or whitespace-only value is rejected with `400` —
            that reads as idempotency being implemented while silently disabled.
            Keys are scoped per merchant, not per terminal.
          schema:
            type: string
            minLength: 1
            maxLength: 255
          example: term-1755500000123456-2847193055
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChargeRequest'
      responses:
        '201':
          description: >-
            Charge approved and committed. The wallet has been debited and the
            VAT receipt is queued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TerminalTransaction'
        '400':
          description: The request was rejected before any wallet was touched.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/PlainTextError'
              examples:
                missingToken:
                  summary: qrToken absent or empty
                  value: |
                    qrToken is required
                nonPositiveAmount:
                  summary: >-
                    amountMNT is zero or negative, or above the per-charge cap
                    of 1,000,000 ₮
                  value: |
                    amountMNT must be positive
                unknownField:
                  summary: >-
                    Malformed JSON, an unknown field, or a body over 1 MiB. Note
                    that `amountMnt` is an unknown field here — this surface
                    spells it `amountMNT`.
                  value: |
                    invalid request body
                blankIdempotencyKey:
                  summary: Idempotency-Key present but blank
                  value: >
                    Idempotency-Key must not be blank (omit the header entirely
                    to opt out of retry collapsing)
                longIdempotencyKey:
                  summary: Idempotency-Key over 255 characters
                  value: |
                    Idempotency-Key too long
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: >-
            The token parsed and verified, but the card or wallet behind it no
            longer exists.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/PlainTextError'
              examples:
                walletNotFound:
                  summary: Unknown wallet
                  value: |
                    wallet not found
        '409':
          description: >-
            The charge was not applied because this operation has already
            happened. Both cases are safe to treat as "already done" rather than
            as a failure — but confirm with `GET /v1/transactions` before
            handing goods over.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/PlainTextError'
              examples:
                tokenSuperseded:
                  summary: >-
                    The scanned token was already spent — ask the cardholder to
                    show a fresh QR code
                  value: |
                    qr token already used
                idempotencyConflict:
                  summary: >-
                    The Idempotency-Key was reused for a different charge, or
                    its original transaction has since been reversed
                  value: |
                    transaction already exists
        '422':
          description: >-
            The request was well-formed but the charge was declined. This is the
            only status that returns JSON with a machine-readable `code`; branch
            on `code`, never on the `error` text.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeclineError'
              examples:
                insufficientFunds:
                  summary: Balance too low
                  value:
                    error: insufficient funds
                    code: INSUFFICIENT_FUNDS
                tokenExpired:
                  summary: The displayed QR code is stale
                  value:
                    error: qr token expired — ask the customer to refresh their card
                    code: TOKEN_EXPIRED
                dailyLimit:
                  summary: The employer's daily spend limit is used up for today
                  value:
                    error: daily spend limit exceeded
                    code: DAILY_LIMIT_EXCEEDED
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
components:
  schemas:
    ChargeRequest:
      type: object
      title: Charge request
      description: >-
        Unknown fields are rejected with `400`. This surface spells the amount
        `amountMNT` with an uppercase suffix; `amountMnt` is an unknown field
        and will fail rather than be read as zero.
      additionalProperties: false
      required:
        - qrToken
        - amountMNT
      properties:
        qrToken:
          type: string
          description: >-
            The barcode payload scanned from the cardholder's wallet pass,
            passed through byte for byte. Always begins with `vqr_`. Single-use:
            it stops working the moment a charge against it commits.
          pattern: ^vqr_
          example: vqr_AXk9Lm0pQr2sTu4vWx6yZa8bCd0eFg2h.Ij4kLm6nOp8qRs0t
        amountMNT:
          type: integer
          format: int64
          description: >-
            Amount in whole Mongolian tugrik. There are no minor units — ₮1,500
            is `1500`. Must be positive and no greater than 1,000,000 per
            charge.
          minimum: 1
          maximum: 1000000
          example: 12500
    TerminalTransaction:
      type: object
      title: Transaction
      description: >-
        The projection a terminal sees. It deliberately omits the wallet, card,
        employer and merchant identifiers: a device handled by cashiers carries
        no more customer-identifying data than it needs.
      required:
        - id
        - amountMNT
        - status
        - type
        - vatReceiptID
        - createdAt
      properties:
        id:
          type: string
          description: >-
            Transaction identifier. Store this — it is the only handle for a
            later reversal.
          example: tx_01k2y7v9j0e8ra7cx3mbq4d5nf
        amountMNT:
          type: integer
          format: int64
          description: >-
            Amount in whole tugrik, always positive. A reversal does not negate
            it; `status` carries that instead.
          example: 12500
        status:
          type: string
          description: '`COMPLETED` for a live charge, `REVERSED` once it has been refunded.'
          enum:
            - COMPLETED
            - REVERSED
          example: COMPLETED
        type:
          type: string
          description: >-
            `CHARGE` is the only type a terminal creates. `CREDIT` (an employer
            top-up) and `RECLAIM` (a leaver's unspent balance being swept) exist
            elsewhere in the system and are never reversible from a terminal.
          enum:
            - CHARGE
            - CREDIT
            - RECLAIM
          example: CHARGE
        vatReceiptID:
          type: string
          description: >-
            The Mongolian VAT receipt number (ДДТД) once the tax authority has
            issued it. Empty on a fresh charge, because issuance is
            asynchronous. Retained after a reversal as the historical record.
          example: ''
        vatStatus:
          type: string
          description: >-
            Where the charge sits in the VAT pipeline. Present only on a
            `CHARGE`. `pending` means the receipt is queued, `issued` means
            `vatReceiptID` is populated, and `voided` means the charge was
            reversed and its receipt cancelled.
          enum:
            - pending
            - issued
            - voided
          example: pending
        terminalID:
          type: string
          description: >-
            The terminal that took the payment. Omitted on transactions created
            outside a terminal.
          example: ter_01k2y7v8t5f3s9wq1mzd7b6cxa
        createdAt:
          type: string
          format: date-time
          description: When the transaction committed, as an RFC 3339 timestamp in UTC.
          example: '2026-08-18T09:14:22.481739Z'
    PlainTextError:
      type: string
      title: Plain-text error
      description: >-
        Every status other than `422` returns `text/plain; charset=utf-8` — a
        bare English message with a trailing newline, and no JSON and no error
        code. Branch on the HTTP status, not on this text.
    DeclineError:
      type: object
      title: Decline
      description: >-
        The body of a `422`. This is the only error shape on this API that is
        JSON; every other status returns plain text.
      required:
        - error
        - code
      properties:
        error:
          type: string
          description: >-
            A human-readable English message, meant for logs and support
            conversations. Do not show it to a cashier and do not parse it — it
            can change.
          example: insufficient funds
        code:
          type: string
          description: >-
            The stable machine-readable reason. Branch on this and map it to
            your own localized cashier-facing message.
          enum:
            - INSUFFICIENT_FUNDS
            - DAILY_LIMIT_EXCEEDED
            - WEEKDAY_NOT_ALLOWED
            - TIME_NOT_ALLOWED
            - WALLET_SUSPENDED
            - CARD_CANCELLED
            - CARD_FROZEN
            - CARD_WALLET_MISMATCH
            - TOKEN_EXPIRED
            - TOKEN_INVALID
          example: INSUFFICIENT_FUNDS
  responses:
    Unauthorized:
      description: >-
        The credentials are missing, unrecognized, wrong, or belong to a
        suspended terminal. All four cases are indistinguishable by design. Stop
        retrying and re-pair the terminal.
      headers:
        WWW-Authenticate:
          description: Always `Basic realm="vibepay-terminal"`.
          schema:
            type: string
      content:
        text/plain:
          schema:
            $ref: '#/components/schemas/PlainTextError'
          examples:
            unauthorized:
              summary: Any authentication failure
              value: |
                unauthorized
    TooManyRequests:
      description: >-
        The API gateway's request ceiling was hit. The limit applies to the
        whole surface rather than to your terminal alone, so back off
        exponentially with jitter and retry — reusing the same `Idempotency-Key`
        if you are retrying a charge.
      content:
        application/json:
          schema:
            type: object
            properties:
              message:
                type: string
                description: Always `Too Many Requests`.
    InternalError:
      description: >-
        Something failed on Vibepay's side. The charge may or may not have been
        applied, so never assume it failed: retry with the same
        `Idempotency-Key`, or confirm with `GET /v1/transactions` before handing
        goods over.
      content:
        text/plain:
          schema:
            $ref: '#/components/schemas/PlainTextError'
          examples:
            internal:
              summary: Unexpected server error
              value: |
                internal error
  securitySchemes:
    terminalBasic:
      type: http
      scheme: basic
      description: >-
        Per-terminal HTTP Basic credentials, issued in the Vibepay merchant
        dashboard under Terminals. The username starts with `term_` and the
        password with `vpt_`. Every authentication failure — missing header,
        unknown username, wrong password, suspended terminal — returns the same
        `401`, so the response can never be used to discover valid usernames.

````