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

# Reverse a transaction

> Posts a compensating entry that exactly negates the original. The reversal is itself a transaction (`kind: "reversal"`, `type: "reversal"`, original type under `reverses.type`); the original becomes `reversed: true` with a `reversed_by` pointer. **Both rows stay in the ledger forever.**

Reversal is all-or-nothing: it always negates the FULL original amount. There is no partial-amount reversal - post a fresh compensating transaction for a partial correction instead. On the reversal row itself, `reversed`/`reversed_by`/`reversed_at` are always `false`/`null`/`null` (a reversal cannot itself be reversed - see `409 cannot_reverse_reversal` below).

Scope: `transactions:write`. `Idempotency-Key` required.

## Only API-originated entries are reversible here

You can only reverse transactions with `source: "api"` - the ones your own integration created. Entries from other channels return `409 source_not_reversible` and are corrected in their originating channel:

- `pos` - the POS's own cancellation flow (the bridge posts the reversal when the bill is voided, keeping the POS and VEXiON in sync)
- `manual` - the staff scanner's undo (it books an opposite entry; the original stays `reversed: false`)
- `online` / `system` - contact support

Reversing a POS entry from the API would desynchronize the ledger from the till.

## Rules

- Original must not already be reversed → `409 already_reversed`
- A tombstone row cannot be reversed → `422 transaction_not_reversible`
- Original must not itself be a reversal → `409 cannot_reverse_reversal` (re-post the action fresh instead of chaining)
- Must not drive any balance negative → `409 would_drive_balance_negative` (top the customer up first, then reverse)
- Originals older than **90 days** → `409 reversal_window_expired` (post a fresh compensating transaction instead)

Tier math is safe: the reversal reuses the original's snapshotted `tier_at_earning` + `applied_multiplier`, never the customer's current tier. A reversal also takes the bill back from level spend, so the customer can drop a level. No event is sent for that.

## The customer is notified

A reversal sends a wallet notification tailored to the card type, carrying the customer's NEW balance: Stamp -> "Transaction reversed. You have 5 stamps.", Reward -> "Transaction reversed. Balance: 120 points.", cashback -> the credit in the business currency when the card shows credit, Discount -> "Transaction reversed. Your level: Silver • 5%.", gift card -> "Transaction reversed. Balance: 38.00€." (a cancelled gift-card purchase reads "Gift card cancelled."). For the numeric card types the message is tied to the value field on the pass, so the phone highlights the number as it changes back, and the pass background (including a tiered card's per-level image) follows the reversed state. Apple Wallet shows a lock-screen banner; Google Wallet updates the object silently (no Google push on reversals). The message is automatic and translated to the customer's language.



## OpenAPI

````yaml /openapi.json post /v1/transactions/{id}/reverse
openapi: 3.1.0
info:
  title: VEXiON cards Public API
  description: >-
    ## Rate limiting


    Two independent layers, checked in this order:


    1. **Per-IP throttle (pre-authentication).** Applied to every `/v1` request
    BEFORE the API key is checked, keyed on the caller's IP address:
       - **600 requests/minute** per IP - a flood ceiling, `429 rate_limit_error` / `rate_limited`.
       - **20 failed authentications per 15 minutes** per IP - a key-guessing ceiling. Only responses that are `401` (missing/invalid/revoked key) count against this ceiling; a `403` from a valid key that simply lacks a scope does not. `429 rate_limit_error` / `too_many_failed_attempts`.

       Both ceilings send `Retry-After` (seconds).
    2. **Per-key, per-bucket limiter (post-authentication).** Documented per
    endpoint (see each operation's `429` response and the `rate_limit` field on
    the API Key resource) - e.g. 60/min default, 120/min for transactions, plus
    the notification-broadcast and lookup-filter buckets described on their own
    endpoints.


    ### Rate-limit response headers


    Every rate-limited response carries the legacy `X-RateLimit-Limit` /
    `X-RateLimit-Remaining` / `X-RateLimit-Reset` family (the last is an **epoch
    timestamp**) AND the IETF standards-track headers, side by side:


    - `RateLimit-Policy: "<bucket>";q=<quota>;w=<window seconds>` - the static
    policy for the bucket that was hit.

    - `RateLimit: "<bucket>";r=<remaining>;t=<seconds until reset>` - live
    state. **`t` is a DURATION (seconds remaining), not the epoch timestamp
    `X-RateLimit-Reset` carries - the two are never the same value.**
  version: 1.0.0
