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

# Create a transaction

> Records a loyalty action against a customer's card. You describe the real-world event with exactly **two verbs** - `award` or `redeem` - and the server derives the concrete `type`, computes the balance, and snapshots tier state.

Scope: `transactions:write`. **`Idempotency-Key` header required** - a retried award without one would double-grant.

**Everything you create is `source: "api"`.** There is no way to create a transaction with any other source - POS, scanner, online, and system entries are written exclusively by their own channels and are read-only through this API. If you bridge your own POS through this endpoint, its entries are honestly `api`; use `external_id` and `metadata.till_id` for till-level correlation.

**Side effect: this refreshes the customer's wallet pass.** Every successful create regenerates/refreshes the customer's installed Apple/Google Wallet pass in the background; an ordinary award or redeem does this silently (no alert shown), but `coupon.redeem` additionally sends a real, customer-visible push notification. Looping this endpoint to backfill historical data notifies real customers - see the Transactions guide's side-effects section.

## action × card type mapping

| card_type | `award` → | `redeem` → |
|---|---|---|
| `stamp` | `stamp.add` | `stamp.redeem` |
| `reward` | `points.earn` | `points.redeem` |
| `reward` (cashback mode) | `cashback.earn` | `cashback.redeem` |
| `discount` | `discount.apply` (or `employee_discount.apply` on employee cards) | - not supported |
| `coupon` | - not supported | `coupon.redeem` |

**Cashback carries POINTS in `value`, in both directions, exactly like a product-rewards card.** Cashback is a presentation mode over the same points ledger, not a separate currency, so `unit` reads `points`. The money view is on `saving_minor` and `balance_after.cashback_minor` - every money field in this API carries a `_minor` suffix.

Unsupported combinations return `422 action_not_valid_for_card_type`. **`membership`, `gift_card` and `custom` cards cannot transact through this endpoint at all** - they return `422 card_type_not_writable` regardless of action. `membership` and `gift_card` are dashboard/POS-managed. A `custom` card carries no mechanics, so there is no balance for a transaction to move.

## The response is card-type-specific

`balance_after` contains **exactly one key** matching the card's type (`stamps_collected`, `points`, `cashback_minor`, or `remaining_amount_minor`) - or is `null` for discount/coupon/membership/custom cards. `unit` names what `value` carries. You never see zero-filled fields from other card families.

## What you supply vs what the server computes

You describe the real-world event - **9 writable fields** (`customer_id`, `card_id`, `action`, `value`, `amount_minor`, `currency`, `location_id`, `external_id`, `occurred_at`) plus `products[]` and `metadata`. Everything else (`type`, `unit`, `source`, `card_type`, `balance_after`, `saving_minor`, tier snapshots, `team_member_id`) is server-computed - supplying any of them returns `422 parameter_unknown`.

Recording late? Supply `occurred_at` (till time, max 90 days back); `created_at` remains our ingest time.

## Server rules

- The body must NOT carry server-computed fields (`type`, `kind`, `unit`, `card_type`, `source`, `balance_after`, `points_delta`, `stamps_delta`, `eligible_spend_minor`, `tier_at_earning`, `applied_multiplier`, legacy `Balance`/`Amount`) - `422 parameter_unknown`
- A `redeem` that would push any balance below zero → `409 insufficient_balance`

## Retries and `external_id`

Send an `external_id` (your order or receipt id) so a retry cannot book the same event twice. The same `external_id` with the same `action` and `value` (and the same `amount_minor` and `currency` when you send them) returns the original transaction with 201. The same `external_id` with a different `action`, `value`, `amount_minor` or `currency` answers `409 external_id_conflict`. Without an `external_id`, the `Idempotency-Key` plays the same role for the ledger entry.

## Currency and money limits

A business has one currency. `currency` is optional. When you send it, it must be the business currency, else `422 currency_mismatch` with `field_errors[0].expected_currency`. Amounts are minor units with 2 decimals for every currency, also HUF.

