> ## Documentation Index
> Fetch the complete documentation index at: https://docs.brand-wallet.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Process a transaction

> Commit a basket transaction for a BrandWallet customer's card: run campaigns, redeem coupons, and deduct spent points in a single call.

```http theme={null}
POST $BASE_URL/v1/transactions/process
```

**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](/api/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

```json theme={null}
{
  "customer": "3d8ec956-de2a-4d43-9c1e-74475b153362",
  "card": "41dd8263-288e-4b55-bf2e-6a82995a0441",
  "user": "usr-ext-1042",
  "branch": "brn-ext-07",
  "items": [
    {
      "id": "4221450",
      "name": "Americano",
      "quantity": 4,
      "amount": 140,
      "productPrice": 150,
      "categories": [
        { "id": "cat-31", "name": "Hot Drinks" }
      ]
    }
  ],
  "amount": 550,
  "orderId": "ORD-2026-000123",
  "redeem": {
    "coupons": [
      { "id": "2f19b6ba-e7f8-4dd5-b328-cbfabc08e412", "productOpaqueId": "4221450", "quantity": 1 }
    ],
    "points": 120
  }
}
```

Field reference: [Body](#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.

### Menu synchronization (lazy sync)

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:

```json theme={null}
{
  "user":   { "id": "usr-ext-1042", "name": "Jane Cashier", "email": "jane@example.com", "phone": "+905551234567" },
  "branch": { "id": "brn-ext-07", "name": "Downtown Store", "phone": "+902121234567", "address": "12 Main St, Istanbul" }
}
```

* **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`.

### 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):

```json theme={null}
"redeem": {
  "coupons": [
    { "id": "2f19b6ba-e7f8-4dd5-b328-cbfabc08e412" },
    { "id": "906550fd-dcf5-4aed-a80e-03e8939fc2b4", "productOpaqueId": "4221450", "quantity": 1 }
  ],
  "points": 120
}
```

* `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:

```json theme={null}
{
  "customer": "3d8ec956-de2a-4d43-9c1e-74475b153362",
  "card": "41dd8263-288e-4b55-bf2e-6a82995a0441",
  "amount": 0,
  "orderId": "FIX-2026-0042",
  "collectables": { "stamp_PESVB": 2 }
}
```

* 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](/api/find-customer) 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](/api/validate-transaction).

### Body

| Field | Type | Description |
| - | - | - |
| `customer` | string | Required. Customer id (from [GET /v1/customers/find](/api/find-customer)). |
| `card` | string | Required. The selected card's id (`find` → `cards[].id`) — a card id, **not a campaign id**. The campaigns run come from this card; `400 card_not_found` when it does not belong to the customer. |
| `amount` | number | Required. The register total: all of **your own** discounts (commercial + coupon) already deducted; BrandWallet-applied deductions (order discounts from promotions, points) **not** deducted — those are applied server-side, and the amount to charge is `payableAmount` in the response. Never send a previous response's discounted amount back as `amount`. |
| `orderId` | string | Optional. Opaque idempotency key — your order/transaction id. `400 duplicate_transaction` when a completed transaction with the same `orderId` exists; must not be empty when sent. |
| `expectedPayableAmount` | number | Optional safety check. Echo the `payableAmount` you received from [validate](/api/validate-transaction); if the server's own calculation differs (the body changed, or promotion state changed between the two calls), the request fails with `400 payable_amount_mismatch` **without any side effects**. |
| `user` / `branch` | string \| object | Optional. Cashier user / branch — as a string, the **opaque id** (your external identifier); as an object (`user: {id, name?, email?, phone?}`, `branch: {id, name?, phone?, address?}`), lazily created/updated like `items[]` (see Cashier & branch synchronization). Default to the authenticated API user and its branch. |
| `items` | array | Basket lines (fields below) — used for product-based campaign matching (stamp counting, and the earning base of product-filtered campaigns) and menu sync; line amounts are not validated against `amount`. |
| `redeem` | object | Coupons and points to spend on this order (fields below). |
| `collectables` | object | **Escape hatch — avoid unless necessary.** `{"<collectableKey>": amount}` — bypasses the campaign calculation and **adds** the value onto the balance (a delta, not a set; see Direct collectable write above). Cannot be combined with `items` or `redeem`. |

`redeem` fields:

| Field | Type | Description |
| - | - | - |
| `coupons` | array | Coupons to redeem (fields below). |
| `points` | number | Points to spend from the card's balance; the monetary value is calculated server-side and reflected in `payableAmount`. |

`redeem.coupons[]` fields:

| Field | Type | Description |
| - | - | - |
| `id` | string | Required. Coupon instance id to redeem (`find` → `coupons[].id`). |
| `productOpaqueId` | string | Optional. For product coupons: the product the coupon was used for (same id space as `items[].id`; the product does not have to be in `items[]`). Recorded on the coupon and the transaction; when the product is also in `items[]`, the coupon-covered quantity is excluded from earning. |
| `quantity` | number | Optional. Quantity covered by the coupon (default 1). |

`items[]` fields:

| Field | Type | Description |
| - | - | - |
| `id` | string | Required. Product **opaque id** (your identifier). If unknown to BrandWallet and `name` is sent, the product is created automatically (see Menu synchronization). |
| `quantity` | number | Line quantity. |
| `amount` | number | Line unit amount (may reflect discounts) — never enters payment math (the money truth is the root `amount`); used as campaign condition input. |
| `productPrice` | number | The product's list price — used **only** by menu sync to create/update the product's price; never enters transaction math. |
| `name` | string | Product name — synced onto the product; required for the product to be auto-created. |
| `categories` | array | The product's categories (fields below) — when sent, the product's category assignment is replaced with this list. |

