Skip to main content
Commits a transaction for the selected card. Three things can happen, individually or together: applicable campaigns run (balances update, reward coupons are granted at thresholds), redeem.coupons are redeemed, and redeem.points are deducted from the card’s point balance.
Auth: Authorization: Bearer <accessToken> — an organization-scoped token from Authentication.
A transaction must do at least one thing (collection, coupon redemption, or point spend); otherwise it fails with 400 invalid_transaction. All validations (coupon validity, point balance) run before any side effect — a request that returns an error changes no balances and redeems no coupons.

Payload schema

Field reference: Body. The sections below explain the behaviors behind these fields.

Idempotency (orderId)

Send your own order/transaction id in the optional orderId field to protect against double submission (POS retries, double clicks, retries after a network timeout). If a completed transaction with the same orderId already exists, the request is rejected with 400 duplicate_transaction without any side effects — treat that response as “already processed”.
  • orderId is an opaque id you generate; the format is up to you, but it must be unique per transaction.
  • When sent, it must not be an empty string.
  • If you do not send it, there is no duplicate protection: after a timeout, verify whether the transaction went through before retrying.

Order discounts (promotions)

The brand can configure promotions that discount the current order (for example “20% off for university students”). When one applies, the discount is computed server-side: it appears in executedPromotions (actionType apply_discount) with its amount, the total comes back in discountAmount, and it is already deducted from payableAmount.
  • Always send the gross amount — never deduct discountAmount yourself, and never send a discounted amount back on process after a validate (that would discount twice; the optional expectedPayableAmount check catches this mistake).
  • On the receipt, model the discount as a loyalty line outside your subtotal (like a loyalty tender) — the same way the spent-points value is handled.
The product and category information you send in items[] is automatically synchronized with your BrandWallet catalog — no separate product upload step; your menu stays current as transactions flow:
  • If the product does not exist in BrandWallet (items[].id does not match) and name is sent, the product is created with name/productPrice/categories; product- and category-filtered campaigns match from the very first transaction. Without name, no product is created and the line is processed as-is.
  • If the product exists, it is updated when the name or productPrice you send differs — your system is the menu’s source of truth. If you omit productPrice, the product’s price is left untouched.
  • The product price is synced only from productPrice; the line amount items[].amount (which may reflect discounts) is never written onto the product price.
  • If categories is sent, the product’s category assignment is replaced with that list. If you omit the field, the existing assignment is untouched.
  • If a category does not exist, it is created from categories[].name; without name, no category is created and the product is processed without it.
  • A sync failure never fails the transaction; the line falls back to legacy behavior (matched by name/id).
To benefit from lazy sync, send at least id + name on lines and id + name on categories; include productPrice to keep product prices current.

Cashier & branch synchronization (lazy sync)

user and branch take two forms: a string (the opaque id, resolved against already-synced records) or an object that is lazily synchronized the same way as items[] — no separate user/branch upload step:
  • If the record does not exist and name is sent, it is created: the branch with its name/phone/address, the cashier as a branch-level user bound to the resolved branch. Without name, nothing is created and the request fails with invalid_user / invalid_branch.
  • If the record exists, incoming fields that differ are updated (name/phone on the user; name/phone/address on the branch) — your system is the source of truth. The user’s email is used only at creation and never updated afterwards; when omitted, an address derived from your id is assigned.
  • String form behavior is unchanged: an unknown opaque id fails with invalid_user / invalid_branch.
  • A value that is neither a string nor an object fails with 400 _invalid_user_payload / _invalid_branch_payload.
Coupons to redeem are sent as objects. For a product coupon (e.g. a free-coffee coupon), tell BrandWallet which product it was used for with productOpaqueId (+ quantity, default 1):
  • id is required; productOpaqueId/quantity are optional and only meaningful for product coupons — leave them out for coupons that are not tied to a product (e.g. a percentage-discount coupon).
  • The product link is recorded: on the coupon itself, in the transaction, and echoed back in the response’s redeemed.coupons — so you (and the brand) can see which product each coupon was spent on.
  • The product does not have to be in items[] (some POS systems don’t send the coupon-covered line at all). When it is in items[], the coupon-covered quantity is excluded from earning: a free product does not collect stamps or points.
  • An unknown productOpaqueId never blocks the redemption — the link is simply recorded as-is.