| Check | Answer |
|---|---|
| `amount_minor` above the bill limit of the currency (EUR 10,000; CZK 250,000; HUF 4,000,000; PLN 45,000; RON 50,000; GBP 9,000; CHF 10,000; USD 11,000, major units) or above the business maximum | `422 amount_too_large`, param `amount_minor` |
| `value` above the limit of the card (points 100,000; cashback worth within the bill limit; stamps one full card) | `422 value_too_large`, param `value` |
| `amount_minor` on a `redeem`, also a coupon redeem | `422 amount_not_valid_for_action`, param `amount_minor` |
| `currency` is not the business currency | `422 currency_mismatch` |

See [Money and currency](/api-reference/money-and-currency).

## Products

Optional `products[]` line items (max 50) are stored verbatim and emitted on `transaction.created`, together with `team_member_id` - the loyalty engine acts only on the aggregate `amount_minor`. **Use `sku` as your stable product key** for mapping lines back to your catalog; line totals need not sum to `amount_minor` (taxes/fees/rounding accepted).

Mistakes are corrected by posting a reversal - never by sending an opposite action.



## OpenAPI

````yaml /openapi.json post /v1/transactions
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:
    post:
      tags:
        - Transactions
      summary: Create a transaction
      description: >-
        Records a loyalty action against a customer's card. You describe the
        real-world event with exactly **two verbs** - `award` or `redeem` - and
        the server derives the concrete `type`, computes the balance, and
        snapshots tier state.


        Scope: `transactions:write`. **`Idempotency-Key` header required** - a
        retried award without one would double-grant.


        **Everything you create is `source: "api"`.** There is no way to create
        a transaction with any other source - POS, scanner, online, and system
        entries are written exclusively by their own channels and are read-only
        through this API. If you bridge your own POS through this endpoint, its
        entries are honestly `api`; use `external_id` and `metadata.till_id` for
        till-level correlation.


        **Side effect: this refreshes the customer's wallet pass.** Every
        successful create regenerates/refreshes the customer's installed
        Apple/Google Wallet pass in the background; an ordinary award or redeem
        does this silently (no alert shown), but `coupon.redeem` additionally
        sends a real, customer-visible push notification. Looping this endpoint
        to backfill historical data notifies real customers - see the
        Transactions guide's side-effects section.


        ## action × card type mapping


        | card_type | `award` → | `redeem` → |

        |---|---|---|

        | `stamp` | `stamp.add` | `stamp.redeem` |

        | `reward` | `points.earn` | `points.redeem` |

        | `reward` (cashback mode) | `cashback.earn` | `cashback.redeem` |

        | `discount` | `discount.apply` (or `employee_discount.apply` on
        employee cards) | - not supported |

        | `coupon` | - not supported | `coupon.redeem` |


        **Cashback carries POINTS in `value`, in both directions, exactly like a
        product-rewards card.** Cashback is a presentation mode over the same
        points ledger, not a separate currency, so `unit` reads `points`. The
        money view is on `saving_minor` and `balance_after.cashback_minor` -
        every money field in this API carries a `_minor` suffix.


        Unsupported combinations return `422 action_not_valid_for_card_type`.
        **`membership`, `gift_card` and `custom` cards cannot transact through
        this endpoint at all** - they return `422 card_type_not_writable`
        regardless of action. `membership` and `gift_card` are
        dashboard/POS-managed. A `custom` card carries no mechanics, so there is
        no balance for a transaction to move.


        ## The response is card-type-specific


        `balance_after` contains **exactly one key** matching the card's type
        (`stamps_collected`, `points`, `cashback_minor`, or
        `remaining_amount_minor`) - or is `null` for
        discount/coupon/membership/custom cards. `unit` names what `value`
        carries. You never see zero-filled fields from other card families.


        ## What you supply vs what the server computes


        You describe the real-world event - **9 writable fields**
        (`customer_id`, `card_id`, `action`, `value`, `amount_minor`,
        `currency`, `location_id`, `external_id`, `occurred_at`) plus
        `products[]` and `metadata`. Everything else (`type`, `unit`, `source`,
        `card_type`, `balance_after`, `saving_minor`, tier snapshots,
        `team_member_id`) is server-computed - supplying any of them returns
        `422 parameter_unknown`.


        Recording late? Supply `occurred_at` (till time, max 90 days back);
        `created_at` remains our ingest time.


        ## Server rules


        - The body must NOT carry server-computed fields (`type`, `kind`,
        `unit`, `card_type`, `source`, `balance_after`, `points_delta`,
        `stamps_delta`, `eligible_spend_minor`, `tier_at_earning`,
        `applied_multiplier`, legacy `Balance`/`Amount`) - `422
        parameter_unknown`

        - A `redeem` that would push any balance below zero → `409
        insufficient_balance`


        ## Retries and `external_id`


        Send an `external_id` (your order or receipt id) so a retry cannot book
        the same event twice. The same `external_id` with the same `action` and
        `value` (and the same `amount_minor` and `currency` when you send them)
        returns the original transaction with 201. The same `external_id` with a
        different `action`, `value`, `amount_minor` or `currency` answers `409
        external_id_conflict`. Without an `external_id`, the `Idempotency-Key`
        plays the same role for the ledger entry.


        ## Currency and money limits


        A business has one currency. `currency` is optional. When you send it,
        it must be the business currency, else `422 currency_mismatch` with
        `field_errors[0].expected_currency`. Amounts are minor units with 2
        decimals for every currency, also HUF.


        | Check | Answer |

        |---|---|

        | `amount_minor` above the bill limit of the currency (EUR 10,000; CZK
        250,000; HUF 4,000,000; PLN 45,000; RON 50,000; GBP 9,000; CHF 10,000;
        USD 11,000, major units) or above the business maximum | `422
        amount_too_large`, param `amount_minor` |

        | `value` above the limit of the card (points 100,000; cashback worth
        within the bill limit; stamps one full card) | `422 value_too_large`,
        param `value` |

        | `amount_minor` on a `redeem`, also a coupon redeem | `422
        amount_not_valid_for_action`, param `amount_minor` |

        | `currency` is not the business currency | `422 currency_mismatch` |


        See [Money and currency](/api-reference/money-and-currency).


        ## Products


        Optional `products[]` line items (max 50) are stored verbatim and
        emitted on `transaction.created`, together with `team_member_id` - the
        loyalty engine acts only on the aggregate `amount_minor`. **Use `sku` as
        your stable product key** for mapping lines back to your catalog; line
        totals need not sum to `amount_minor` (taxes/fees/rounding accepted).


        Mistakes are corrected by posting a reversal - never by sending an
        opposite action.
      operationId: createTransaction
      parameters:
        - name: Idempotency-Key
          in: header
          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).
          required: true
          example: order_2026_06_30_00427
          schema:
            type: string
            maxLength: 255
            minLength: 8
            pattern: ^[A-Za-z0-9_-]{8,255}$
        - name: Accept-Language
          in: header
          description: >-
            Preferred language for error messages (sk, cs, en, de). Defaults to
            organization language.
          required: false
          example: en
          schema:
            type: string
            enum:
              - sk
              - cs
              - en
              - de
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TransactionCreateRequest'
            examples:
              Award points from a purchase:
                summary: Spend-driven earn - server computes points from amount_minor
                value:
                  customer_id: cus_01HW9R4P2ZK7N8MTX1RVABCDEF
                  action: award
                  amount_minor: 1250
                  currency: EUR
                  location_id: loc_01HW9R4P2ZK7N8MTX1RVLOCS01
                  external_id: demo-receipt-abc-123
                  products:
                    - name: Flat White
                      sku: FW-12
                      quantity: 1
                      amount_minor: 350
                    - name: Cinnamon Roll
                      sku: CR-04
                      quantity: 1
                      amount_minor: 900
                  metadata:
                    till_id: till-3
              Add a stamp:
                summary: Stamp card award with explicit value
                value:
                  customer_id: cus_01HW9R4P2ZK7N8MTX1RVSTAMP1
                  action: award
                  value: 1
                  amount_minor: 350
                  external_id: order-2026-07-03-0042
              Redeem a filled stamp card:
                summary: Consumes one completed stamp row
                value:
                  customer_id: cus_01HW9R4P2ZK7N8MTX1RVSTAMP1
                  action: redeem
                  value: 1
              Redeem points:
                summary: Customer spends points on a reward
                value:
                  customer_id: cus_01HW9R4P2ZK7N8MTX1RVABCDEF
                  action: redeem
                  value: 500
              Apply a discount:
                summary: >-
                  Discount card on a bill - server picks discount.apply or
                  employee_discount.apply
                value:
                  customer_id: cus_01HW9R4P2ZK7N8MTX1RVDISC01
                  action: award
                  amount_minor: 2800
                  external_id: demo-bill-001251
        required: true
      responses:
        '201':
          description: >-
            Transaction created. Server-computed balances are populated on the
            response.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Transaction'
              examples:
                Reward card - points earned:
                  summary: 'balance_after: {points}'
                  value:
                    id: txn_01HW9R5Q3AB7N8MTX1RVTXNDEF
                    object: transaction
                    kind: normal
                    type: points.earn
                    unit: points
                    card_id: crd_01HW9R4P2ZK7N8MTX1RVCARD01
                    card_type: reward
                    customer_id: cus_01HW9R4P2ZK7N8MTX1RVABCDEF
                    location_id: loc_01HW9R4P2ZK7N8MTX1RVLOCS01
                    team_member_id: null
                    action: award
                    value: 18
                    amount_minor: 1250
                    eligible_spend_minor: null
                    saving_minor: null
                    currency: EUR
                    balance_after:
                      points: 124
                    tier_at_earning: silver
                    applied_multiplier: 1.5
                    source: api
                    external_id: demo-receipt-abc-123
                    reversed: false
                    reverses: null
                    reversed_by: null
                    reversed_at: null
                    products:
                      - name: Flat White
                        sku: FW-12
                        quantity: 1
                        amount_minor: 350
                        currency: EUR
                        category: drinks
                      - name: Cinnamon Roll
                        sku: CR-04
                        quantity: 1
                        amount_minor: 900
                        currency: EUR
                        category: pastry
                    metadata:
                      till_id: till-3
                    created_at: 1750691210
                    occurred_at: 1750691210
                Stamp card - stamp added:
                  summary: 'balance_after: {stamps_collected}'
                  value:
                    id: txn_01HXR9JNEW00000000000000A2
                    object: transaction
                    kind: normal
                    type: stamp.add
                    unit: stamps
                    card_id: crd_01HW9R4P2ZK7N8MTX1RVCARD02
                    card_type: stamp
                    customer_id: cus_01HW9R4P2ZK7N8MTX1RVABCDEF
                    location_id: loc_01HW9R4P2ZK7N8MTX1RVLOCS01
                    team_member_id: null
                    action: award
                    value: 1
                    amount_minor: 350
                    eligible_spend_minor: null
                    saving_minor: null
                    currency: EUR
                    balance_after:
                      stamps_collected: 7
                    tier_at_earning: null
                    applied_multiplier: null
                    source: api
                    external_id: null
                    reversed: false
                    reverses: null
                    reversed_by: null
                    reversed_at: null
                    products: []
                    metadata: {}
                    created_at: 1750691100
                    occurred_at: 1750691100
                Cashback card - cashback earned:
                  summary: 'balance_after: {cashback_minor}'
                  value:
                    id: txn_01HXR9JNEW00000000000000A5
                    object: transaction
                    kind: normal
                    type: cashback.earn
                    unit: points
                    card_id: crd_01HW9R4P2ZK7N8MTX1RVCARD05
                    card_type: reward
                    customer_id: cus_01HW9R4P2ZK7N8MTX1RVABCDEF
                    location_id: loc_01HW9R4P2ZK7N8MTX1RVLOCS01
                    team_member_id: null
                    action: award
                    value: 63
                    amount_minor: 1250
                    eligible_spend_minor: null
                    saving_minor: null
                    currency: EUR
                    balance_after:
                      cashback_minor: 512
                    tier_at_earning: null
                    applied_multiplier: null
                    source: api
                    external_id: demo-receipt-abc-124
                    reversed: false
                    reverses: null
                    reversed_by: null
                    reversed_at: null
                    products: []
                    metadata: {}
                    created_at: 1750691300
                    occurred_at: 1750691300
                Discount card - discount applied:
                  summary: 'balance_after: null (no running balance)'
                  value:
                    id: txn_01HXR9JNEW00000000000000A4
                    object: transaction
                    kind: normal
                    type: discount.apply
                    unit: none
                    card_id: crd_01HW9R4P2ZK7N8MTX1RVCARD04
                    card_type: discount
                    customer_id: cus_01HW9R4P2ZK7N8MTX1RVABCDEF
                    location_id: loc_01HW9R4P2ZK7N8MTX1RVLOCS01
                    team_member_id: null
                    action: award
                    value: 0
                    amount_minor: 70
                    eligible_spend_minor: null
                    saving_minor: 210
                    currency: EUR
                    balance_after: null
                    tier_at_earning: silver
                    applied_multiplier: null
                    source: api
                    external_id: demo-bill-001251
                    reversed: false
                    reverses: null
                    reversed_by: null
                    reversed_at: null
                    products:
                      - name: Cookie
                        sku: CK-01
                        quantity: 1
                        amount_minor: 280
                        currency: EUR
                        category: pastry
                    metadata: {}
                    created_at: 1750832686
                    occurred_at: 1750832686
          headers: {}
        '400':
          description: Malformed body or missing required field.
          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 'transactions:write'.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                Insufficient scope:
                  summary: missing transactions:write
                  value:
                    error:
                      type: permission_error
                      code: insufficient_scope
                      message: >-
                        This endpoint requires scope 'transactions:write'. Key
                        has: ['transactions:read'].
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
          headers: {}
        '404':
          description: Card, customer, or location not found in your organization.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                Customer not found:
                  summary: an id of another organization, or an unknown id
                  value:
                    error:
                      type: not_found_error
                      code: resource_missing
                      message: 'No such customer: cus_01HW9R4P2ZK7N8MTX1RVXXXXXX'
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
          headers: {}
        '409':
          description: >-
            Insufficient balance, `external_id_conflict` (the `external_id` was
            already used for a different action, value, `amount_minor` or
            `currency`), or an Idempotency-Key conflict (same key reused with a
            different body, or a request with the same key already in flight).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                Insufficient balance:
                  summary: redeem would go negative
                  value:
                    error:
                      type: invalid_request_error
                      code: insufficient_balance
                      message: 'Cannot redeem 500 points: the customer holds 124.'
                      param: value
                      doc_url: >-
                        https://vexioncards.mintlify.site/api-reference/errors#insufficient_balance
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
                Idempotency conflict:
                  summary: same key, different body
                  value:
                    error:
                      type: idempotency_error
                      code: idempotency_key_conflict
                      message: >-
                        An idempotency key was reused with a different request
                        body. To retry safely, use a new idempotency key.
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
                Idempotency key in use:
                  summary: same key, concurrent duplicate
                  value:
                    error:
                      type: invalid_request_error
                      code: idempotency_key_in_use
                      message: >-
                        A request with this idempotency key is already being
                        processed. Retry shortly.
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
                external_id conflict:
                  summary: same external_id, other amount
                  value:
                    error:
                      type: invalid_request_error
                      code: external_id_conflict
                      message: >-
                        external_id was already used for a different transaction
                        (recorded action=award, value=12, amount_minor=1250,
                        currency=EUR). Reuse an external_id only to retry the
                        identical transaction; use a fresh external_id for a new
                        event.
                      param: external_id
                      doc_url: >-
                        https://vexioncards.mintlify.site/api-reference/errors#external_id_conflict
                      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: >-
            Semantic violation - type doesn't match card family, mechanics field
            not allowed for type, a money limit (`amount_too_large`,
            `value_too_large`, `amount_not_valid_for_action`) or
            `currency_mismatch`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                Action not valid for card type:
                  summary: redeem on a discount card
                  value:
                    error:
                      type: invalid_request_error
                      code: action_not_valid_for_card_type
                      message: >-
                        Action 'redeem' is not supported on card_type
                        'discount'.
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
                Server-computed field on input:
                  summary: client sent balance_after
                  value:
                    error:
                      type: invalid_request_error
                      code: parameter_unknown
                      message: balance_after is server-computed and cannot be supplied.
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
                      param: balance_after
                Invalid action:
                  summary: unknown verb
                  value:
                    error:
                      type: invalid_request_error
                      code: validation_failed
                      message: One or more fields failed validation.
                      field_errors:
                        - param: action
                          code: invalid_enum_value
                          message: 'action must be one of: award, redeem.'
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
                Card type not writable:
                  summary: >-
                    customer_id resolves to a gift_card, membership or custom
                    card - not writable at all
                  value:
                    error:
                      type: invalid_request_error
                      code: card_type_not_writable
                      message: >-
                        Transactions cannot be created for gift_card cards
                        through the API yet.
                      param: card_id
                      doc_url: >-
                        https://vexioncards.mintlify.site/api-reference/errors#card_type_not_writable
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
                Currency is not the business currency:
                  summary: currency EUR sent by a business that uses CZK
                  value:
                    error:
                      type: invalid_request_error
                      code: currency_mismatch
                      message: >-
                        currency EUR does not match the business currency CZK.
                        Send CZK or omit the field.
                      field_errors:
                        - param: currency
                          code: currency_mismatch
                          message: Expected CZK.
                          expected_currency: CZK
                      param: currency
                      doc_url: >-
                        https://vexioncards.mintlify.site/api-reference/errors#currency_mismatch
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
                Bill above the limit:
                  summary: amount_minor above the largest bill of the currency
                  value:
                    error:
                      type: invalid_request_error
                      code: amount_too_large
                      message: >-
                        amount_minor is above the largest single transaction of
                        10,000 EUR.
                      param: amount_minor
                      doc_url: >-
                        https://vexioncards.mintlify.site/api-reference/errors#amount_too_large
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
                Value above the limit:
                  summary: value above 100,000 points on a points card
                  value:
                    error:
                      type: invalid_request_error
                      code: value_too_large
                      message: >-
                        value is too large for this card: the most one call may
                        book is 100,000 points.
                      param: value
                      doc_url: >-
                        https://vexioncards.mintlify.site/api-reference/errors#value_too_large
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
                Redeem with a bill:
                  summary: amount_minor on a redeem
                  value:
                    error:
                      type: invalid_request_error
                      code: amount_not_valid_for_action
                      message: >-
                        A redeem books points, stamps or credit and carries no
                        bill: remove amount_minor.
                      param: amount_minor
                      doc_url: >-
                        https://vexioncards.mintlify.site/api-reference/errors#amount_not_valid_for_action
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
          headers: {}
        '429':
          description: >-
            Per-minute or per-day rate limit exceeded. Respect the Retry-After
            header.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          headers: {}
        '500':
          description: Server error. Retry-safe with an Idempotency-Key.
          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 \
              -H "Authorization: Bearer $VXN_API_KEY" \
              -H "Content-Type: application/json" \
              -H "Idempotency-Key: $(uuidgen)" \
              -d '{
                "customer_id": "cus_01HW9R4P2ZK7N8MTX1RVABCDEF",
                "action": "award",
                "amount_minor": 1250,
                "currency": "EUR",
                "external_id": "demo-receipt-abc-123",
                "products": [
                  { "name": "Flat White", "sku": "FW-12", "quantity": 1, "amount_minor": 350 }
                ]
              }'
        - lang: javascript
          label: Node.js
          source: >-
            const res = await
            fetch('https://api.vexioncards.one/v1/transactions', {
              method: 'POST',
              headers: {
                'Authorization': `Bearer ${process.env.VXN_API_KEY}`,
                'Content-Type': 'application/json',
                'Idempotency-Key': crypto.randomUUID(),
              },
              body: JSON.stringify({
                customer_id: 'cus_01HW9R4P2ZK7N8MTX1RVABCDEF',
                action: 'award',
                amount_minor: 1250,
                currency: 'EUR',
                external_id: 'demo-receipt-abc-123',
                products: [{ name: 'Flat White', sku: 'FW-12', quantity: 1, amount_minor: 350 }],
              }),
            });

            const txn = await res.json();

            console.log(`${txn.type}: +${txn.value} ${txn.unit} -> balance`,
            txn.balance_after);
        - lang: python
          label: Python
          source: >-
            import os, uuid, requests


            res = requests.post(
                "https://api.vexioncards.one/v1/transactions",
                headers={
                    "Authorization": f"Bearer {os.environ['VXN_API_KEY']}",
                    "Idempotency-Key": str(uuid.uuid4()),
                },
                json={
                    "customer_id": "cus_01HW9R4P2ZK7N8MTX1RVABCDEF",
                    "action": "award",
                    "amount_minor": 1250,
                    "currency": "EUR",
                    "external_id": "demo-receipt-abc-123",
                    "products": [{"name": "Flat White", "sku": "FW-12", "quantity": 1, "amount_minor": 350}],
                },
            )

            txn = res.json()

            print(f"{txn['type']}: +{txn['value']} {txn['unit']} ->
            {txn['balance_after']}")
components:
  schemas:
    TransactionCreateRequest:
      type: object
      description: >-
        Describe the real-world action; the server computes everything else
        (`type`, `unit`, `balance_after`, tier snapshots).


        **Server-authoritative balances:** any body carrying `type`, `kind`,
        `unit`, `card_type`, `balance_after`, `points_delta`, `stamps_delta`,
        `eligible_spend_minor`, `tier_at_earning`, `applied_multiplier`, or any
        legacy `Balance`/`Amount` field returns `422 parameter_unknown`. There
        is no trust-the-client path.


        **Idempotency-Key header is REQUIRED** on this endpoint - a retried
        `award` without one would double-grant.
      required:
        - customer_id
        - action
      properties:
        customer_id:
          type: string
          pattern: ^cus_[A-Za-z0-9]+$
          description: >-
            The customer (cus_ id) this entry credits or debits. The server
            derives the card (and `card_type`) from the customer's enrollment.
          example: cus_01HW9R4P2ZK7N8MTX1RVABCDEF
        card_id:
          type:
            - string
            - 'null'
          pattern: ^crd_[A-Za-z0-9]+$
          description: >-
            Optional consistency check - when provided, must match the
            customer's enrolled card (`422 validation_failed` otherwise).
          example: crd_01HW9R4P2ZK7N8MTX1RVCARD01
        action:
          $ref: '#/components/schemas/TransactionAction'
        value:
          type:
            - integer
            - 'null'
          description: >-
            Stamps or points to add or redeem. On a cashback card `value` is
            points too, also on `redeem`: the money view is on `saving_minor`
            and `balance_after.cashback_minor`. Omit it where the card earns
            from spend and send `amount_minor` instead: a Reward `award` then
            books `round(amount_minor / 100 x earn_rate_per_unit x level
            multiplier)`, rounded once at the end to the nearest whole point
            (halves round up). The level multiplier applies only on a card with
            levels. A card with no `earn_rate_per_unit` set earns 1 point per
            currency unit. A rate of 0 earns 0 points: the request answers 201
            with `value: 0` and the balance does not change. When you send both
            `value` and `amount_minor` on a tiered Reward `award`, the points
            are your `value` times the customer's level multiplier, rounded to
            the nearest whole point (5 x 1.25 = 6.25 books 6), and the bill
            (`amount_minor`) still counts toward the customer's level spend, so
            the customer can level up. An explicit `value` always books `value`
            points, also on a card with rate 0. A `redeem` never adds spend.
            Stamp cards have no spend-driven mechanic: `value` is always
            required there. Maximum 100,000,000,000. An explicit `value` has a
            limit per call: a points card 100,000 points; a cashback card,
            points whose worth (`value` x `cashback_point_value`) stays within
            the largest bill of the currency; a stamp `award` one full card
            (`stamps_required`). Above the limit answers `422 value_too_large`
            on `value`.
          example: null
          minimum: 0
          maximum: 100000000000
        amount_minor:
          type:
            - integer
            - 'null'
          minimum: 0
          description: >-
            Bill total in minor units. Required for `award` on spend-driven
            cards (reward by spend, cashback, discount); optional otherwise.
            Maximum 100,000,000,000. The largest single bill is limited per
            currency, in major units: EUR 10,000; CZK 250,000; HUF 4,000,000;
            PLN 45,000; RON 50,000; GBP 9,000; CHF 10,000; USD 11,000. Above it
            answers `422 amount_too_large` on `amount_minor`. A maximum
            transaction amount that the business set in the dashboard applies
            too. Amounts are in minor units with 2 decimals for every currency,
            also HUF (1,990 Ft is 199000). A `redeem` carries no bill:
            `amount_minor` on a redeem answers `422
            amount_not_valid_for_action`. See [Money and
            currency](/api-reference/money-and-currency).
          example: 1250
          maximum: 100000000000
        currency:
          type:
            - string
            - 'null'
          minLength: 3
          maxLength: 3
          description: >-
            ISO 4217. Optional. A business has one currency (`default_currency`
            on `GET /v1/organization`). When you send it, it must be that
            currency, in any letter case. Another code, or a code we do not
            support, answers `422 currency_mismatch`, and
            `field_errors[0].expected_currency` names the currency to send.
            Leave it out to book in the business currency. See [Money and
            currency](/api-reference/money-and-currency).
          example: EUR
        location_id:
          type:
            - string
            - 'null'
          pattern: ^loc_[A-Za-z0-9]+$
          description: Attribution. Optional.
          example: loc_01HW9R4P2ZK7N8MTX1RVLOCS01
        external_id:
          type:
            - string
            - 'null'
          maxLength: 255
          description: >-
            Your correlation reference (order or receipt id). Indexed for list
            filtering. Reusing an `external_id` to retry the same transaction
            returns the original transaction with 201 and records nothing new. A
            retry may leave `amount_minor` and `currency` out. If it sends them,
            they must match what was booked. Reusing it for a different
            `action`, `value`, `amount_minor` or `currency` answers `409
            external_id_conflict`: use a fresh `external_id` for every new
            event.
          example: pos-receipt-abc-123
        occurred_at:
          type:
            - integer
            - 'null'
          description: >-
            Unix seconds when the event actually happened at your till/checkout.
            **Defaults to now.** Supply it when recording late (offline POS
            catch-up, batch import) so business-day reconciliation stays honest.
            Bounds: not in the future, not older than 90 days (`422
            occurred_at_out_of_range`) - matching the reversal window. Sorting
            and `created[gte|lte]` filters always use `created_at` (ingest
            time), never this field.
          example: null
        products:
          type: array
          maxItems: 50
          items:
            $ref: '#/components/schemas/TransactionProduct'
          description: >-
            Optional line items - see TransactionProduct. Use `sku` as your
            product correlation key. Line items use the TransactionProduct
            shape: product_id, name, sku, quantity, amount_minor (per-line
            money, minor units), currency, category, metadata. The request
            schema previously declared NO properties at all, so any field sent
            was rejected as parameter_unknown with nothing in the contract to
            guide you.
        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.
          maxProperties: 50
          propertyNames:
            pattern: ^[a-zA-Z0-9_]{1,40}$
      example:
        customer_id: cus_01HW9R4P2ZK7N8MTX1RVABCDEF
        action: award
        amount_minor: 1250
        currency: EUR
        location_id: loc_01HW9R4P2ZK7N8MTX1RVLOCS01
        external_id: demo-receipt-abc-123
        products:
          - name: Flat White
            sku: FW-12
            quantity: 1
            amount_minor: 350
          - name: Cinnamon Roll
            sku: CR-04
            quantity: 1
            amount_minor: 900
        metadata:
          till_id: till-3
    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.
    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`.
    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
    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
    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
  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.