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, legacyBalance/Amount) -422 parameter_unknown - A
redeemthat 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.
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.
Authorizations
Your secret API key as a Bearer token: Authorization: Bearer vxn_sk_.... Keys are created and scoped in the dashboard - see Authentication.
Headers
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.
8 - 255^[A-Za-z0-9_-]{8,255}$Preferred language for error messages (sk, cs, en, de). Defaults to organization language.
sk, cs, en, de Body
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.
The customer (cus_ id) this entry credits or debits. The server derives the card (and card_type) from the customer's enrollment.
^cus_[A-Za-z0-9]+$"cus_01HW9R4P2ZK7N8MTX1RVABCDEF"
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.
award, redeem Optional consistency check - when provided, must match the customer's enrolled card (422 validation_failed otherwise).
^crd_[A-Za-z0-9]+$"crd_01HW9R4P2ZK7N8MTX1RVCARD01"
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.
0 <= x <= 100000000000null
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.
0 <= x <= 1000000000001250
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.
3"EUR"
Attribution. Optional.
^loc_[A-Za-z0-9]+$"loc_01HW9R4P2ZK7N8MTX1RVLOCS01"
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.
255"pos-receipt-abc-123"
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.
null
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.
50Free-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.
Response
Transaction created. Server-computed balances are populated on the response.
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.
Opaque ledger identifier, txn_ prefix. Stable forever.
^txn_[A-Za-z0-9]+$"txn_01HW9R5Q3AB7N8MTX1RVTXNDEF"
Always "transaction".
"transaction"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.
normal, reversal "normal"
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).
* = 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.
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 "points.earn"
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).
stamps, points, gift_card_minor, membership_uses, none "points"
The card template this transaction targets. One transaction touches one card.
^crd_[A-Za-z0-9]+$"crd_01HW9R4P2ZK7N8MTX1RVCARD01"
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.
stamp, reward, discount, coupon, membership, gift_card, custom, null "reward"
The customer this entry credits or debits.
^cus_[A-Za-z0-9]+$"cus_01HW9R4P2ZK7N8MTX1RVABCDEF"
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.
award, redeem 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.
18
ISO 4217. Defaults to the organization's default currency.
3"EUR"
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 developerpos- 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 reversalonline- originated from a first-class online checkout integrationsystem- 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).
"api"
True once a reversal is posted against this entry.
false
Unix seconds, server-assigned.
1750691210
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.
1750691210
Location attribution. Optional.
^loc_[A-Za-z0-9]+$"loc_01HW9R4P2ZK7N8MTX1RVLOCS01"
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.
^tm_[A-Za-z0-9]+$null
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.
1250
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.
null
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.
null
Server-authoritative balance snapshot. Shape varies by card_type.
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.
"silver"
For tiered reward cards: the earn multiplier in effect. Round-trips verbatim to the reversal row.
1.5
Your correlation reference (order id, receipt number). Indexed for list filtering. (Public name for the platform's internal receipt reference.)
255"pos-receipt-abc-123"
On reversal rows only: {id, type} of the original - lets receivers branch on the reversed type without a second GET. Null on originals.
null
The txn_ of the reversal that voids this row. Null while un-reversed.
null
Unix seconds the reversal was posted.
null
Optional line items. Stored verbatim, emitted on webhooks, never interpreted by the loyalty engine. Use sku for product correlation.
50Your 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.