Product-filtered campaigns

A campaign action can be configured with a product filter (only certain products or categories earn). In that case the earning base is computed from the matching items[] lines only: stamps count the matching quantities, and points/discounts are computed from the matching lines’ totals (quantity × amount). Payment math is unaffected — point spend and payableAmount are always based on the root amount. Send complete items[] lines (with quantity and the line amount) for these campaigns to work correctly.

Direct collectable write (collectables) — escape hatch

Avoid this unless you have to. The normal flow is amount/items based: campaign rules compute what is earned, and statistics and tier counters advance correctly. collectables bypasses that computation entirely and adds straight onto the balance — it exists only for cases where skipping the campaign math is intentional, such as migrations or manual corrections.
Put a collectables object in the payload to add a value directly to one of the card’s collectables:
  • The key is the collectable key of the card’s campaign (e.g. stamp_PESVB, point_X7K2M) — visible in the collectables object of the GET /v1/customers/find response. The value is a positive number: a stamp count for stamp cards, a point amount for point cards; it is applied as-is instead of the campaign calculation and written verbatim into the transaction’s incoming data.
  • The value is a delta, it does not set the balance: sending {"stamp_PESVB": 2} for a customer who has 5 stamps results in 7 — it adds 2 stamps, it does not set the balance to 2.
  • Cannot be combined with items → 400 _collectables_with_items; cannot be combined with redeem → 400 _collectables_with_redeem.
  • amount is still required — send "amount": 0 when you only want to write a collectable. With amount > 0, other amount-driven actions (e.g. discounts) still run normally.
  • Threshold conversion works normally (a reward coupon is granted when the threshold is reached), but tier/statistics counters (totalAmount) do not advance through this path.
  • A key that matches no campaign action on the card fails with 400 invalid_transaction; an invalid value (non-positive or non-numeric) or a reserved key fails with 400 invalid_collectables.
  • Preview it the same way with POST /v1/transactions/validate.

Body

redeem fields: redeem.coupons[] fields: items[] fields: items[].categories[] fields:

Example

Response

Returns 200 with a summary of what the transaction did. message is a human-readable summary and always ends with the amount to charge (Amount to pay: X); read the amount structurally from payableAmount, do not parse the message.
Notes:
  • Optional response fields are omitted when they have no value (field absence instead of null).
  • Coupon ids returned in rewards are added to the customer’s coupon list and can be redeemed via redeem.coupons in later transactions.
  • Possible 400 errors: invalid_customer, invalid_user, invalid_branch, card_not_found, no_applicable_campaign, invalid_transaction, point_campaign_not_found (redeem.points sent but the card’s campaign has no point spend), insufficient_collectable_balance, invalid_points_to_spend, invalid_amount_value (point value exceeds amount), invalid_coupon, coupon_is_not_valid, duplicate_transaction, payable_amount_mismatch (only when expectedPayableAmount is sent and differs from the server’s calculation — nothing is committed), _collectables_with_items / _collectables_with_redeem (collectables combined with items/redeem), invalid_collectables (invalid collectables key or value), _invalid_user_payload / _invalid_branch_payload (user/branch is neither a string nor an object).
  • Upstream failures can return 502 (_core_api_internal_error / _core_api_timeout). If you send orderId, retrying with the same orderId is safe: you get 400 duplicate_transaction when the transaction already went through.
  • Preview the whole transaction before committing with POST /v1/transactions/validate: the same body and the same response shape, computed as a dry-run with nothing committed.
  • To undo a committed transaction (cancelled order, cashier mistake), call POST /v1/transactions/:transactionId/rollback with the returned transactionId.
Last modified on October 8, 2026