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
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”.
orderIdis 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 inexecutedPromotions (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 deductdiscountAmountyourself, and never send a discounted amount back on process after a validate (that would discount twice; the optionalexpectedPayableAmountcheck 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.
Menu synchronization (lazy sync)
The product and category information you send initems[] 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[].iddoes not match) andnameis sent, the product is created withname/productPrice/categories; product- and category-filtered campaigns match from the very first transaction. Withoutname, no product is created and the line is processed as-is. - If the product exists, it is updated when the
nameorproductPriceyou send differs — your system is the menu’s source of truth. If you omitproductPrice, the product’s price is left untouched. - The product price is synced only from
productPrice; the line amountitems[].amount(which may reflect discounts) is never written onto the product price. - If
categoriesis 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; withoutname, 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 leastid+nameon lines andid+nameon categories; includeproductPriceto 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
nameis sent, it is created: the branch with itsname/phone/address, the cashier as a branch-level user bound to the resolved branch. Withoutname, nothing is created and the request fails withinvalid_user/invalid_branch. - If the record exists, incoming fields that differ are updated (
name/phoneon the user;name/phone/addresson the branch) — your system is the source of truth. The user’semailis 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.
Coupon redemption & product link (redeem.coupons)
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):
idis required;productOpaqueId/quantityare 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 initems[], the coupon-covered quantity is excluded from earning: a free product does not collect stamps or points. - An unknown
productOpaqueIdnever 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 matchingitems[] 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 isPut aamount/itemsbased: campaign rules compute what is earned, and statistics and tier counters advance correctly.collectablesbypasses 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.
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 thecollectablesobject 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 withredeem→400 _collectables_with_redeem. amountis still required — send"amount": 0when you only want to write a collectable. Withamount > 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 with400 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
rewardsare added to the customer’s coupon list and can be redeemed viaredeem.couponsin later transactions. - Possible 400 errors:
invalid_customer,invalid_user,invalid_branch,card_not_found,no_applicable_campaign,invalid_transaction,point_campaign_not_found(redeem.pointssent but the card’s campaign has no point spend),insufficient_collectable_balance,invalid_points_to_spend,invalid_amount_value(point value exceedsamount),invalid_coupon,coupon_is_not_valid,duplicate_transaction,payable_amount_mismatch(only whenexpectedPayableAmountis sent and differs from the server’s calculation — nothing is committed),_collectables_with_items/_collectables_with_redeem(collectablescombined withitems/redeem),invalid_collectables(invalidcollectableskey or value),_invalid_user_payload/_invalid_branch_payload(user/branchis neither a string nor an object). - Upstream failures can return 502 (
_core_api_internal_error/_core_api_timeout). If you sendorderId, retrying with the sameorderIdis safe: you get400 duplicate_transactionwhen 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.