servers:
  - url: https://api.vexioncards.one
    description: Production
  - url: https://migration.api.vexioncards.one
    description: Sandbox
security:
  - apiKey: []
tags:
  - name: Customers
  - name: Cards
  - name: Transactions
  - name: Notifications
  - name: Locations
  - name: Products
  - name: API keys
  - name: Organization
  - name: Webhook endpoints
  - name: Events
  - name: Custom fields
  - name: Team members
  - name: Icons
paths:
  /v1/transactions/{id}/reverse:
    post:
      tags:
        - Transactions
      summary: Reverse a transaction
      description: >-
        Posts a compensating entry that exactly negates the original. The
        reversal is itself a transaction (`kind: "reversal"`, `type:
        "reversal"`, original type under `reverses.type`); the original becomes
        `reversed: true` with a `reversed_by` pointer. **Both rows stay in the
        ledger forever.**


        Reversal is all-or-nothing: it always negates the FULL original amount.
        There is no partial-amount reversal - post a fresh compensating
        transaction for a partial correction instead. On the reversal row
        itself, `reversed`/`reversed_by`/`reversed_at` are always
        `false`/`null`/`null` (a reversal cannot itself be reversed - see `409
        cannot_reverse_reversal` below).


        Scope: `transactions:write`. `Idempotency-Key` required.


        ## Only API-originated entries are reversible here


        You can only reverse transactions with `source: "api"` - the ones your
        own integration created. Entries from other channels return `409
        source_not_reversible` and are corrected in their originating channel:


        - `pos` - the POS's own cancellation flow (the bridge posts the reversal
        when the bill is voided, keeping the POS and VEXiON in sync)

        - `manual` - the staff scanner's undo (it books an opposite entry; the
        original stays `reversed: false`)

        - `online` / `system` - contact support


        Reversing a POS entry from the API would desynchronize the ledger from
        the till.


        ## Rules


        - Original must not already be reversed → `409 already_reversed`

        - A tombstone row cannot be reversed → `422 transaction_not_reversible`

        - Original must not itself be a reversal → `409 cannot_reverse_reversal`
        (re-post the action fresh instead of chaining)

        - Must not drive any balance negative → `409
        would_drive_balance_negative` (top the customer up first, then reverse)

        - Originals older than **90 days** → `409 reversal_window_expired` (post
        a fresh compensating transaction instead)


        Tier math is safe: the reversal reuses the original's snapshotted
        `tier_at_earning` + `applied_multiplier`, never the customer's current
        tier. A reversal also takes the bill back from level spend, so the
        customer can drop a level. No event is sent for that.


        ## The customer is notified


        A reversal sends a wallet notification tailored to the card type,
        carrying the customer's NEW balance: Stamp -> "Transaction reversed. You
        have 5 stamps.", Reward -> "Transaction reversed. Balance: 120 points.",
        cashback -> the credit in the business currency when the card shows
        credit, Discount -> "Transaction reversed. Your level: Silver • 5%.",
        gift card -> "Transaction reversed. Balance: 38.00€." (a cancelled
        gift-card purchase reads "Gift card cancelled."). For the numeric card
        types the message is tied to the value field on the pass, so the phone
        highlights the number as it changes back, and the pass background
        (including a tiered card's per-level image) follows the reversed state.
        Apple Wallet shows a lock-screen banner; Google Wallet updates the
        object silently (no Google push on reversals). The message is automatic
        and translated to the customer's language.
      operationId: reverseTransaction
      parameters:
        - name: id
          in: path
          required: true
          description: The ORIGINAL transaction to reverse (`txn_` prefix).
          example: txn_01HW9R5Q3AB7N8MTX1RVTXNDEF
          schema:
            type: string
            pattern: ^txn_[A-Za-z0-9]+$
        - name: Idempotency-Key
          in: header
          required: true
          description: >-
            Required. Stable opaque string, 8-255 chars. Same key + same body
            within 24 h replays the original response (`Idempotent-Replayed:
            true`); same key + different body → `409 idempotency_key_conflict`.
            See [Idempotency](/api-reference/idempotency).
          schema:
            type: string
            minLength: 8
            maxLength: 255
          example: rev-9c1d6b00-2a3f-4f9c-8a6f-2e10b9e34c12
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TransactionReverseRequest'
            examples:
              With reason:
                summary: Recommended - audit trail
                value:
                  reason: wrong amount entered at till
              Empty body:
                summary: Reverse without a reason
                value: {}
      responses:
        '201':
          description: >-
            Reversal transaction created. Returns the NEW reversal row
            (kind='reversal'), not the original.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Transaction'
              examples:
                Reversal created:
                  summary: kind=reversal, balance restored
                  value:
                    id: txn_01HW9R6P5BCDEFGHJKMNPQRSTV
                    object: transaction
                    kind: reversal
                    type: reversal
                    unit: points
                    card_id: crd_01HW9R4P2ZK7N8MTX1RVCARD01
                    card_type: reward
                    customer_id: cus_01HW9R4P2ZK7N8MTX1RVABCDEF
                    location_id: loc_01HW9R4P2ZK7N8MTX1RVLOCS01
                    team_member_id: null
                    action: redeem
                    value: 18
                    amount_minor: null
                    eligible_spend_minor: null
                    saving_minor: null
                    currency: EUR
                    balance_after:
                      points: 106
                    tier_at_earning: silver
                    applied_multiplier: 1.5
                    source: api
                    external_id: null
                    reversed: false
                    reverses:
                      id: txn_01HW9R5Q3AB7N8MTX1RVTXNDEF
                      type: points.earn
                    reversed_by: null
                    reversed_at: null
                    products: []
                    metadata:
                      reversal_reason: wrong amount entered at till
                    created_at: 1750695000
                    occurred_at: 1750695000
          headers: {}
        '400':
          description: Malformed query parameter, invalid filter value, or invalid header.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          headers: {}
        '401':
          description: Missing, invalid, or revoked API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          headers: {}
        '403':
          description: API key lacks the required scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          headers: {}
        '404':
          description: Transaction not found in your organization.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          headers: {}
        '409':
          description: >-
            `already_reversed`, `cannot_reverse_reversal`,
            `source_not_reversible`, `would_drive_balance_negative` or
            `reversal_window_expired`. No existing reversal is returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                Source not reversible:
                  summary: POS entry - correct it at the POS
                  value:
                    error:
                      type: invalid_request_error
                      code: source_not_reversible
                      message: >-
                        Transactions with source 'pos' cannot be reversed via
                        the API. The POS cancellation flow posts the reversal
                        when the bill is voided.
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
                Already reversed:
                  summary: One reversal per original
                  value:
                    error:
                      type: invalid_request_error
                      code: already_reversed
                      message: >-
                        txn_01HW9R5Q3AB7N8MTX1RVTXNDEF was already reversed by
                        txn_01HW9R6P5BCDEFGHJKMNPQRSTV.
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
                Cannot reverse a reversal:
                  summary: Chaining forbidden
                  value:
                    error:
                      type: invalid_request_error
                      code: cannot_reverse_reversal
                      message: >-
                        Reversal rows cannot be reversed. Post a fresh
                        transaction to re-apply the original action.
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
                Would drive balance negative:
                  summary: Points already spent
                  value:
                    error:
                      type: invalid_request_error
                      code: would_drive_balance_negative
                      message: >-
                        Reversing this earn (18 points) would drive the balance
                        below zero (current: 5). Award points first, then
                        reverse.
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
                Window expired:
                  summary: Older than 90 days
                  value:
                    error:
                      type: invalid_request_error
                      code: reversal_window_expired
                      message: >-
                        Transactions older than 90 days cannot be reversed. Post
                        a compensating transaction instead.
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
          headers: {}
        '413':
          description: >-
            Request body exceeds the 1 MB cap. The body was never parsed, so the
            request had no effect.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  type: invalid_request_error
                  code: request_too_large
                  message: The request body exceeds the maximum allowed size.
                  doc_url: >-
                    https://vexioncards.mintlify.site/api-reference/errors#request_too_large
                  request_id: req_5f8b2c4a9e1d3
        '422':
          description: >-
            `transaction_not_reversible`: the row is a tombstone and cannot be
            reversed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          headers: {}
        '429':
          description: Per-minute or per-day rate limit exceeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          headers: {}
        '500':
          description: Server error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          headers: {}
      deprecated: false
      x-codeSamples:
        - lang: curl
          label: cURL
          source: >-
            curl -X POST
            https://api.vexioncards.one/v1/transactions/txn_01HW9R5Q3AB7N8MTX1RVTXNDEF/reverse
            \
              -H "Authorization: Bearer $VXN_API_KEY" \
              -H "Content-Type: application/json" \
              -H "Idempotency-Key: $(uuidgen)" \
              -d '{"reason": "wrong amount entered at till"}'
        - lang: javascript
          label: Node.js
          source: >-
            const reversal = await (await fetch(
              'https://api.vexioncards.one/v1/transactions/txn_01HW9R5Q3AB7N8MTX1RVTXNDEF/reverse',
              {
                method: 'POST',
                headers: {
                  Authorization: `Bearer ${process.env.VXN_API_KEY}`,
                  'Content-Type': 'application/json',
                  'Idempotency-Key': crypto.randomUUID(),
                },
                body: JSON.stringify({ reason: 'wrong amount entered at till' }),
              }
            )).json();

            console.log(`Reversed ${reversal.reverses.id}
            (${reversal.reverses.type})`);
        - lang: python
          label: Python
          source: >-
            import os, uuid, requests

            reversal = requests.post(
                "https://api.vexioncards.one/v1/transactions/txn_01HW9R5Q3AB7N8MTX1RVTXNDEF/reverse",
                headers={
                    "Authorization": f"Bearer {os.environ['VXN_API_KEY']}",
                    "Idempotency-Key": str(uuid.uuid4()),
                },
                json={"reason": "wrong amount entered at till"},
            ).json()

            print(f"Reversed {reversal['reverses']['id']}
            ({reversal['reverses']['type']})")
components:
  schemas:
    TransactionReverseRequest:
      type: object
      description: >-
        Optional body for reversals. The reversal itself is a transaction
        (`kind: "reversal"`) whose effect exactly negates the original.
      properties:
        reason:
          type:
            - string
            - 'null'
          maxLength: 500
          description: >-
            Free-form reason, stored on the reversal as
            `metadata.reversal_reason`. Strongly recommended for audit trails.
          example: wrong amount entered at till
      example:
        reason: wrong amount entered at till
    Transaction:
      type: object
      description: >-
        One immutable ledger entry. Transactions are the source of truth for
        every balance - the server computes `balance_after`, `type`, and `unit`;
        clients only ever describe the real-world action.


        **Reversal model:** mistakes are never edited - post a reversal (POST
        /v1/transactions/{id}/reverse). The reversal is itself a transaction
        (`kind: "reversal"`) whose effect exactly negates the original; both
        rows stay in the ledger forever. On a reversal, `action` describes what
        is being undone (the original action), not a new customer action -
        detect reversals via `kind`/`type`, never by parsing `action`.
      required:
        - id
        - object
        - kind
        - type
        - unit
        - card_id
        - card_type
        - customer_id
        - action
        - value
        - currency
        - source
        - reversed
        - created_at
        - occurred_at
      properties:
        id:
          type: string
          pattern: ^txn_[A-Za-z0-9]+$
          description: Opaque ledger identifier, `txn_` prefix. Stable forever.
          example: txn_01HW9R5Q3AB7N8MTX1RVTXNDEF
        object:
          type: string
          const: transaction
          description: Always `"transaction"`.
        kind:
          $ref: '#/components/schemas/TransactionKind'
        type:
          $ref: '#/components/schemas/TransactionType'
        unit:
          $ref: '#/components/schemas/TransactionUnit'
        card_id:
          type: string
          pattern: ^crd_[A-Za-z0-9]+$
          description: >-
            The card template this transaction targets. One transaction touches
            one card.
          example: crd_01HW9R4P2ZK7N8MTX1RVCARD01
        card_type:
          type:
            - string
            - 'null'
          enum:
            - stamp
            - reward
            - discount
            - coupon
            - membership
            - gift_card
            - custom
            - null
          description: >-
            Server-set, denormalized from the card. Cashback is NOT a separate
            type - cashback transactions target a `reward` card in cashback
            redemption mode. POST /v1/transactions accepts stamp, reward,
            discount and coupon cards; every other card type returns 422
            `card_type_not_writable`. NULL when the card this transaction
            belongs to has been HARD-deleted: card_type is denormalized from the
            Card document and nothing on the transaction row preserves it, so it
            is unrecoverable once the card is gone. `card_id` is still returned
            for audit, but GET /v1/cards/{id} for it will 404. Every other field
            on the row stays valid.
          example: reward
        customer_id:
          type: string
          pattern: ^cus_[A-Za-z0-9]+$
          description: The customer this entry credits or debits.
          example: cus_01HW9R4P2ZK7N8MTX1RVABCDEF
        location_id:
          type:
            - string
            - 'null'
          pattern: ^loc_[A-Za-z0-9]+$
          description: Location attribution. Optional.
          example: loc_01HW9R4P2ZK7N8MTX1RVLOCS01
        team_member_id:
          type:
            - string
            - 'null'
          pattern: ^tm_[A-Za-z0-9]+$
          description: >-
            The team member (scanner staff) who recorded this entry. **Populated
            only on `source: "manual"` transactions** - the cashier scanner runs
            under a signed-in team member. Null everywhere else: `api` entries
            are attributed to the API key, `pos` entries to the till (the POS
            does not identify the operating person), `online`/`system` have no
            human operator.
          example: null
        action:
          $ref: '#/components/schemas/TransactionAction'
        value:
          type: number
          description: >-
            The units the action carried, paired with `unit`: stamps
            added/redeemed, points granted/consumed, points on a cashback card
            too (cashback is a presentation mode over the same points ledger -
            the money view is on `saving_minor` and
            `balance_after.cashback_minor`, never here), or gift-card minor
            units to load/spend. Whole for `source: api` rows. Rows from the
            staff scanner (`manual`) and from a POS (`pos`) can carry up to 4
            decimals on any Reward card, flat or tiered: for example 10 EUR at
            0.5 points per EUR on a 1.5x level yields exactly 7.5 on a scanner
            row. Treat this as a decimal.
          example: 18
        amount_minor:
          type:
            - integer
            - 'null'
          description: >-
            The bill total in minor units (cents) that drove the action.
            Required for `award` on spend-driven cards (reward-by-spend,
            cashback, discount); optional otherwise.
          example: 1250
        eligible_spend_minor:
          type:
            - integer
            - 'null'
          description: >-
            Only set on `type: "employee_discount.apply"` and on its reversal
            (`reverses.type === "employee_discount.apply"`) - a reversal's own
            `type` is always `"reversal"`, never the original type. Sum of
            loyalty-enabled line items (with VAT) so reversal math reconciles to
            the exact budget increment applied. Never accepted on input. It is
            null on `source: api` rows.
          example: null
        saving_minor:
          type:
            - integer
            - 'null'
          description: >-
            Server-computed monetary benefit the customer received on this bill,
            in minor units - the discount granted (`discount.apply`,
            `employee_discount.apply`), the credit used (`cashback.redeem`) or a
            referral bonus row. Null on earns and on every row with no money
            value, including `points.redeem` and `coupon.redeem`. Sum this per
            period for 'total value given' reporting. Never accepted on input.
          example: null
        currency:
          type: string
          minLength: 3
          maxLength: 3
          description: ISO 4217. Defaults to the organization's default currency.
          example: EUR
        balance_after:
          $ref: '#/components/schemas/BalanceAfter'
        tier_at_earning:
          type:
            - string
            - 'null'
          description: >-
            For Reward cards with levels: the name of the level (its display
            name, for example `Silver`) active when this entry was recorded, not
            a slug. Null on discount rows and on rows from some POS channels,
            where `applied_multiplier` is still set. Round-trips verbatim onto
            the reversal row so refund math uses the ORIGINAL tier, not the
            customer's current one.
          example: silver
        applied_multiplier:
          type:
            - number
            - 'null'
          description: >-
            For tiered reward cards: the earn multiplier in effect. Round-trips
            verbatim to the reversal row.
          example: 1.5
        source:
          $ref: '#/components/schemas/TransactionSource'
        external_id:
          type:
            - string
            - 'null'
          maxLength: 255
          description: >-
            Your correlation reference (order id, receipt number). Indexed for
            list filtering. (Public name for the platform's internal receipt
            reference.)
          example: pos-receipt-abc-123
        reversed:
          type: boolean
          description: True once a reversal is posted against this entry.
          example: false
        reverses:
          type:
            - object
            - 'null'
          description: >-
            On reversal rows only: `{id, type}` of the original - lets receivers
            branch on the reversed type without a second GET. Null on originals.
          properties:
            id:
              type: string
              description: The original `txn_` id.
            type:
              $ref: '#/components/schemas/TransactionType'
          example: null
        reversed_by:
          type:
            - string
            - 'null'
          description: >-
            The `txn_` of the reversal that voids this row. Null while
            un-reversed.
          example: null
        reversed_at:
          type:
            - integer
            - 'null'
          description: Unix seconds the reversal was posted.
          example: null
        products:
          type: array
          maxItems: 50
          items:
            $ref: '#/components/schemas/TransactionProduct'
          description: >-
            Optional line items. Stored verbatim, emitted on webhooks, never
            interpreted by the loyalty engine. Use `sku` for product
            correlation.
          example:
            - name: Espresso doppio
              sku: ESP-D
              quantity: 1
              amount_minor: 1250
              currency: EUR
              category: drinks
        metadata:
          type: object
          additionalProperties:
            type: string
            minLength: 1
            maxLength: 500
          description: >-
            Your free-form key/value bag. NOT renderable in pass/notification
            templates (unlike `customer.custom`). Shape IS enforced (422
            `validation_failed`): max 50 keys, key must match
            `^[a-zA-Z0-9_]{1,40}$`, values must be strings of at most 500 chars.
            The platform never interprets the contents. The only server-set key
            is `metadata.reversal_reason` on reversal rows.
          example:
            till_id: till-3
          maxProperties: 50
          propertyNames:
            pattern: ^[a-zA-Z0-9_]{1,40}$
        created_at:
          type: integer
          description: Unix seconds, server-assigned.
          example: 1750691210
        occurred_at:
          type: integer
          description: >-
            Unix seconds when the event happened at its origin (till time for
            POS entries, request time for API entries). Usually equals
            `created_at`; differs when a POS webhook or batch import arrives
            late. Range filters (`created[gte|lte]`) and sort use `created_at` -
            use `occurred_at` for business-day reconciliation.
          example: 1750691210
    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - type
            - code
            - message
          properties:
            type:
              type: string
              enum:
                - invalid_request_error
                - authentication_error
                - permission_error
                - not_found_error
                - rate_limit_error
                - idempotency_error
                - api_error
            code:
              type: string
            message:
              type: string
            retry_after:
              type: integer
              description: >-
                Seconds to wait before retrying. Present on rate-limit (429) and
                idempotency-key-in-use (409) errors; mirrors the `Retry-After`
                HTTP header. Absent on error types that carry no retry guidance.
              example: 5
            param:
              type:
                - string
                - 'null'
            request_id:
              type: string
            doc_url:
              type: string
              format: uri
            field_errors:
              type: array
              description: >-
                Per-field errors on a 422. At most 100 entries: a request with
                more errors gets the first 100.
              items:
                type: object
                properties:
                  param:
                    type: string
                  code:
                    type: string
                  message:
                    type: string
                  expected_currency:
                    type: string
                    description: >-
                      On `currency_mismatch`: the business currency the request
                      must use. Absent on every other field error.
          description: >-
            The error payload: type (category), code (machine-readable), message
            (human-readable), retry_after (seconds, on
            rate-limit/idempotency-in-use errors only), param (offending field),
            request_id, doc_url, field_errors[].
      description: >-
        Standard error envelope. Branch on `error.code`; include
        `error.request_id` in support requests. See the Errors guide.
    TransactionKind:
      type: string
      enum:
        - normal
        - reversal
      description: >-
        Server-set. `normal` = a regular ledger entry. `reversal` = a
        compensating row created by POST .../reverse, carrying `reverses: {id,
        type}`. Tombstones exist internally but never surface in the API.
      example: normal
    TransactionType:
      type: string
      enum:
        - card.issue
        - card.link
        - card.unlink
        - cashback.earn
        - cashback.redeem
        - coupon.issue
        - coupon.redeem
        - discount.apply
        - discount.tier_change
        - employee_discount.apply
        - gift_card.create
        - gift_card.refund
        - gift_card.spend
        - gift_card.topup
        - import
        - manual_adjust
        - membership.activate
        - membership.expire
        - membership.renew
        - membership.use
        - points.earn
        - points.manual_adjust
        - points.redeem
        - purchase
        - reversal
        - stamp.add
        - stamp.bonus
        - stamp.redeem
        - tier.change
      description: >-
        Server-set stable public classification (29 values). Partners building
        dashboards or per-event handlers should switch on `type`, never parse
        `action`.


        **You never send `type`** - the server derives it from `action` × the
        card's type (see the mapping table on POST /v1/transactions).


        | Family | Types |

        |---|---|

        | Generic | `purchase` (bill without loyalty action), `manual_adjust`,
        `reversal` (original type under `reverses.type`) |

        | Stamp | `stamp.add`, `stamp.redeem`, `stamp.bonus` |

        | Points | `points.earn`, `points.redeem`, `points.manual_adjust`* |

        | Cashback | `cashback.earn`, `cashback.redeem` |

        | Discount | `discount.apply`, `discount.tier_change`*,
        `employee_discount.apply` (carries `eligible_spend_minor`) |

        | Coupon | `coupon.issue`, `coupon.redeem` |

        | Gift card | `gift_card.create`, `gift_card.topup`, `gift_card.spend`,
        `gift_card.refund` |

        | Membership | `membership.activate`, `membership.use`,
        `membership.renew`, `membership.expire`* |

        | Lifecycle* | `tier.change`, `card.issue`, `card.link`, `card.unlink`,
        `import` |


        \* = **server-emitted only** - can never be produced via POST. Where one
        of them appears at all, it is in list results and webhooks; the seven
        types named below never appear.


        Namespace note: transaction types use verb stems (`points.earn`);
        webhook event names use past participles (`transaction.created`).
        Subscribe to the webhook name, branch on the inner `type`.


        **Declared but NOT emitted by v1 today.** These seven are reserved in
        the enum for forward compatibility and no current code path produces
        them, so a `switch` branch on any of them is dead code until this note
        is removed: `card.issue`, `card.link`, `card.unlink`, `coupon.issue`,
        `points.manual_adjust`, `stamp.bonus`, `tier.change`.


        `type` is DERIVED per row from the underlying event and its deltas,
        never stored, so the set of producible values is fixed by the classifier
        rather than by data. `coupon.issue` is structurally impossible rather
        than merely unimplemented: coupon issuance is not a ledger row, so every
        coupon transaction is a redemption.
      example: points.earn
    TransactionUnit:
      type: string
      enum:
        - stamps
        - points
        - gift_card_minor
        - membership_uses
        - none
      description: >-
        Server-set. Names what the integer in `value` carries, so partners never
        guess: `stamps`, `points` (cashback rides the same points ledger as any
        other Reward card - see the Transaction `value` description; the money
        view lives on `saving_minor` and `balance_after.cashback_minor`, never
        on `unit`/`value`), `gift_card_minor` (cents), `membership_uses`, or
        `none` (for example `purchase`, `discount.apply`,
        `employee_discount.apply`, `coupon.redeem`, `discount.tier_change` and
        `import` rows).
      example: points
    TransactionAction:
      type: string
      enum:
        - award
        - redeem
      description: >-
        `award` adds stamps or points, `redeem` uses them up. On a reversal
        (`kind: "reversal"`), `action` describes what is being undone (the
        original action), not a new customer action; detect reversals via
        `kind`/`type`, never by parsing `action`.
    BalanceAfter:
      type:
        - object
        - 'null'
      description: Server-authoritative balance snapshot. Shape varies by card_type.
      properties:
        stamps_collected:
          type: integer
          description: STAMP cards only - current stamp count after this entry.
        points:
          type: number
          description: >-
            REWARD cards only - point balance after this entry. Can carry up to
            4 decimals after `manual` or `pos` entries, as for
            Transaction.value. Treat it as a decimal.
        remaining_amount_minor:
          type: integer
          description: GIFT cards only - remaining balance in minor units.
        cashback_minor:
          type: integer
          description: >-
            CASHBACK-mode reward cards only - cashback balance in minor units.
            Computed with the card's current point value, so older rows change
            if the value changes.
      example:
        points: 124
    TransactionSource:
      type: string
      description: >-
        Where the ledger entry originated. **Server-stamped - never accepted on
        input.** Every transaction you create via `POST /v1/transactions` is
        stamped `"api"`, no exceptions.


        Values you will see on reads:


        - `api` - created through the Public API by you or your developer

        - `pos` - pushed by a connected POS bridge after a real-world bill (the
        specific POS vendor is never surfaced)

        - `manual` - recorded by staff through the VEXiON cashier scanner. A
        scanner undo appears as an opposite entry, not as a reversal

        - `online` - originated from a first-class online checkout integration

        - `system` - generated by VEXiON itself (tier demotion, expiry sweeps,
        referral payouts, transform reconciliation, import audit rows)


        Treat this as an **open set** - new origin values may be added without a
        version bump. Code defensively: branch on the values you know, pass
        unknown ones through.


        Entries with any source other than `api` are **read-only through this
        API**: you can list and retrieve them, but you cannot create or reverse
        them (corrections happen in their originating channel - POS cancellation
        flow, scanner undo, or support).
      example: api
    TransactionProduct:
      type: object
      description: >-
        A line item on the bill. Stored verbatim and emitted in the
        `transaction.created` webhook - the loyalty engine does NOT interpret
        line items (rules act on the aggregate `amount_minor`); they exist for
        per-product reporting and basket analytics.


        **Product correlation, in resolution order:** (1) an explicit
        `product_id` you supply, validated against the transaction's location
        catalog; (2) `sku` matched against the catalog; (3) POS `pos_ref`
        matching on POS-originated entries. Unresolved lines stay free-form
        (`product_id: null`) and back-link when the catalog entry appears.


        Up to 50 line items per transaction. Line totals are NOT required to sum
        to the transaction's `amount_minor` (taxes, service fees, rounding are
        accepted silently).
      required:
        - name
      properties:
        product_id:
          type:
            - string
            - 'null'
          pattern: ^prd_[A-Za-z0-9]+$
          description: >-
            Reference into the location's product catalog. Optional on input
            (must belong to the transaction's location - `422 product_not_found`
            otherwise); on reads it is resolver-stamped from `sku` / POS mapping
            when you omit it. Null when unresolved.
          example: prd_01HW9R7T2CD8N8MTX1RVPRDFW1
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Display name of the line item. The only required field.
          example: Flat White
        sku:
          type: string
          minLength: 1
          maxLength: 64
          description: >-
            Your stable product identifier - the correlation key for mapping
            this line to your product catalog. Strongly recommended on every
            line.
          example: FW-12
        quantity:
          type: integer
          minimum: 1
          default: 1
          description: >-
            Units purchased. Capped at 100,000 units per line item to prevent
            overflow abuse.
          example: 1
          maximum: 100000
        amount_minor:
          type: integer
          description: Line total in minor units (cents). Maximum 100,000,000,000.
          example: 350
          minimum: 0
          maximum: 100000000000
        currency:
          type: string
          minLength: 3
          maxLength: 3
          description: ISO 4217. Defaults to the transaction's currency.
          example: EUR
        category:
          type: string
          minLength: 1
          maxLength: 100
          description: Free-form category for basket analytics.
          example: drinks
        metadata:
          type: object
          additionalProperties:
            type: string
            minLength: 1
            maxLength: 500
          description: >-
            Free-form key/value bag, not renderable in templates. Shape IS
            enforced (422 `validation_failed`): max 50 keys, key must match
            `^[a-zA-Z0-9_]{1,40}$`, values must be strings of at most 500 chars.
            The platform never interprets the contents.
          example:
            pos_line_id: e9dbedb5
          maxProperties: 50
          propertyNames:
            pattern: ^[a-zA-Z0-9_]{1,40}$
      example:
        name: Flat White
        sku: FW-12
        quantity: 1
        amount_minor: 350
        currency: EUR
        category: drinks
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: >-
        Your secret API key as a Bearer token: `Authorization: Bearer
        vxn_sk_...`. Keys are created and scoped in the dashboard - see
        [Authentication](/api-reference/introduction#authentication).

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.