`items[].categories[]` fields:

| Field | Type | Description |
| - | - | - |
| `id` | string | Required. Category **opaque id** (your identifier); auto-created with `name` when unknown. |
| `name` | string | Category name — synced onto the category; required for the category to be auto-created. |

### Example

```bash theme={null}
curl --request POST \
  --url "$BASE_URL/v1/transactions/process" \
  --header 'Authorization: Bearer <accessToken>' \
  --header 'Content-Type: application/json' \
  --data '{
    "customer": "3d8ec956-de2a-4d43-9c1e-74475b153362",
    "card": "41dd8263-288e-4b55-bf2e-6a82995a0441",
    "items": [
      { "id": "4221450", "name": "Americano", "quantity": 4, "amount": 140, "productPrice": 150 }
    ],
    "amount": 550,
    "orderId": "ORD-2026-000123",
    "redeem": { "points": 120 }
  }'
```

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

```json theme={null}
{
  "transactionId": "7f1b6c20-8f2e-40d7-966f-29707f1a77b9",
  "message": "You collected 4 stamps, earned a Free Coffee coupon. Amount to pay: 538",
  "executedActions": [
    {
      "actionName": "collect-stamps",
      "transactionCategory": "collect-stamps",
      "netCollected": 4,
      "previousCollectableValue": 3,
      "result": { "value": 2, "total": 12 },
      "rewards": ["2f19b6ba-e7f8-4dd5-b328-cbfabc08e412"],
      "milestones": [
        {
          "label": "10th purchase gift",
          "threshold": 10,
          "track": "total",
          "type": "issue-coupon",
          "triggerCount": 1,
          "result": [{ "_id": "7c2f4e1a-93bd-4f0e-8a34-5b7f2d9c6e10" }]
        }
      ]
    }
  ],
  "executedPromotions": [
    {
      "promotion": "b34466d6-4e79-4523-aaf4-beaa672b3b8c",
      "name": "Weekend double coupon",
      "actionType": "issue_coupon",
      "coupons": [
        {
          "_id": "5d8a1c2e-40f7-4b6a-9e21-3c9b8f7a6d54",
          "coupon": "9a417359-a02c-4a4a-8f4a-c31a24f0b0c5",
          "code": "WKND-4821"
        }
      ]
    }
  ],
  "amount": 550,
  "discountAmount": 0,
  "payableAmount": 538,
  "redeemed": {
    "couponIds": ["2f19b6ba-e7f8-4dd5-b328-cbfabc08e412"],
    "coupons": [
      {
        "id": "2f19b6ba-e7f8-4dd5-b328-cbfabc08e412",
        "product": { "opaqueId": "4221450", "name": "Americano", "quantity": 1 }
      }
    ],
    "autoAppliedCouponIds": [],
    "points": 120,
    "pointsValue": 12
  }
}
```

| Field | Type | Description |
| - | - | - |
| `transactionId` | string | Id of the committed transaction — pass it to [POST /v1/transactions/:transactionId/rollback](/api/rollback-transaction) to undo the transaction. Not returned by `validate` (a dry-run writes no transaction). |
| `transactionIds` | string\[] | Returned only when the card ran more than one campaign: all transaction ids written, in run order (`transactionId` is the first). Roll them back in **reverse** order. |
| `message` | string | Transaction summary; always ends with `Amount to pay: X`. |
| `executedActions` | array | Campaign actions executed in this transaction. |
| `executedActions[].actionName` | string | Name of the campaign action, e.g. `collect-stamps`. |
| `executedActions[].transactionCategory` | string | Transaction category the action belongs to. |
| `executedActions[].netCollected` | number | Amount collected by this action in the current transaction. |
| `executedActions[].previousCollectableValue` | number | Collectable value before the action ran. |
| `executedActions[].result` | object | Updated state: `value` (current balance — reset on threshold conversion) and `total` (lifetime total). |
| `executedActions[].rewards` | string\[] | Coupon instance ids granted when a threshold was reached. |
| `executedActions[].milestones` | array | Milestone crossings triggered in this transaction: `{label, threshold, track, type, triggerCount, result}`. Empty when the campaign defines no milestones. |
| `executedPromotions` | array | Promotions applied in this transaction (omitted when none): `{promotion, name, actionType, ...}` — e.g. an `issue_coupon` promotion lists the granted coupons, a `modify_calculator` promotion lists the applied modifiers, and an `apply_discount` promotion carries the order discount it applied (`discount` config + `amount`). |
| `amount` | number | The register total from the request. |
| `discountAmount` | number | Total discount BrandWallet applied to this order (discount campaign actions + `apply_discount` promotions); `0` when none. |
| `payableAmount` | number | **The amount to charge**: `amount` − `discountAmount` − the monetary value of spent points. |
| `redeemed` | object | `couponIds` (redeemed coupons, including auto-applied ones), `coupons` (echo of the requested coupons with their resolved `product` link, when one was sent), `autoAppliedCouponIds` (the subset redeemed automatically as auto-apply coupons), `points` (points spent), `pointsValue` (their monetary value). |

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](/api/validate-transaction): 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](/api/rollback-transaction) with the returned `transactionId`.


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