{
  "openapi": "3.0.3",
  "info": {
    "title": "Mekari POS Public API",
    "version": "2.0.0",
    "description": "Commercial public API of Mekari POS, served through Mekari API Gateway (MAG) with HMAC authentication. Requires the merchant's Public API add-on. Every time, in answers and in webhook bodies, is RFC 3339 in UTC with `Z` and whole seconds, for example `2026-10-15T03:04:05Z`. Every amount is an integer in whole units of `currency` (IDR: rupiah); money fields have no unit suffix."
  },
  "servers": [
    {
      "url": "https://sandbox-api.mekari.com/v2/mekari-pos/v2",
      "description": "Sandbox"
    },
    {
      "url": "https://api.mekari.com/v2/mekari-pos/v2",
      "description": "Production"
    }
  ],
  "security": [
    {
      "hmacAuth": []
    }
  ],
  "x-mag-parents": [
    {
      "resource": "outlet",
      "description_en": "Access outlet data",
      "description_id": "Akses data outlet"
    },
    {
      "resource": "customer",
      "description_en": "Access and manage customers",
      "description_id": "Akses dan kelola pelanggan"
    },
    {
      "resource": "user",
      "description_en": "Access staff data",
      "description_id": "Akses data staf"
    },
    {
      "resource": "shift",
      "description_en": "Access shift data",
      "description_id": "Akses data shift"
    },
    {
      "resource": "promotion",
      "description_en": "Access promotions",
      "description_id": "Akses promosi"
    },
    {
      "resource": "voucher",
      "description_en": "Access vouchers",
      "description_id": "Akses voucher"
    },
    {
      "resource": "category",
      "description_en": "Access and manage product categories",
      "description_id": "Akses dan kelola kategori produk"
    },
    {
      "resource": "collection",
      "description_en": "Access and manage product collections",
      "description_id": "Akses dan kelola koleksi produk"
    },
    {
      "resource": "product",
      "description_en": "Access and manage products",
      "description_id": "Akses dan kelola produk"
    },
    {
      "resource": "raw-material",
      "description_en": "Access raw materials",
      "description_id": "Akses bahan baku"
    },
    {
      "resource": "recipe",
      "description_en": "Access recipes",
      "description_id": "Akses resep"
    },
    {
      "resource": "inventory",
      "description_en": "Access and manage inventory",
      "description_id": "Akses dan kelola inventori"
    },
    {
      "resource": "order",
      "description_en": "Access orders",
      "description_id": "Akses pesanan"
    },
    {
      "resource": "return",
      "description_en": "Access returns",
      "description_id": "Akses retur"
    },
    {
      "resource": "approval",
      "description_en": "Request manager approval",
      "description_id": "Meminta persetujuan manajer"
    },
    {
      "resource": "refund",
      "description_en": "Manage refunds",
      "description_id": "Mengelola pengembalian dana"
    },
    {
      "resource": "exchange",
      "description_en": "Manage exchanges",
      "description_id": "Mengelola penukaran"
    },
    {
      "resource": "order-source",
      "description_en": "Manage the order source",
      "description_id": "Mengelola sumber pesanan"
    },
    {
      "resource": "payment-method",
      "description_en": "View payment methods",
      "description_id": "Melihat metode pembayaran"
    },
    {
      "resource": "webhook",
      "description_en": "Receive event notifications",
      "description_id": "Menerima notifikasi kejadian"
    }
  ],
  "tags": [
    {
      "name": "Outlets",
      "description": "The merchant's outlets. Most endpoints work on one outlet, sent in the `X-Outlet-ID` header."
    },
    {
      "name": "Users",
      "description": "Staff users of the merchant. Every write runs as one of them, sent in the `X-User-ID` header."
    },
    {
      "name": "Shifts",
      "description": "Cashier shifts that are open now."
    },
    {
      "name": "Customers",
      "description": "Customers (members), with their loyalty points."
    },
    {
      "name": "Promotions",
      "description": "Product promotions."
    },
    {
      "name": "Vouchers",
      "description": "Order discount vouchers."
    },
    {
      "name": "Categories",
      "description": "Product categories, shared by all outlets."
    },
    {
      "name": "Collections",
      "description": "Product collections."
    },
    {
      "name": "Products",
      "description": "Products, their variants and prices."
    },
    {
      "name": "Inventory",
      "description": "Stock levels, stock movements, stock adjustments and stock counts."
    },
    {
      "name": "Raw materials",
      "description": "Raw materials used by recipes."
    },
    {
      "name": "Recipes",
      "description": "Recipes (bills of materials) of products."
    },
    {
      "name": "Orders",
      "description": "Completed sales, and the paid orders your application creates."
    },
    {
      "name": "Order source",
      "description": "How your application is known on the orders it creates. Register it once per merchant with `PUT /order-source`, before the first `POST /orders`."
    },
    {
      "name": "Payment methods",
      "description": "The payment methods a tender can name by `payment_method_id`. Cash needs `X-User-ID` with an open shift; every other listed method is a custom method of the merchant."
    },
    {
      "name": "Returns",
      "description": "Returns of paid orders, and the quote that prices a return first. Creating a return needs the acting user to have an open shift at the outlet."
    },
    {
      "name": "Refunds",
      "description": "Refunds of paid orders without taking goods back. Creating a refund needs the acting user to have an open shift at the outlet."
    },
    {
      "name": "Exchanges",
      "description": "Exchanges of paid orders, and the quote that prices an exchange first. Creating an exchange needs the acting user to have an open shift at the outlet."
    },
    {
      "name": "Approvals",
      "description": "Manager approval by PIN or one-time code. The `approval_token` these calls return is sent with a return, refund or exchange when the outlet requires approval."
    },
    {
      "name": "Webhooks",
      "description": "Where Mekari POS sends event notifications for your application. Each\napplication manages its own endpoints, one per key (`orders`, `products`,\n`collections`, `inventory`); another application of the same merchant\nnever sees them. These calls change your own settings, so they need no\n`X-User-ID` and no `Idempotency-Key`.\n\n## Event body\n\nEvery event is one JSON object with the same keys (schema `WebhookEvent`):\n`id`, `type`, `schema_version`, `occurred_at`, `company_id`, `outlet_id`\nand `data`.\n\n- `id` identifies the change. It is the same on every retry and for every\n  endpoint that receives the change. It is also sent as `X-Mekari-Event-ID`.\n- `company_id` is the merchant's company id, the one you know from\n  Mekari SSO.\n- `outlet_id` is the outlet of the change, or `null` for company-wide events.\n- `occurred_at` is business time in UTC (`Z`), whole seconds.\n- `schema_version` is `1`. New fields may be added to `data` without a\n  new version, so ignore fields you do not know.\n\n## Thin `data`\n\nWith `payload` set to `thin` (the default), `data` only names the\nresource. Call the matching `GET` to read it.\n\n| Events | `data` |\n|---|---|\n| `order.paid`, `order.payment_updated`, `order.returned`, `order.refunded`, `order.exchanged` | `WebhookOrderData` |\n| `order.accepted`, `order.rejected`, `order.preparing`, `order.ready`, `order.served`, `order.completed`, `order.cancelled` | `WebhookKitchenData` |\n| `product.created`, `product.updated`, `product.deleted` | `WebhookProductData` |\n| `sku.changed` | `WebhookSkuData` |\n| `collection.created`, `collection.updated`, `collection.deleted`, `collection.reordered` | `WebhookCollectionData` |\n| `inventory.availability_changed` | `WebhookAvailabilityData` |\n\nNotes on the order and kitchen events:\n\n- Order record events carry the order fields; `order.returned`,\n  `order.refunded` and `order.exchanged` add `return_id`, `refund_id` or\n  `exchange_id`. Their `order_id` is the original order, and the points\n  are the ones stored on it when the event was written.\n- `order.paid` means the sale is completed at the till. For a Terms\n  sale (pay later) check `payment_status`: it can still be `unpaid`.\n  `order.payment_updated` is sent on every Terms payment, partial or\n  full. Both carry `payment_status` and `amount_owed`.\n- Kitchen events carry `kitchen_order_id`, `kitchen_status`, `order_id`,\n  `sales_type` and `updated_at`. `kitchen_status` is the event name\n  after `order.`: `order.ready` has `kitchen_status` `ready`. `order_id`\n  is `null` until a POS order exists for the ticket. A set `order_id`\n  does not mean the order is paid: `order.paid` says the sale is\n  completed at the till; for a Terms sale check `payment_status`.\n- On `sku.changed`, `product_id` is `null` when the variant no longer exists.\n- `inventory.availability_changed` never carries a quantity.\n\n## Full `data`\n\nWith `payload` set to `full` (not for the `inventory` endpoint), `data` is\nthe same object that the matching `GET` returns in its own `data`:\n`GET /orders/{id}`, `GET /products/{id}` or `GET /collections/{id}`. It is\nread when the event is sent, so it shows the resource as it is then, not\nas it was at the time of the change. For `order.returned`,\n`order.refunded` and `order.exchanged`, the order also has `return_id`,\n`refund_id` or `exchange_id`, so you still know which one happened.\n\n- Image URLs are empty strings, never `null`. Call `GET` to read\n  images.\n- A product carries no stock, because stock is per outlet. Read it with\n  `GET /inventory/stock/{item_type}/{item_id}`.\n- An order carries the same customer (member) data as `GET /orders/{id}`.\n- These are always thin, even on a `full` endpoint: `*.deleted` events,\n  any event whose resource no longer exists, `sku.changed`,\n  `inventory.availability_changed`, and a kitchen event while its\n  `order_id` is still `null`. Once `order_id` is set, a kitchen event on a\n  `full` endpoint carries the full order.\n- Rarely a `full` endpoint receives a thin body, when the resource could\n  not be read at that moment. Check which fields are present, and call\n  `GET` when you need the rest.\n\n## Headers and signature\n\n| Header | Value |\n|---|---|\n| `X-Mekari-Signature` | `v1=` and the hex HMAC-SHA256 of the signed string |\n| `X-Mekari-Timestamp` | Unix time in seconds, when this attempt was signed |\n| `X-Mekari-Key-Version` | The version of the secret that signed it |\n| `X-Mekari-Delivery-ID` | Identifies the delivery of this event to this endpoint |\n| `X-Mekari-Event-ID` | The event `id` |\n| `User-Agent` | `mekaripos-webhooks/1.0` |\n\nThe signed string is, in this order, joined with a dot:\n`timestamp \".\" delivery_id \".\" event_id \".\" key_version \".\" raw_body`.\nThe key is the signing secret of that key version. Use the raw request\nbody bytes, before any JSON parsing.\n\n1. Read the headers and the raw body.\n2. Reject the request when the timestamp is older than 5 minutes.\n3. Pick the secret that matches `X-Mekari-Key-Version`, build the string,\n   compute the HMAC-SHA256 and compare it with the `v1` value in constant\n   time.\n\nAfter `rotate-secret` (or a `PUT` that changes the `url`), the new\nversion signs every request from that moment, retries included. Keep the\nprevious secret for 24 hours only to verify requests that were already on\ntheir way, then delete it. Deploy the new secret quickly: until then your\nreceiver cannot verify new requests.\n\n## Delivery, retries and pause\n\nAnswer with any `2xx` status within 10 seconds. Mekari POS never follows\nredirects: a `3xx` answer is a final failure, and the `Location` is not\nrequested.\n\n- A transport error, a timeout, `408`, `429` and any `5xx` are retried.\n  Any other `4xx`, and any `3xx`, fail at once and are not retried.\n- There are at most 8 attempts: the first at once, then after about\n  30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours and\n  6 hours (about 10 hours 42 minutes in all). A `Retry-After` header with a number\n  of seconds is honoured when it is longer than that delay, up to 1 hour.\n- A delivery that fails for good counts as one failure. After 10 failures\n  in a row, with no success between, the endpoint is `paused` and sends\n  nothing. A `PUT` on the endpoint makes it `active` again; deliveries\n  still waiting from before the pause are cancelled, not sent. Any `2xx`\n  resets the count.\n- Retries are made about once a minute, so the real delay can be up to a\n  minute longer than the times above.\n\n## What to expect\n\n- Delivery is at least once. The same event can arrive more than once:\n  dedupe on `id`.\n- There is no ordering. Order events by `occurred_at`, and kitchen\n  events by their `kitchen_status`.\n- Events are not replayed. After an outage on your side, or after the\n  endpoint was paused, reconcile with `GET /orders`, `GET /products` and\n  `GET /collections`.\n- You do not receive the change that your own application made through\n  this API. Other applications of the same merchant do. This does not\n  apply to `inventory.availability_changed`: it carries no origin, so you\n  may receive an availability change caused by your own stock write.\n- Nothing is sent when the merchant's Public API add-on is off or expired."
    }
  ],
  "x-tagGroups": [
    {
      "name": "Outlets & Staff",
      "tags": [
        "Outlets",
        "Users",
        "Shifts"
      ]
    },
    {
      "name": "Customers & Promotions",
      "tags": [
        "Customers",
        "Promotions",
        "Vouchers"
      ]
    },
    {
      "name": "Catalog",
      "tags": [
        "Categories",
        "Collections",
        "Products"
      ]
    },
    {
      "name": "Inventory",
      "tags": [
        "Inventory",
        "Raw materials",
        "Recipes"
      ]
    },
    {
      "name": "Sales",
      "tags": [
        "Order source",
        "Payment methods",
        "Orders",
        "Returns",
        "Refunds",
        "Exchanges",
        "Approvals"
      ]
    },
    {
      "name": "Webhooks",
      "tags": [
        "Webhooks"
      ]
    }
  ],
  "paths": {
    "/outlets": {
      "get": {
        "operationId": "listOutlets",
        "summary": "List outlets",
        "description": "Every active outlet of the merchant. Use an outlet `id` as `X-Outlet-ID` on outlet-scoped operations.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `INVALID_CURSOR` | The `cursor` came with another `limit`. |\n",
        "tags": [
          "Outlets"
        ],
        "x-mag-scope": "outlet:list",
        "x-mag-description-en": "View the list of outlets",
        "x-mag-description-id": "Melihat daftar outlet",
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "Outlets",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "required": [
                            "items"
                          ],
                          "properties": {
                            "items": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/Outlet"
                              }
                            },
                            "next_cursor": {
                              "type": "string",
                              "description": "Present only when more outlets remain; pass it as `cursor`."
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/users": {
      "get": {
        "operationId": "listUsers",
        "summary": "List staff",
        "description": "Active staff of the merchant. Use a user `id` as `X-User-ID` on write operations. Never includes PINs, emails, phone numbers or credentials.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `INVALID_CURSOR` | The `cursor` came with another `limit`. |\n",
        "tags": [
          "Users"
        ],
        "x-mag-scope": "user:list",
        "x-mag-description-en": "View the list of active staff",
        "x-mag-description-id": "Melihat daftar staf aktif",
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "Staff",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "required": [
                            "items"
                          ],
                          "properties": {
                            "items": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/User"
                              }
                            },
                            "next_cursor": {
                              "type": "string",
                              "description": "Present only when more staff remain; pass it as `cursor`."
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/shifts": {
      "get": {
        "operationId": "listOpenShifts",
        "summary": "List open shifts",
        "description": "Shifts that are open now at the outlet in `X-Outlet-ID`, oldest first. The list is not paged. An empty list is a normal answer (no cashier has an open shift). Only open shifts are listed. Never includes device ids, notes or cash expectations.",
        "tags": [
          "Shifts"
        ],
        "x-mag-scope": "shift:list",
        "x-mag-description-en": "View open shifts at an outlet",
        "x-mag-description-id": "Melihat shift yang sedang buka di outlet",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          }
        ],
        "responses": {
          "200": {
            "description": "Open shifts",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "required": [
                            "items"
                          ],
                          "properties": {
                            "items": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/Shift"
                              }
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/customers": {
      "get": {
        "operationId": "listCustomers",
        "summary": "List customers",
        "description": "Live customers of the merchant, ordered by name. `search` matches part of the name, phone or card number. The list has no balances, member tier or origin outlet; read one customer for those. Pages are offset-based: customers added or removed while you page can shift rows between pages.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `INVALID_CURSOR` | The `cursor` came with another `limit`, another `search` or another endpoint. |\n",
        "tags": [
          "Customers"
        ],
        "x-mag-scope": "customer:list",
        "x-mag-description-en": "View the list of customers",
        "x-mag-description-id": "Melihat daftar pelanggan",
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "Part of a name, phone or card number. A cursor is only valid with the same `search`.",
            "schema": {
              "type": "string",
              "maxLength": 255
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Customers",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "required": [
                            "items"
                          ],
                          "properties": {
                            "items": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/CustomerSummary"
                              }
                            },
                            "next_cursor": {
                              "type": "string",
                              "description": "Present only when more customers remain; pass it as `cursor`."
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "operationId": "createCustomer",
        "summary": "Create a customer",
        "description": "Creates a customer as the acting user. Points and member tier are not part of the body: a field that is not listed below, such as `points_balance`, is ignored. Set points with `PUT /customers/{id}/points`. Errors: `403 CUSTOMER_SEAT_LIMIT` when the plan's customer cap is reached, `409 DUPLICATE_PHONE` or `409 DUPLICATE_CARD_NUMBER` when another live customer uses the value.",
        "tags": [
          "Customers"
        ],
        "x-mag-scope": "customer:create",
        "x-mag-description-en": "Create a customer",
        "x-mag-description-id": "Membuat pelanggan",
        "parameters": [
          {
            "$ref": "#/components/parameters/UserID"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "phone"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 255
                  },
                  "phone": {
                    "type": "string",
                    "maxLength": 64
                  },
                  "email": {
                    "type": "string",
                    "format": "email",
                    "maxLength": 255
                  },
                  "card_number": {
                    "type": "string",
                    "maxLength": 64,
                    "description": "Letters and digits only."
                  },
                  "birthday": {
                    "type": "string",
                    "format": "date"
                  },
                  "address": {
                    "type": "string"
                  },
                  "notes": {
                    "type": "string"
                  },
                  "origin_outlet_id": {
                    "type": "string",
                    "format": "uuid",
                    "description": "The outlet the customer was first registered at."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "$ref": "#/components/responses/CustomerDetail"
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/customers/{id}": {
      "get": {
        "operationId": "getCustomer",
        "summary": "Get a customer",
        "description": "One live customer with points balance and member tier. `store_credit` is present only when the merchant's plan includes member balance. An id of another merchant answers `404 NOT_FOUND`.",
        "tags": [
          "Customers"
        ],
        "x-mag-scope": "customer:read",
        "x-mag-description-en": "View customer details, including points and balance",
        "x-mag-description-id": "Melihat detail pelanggan, termasuk poin dan saldo",
        "parameters": [
          {
            "$ref": "#/components/parameters/ID"
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/CustomerDetail"
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "patch": {
        "operationId": "updateCustomer",
        "summary": "Update a customer",
        "description": "Partial update as the acting user: a field that is absent keeps its value; `null` clears it.\n\n- An empty string is `400` on `email`, `card_number`, `birthday` and `origin_outlet_id`; `name` and `phone` cannot be cleared.\n- A field that is not listed below, such as `points_balance`, is ignored.\n- `409 DUPLICATE_PHONE` or `409 DUPLICATE_CARD_NUMBER` when another live customer uses the value.\n- Concurrent updates: the last write wins.\n",
        "tags": [
          "Customers"
        ],
        "x-mag-scope": "customer:update",
        "x-mag-description-en": "Update customer details",
        "x-mag-description-id": "Memperbarui detail pelanggan",
        "parameters": [
          {
            "$ref": "#/components/parameters/ID"
          },
          {
            "$ref": "#/components/parameters/UserID"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKeyOptional"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 255
                  },
                  "phone": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 64
                  },
                  "email": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 255,
                    "description": "An email address, or `null` to clear."
                  },
                  "card_number": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 64,
                    "description": "Letters and digits only, or `null` to clear."
                  },
                  "birthday": {
                    "type": "string",
                    "nullable": true,
                    "description": "A `YYYY-MM-DD` date, or `null` to clear."
                  },
                  "address": {
                    "type": "string",
                    "nullable": true,
                    "description": "Any text, or `null` to clear. An empty string is stored as sent."
                  },
                  "notes": {
                    "type": "string",
                    "nullable": true,
                    "description": "Any text, or `null` to clear. An empty string is stored as sent."
                  },
                  "origin_outlet_id": {
                    "type": "string",
                    "nullable": true,
                    "description": "An outlet `id`, or `null` to clear."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/CustomerDetail"
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/customers/{id}/points": {
      "put": {
        "operationId": "setCustomerPoints",
        "summary": "Set a customer's points balance",
        "description": "Sets the customer's loyalty points balance to `points_balance`, recorded as an adjustment by the acting user. The user's role needs both customer edit and loyalty manage permissions.",
        "tags": [
          "Customers"
        ],
        "x-mag-scope": "customer:points:update",
        "x-mag-description-en": "Set a customer's loyalty points balance",
        "x-mag-description-id": "Mengatur saldo poin loyalitas pelanggan",
        "parameters": [
          {
            "$ref": "#/components/parameters/ID"
          },
          {
            "$ref": "#/components/parameters/UserID"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKeyOptional"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "points_balance"
                ],
                "properties": {
                  "points_balance": {
                    "type": "integer",
                    "format": "int64",
                    "minimum": 0
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/CustomerDetail"
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/promotions": {
      "get": {
        "operationId": "listPromotions",
        "summary": "List promotions",
        "description": "Promotions of the merchant, oldest first. `status` is the effective status the back office shows (an active promotion outside its time range is `inactive`). Never includes voucher codes. Pages are offset-based: rows created while you page can shift a page. Needs the product discount feature in the merchant's plan, otherwise `403 PLAN_FEATURE_REQUIRED`.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `INVALID_CURSOR` | The `cursor` came with another `limit` or another endpoint. |\n",
        "tags": [
          "Promotions"
        ],
        "x-mag-scope": "promotion:list",
        "x-mag-description-en": "View the list of promotions",
        "x-mag-description-id": "Melihat daftar promosi",
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "Promotions",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "required": [
                            "items"
                          ],
                          "properties": {
                            "items": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/Promotion"
                              }
                            },
                            "next_cursor": {
                              "type": "string",
                              "description": "Present only when more promotions remain; pass it as `cursor`."
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/promotions/{id}": {
      "get": {
        "operationId": "getPromotion",
        "summary": "Get a promotion",
        "description": "One promotion with its rules, products and collections. Never includes voucher codes. An id of another merchant answers `404 NOT_FOUND`. Needs the product discount feature in the merchant's plan, otherwise `403 PLAN_FEATURE_REQUIRED`.",
        "tags": [
          "Promotions"
        ],
        "x-mag-scope": "promotion:read",
        "x-mag-description-en": "View promotion details",
        "x-mag-description-id": "Melihat detail promosi",
        "parameters": [
          {
            "$ref": "#/components/parameters/ID"
          }
        ],
        "responses": {
          "200": {
            "description": "The promotion",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/PromotionDetail"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/vouchers/{code}": {
      "get": {
        "operationId": "checkVoucher",
        "summary": "Check a voucher code",
        "description": "Checks that the code is live: it exists, is unused, its promotion is active and inside its dates, and quota is left. It answers `200` with `valid: false` and a `reason` for any code that is not live, and never `404`. When `valid` is true, `restrictions` shows the rules of the promotion. Outlet, customer and minimum purchase rules are not checked here; they are checked when the order is created, so a live code can still be refused for an order. Codes are never listed. Needs the order discount voucher feature in the merchant's plan, otherwise `403 PLAN_FEATURE_REQUIRED`.",
        "tags": [
          "Vouchers"
        ],
        "x-mag-scope": "voucher:read",
        "x-mag-path": "/v2/vouchers/[^/]+",
        "x-mag-description-en": "Check whether a voucher code can be used",
        "x-mag-description-id": "Memeriksa apakah kode voucher dapat digunakan",
        "parameters": [
          {
            "name": "code",
            "in": "path",
            "required": true,
            "description": "The voucher code as the customer gives it. Not case-sensitive.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The voucher check",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Voucher"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/categories": {
      "get": {
        "operationId": "listCategories",
        "summary": "List product categories",
        "description": "Every category the merchant can use: its own and the shared platform categories, shallow levels first (a tree flattened by `parent_id`). Company-wide: no `X-Outlet-ID` (a sent one is ignored). Pages are offset-based: categories added or removed while you page can shift rows between pages. `POST /categories` creates a category.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `INVALID_CURSOR` | The `cursor` came with another `limit` or another endpoint. |\n",
        "tags": [
          "Categories"
        ],
        "x-mag-scope": "category:list",
        "x-mag-description-en": "View the list of product categories",
        "x-mag-description-id": "Melihat daftar kategori produk",
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "Categories",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "required": [
                            "items"
                          ],
                          "properties": {
                            "items": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/CategoryItem"
                              }
                            },
                            "next_cursor": {
                              "type": "string",
                              "description": "Present only when more categories remain; pass it as `cursor`."
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "operationId": "createCategory",
        "summary": "Create a product category",
        "description": "Creates a category as the acting user. Omit `parent_id` for a top-level category.\n\n- Categories can be nested up to 5 levels, and a category that already has products cannot get a child.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `VALIDATION_FAILED` | The category would sit deeper than 5 levels. |\n| 404 | `NOT_FOUND` | Unknown parent. |\n| 409 | `DATA_CONFLICT` | The name is already used under the same parent, or the parent already has products (the message tells which). |\n",
        "tags": [
          "Categories"
        ],
        "x-mag-scope": "category:create",
        "x-mag-description-en": "Create a product category",
        "x-mag-description-id": "Membuat kategori produk",
        "parameters": [
          {
            "$ref": "#/components/parameters/UserID"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 255
                  },
                  "parent_id": {
                    "type": "string",
                    "format": "uuid",
                    "nullable": true,
                    "description": "The parent category `id`. Omit for a top-level category."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The new category",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Category"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "description": "`DATA_CONFLICT` for both cases: the name is already used under the same parent, or the parent already has products. Only the message differs.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/collections": {
      "get": {
        "operationId": "listCollections",
        "summary": "List product collections",
        "description": "Every collection of the merchant, shallow levels first (a tree flattened by `parent_id`). Company-wide: no `X-Outlet-ID` (a sent one is ignored). Pages are offset-based: collections added or removed while you page can shift rows between pages.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `INVALID_CURSOR` | The `cursor` came with another `limit` or another endpoint. |\n",
        "tags": [
          "Collections"
        ],
        "x-mag-scope": "collection:list",
        "x-mag-description-en": "View the list of product collections",
        "x-mag-description-id": "Melihat daftar koleksi produk",
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "Collections",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "required": [
                            "items"
                          ],
                          "properties": {
                            "items": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/Collection"
                              }
                            },
                            "next_cursor": {
                              "type": "string",
                              "description": "Present only when more collections remain; pass it as `cursor`."
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "operationId": "createCollection",
        "summary": "Create a product collection",
        "description": "Creates a collection as the acting user. Omit `parent_id` for a top-level collection. Collections can be nested up to 5 levels (`400 VALIDATION_FAILED` when deeper); an unknown parent answers `404 NOT_FOUND`. Use `Idempotency-Key` to retry safely. An unknown field is ignored.",
        "tags": [
          "Collections"
        ],
        "x-mag-scope": "collection:create",
        "x-mag-description-en": "Create a product collection",
        "x-mag-description-id": "Membuat koleksi produk",
        "parameters": [
          {
            "$ref": "#/components/parameters/UserID"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 150
                  },
                  "parent_id": {
                    "type": "string",
                    "format": "uuid",
                    "nullable": true,
                    "description": "The parent collection `id`. Omit for a top-level collection."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "$ref": "#/components/responses/Collection"
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/collections/{id}": {
      "get": {
        "operationId": "getCollection",
        "summary": "Get a product collection",
        "description": "One collection. An id of another merchant answers `404 NOT_FOUND`.",
        "tags": [
          "Collections"
        ],
        "x-mag-scope": "collection:read",
        "x-mag-description-en": "View product collection details",
        "x-mag-description-id": "Melihat detail koleksi produk",
        "parameters": [
          {
            "$ref": "#/components/parameters/ID"
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/Collection"
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "patch": {
        "operationId": "updateCollection",
        "summary": "Update a product collection",
        "description": "Renames the collection and can move it: send `name` and, to nest it, `parent_id`. Leaving `parent_id` out keeps the parent; `null` makes it a top-level collection. Moving it under itself or one of its descendants, or deeper than 5 levels, answers `400 VALIDATION_FAILED`. An id of another merchant answers `404 NOT_FOUND`. Concurrent updates: the last write wins.",
        "tags": [
          "Collections"
        ],
        "x-mag-scope": "collection:update",
        "x-mag-description-en": "Update a product collection",
        "x-mag-description-id": "Memperbarui koleksi produk",
        "parameters": [
          {
            "$ref": "#/components/parameters/ID"
          },
          {
            "$ref": "#/components/parameters/UserID"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKeyOptional"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 150
                  },
                  "parent_id": {
                    "type": "string",
                    "format": "uuid",
                    "nullable": true,
                    "description": "The parent collection `id`. Absent keeps the parent; `null` makes the collection top level; an empty string is `400`."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Collection"
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "delete": {
        "operationId": "deleteCollection",
        "summary": "Delete a product collection",
        "description": "Deletes a collection that has no sub-collections (`409 DATA_CONFLICT` otherwise). Its products stay and lose the collection. An id of another merchant answers `404 NOT_FOUND`. With the Price Management master switch off, company-wide legacy product configuration writes return 409 PRICE_ROLLOUT_PAUSED when any company outlet is prepared or was activated. No product, price, history, feed or product-change event is written. Unprepared legacy companies retain the existing compatibility semantics.",
        "tags": [
          "Collections"
        ],
        "x-mag-scope": "collection:delete",
        "x-mag-description-en": "Delete a product collection",
        "x-mag-description-id": "Menghapus koleksi produk",
        "parameters": [
          {
            "$ref": "#/components/parameters/ID"
          },
          {
            "$ref": "#/components/parameters/UserID"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKeyOptional"
          }
        ],
        "responses": {
          "204": {
            "description": "Deleted. No body."
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/products": {
      "get": {
        "operationId": "listProducts",
        "summary": "List products",
        "description": "Products sold at the outlet in `X-Outlet-ID` (a product with an empty `outlet_ids` list is sold at every outlet), with their variants and prices. Stock is not included; read it with `GET /inventory/stock/{item_type}/{item_id}`. Filter with `status`; without it every status is returned. Pages are offset-based: products added or removed while you page can shift rows between pages.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `INVALID_CURSOR` | The `cursor` came with another `limit`, another `X-Outlet-ID`, another `status` or another endpoint. |\n",
        "tags": [
          "Products"
        ],
        "x-mag-scope": "product:list",
        "x-mag-description-en": "View the list of products",
        "x-mag-description-id": "Melihat daftar produk",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Only products with this status. The cursor is only valid with the same `status`.",
            "schema": {
              "type": "string",
              "enum": [
                "active",
                "archived"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Products",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "required": [
                            "items"
                          ],
                          "properties": {
                            "items": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/Product"
                              }
                            },
                            "next_cursor": {
                              "type": "string",
                              "description": "Present only when more products remain; pass it as `cursor`."
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "operationId": "createProduct",
        "summary": "Create a product",
        "description": "Creates a product and its variants as the acting user. The answer is the product, without stock.\n\n### Request\n\n- Send at least one variant (`sku` is required).\n- For a product without variant options, leave out `variant_groups` and `variant_values`.\n- For a product with options, name them in `variant_groups` and select them per variant in `variant_values` (by name; never POS ids): POS reuses or creates the variant groups and values. `variant_values` without `variant_groups` answer `400 VALIDATION_FAILED`.\n- Images are copied from the public HTTPS URLs you send (`attachments`, `variants[].attachment`) before the product is saved. An image that cannot be used answers `400 VALIDATION_FAILED` with its field.\n- An unknown field is ignored.\n\n### Behaviour\n\n- A SKU that is already used answers `409`.\n- Use `Idempotency-Key` to retry safely.\n- `variants[].price` is required: leaving it out or sending `null` answers `400 VALIDATION_FAILED` on `variants[i].price`; `0` is allowed.\n- With Price Management enabled, the supplied prices including zero initialize only generated new product/SKU cells. Values above 999999999999 answer 422. Product-create permission permits this initialization.\n- Every active assigned outlet must be in the current actor's grants. This keeps the company-wide inherited-base meaning; X-Outlet-ID selects product visibility/read context. Canonical price/history/receipt/feed, final compatibility maxima and product.created outbox share the same transaction. Origin MAG-client suppression is retained.\nWith the Price Management master switch off, company-wide legacy product configuration writes return 409 PRICE_ROLLOUT_PAUSED when any company outlet is prepared or was activated. No product, price, history, feed or product-change event is written. Unprepared legacy companies retain the existing compatibility semantics.\n",
        "tags": [
          "Products"
        ],
        "x-mag-scope": "product:create",
        "x-mag-description-en": "Create a product",
        "x-mag-description-id": "Membuat produk",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/UserID"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateProduct"
              }
            }
          }
        },
        "responses": {
          "201": {
            "$ref": "#/components/responses/Product"
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/products/{id}": {
      "get": {
        "operationId": "getProduct",
        "summary": "Get a product",
        "description": "One product with its variants. Stock is not included; read it with `GET /inventory/stock/{item_type}/{item_id}`. A product that does not exist, belongs to another merchant, or is not sold at this outlet answers `404 NOT_FOUND`.",
        "tags": [
          "Products"
        ],
        "x-mag-scope": "product:read",
        "x-mag-description-en": "View product details",
        "x-mag-description-id": "Melihat detail produk",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/ID"
          }
        ],
        "responses": {
          "200": {
            "description": "The product",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Product"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "patch": {
        "operationId": "updateProduct",
        "summary": "Update a product",
        "description": "Writes the name and the variant prices (there is no separate name or price route). The selling status is changed with `PATCH /products/{id}/status`.\n\n### Request\n\n- Send `name` and `variants`, each variant with its `price`.\n- The `variants` you send are the full set to keep: an existing variant you leave out is deleted.\n- Identify an existing variant by `variant_id`; leave it out and send `sku` to add a variant.\n- A variant with a new `sku` and no `variant_id` is a new variant, even when you meant to change an existing one: that existing variant is then removed, because it is not listed.\n- An `id` key on a variant is ignored. An unknown field is ignored.\n- Images are copied from the public HTTPS URLs you send.\n\n### Behaviour\n\n- A field you do not send is kept: no `collection_id`, `outlet_ids` or `attachments` key keeps the stored value, and `null` clears it (no collection, sold at every outlet, no images; `[]` clears `outlet_ids` and `attachments` too).\n- On an existing variant the same holds for `sku`, `barcode`, `cost`, `attachment` and `variant_values`: a key you leave out keeps the stored value, and `null` clears `barcode`, `cost` (to 0) and `attachment`.\n- The option values of an existing variant can be replaced with a list.\n- To remove option values, send `variant_values: null` or `[]` on every variant that has them, and `variant_groups: []` (or no `variant_groups` key). The options of all variants are cleared together, so the product ends with no variant groups, as `POST /products` creates it without options. The SKU, barcode, price and cost of each variant stay.\n- A clear is refused with `400 VALIDATION_FAILED` on `variants[i].variant_values` when any variant would still have options (a variant that leaves `variant_values` out keeps its options; a list gives options) or `variant_groups` is not empty. Nothing is changed.\n- A new variant has nothing stored: what it leaves out is empty or 0.\n- A service product needs its Duration option on every SKU, so clearing the options of a service product fails with `400 SERVICE_DURATION_REQUIRED`: one `data.fields` entry with code `DURATION_REQUIRED` on `variants[i].variant_values` for each SKU left without a Duration. Nothing is changed.\n- The category and the product details this body cannot express (description, translations) are never changed here.\n- Concurrent updates: the last write wins within the legacy scalar contract.\n- With Price Management enabled, real inherited-base changes and initial prices for new SKUs on an existing product also require catalog.price.edit. A scalar echo creates no price intent.\n- Every target outlet must be in the current actor's grants. Explicit, missing, suspended and permanent-owned cells are protected. New and existing intents share one command and the caller's metadata/outbox transaction.\n- The current local user owns the audit. MAG client metadata is retained. Compatibility responses use final permanent maxima.\n\n### Errors\n\nA SKU that is already used answers `409`.\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `VALIDATION_FAILED` | A variant has no `price`, or `variant_values` is `null` or `[]` on an existing variant that has option values while another variant keeps or gets options, or `variant_groups` is not empty (on `variants[i].variant_values`). |\n| 400 | `SERVICE_DURATION_REQUIRED` | A service product's SKU would have no Duration option (for example its options are cleared). `data.fields` has code `DURATION_REQUIRED` on `variants[i].variant_values`. |\n| 404 | `NOT_FOUND` | The product does not exist, belongs to another merchant, or is not sold at the outlet in `X-Outlet-ID`. |\nWith the Price Management master switch off, company-wide legacy product configuration writes return 409 PRICE_ROLLOUT_PAUSED when any company outlet is prepared or was activated. No product, price, history, feed or product-change event is written. Unprepared legacy companies retain the existing compatibility semantics.\n",
        "tags": [
          "Products"
        ],
        "x-mag-scope": "product:update",
        "x-mag-description-en": "Update a product",
        "x-mag-description-id": "Memperbarui produk",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/ID"
          },
          {
            "$ref": "#/components/parameters/UserID"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKeyOptional"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateProduct"
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Product"
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "delete": {
        "operationId": "deleteProduct",
        "summary": "Delete a product",
        "description": "Deletes the product and its variants as the acting user, the way the back office does: they leave the catalog and sales, and past orders keep them. A product that does not exist, belongs to another merchant, or is not sold at the outlet in `X-Outlet-ID` answers `404 NOT_FOUND`. With the Price Management master switch off, company-wide legacy product configuration writes return 409 PRICE_ROLLOUT_PAUSED when any company outlet is prepared or was activated. No product, price, history, feed or product-change event is written. Unprepared legacy companies retain the existing compatibility semantics.",
        "tags": [
          "Products"
        ],
        "x-mag-scope": "product:delete",
        "x-mag-description-en": "Delete a product",
        "x-mag-description-id": "Menghapus produk",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/ID"
          },
          {
            "$ref": "#/components/parameters/UserID"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKeyOptional"
          }
        ],
        "responses": {
          "204": {
            "description": "Deleted. No body."
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/products/{id}/status": {
      "patch": {
        "operationId": "updateProductStatus",
        "summary": "Change a product's selling status",
        "description": "Sets the product to `active` (sold) or `archived` (not sold) as the acting user, and answers the product. A product that does not exist, belongs to another merchant, or is not sold at that outlet answers `404 NOT_FOUND`. With the Price Management master switch off, company-wide legacy product configuration writes return 409 PRICE_ROLLOUT_PAUSED when any company outlet is prepared or was activated. No product, price, history, feed or product-change event is written. Unprepared legacy companies retain the existing compatibility semantics.",
        "tags": [
          "Products"
        ],
        "x-mag-scope": "product:status:update",
        "x-mag-description-en": "Change a product's selling status",
        "x-mag-description-id": "Mengubah status penjualan produk",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/ID"
          },
          {
            "$ref": "#/components/parameters/UserID"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKeyOptional"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateProductStatus"
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Product"
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/raw-materials": {
      "get": {
        "operationId": "listRawMaterials",
        "summary": "List raw materials",
        "description": "Raw materials of the merchant by name, with their unit. Never includes costs. Stock is read per outlet with `GET /inventory/stock/raw_material/{item_id}`. Pages are offset-based: rows created while you page can shift a page. Needs the raw materials and recipes feature in the merchant's plan (an F&B plan), otherwise `403 PLAN_FEATURE_REQUIRED`.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `INVALID_CURSOR` | The `cursor` came with another `limit`, another `X-Outlet-ID` or another endpoint. |\n",
        "tags": [
          "Raw materials"
        ],
        "x-mag-scope": "raw-material:list",
        "x-mag-description-en": "View the list of raw materials",
        "x-mag-description-id": "Melihat daftar bahan baku",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "Raw materials",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "required": [
                            "items"
                          ],
                          "properties": {
                            "items": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/RawMaterial"
                              }
                            },
                            "next_cursor": {
                              "type": "string",
                              "description": "Present only when more rows remain; pass it as `cursor`."
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/raw-materials/{id}": {
      "get": {
        "operationId": "getRawMaterial",
        "summary": "Get a raw material",
        "description": "One raw material. Never includes costs. An id of another merchant answers `404 NOT_FOUND`. Needs the raw materials and recipes feature in the merchant's plan (an F&B plan), otherwise `403 PLAN_FEATURE_REQUIRED`.",
        "tags": [
          "Raw materials"
        ],
        "x-mag-scope": "raw-material:read",
        "x-mag-description-en": "View raw material details",
        "x-mag-description-id": "Melihat detail bahan baku",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/ID"
          }
        ],
        "responses": {
          "200": {
            "description": "The raw material",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/RawMaterial"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/recipes": {
      "get": {
        "operationId": "listRecipes",
        "summary": "List recipes",
        "description": "Recipes of the merchant by name, with the number of ingredient lines. Never includes costs. Pages are offset-based: rows created while you page can shift a page. Needs the raw materials and recipes feature in the merchant's plan (an F&B plan), otherwise `403 PLAN_FEATURE_REQUIRED`.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `INVALID_CURSOR` | The `cursor` came with another `limit` or another endpoint. |\n",
        "tags": [
          "Recipes"
        ],
        "x-mag-scope": "recipe:list",
        "x-mag-description-en": "View the list of recipes",
        "x-mag-description-id": "Melihat daftar resep",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "Recipes",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "required": [
                            "items"
                          ],
                          "properties": {
                            "items": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/Recipe"
                              }
                            },
                            "next_cursor": {
                              "type": "string",
                              "description": "Present only when more rows remain; pass it as `cursor`."
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/recipes/{id}": {
      "get": {
        "operationId": "getRecipe",
        "summary": "Get a recipe",
        "description": "One recipe with its ingredient lines. Never includes costs. An id of another merchant answers `404 NOT_FOUND`. Needs the raw materials and recipes feature in the merchant's plan (an F&B plan), otherwise `403 PLAN_FEATURE_REQUIRED`.",
        "tags": [
          "Recipes"
        ],
        "x-mag-scope": "recipe:read",
        "x-mag-description-en": "View recipe details",
        "x-mag-description-id": "Melihat detail resep",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/ID"
          }
        ],
        "responses": {
          "200": {
            "description": "The recipe",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/RecipeDetail"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/inventory/stock/{item_type}/{item_id}": {
      "get": {
        "operationId": "getInventoryStock",
        "summary": "Get the stock of one item",
        "description": "The on-hand quantity of one item at the outlet in `X-Outlet-ID`, in the item's own unit. `item_type` is `product` (`item_id` is a variant id) or `raw_material` (`item_id` is a raw material id; needs the raw material plan feature). An item with no stock movement at the outlet answers `0`. An item of another merchant answers `404 NOT_FOUND`. This is the one place to read stock.",
        "tags": [
          "Inventory"
        ],
        "x-mag-scope": "inventory:stock:read",
        "x-mag-path": "/v2/inventory/stock/[a-z_]+/[a-z0-9-]+",
        "x-mag-description-en": "View item stock at an outlet",
        "x-mag-description-id": "Melihat stok item di outlet",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "name": "item_type",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "product",
                "raw_material"
              ]
            }
          },
          {
            "name": "item_id",
            "in": "path",
            "required": true,
            "description": "A variant `id` for a product, or a raw material `id`.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The item's stock",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/InventoryStock"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/inventory/movements": {
      "get": {
        "operationId": "listInventoryMovements",
        "summary": "List stock movements",
        "description": "The stock movement log of the outlet in `X-Outlet-ID`, without filters. Pages are offset-based: rows added while you page can shift a page.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `INVALID_CURSOR` | The `cursor` came with another `limit`, another `X-Outlet-ID` or another endpoint. |\n",
        "tags": [
          "Inventory"
        ],
        "x-mag-scope": "inventory:movement:list",
        "x-mag-description-en": "View stock movement history",
        "x-mag-description-id": "Melihat riwayat pergerakan stok",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "Stock movements",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "required": [
                            "items"
                          ],
                          "properties": {
                            "items": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/InventoryMovement"
                              }
                            },
                            "next_cursor": {
                              "type": "string",
                              "description": "Present only when more rows remain; pass it as `cursor`."
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/inventory/adjustments": {
      "post": {
        "operationId": "createInventoryAdjustment",
        "summary": "Adjust stock",
        "description": "Adds `delta` (negative to remove) to the stock of one item at the outlet in `X-Outlet-ID`, as the acting user.\n\n### Request\n\n- Name the item by `item_type` + `item_id` (send both).\n- `delta` must not be 0.\n- `reason` is one of `damaged`, `defective`, `expired`, `theft`, `natural_shrinkage`, `sample_tester`, `staff_consumption`, `production_use`, `found_stock`, `supplier_bonus`, `return_to_stock`, `recount_adjustment`, `system_correction`, `initial_stock_entry`; any other value answers `400 VALIDATION_FAILED`.\n- The reason must fit the sign of `delta`. Loss reasons (`damaged`, `defective`, `expired`, `theft`, `natural_shrinkage`, `sample_tester`, `staff_consumption`, `production_use`) need a negative `delta`.\n- Gain reasons (`found_stock`, `supplier_bonus`, `return_to_stock`) need a positive `delta`. The other three take either sign.\n- A reason that does not fit the sign answers `400 VALIDATION_FAILED` on field `reason`.\n- An item of `item_type: raw_material` needs a plan with raw materials and recipes, otherwise `403 PLAN_FEATURE_REQUIRED`.\n- An unknown field is ignored.\n\n### Behaviour\n\n- Use `Idempotency-Key` to retry safely.\n- If another system is also your stock master, the two writers can overwrite each other.\n",
        "tags": [
          "Inventory"
        ],
        "x-mag-scope": "inventory:adjustment:create",
        "x-mag-description-en": "Adjust stock",
        "x-mag-description-id": "Menyesuaikan stok",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/UserID"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "item_type",
                  "item_id",
                  "delta",
                  "reason"
                ],
                "properties": {
                  "item_type": {
                    "type": "string",
                    "enum": [
                      "product",
                      "raw_material"
                    ]
                  },
                  "item_id": {
                    "type": "string",
                    "format": "uuid",
                    "description": "A variant `id` for a product, or a raw material `id`."
                  },
                  "delta": {
                    "type": "integer",
                    "format": "int64",
                    "description": "Signed change in quantity; cannot be 0."
                  },
                  "reason": {
                    "type": "string",
                    "maxLength": 64,
                    "description": "One of the reasons listed above; it must fit the sign of `delta`.",
                    "enum": [
                      "damaged",
                      "defective",
                      "expired",
                      "theft",
                      "natural_shrinkage",
                      "sample_tester",
                      "staff_consumption",
                      "production_use",
                      "found_stock",
                      "supplier_bonus",
                      "return_to_stock",
                      "recount_adjustment",
                      "system_correction",
                      "initial_stock_entry"
                    ]
                  },
                  "note": {
                    "type": "string",
                    "maxLength": 255
                  },
                  "unit_cost": {
                    "type": "integer",
                    "format": "int64",
                    "minimum": 0
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The posted adjustment",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/InventoryAdjustment"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/inventory/snapshots/{item_type}/{item_id}": {
      "put": {
        "operationId": "setInventorySnapshot",
        "summary": "Set item stock to an exact quantity",
        "description": "Counts one item at the outlet in `X-Outlet-ID` as the acting user: the stock ends at `quantity`, whatever it was before. Sending the same count again changes nothing.\n\n### Request\n\n- `item_type` is `product` (`item_id` is a variant id) or `raw_material` (`item_id` is a raw material id; needs the raw material plan feature, otherwise `403 PLAN_FEATURE_REQUIRED`).\n- `reason` is one of `damaged`, `defective`, `expired`, `theft`, `natural_shrinkage`, `sample_tester`, `staff_consumption`, `production_use`, `found_stock`, `supplier_bonus`, `return_to_stock`, `recount_adjustment`, `system_correction`, `initial_stock_entry`; any other value answers `400 VALIDATION_FAILED`.\n- `unit_cost` is ignored for a raw material.\n- An unknown field is ignored.\n\n### Behaviour\n\n- If another system is also your stock master, the two writers can overwrite each other.\n",
        "tags": [
          "Inventory"
        ],
        "x-mag-scope": "inventory:snapshot:update",
        "x-mag-path": "/v2/inventory/snapshots/[a-z_]+/[a-z0-9-]+",
        "x-mag-description-en": "Set item stock to an exact quantity",
        "x-mag-description-id": "Mengatur stok item ke jumlah tertentu",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/UserID"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKeyOptional"
          },
          {
            "name": "item_type",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "product",
                "raw_material"
              ]
            }
          },
          {
            "name": "item_id",
            "in": "path",
            "required": true,
            "description": "A variant `id` for a product, or a raw material `id`.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "quantity",
                  "reason"
                ],
                "properties": {
                  "quantity": {
                    "type": "integer",
                    "format": "int64",
                    "minimum": 0,
                    "description": "The exact on-hand quantity to end at."
                  },
                  "reason": {
                    "type": "string",
                    "maxLength": 64,
                    "description": "One of the reasons listed above."
                  },
                  "note": {
                    "type": "string",
                    "maxLength": 255
                  },
                  "unit_cost": {
                    "type": "integer",
                    "format": "int64",
                    "minimum": 0
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The recorded count",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/InventoryAdjustment"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/order-source": {
      "get": {
        "operationId": "getOrderSource",
        "summary": "Get the order source",
        "description": "The order source of your application: the `source_code` that marks its orders, and the name of the payment method POS records its orders on. Works for the whole merchant, so it needs no `X-Outlet-ID` and no `X-User-ID`. An application that has not registered a source answers `404 NOT_FOUND`: register it with `PUT /order-source` first.",
        "tags": [
          "Order source"
        ],
        "x-mag-scope": "order-source:read",
        "x-mag-description-en": "View the order source",
        "x-mag-description-id": "Melihat sumber pesanan",
        "responses": {
          "200": {
            "description": "The order source",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/OrderSource"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "put": {
        "operationId": "putOrderSource",
        "summary": "Set the order source",
        "description": "Registers your application as an order source, or changes it. Each application has one source per merchant. Send it once per merchant, before the first `POST /orders`.\n\n### Behaviour\n\n- The first call answers `201`; a later call answers `200` and can change `display_name` at any time.\n- It works for the whole merchant, so it needs no `X-Outlet-ID`, no `X-User-ID` and no `Idempotency-Key`: sending the same body again changes nothing.\n- The first call also creates the payment method that POS records your paid orders on, named `Paid on <display_name>`.\n- The merchant does not see it in the payment method list, and it does not use one of the merchant's custom payment method places.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `VALIDATION_FAILED` | `source_code` or `display_name` is missing or invalid, or `source_code` is a reserved word. |\n| 401 | `COMPANY_NOT_FOUND` | The company is not found. |\n| 403 | `PLAN_FEATURE_REQUIRED` | The merchant's plan does not include the Public API add-on. This answer has no `data`. |\n| 403 | `SUBSCRIPTION_EXPIRED` | The subscription has expired. |\n| 409 | `SOURCE_CODE_TAKEN` | Another application of the same merchant uses this `source_code`. |\n| 409 | `SOURCE_CODE_LOCKED` | The source already has an order, so its `source_code` can no longer change. |\n",
        "tags": [
          "Order source"
        ],
        "x-mag-scope": "order-source:write",
        "x-mag-description-en": "Set the order source",
        "x-mag-description-id": "Mengatur sumber pesanan",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OrderSourceRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The source already existed and was updated; same body as `201`.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/OrderSource"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "201": {
            "description": "The source was created.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/OrderSource"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/payment-methods": {
      "get": {
        "operationId": "listPaymentMethods",
        "summary": "List payment methods",
        "description": "The payment methods a tender of `POST /orders` and an exchange tender (`POST /exchanges`, `POST /exchanges/quote`) can name by `payment_method_id` at the outlet in `X-Outlet-ID`. Pages are offset-based.\n\n- The list holds active methods of type `cash` or `custom` that are available at that outlet.\n- QRIS, Terms, points, customer balance and integration rails are not listed.\n- A return or refund tender (`refund_tenders`) names a tender of the order instead: pick it from `eligible_refund_tenders` of `POST /returns/quote`.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `INVALID_CURSOR` | The `cursor` came with another `limit`, another `X-Outlet-ID` or another endpoint. |\n",
        "tags": [
          "Payment methods"
        ],
        "x-mag-scope": "payment-method:list",
        "x-mag-description-en": "View the list of payment methods",
        "x-mag-description-id": "Melihat daftar metode pembayaran",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "Payment methods",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "required": [
                            "items"
                          ],
                          "properties": {
                            "items": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/PaymentMethod"
                              }
                            },
                            "next_cursor": {
                              "type": "string",
                              "description": "Present only when more payment methods remain; pass it as `cursor`."
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/orders": {
      "get": {
        "operationId": "listOrders",
        "summary": "List orders",
        "description": "The orders sold at the outlet in `X-Outlet-ID`, newest sale first, with their lines and tenders. Pages are keyset-based, so a page does not shift when new orders arrive. `sold_at_from` and `sold_at_to` bound the sale time (both inclusive); `sold_at_from` after `sold_at_to` answers `400 VALIDATION_FAILED`. A `cursor` is only valid with the same `limit`, the same `X-Outlet-ID`, the same `sold_at_from` and `sold_at_to` and the same endpoint.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `INVALID_CURSOR` | The `cursor` came with another `limit`, another `X-Outlet-ID`, another `sold_at_from`, another `sold_at_to` or another endpoint. |\n",
        "tags": [
          "Orders"
        ],
        "x-mag-scope": "order:list",
        "x-mag-description-en": "View the list of orders",
        "x-mag-description-id": "Melihat daftar pesanan",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "name": "sold_at_from",
            "in": "query",
            "required": false,
            "description": "Only orders sold at or after this time (RFC 3339).",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "sold_at_to",
            "in": "query",
            "required": false,
            "description": "Only orders sold at or before this time (RFC 3339).",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Orders",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "required": [
                            "items"
                          ],
                          "properties": {
                            "items": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/Order"
                              }
                            },
                            "next_cursor": {
                              "type": "string",
                              "description": "Present only when more rows remain; pass it as `cursor` with the same `limit`."
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "operationId": "createOrder",
        "summary": "Create a paid order",
        "description": "Sends a paid `delivery`, `takeaway` or `pickup` order from your application. A new order answers `201`.\n\n### Before you call\n\n- Register your source with `PUT /order-source` first.\n- `X-User-ID` is optional. Without it the order runs as your source and needs no open shift.\n- `X-User-ID` is needed to use `manual_price`, a line `discount`, `order_discount`, a manual SKU line or a cash tender.\n- Whenever you send `X-User-ID`, the user needs an open shift at the outlet and the cashier app permission to create sales.\n\n### Request\n\n- POS prices every line itself: you send items and quantities, never prices, and `expected_total` is your check of the result.\n- Active outlet rollout cohorts use their assigned outlet's canonical current base, including active finite plans and due permanent assignments. Zero is configured; reads do not materialize plans. Legacy and first-preparing cohorts use the legacy engine.\n- A live catalog item without that base answers `422 PRICE_OUTLET_PRICE_MISSING`; there is no compatibility-scalar fallback in enabled mode.\n- Source-specific manual-price, promotion/member and idempotency rules still apply. A previously activated outlet pauses on master/company disablement; it never reverts to legacy. Both rail-pricing passes use one mode observation, revalidated in the financial transaction before writes. Committed replay retains its existing result.\n- A catalog line needs `variant_id` and a manual line a `manual_*` `sku`, never both. Any other `sku` is refused on `lines[i].sku`.\n- A manual line needs `X-User-ID`, `manual_price` and `product_name`.\n\n### Behaviour\n\n- POS writes the order, its payments and a ticket on the outlet's kitchen board in one step, and sends `order.paid`.\n- Stock, loyalty points and the voucher are applied when the ticket is accepted: at once with `accepted_type` `auto`, or when the cashier accepts it with `manual` (the default).\n- When the cashier rejects the ticket, POS records a refund to the original payment, and refunding your customer is up to you.\n- An `external_order_id` your source already sent answers `409 DUPLICATE_EXTERNAL_ORDER_ID` with `data.order_id`, the id of the first order, whatever the `Idempotency-Key` and the body (even for an order sent to another outlet of the same merchant).\n\n### Retries and idempotency\n\n- To retry safely after a timeout or a network error, send the same `Idempotency-Key` and the same body: you get the stored answer.\n- An error from this call is stored under its `Idempotency-Key`: after you fix the cause, send a new key.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `VALIDATION_FAILED` | A field is wrong, with `data.fields`. A tender problem names `tenders[i].payment_method_id` (an unknown method, a method that `GET /payment-methods` does not list at the outlet, with code `NOT_ALLOWED`, or a cash tender without `X-User-ID`), and `tenders` when the tenders plus the payment fee do not add up to the total. An unknown voucher names `voucher_code`. A line that breaks a line rule of Request. |\n| 400 | `IDEMPOTENCY_KEY_REQUIRED` | `Idempotency-Key` is missing. |\n| 403 | `PERMISSION_DENIED` | You sent `X-User-ID` and the user has no cashier app permission to create sales. |\n| 403 | `PLAN_FEATURE_REQUIRED` | The plan has no kitchen order management, or no Public API add-on. For the kitchen feature, `data.reason` is `not_in_plan` or `disabled` (the merchant can turn it on in Settings > Features) and `data.feature` is the feature key; the add-on answer has no `data`. |\n| 403 | `OUTLET_FORBIDDEN` | The outlet is not allowed. |\n| 409 | `DUPLICATE_EXTERNAL_ORDER_ID` | Your source already sent this `external_order_id`; `data.order_id` is the first order. |\n| 409 | `CART_REPRICED` | `expected_total` differs from the total POS priced. `data` holds the POS amounts and includes `total`, `subtotal`, `line_discount`, `tax`, `service_fee`, `payment_fee` and `rounding`. Nothing is recorded, so send the order again with the POS total. |\n| 409 | `PRICE_ROLLOUT_PAUSED` | New outlet price decisions are paused, including after master/company disablement. |\n| 409 | `PRICE_ROLLOUT_CHANGED` | The rollout mode or version changed after pricing; nothing is recorded. |\n| 409 | `ORDER_SOURCE_NOT_REGISTERED` | Your source is not registered with `PUT /order-source`. |\n| 409 | `SHIFT_NOT_OPEN` | You sent `X-User-ID` and the user has no open shift at the outlet. |\n| 409 | `SALES_TYPE_DISABLED` | The outlet does not sell this `sales_type`. |\n| 409 | `IDEMPOTENCY_KEY_REUSED` | The key was used with another body. |\n| 409 | `IDEMPOTENCY_IN_PROGRESS` | The first request with this key is still running. |\n| 422 | `INVALID_USER` | `X-User-ID` is an unknown user. |\n| 422 | `KITCHEN_ACCEPT_STOCK_INSUFFICIENT` | Only with `accepted_type` `auto`: stock is short, `data.shortages` names the items, and nothing is recorded. |\n",
        "tags": [
          "Orders"
        ],
        "x-mag-scope": "order:create",
        "x-mag-description-en": "Create orders",
        "x-mag-description-id": "Membuat pesanan",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "name": "X-User-ID",
            "in": "header",
            "required": false,
            "description": "Optional here. The acting user (`id` from `GET /users`). Needed for `manual_price`, a line `discount`, `order_discount`, a manual SKU line and a cash tender.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PartnerOrderRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The new order",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/OrderDetail"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/orders/{id}": {
      "get": {
        "operationId": "getOrder",
        "summary": "Get an order",
        "description": "One order with its lines, tenders and buyer. `member` is the buyer's current member profile, and `null` for a walk-in order. An order sold at another outlet, or of another merchant, answers `404 NOT_FOUND`.",
        "tags": [
          "Orders"
        ],
        "x-mag-scope": "order:read",
        "x-mag-description-en": "View order details",
        "x-mag-description-id": "Melihat detail pesanan",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/ID"
          }
        ],
        "responses": {
          "200": {
            "description": "The order",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/OrderDetail"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/returns": {
      "get": {
        "operationId": "listReturns",
        "summary": "List returns",
        "description": "The returns recorded at the outlet in `X-Outlet-ID`, newest first, with their lines and refund tenders. Filter by `order_id` to list the returns of one order. An `order_id` that is not a UUID answers `400 VALIDATION_FAILED`. Pages are keyset-based. Needs a plan with the refund order list, otherwise `403 PLAN_FEATURE_REQUIRED`.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `INVALID_CURSOR` | The `cursor` came with another `limit`, another `X-Outlet-ID`, another `order_id` or another endpoint. |\n",
        "tags": [
          "Returns"
        ],
        "x-mag-scope": "return:list",
        "x-mag-description-en": "View the list of returns",
        "x-mag-description-id": "Melihat daftar retur",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/OrderIDFilter"
          }
        ],
        "responses": {
          "200": {
            "description": "Returns",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "required": [
                            "items"
                          ],
                          "properties": {
                            "items": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/Return"
                              }
                            },
                            "next_cursor": {
                              "type": "string",
                              "description": "Present only when more rows remain; pass it as `cursor` with the same `limit`."
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "operationId": "createReturn",
        "summary": "Create a return",
        "description": "Takes some or all lines of a paid order back. The server prices the refund from the original order; you send the lines, not the amounts. A new return answers `201`; a retry with the same `Idempotency-Key` answers `201` with the same record.\n\n### Before you call\n\n- The company needs a plan with returns.\n- With `X-User-ID`, the acting user needs the return permission and an open shift at the outlet.\n- Without `X-User-ID` (MPOS-2389): you may return your own partner order with no acting user and no open shift. The call then runs as your order source, the same actor as `POST /orders` without a user. All of these must hold:\n  - the order was created by your application's order source with `POST /orders`;\n  - every `refund_tenders` row names your source's partner rail: the `payment_method_id` of the order's tender on that rail. Never cash, never another payment method;\n  - the outlet needs no approval for returns, and you send no `approval_token`.\n- Anything else without `X-User-ID` answers `400 USER_ID_REQUIRED`. Exchanges always need `X-User-ID` and an open shift.\n- Send `approval_token` when the outlet needs an approval for returns (this needs `X-User-ID`).\n- An order of a kitchen sales type (`dine_in`, `takeaway`, `pickup`, `delivery`, `qr_ordering`) can be returned, refunded or exchanged only once its kitchen ticket is served or completed (`kitchen_status` on `GET /orders/{id}` is `served` or `completed`). Other sales types and the replacement order of an exchange are not affected.\n\n### Request\n\n- A returned line names a line of the order by `order_line_id`.\n- `refund_tenders`: each row names an eligible tender of the order by `payment_method_id` (from `eligible_refund_tenders` of `POST /returns/quote`) or the cash method.\n- More than one tender is allowed only for a full refund of an untouched order, and the amounts must add up to the refund.\n\n### Behaviour\n\n- On a Terms Payment order the list holds the tenders of its settlement payments in place of the Terms Payment line.\n- A group with `refundable: false` (for example `reason: legacy_tender`, a tender recorded without a payment method) is refused: refund it in POS, or to another eligible tender.\n- A partner order (one created with `POST /orders`) is refunded to the original tender, never in cash.\n- A cash refund larger than the shift's petty cash records nothing.\n- Returning a kitchen sale that has a kitchen ticket moves no stock back.\n- A Dine In table bill is several orders, one per round: return, refund or exchange one round at a time. Reverse the rounds a table settlement paid instead of the settlement itself.\n- A Dine In round paid through a table settlement lists its share of the settlement's tenders in `eligible_refund_tenders`.\n- The kitchen check runs before the approval, the petty cash and the tenders, so an `approval_token` is not used up.\n\n### Retries and idempotency\n\n- After a timeout or a network error, retry with the same `Idempotency-Key` and the same body: the stored answer is replayed, or the write runs once.\n- An error from this call is stored under the `Idempotency-Key`: after you fix the cause, send a new key. But never send a new key after `409 DATA_CONFLICT` (see the next bullet).\n- On `409 DATA_CONFLICT` the same request is already recorded or still being committed, or the acting user's shift was closed while it ran.\n- Do not send the same request again with a new `Idempotency-Key`: a new key can create a second return. First read `GET /returns?order_id=` for the order, and create again with a new key only if the return is still absent after a short wait.\n- `409 KITCHEN_NOT_FULFILLED` is not stored: once the kitchen has served or completed the order, retry with the same key and the same body.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `VALIDATION_FAILED` | The body is not valid. A line that is not part of the order has a field error on `lines[i].order_line_id` with code `NOT_FOUND` (`INVALID_FORMAT` when the `order_line_id` is not a UUID). |\n| 400 | `IDEMPOTENCY_KEY_REQUIRED` | `Idempotency-Key` is missing. |\n| 400 | `USER_ID_REQUIRED` | `X-User-ID` is missing, and the call is not your own partner order refunded to your rail with no approval (see Before you call). |\n| 403 | `PLAN_FEATURE_REQUIRED` | The plan has no returns. `data.reason` is `not_in_plan` or `disabled` (the merchant can turn it on in Settings > Features), and `data.feature` is the feature key. A missing Public API add-on answers the same code without `data`. |\n| 403 | `APPROVAL_REQUIRED` | The outlet needs an approval and no token was sent. |\n| 403 | `APPROVER_NOT_ELIGIBLE` | The token's approver is not allowed. |\n| 403 | `APPROVAL_TOKEN_INVALID` | The token is unknown, used, expired, or issued for another order, user or type; see `data.reason`. |\n| 403 | `PERMISSION_DENIED` | The acting user may not do this. |\n| 403 | `OUTLET_FORBIDDEN` | The outlet is not allowed. |\n| 404 | `NOT_FOUND` | Unknown original order, or an order of another outlet. |\n| 409 | `SHIFT_NOT_OPEN` | You sent `X-User-ID` and the user has no open shift at the outlet. |\n| 409 | `INSUFFICIENT_PETTY_CASH` | A cash refund is larger than the shift's petty cash. |\n| 409 | `IDEMPOTENCY_KEY_REUSED` | The key was used with another body. |\n| 409 | `IDEMPOTENCY_IN_PROGRESS` | The first request with this key is still running. |\n| 409 | `DATA_CONFLICT` | See Retries and idempotency. |\n| 409 | `DINE_IN_ROUND_UNPAID` | The Dine In round's bill is not paid yet. |\n| 409 | `SETTLEMENT_SALE_NOT_REVERSIBLE` | The order is the payment record of a whole table (dine-in or online settlement). |\n| 409 | `KITCHEN_NOT_FULFILLED` | The kitchen ticket is not served or completed, or there is no ticket; see `data.kitchen_status` (`null` when there is no ticket). |\n| 422 | `UNPROCESSABLE` | A business rule failed; read `message`. |\n| 422 | `POST_SALE_DISABLED` | The merchant turned this operation off in the post-sale policy. |\n| 422 | `POST_SALE_WINDOW_EXPIRED` | The order is older than the policy allows; `data.max_days` is the limit in days. |\n| 422 | `INVALID_USER` | The acting user is not valid. |\n| 422 | `REFUND_TENDER_NOT_ELIGIBLE` | A tender id is not eligible, or its group has `refundable: false`; the field is in `data.fields`. |\n| 422 | `PARTNER_REFUND_ORIGINAL_TENDER` | A partner order: a cash row is above the cash the order was paid with. |\n| 422 | `REFUND_TENDER_INVALID_AMOUNT` | A tender amount is not above 0. |\n| 422 | `REFUND_TENDER_DUPLICATE` | The same tender appears twice. |\n| 422 | `REFUND_MULTI_TENDER_REQUIRES_FULL` | More than one tender, but not a full refund of an untouched order. |\n| 422 | `REFUND_TENDER_SUM_MISMATCH` | The tender amounts do not add up to the refund. |\n| 422 | `REFUND_TENDER_EXCEEDS_REMAINING` | A Dine In round asks more of one non-cash tender than it can still take. |\n",
        "tags": [
          "Returns"
        ],
        "x-mag-scope": "return:create",
        "x-mag-description-en": "Create returns",
        "x-mag-description-id": "Membuat retur",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "name": "X-User-ID",
            "in": "header",
            "required": false,
            "description": "Optional here (MPOS-2389). The acting user (`id` from `GET /users`), checked with the cashier app permission; the user then needs an open shift at the outlet. Without it the call runs as your order source and needs no shift, but only on your own partner order refunded to your partner rail with no approval; anything else is `400 USER_ID_REQUIRED`.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": true,
            "description": "Unique per request; a retry with the same key and body returns the stored response for 7 days, also without `X-User-ID`. The acting user is part of the request: the same key sent once with and once without `X-User-ID` (or with another user) is `409 IDEMPOTENCY_KEY_REUSED`.",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 128
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ReturnCreateRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The new return",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Return"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/returns/{return_id}": {
      "get": {
        "operationId": "getReturn",
        "summary": "Get a return",
        "description": "One return by its `id`, with its lines and refund tenders. An unknown id, a non-UUID id and a return of another outlet answer `404 NOT_FOUND`. List the returns of an order with `GET /returns?order_id=`.",
        "tags": [
          "Returns"
        ],
        "x-mag-scope": "return:read",
        "x-mag-description-en": "View return details",
        "x-mag-description-id": "Melihat detail retur",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "name": "return_id",
            "in": "path",
            "required": true,
            "description": "The `id` of the return.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The return",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Return"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/approvals/eligible-approvers": {
      "get": {
        "operationId": "listEligibleApprovers",
        "summary": "List who can approve",
        "description": "Tells whether a manager approval is needed at the outlet in `X-Outlet-ID` for the asked types, which rule applies (`policy`) and who can give it. The acting user does not need an open shift for this read; the PIN and one-time code calls do.\n\n### Request\n\n- Send `types` as a comma list, for example `types=refund,return`.\n\n### Behaviour\n\n- `policy` is the strictest one across the asked types: `pin`, `otp_preferred` or `otp_only`.\n- When `is_enabled` is false no approval is needed and `approvers` is empty.\n- An approver's phone number is never returned: `can_receive_otp` says whether a one-time code can reach the approver, and `otp_unavailable_reason` says why not.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `VALIDATION_FAILED` | `types` is missing or has an unknown value. |\n| 400 | `USER_ID_REQUIRED` | `X-User-ID` is missing. |\n| 403 | `PERMISSION_DENIED` | The acting user may not do this. |\n| 403 | `OUTLET_FORBIDDEN` | The outlet is not allowed. |\n| 422 | `INVALID_USER` | The acting user is not valid. |\n",
        "tags": [
          "Approvals"
        ],
        "x-mag-scope": "approval:approver:list",
        "x-mag-description-en": "View eligible approvers",
        "x-mag-description-id": "Melihat pemberi persetujuan",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/UserID"
          },
          {
            "name": "types",
            "in": "query",
            "required": true,
            "description": "Comma separated cancellation types, for example `refund,return`. Repeats are dropped.",
            "schema": {
              "type": "string",
              "example": "refund,return"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The approval line",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/EligibleApprovers"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/approvals/pin": {
      "post": {
        "operationId": "verifyApprovalPin",
        "summary": "Verify a manager PIN",
        "description": "Checks a manager PIN for one paid order and returns an `approval_token` to send with the return, refund or exchange. This call takes no `Idempotency-Key`.\n\n### Before you call\n\n- The acting user needs an open shift.\n- Never store or log the PIN.\n\n### Request\n\n- Leave `approver_id` out to accept any approver of the outlet whose PIN matches.\n\n### Behaviour\n\n- The token is valid for 5 minutes, for this `order_id` and the acting user only, covers the listed `cancellation_types`, and works once.\n- After 3 wrong PINs for the same order and `gate_reason` at the outlet, by any user, the check is locked for 15 minutes.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `VALIDATION_FAILED` | The body is not valid. |\n| 400 | `USER_ID_REQUIRED` | `X-User-ID` is missing. |\n| 403 | `PIN_INVALID` | A wrong PIN; see `data.attempts_remaining`. |\n| 403 | `OTP_REQUIRED` | The outlet needs a one-time code and no PIN fallback is open. |\n| 403 | `PERMISSION_DENIED` | The acting user may not do this. |\n| 403 | `OUTLET_FORBIDDEN` | The outlet is not allowed. |\n| 404 | `NOT_FOUND` | The `order_id` is not an order of the outlet in `X-Outlet-ID`. |\n| 409 | `PIN_LOCKED` | The check is locked; see `data.unlock_at`. |\n| 409 | `SHIFT_NOT_OPEN` | The acting user has no open shift. |\n| 422 | `INVALID_USER` | The acting user is not valid. |\n",
        "tags": [
          "Approvals"
        ],
        "x-mag-scope": "approval:pin:verify",
        "x-mag-description-en": "Verify a manager PIN",
        "x-mag-description-id": "Memverifikasi PIN manajer",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/UserID"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ApprovalPinRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The approval grant",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/ApprovalGrant"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/approvals/otp": {
      "post": {
        "operationId": "requestApprovalOtp",
        "summary": "Send an approval OTP",
        "description": "Sends a 6 digit one-time code to one approver. This call takes no `Idempotency-Key`.\n\n### Behaviour\n\n- The code is valid for 5 minutes and at most 3 codes can be sent for the same order, user and types.\n- Sending the same body again is a resend; a resend inside the wait answers `409 OTP_RESEND_COOLDOWN` with `data.resend_after` (this API never answers `429`).\n- The approver's phone number is never returned.\n- `OTP_UNAVAILABLE` means no code can be sent now; `data.reason` is `no_reachable_approver`, `quota_exhausted`, `provider_error`, `cashier_cap_reached` or `resend_exhausted`, and `data.fallback_authorized` is true: a PIN fallback is then open until `data.fallback_expires_at`, so call `POST /approvals/pin` instead.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `VALIDATION_FAILED` | The body is not valid. |\n| 400 | `USER_ID_REQUIRED` | `X-User-ID` is missing. |\n| 403 | `PLAN_FEATURE_REQUIRED` | The plan has no approval by one-time code. |\n| 403 | `OTP_NOT_ENABLED` | The outlet does not use approval by one-time code. |\n| 403 | `APPROVER_NOT_ELIGIBLE` | The approver is not on the outlet's approval line, or cannot receive a code while another approver can. |\n| 403 | `PERMISSION_DENIED` | The acting user may not do this. |\n| 403 | `OUTLET_FORBIDDEN` | The outlet is not allowed. |\n| 404 | `NOT_FOUND` | The `order_id` is not an order of the outlet in `X-Outlet-ID`. |\n| 409 | `PIN_LOCKED` | The PIN check is locked; see `data.unlock_at`. |\n| 409 | `OTP_RESEND_COOLDOWN` | A resend inside the wait; see `data.resend_after`. |\n| 409 | `OTP_UNAVAILABLE` | No code can be sent now; see Behaviour. |\n| 409 | `SHIFT_NOT_OPEN` | The acting user has no open shift. |\n| 422 | `INVALID_USER` | The acting user is not valid. |\n",
        "tags": [
          "Approvals"
        ],
        "x-mag-scope": "approval:otp:request",
        "x-mag-description-en": "Send an approval OTP",
        "x-mag-description-id": "Mengirim OTP persetujuan",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/UserID"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ApprovalOtpRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The sent code",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/ApprovalOtpSent"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/approvals/otp/verify": {
      "post": {
        "operationId": "verifyApprovalOtp",
        "summary": "Verify an approval OTP",
        "description": "Exchanges the code the approver received for an `approval_token`, with the same rules as `POST /approvals/pin`. This call takes no `Idempotency-Key`.\n\n- Wrong codes share the 3 tries and the 15 minute lock of the PIN check.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `VALIDATION_FAILED` | The body is not valid. |\n| 400 | `USER_ID_REQUIRED` | `X-User-ID` is missing. |\n| 403 | `OTP_INVALID` | A wrong code; see `data.attempts_remaining`. |\n| 403 | `OTP_EXPIRED` | The code expired, or the `request_id` is unknown or belongs to another user. |\n| 403 | `PERMISSION_DENIED` | The acting user may not do this. |\n| 403 | `OUTLET_FORBIDDEN` | The outlet is not allowed. |\n| 409 | `PIN_LOCKED` | The shared lock is on; see `data.unlock_at`. |\n| 409 | `SHIFT_NOT_OPEN` | The acting user has no open shift. |\n| 422 | `INVALID_USER` | The acting user is not valid. |\n",
        "tags": [
          "Approvals"
        ],
        "x-mag-scope": "approval:otp:verify",
        "x-mag-description-en": "Verify an approval OTP",
        "x-mag-description-id": "Memverifikasi OTP persetujuan",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/UserID"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ApprovalOtpVerifyRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The approval grant",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/ApprovalGrant"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/returns/quote": {
      "post": {
        "operationId": "quoteReturn",
        "summary": "Quote a return",
        "description": "Prices a proposed return of lines of a paid order at the outlet in `X-Outlet-ID`, and shows which tenders can be refunded. Nothing is recorded and the answer is not binding.\n\n### Before you call\n\n- The company needs a plan with returns.\n- With `X-User-ID`, the acting user needs the return permission and an open shift.\n- Without `X-User-ID` (MPOS-2389): you may quote a return of your own partner order with no acting user and no open shift. The call then runs as your order source, the same actor as `POST /orders` without a user. All of these must hold:\n  - the order was created by your application's order source with `POST /orders`;\n  - every `refund_tenders` row names your source's partner rail: the `payment_method_id` of the order's tender on that rail. Never cash, never another payment method;\n  - the outlet needs no approval for returns.\n  - Without `refund_tenders`, every tender of the order must be on that rail (the refund defaults to the original tenders).\n- Anything else without `X-User-ID` answers `400 USER_ID_REQUIRED`. Exchanges always need `X-User-ID` and an open shift.\n- This call takes no `Idempotency-Key`.\n- An order of a kitchen sales type (`dine_in`, `takeaway`, `pickup`, `delivery`, `qr_ordering`) can be returned, refunded or exchanged only once its kitchen ticket is served or completed (`kitchen_status` on `GET /orders/{id}` is `served` or `completed`). Other sales types and the replacement order of an exchange are not affected.\n\n### Request\n\n- `refund_tenders` is optional on a quote. When sent, each tender must name a refundable tender of `eligible_refund_tenders`.\n- On a partner order, a cash tender above the cash the order was paid with answers `422 PARTNER_REFUND_ORIGINAL_TENDER` on the quote already. On a Dine In round, a tender above what its table settlement tender can still take answers `422 REFUND_TENDER_EXCEEDS_REMAINING` on the quote already.\n- The other amount checks run when the return is created: an amount that is not above 0, the same payment method twice, more than one tender when the refund is not a full refund of an untouched order, and amounts that do not add up to the refund.\n\n### Behaviour\n\n- A tender recorded without a payment method shows `payment_method_id: null`, `refundable: false` and `reason: legacy_tender`, and is never in `suggested_refund_tenders`.\n- A partner order (one created with `POST /orders`) is refunded to the original tender. On it, the Cash group shows `refundable: false` and `reason: partner_original_tender` unless the order was paid in cash; when it was, Cash is refundable up to the cash the order paid. A quote that sends a cash tender above that answers `422 PARTNER_REFUND_ORIGINAL_TENDER`, as `POST /returns` does.\n- A Dine In table bill is several orders, one per round: return, refund or exchange one round at a time. Reverse the rounds a table settlement paid instead of the settlement itself.\n- A Dine In round paid through a table settlement lists its share of the settlement's tenders in `eligible_refund_tenders`.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `VALIDATION_FAILED` | The body is not valid. A line that is not part of the order has a field error on `lines[i].order_line_id` with code `NOT_FOUND` (`INVALID_FORMAT` when the `order_line_id` is not a UUID). |\n| 400 | `USER_ID_REQUIRED` | `X-User-ID` is missing, and the call is not your own partner order refunded to your rail with no approval (see Before you call). |\n| 403 | `PLAN_FEATURE_REQUIRED` | The plan has no returns. `data.reason` is `not_in_plan` or `disabled` (the merchant can turn it on in Settings > Features), and `data.feature` is the feature key. A missing Public API add-on answers the same code without `data`. |\n| 403 | `PERMISSION_DENIED` | The acting user may not do this. |\n| 403 | `OUTLET_FORBIDDEN` | The outlet is not allowed. |\n| 404 | `NOT_FOUND` | Unknown original order, or an order of another outlet. |\n| 409 | `SHIFT_NOT_OPEN` | You sent `X-User-ID` and the user has no open shift. |\n| 409 | `DINE_IN_ROUND_UNPAID` | The Dine In round's bill is not paid yet. |\n| 409 | `SETTLEMENT_SALE_NOT_REVERSIBLE` | The order is the payment record of a whole table (dine-in or online settlement). |\n| 409 | `KITCHEN_NOT_FULFILLED` | The kitchen ticket is not served or completed, or there is no ticket; see `data.kitchen_status` (`null` when there is no ticket). |\n| 422 | `UNPROCESSABLE` | A business rule failed, for example a terms payment order that is not settled. |\n| 422 | `POST_SALE_DISABLED` | The merchant turned this operation off in the post-sale policy. |\n| 422 | `POST_SALE_WINDOW_EXPIRED` | The order is older than the policy allows; `data.max_days` is the limit in days. |\n| 422 | `INVALID_USER` | The acting user is not valid. |\n| 422 | `REFUND_TENDER_NOT_ELIGIBLE` | A sent tender is not a refundable tender of `eligible_refund_tenders`. |\n| 422 | `REFUND_TENDER_EXCEEDS_REMAINING` | A Dine In round asks more of one non-cash tender than it can still take. |\n| 422 | `PARTNER_REFUND_ORIGINAL_TENDER` | A partner order: a sent cash tender is above the cash the order was paid with. |\n",
        "tags": [
          "Returns"
        ],
        "x-mag-scope": "return:quote",
        "x-mag-description-en": "Quote a return",
        "x-mag-description-id": "Menghitung retur",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "name": "X-User-ID",
            "in": "header",
            "required": false,
            "description": "Optional here (MPOS-2389). The acting user (`id` from `GET /users`), checked with the cashier app permission; the user then needs an open shift at the outlet. Without it the call runs as your order source and needs no shift, but only on your own partner order refunded to your partner rail with no approval; anything else is `400 USER_ID_REQUIRED`.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ReturnQuoteRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The return quote",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/ReturnQuote"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/refunds": {
      "get": {
        "operationId": "listRefunds",
        "summary": "List refunds",
        "description": "The refunds recorded at the outlet in `X-Outlet-ID`, newest first, including those made in the cashier app. Pages are keyset-based.\n\n- Send `order_id` to list only the refunds of one order (an order can have more than one).\n- A `cursor` is only valid with the same `limit`, the same `order_id` and the same endpoint.\n- Needs a plan with the refund order list.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `INVALID_CURSOR` | The `cursor` came with another `limit`, another `order_id` or another endpoint. |\n| 400 | `VALIDATION_FAILED` | `order_id` is not a UUID. |\n| 403 | `PLAN_FEATURE_REQUIRED` | The plan has no refund order list. `data.reason` is `not_in_plan` or `disabled` (the merchant can turn it on in Settings > Features), and `data.feature` is the feature key. A missing Public API add-on answers the same code without `data`. |\n",
        "tags": [
          "Refunds"
        ],
        "x-mag-scope": "refund:list",
        "x-mag-description-en": "View the list of refunds",
        "x-mag-description-id": "Melihat daftar pengembalian dana",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/OrderIDFilter"
          }
        ],
        "responses": {
          "200": {
            "description": "Refunds",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "required": [
                            "items"
                          ],
                          "properties": {
                            "items": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/Refund"
                              }
                            },
                            "next_cursor": {
                              "type": "string",
                              "description": "Present only when more rows remain; pass it as `cursor` with the same `limit` and `order_id`."
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "operationId": "createRefund",
        "summary": "Create a refund",
        "description": "Gives money back on a paid order without taking goods back. A new refund answers `201`; a retry with the same `Idempotency-Key` answers `201` with the same record.\n\n### Before you call\n\n- The company needs a plan with the refund order list.\n- With `X-User-ID`, the acting user needs the refund permission and an open shift at the outlet.\n- Without `X-User-ID` (MPOS-2389): you may refund your own partner order with no acting user and no open shift. The call then runs as your order source, the same actor as `POST /orders` without a user. All of these must hold:\n  - the order was created by your application's order source with `POST /orders`;\n  - every `refund_tenders` row names your source's partner rail: the `payment_method_id` of the order's tender on that rail. Never cash, never another payment method;\n  - the outlet needs no approval for refunds, and you send no `approval_token`.\n- Anything else without `X-User-ID` answers `400 USER_ID_REQUIRED`. Exchanges always need `X-User-ID` and an open shift.\n- Approval works as on `POST /returns`: send `approval_token` when the outlet needs an approval (this needs `X-User-ID`).\n- An order of a kitchen sales type (`dine_in`, `takeaway`, `pickup`, `delivery`, `qr_ordering`) can be refunded only once its kitchen ticket is served or completed (`kitchen_status` on `GET /orders/{id}` is `served` or `completed`). Other sales types and the replacement order of an exchange are not affected.\n\n### Request\n\n- `refund_amount` is the money to give back, in whole units of `currency`. It cannot be more than what is left to refund.\n- `refund_tenders`: each row names an eligible tender of the order by `payment_method_id` (from `eligible_refund_tenders` of `POST /returns/quote`) or the cash method.\n- More than one tender is allowed only for a full refund of an untouched order, and the amounts must add up to the refund.\n\n### Behaviour\n\n- On a Terms Payment order the list holds the tenders of its settlement payments in place of the Terms Payment line.\n- A group with `refundable: false` (for example a Mekari Pay QRIS payment, or `reason: legacy_tender`) is refused: refund it in POS, or to another eligible tender.\n- A partner order (one created with `POST /orders`) is refunded to the original tender, never in cash.\n- A cash refund larger than the shift's petty cash records nothing.\n- A Dine In table bill is several orders, one per round: return, refund or exchange one round at a time. Reverse the rounds a table settlement paid instead of the settlement itself.\n- A Dine In round paid through a table settlement lists its share of the settlement's tenders in `eligible_refund_tenders`.\n- The kitchen check runs before the approval, the petty cash and the tenders, so an `approval_token` is not used up.\n\n### Retries and idempotency\n\n- After a timeout or a network error, retry with the same `Idempotency-Key` and the same body: the stored answer is replayed, or the write runs once.\n- An error from this call is stored under the `Idempotency-Key`: after you fix the cause, send a new key. But never send a new key after `409 DATA_CONFLICT` (see the next bullet).\n- On `409 DATA_CONFLICT` the same request is already recorded or still being committed, or the acting user's shift was closed while it ran.\n- Do not send the same request again with a new `Idempotency-Key`: a new key can create a second refund. First read `GET /refunds?order_id=...` for the order, wait a short while, and if the refund is still absent and you still cannot tell, ask the merchant to check the back office before you create again.\n- `409 KITCHEN_NOT_FULFILLED` is not stored: once the kitchen has served or completed the order, retry with the same key and the same body.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `VALIDATION_FAILED` | The body is not valid. |\n| 400 | `IDEMPOTENCY_KEY_REQUIRED` | `Idempotency-Key` is missing. |\n| 400 | `USER_ID_REQUIRED` | `X-User-ID` is missing, and the call is not your own partner order refunded to your rail with no approval (see Before you call). |\n| 403 | `PLAN_FEATURE_REQUIRED` | The plan has no refund order list. `data.reason` is `not_in_plan` or `disabled` (the merchant can turn it on in Settings > Features), and `data.feature` is the feature key. A missing Public API add-on answers the same code without `data`. |\n| 403 | `APPROVAL_REQUIRED` | The outlet needs an approval and no token was sent. |\n| 403 | `APPROVER_NOT_ELIGIBLE` | The approver is not allowed. |\n| 403 | `APPROVAL_TOKEN_INVALID` | The token is bad; see `data.reason`. |\n| 403 | `PERMISSION_DENIED` | The acting user may not do this. |\n| 403 | `OUTLET_FORBIDDEN` | The outlet is not allowed. |\n| 404 | `NOT_FOUND` | Unknown original order, or an order of another outlet. |\n| 409 | `SHIFT_NOT_OPEN` | You sent `X-User-ID` and the user has no open shift at the outlet. |\n| 409 | `INSUFFICIENT_PETTY_CASH` | A cash refund is larger than the shift's petty cash. |\n| 409 | `IDEMPOTENCY_KEY_REUSED` | The key was used with another body. |\n| 409 | `IDEMPOTENCY_IN_PROGRESS` | The first request with this key is still running. |\n| 409 | `DATA_CONFLICT` | See Retries and idempotency. |\n| 409 | `DINE_IN_ROUND_UNPAID` | The Dine In round's bill is not paid yet. |\n| 409 | `SETTLEMENT_SALE_NOT_REVERSIBLE` | The order is the payment record of a whole table (dine-in or online settlement). |\n| 409 | `KITCHEN_NOT_FULFILLED` | The kitchen ticket is not served or completed, or there is no ticket; see `data.kitchen_status` (`null` when there is no ticket). |\n| 422 | `UNPROCESSABLE` | `refund_amount` is more than what is left to refund. |\n| 422 | `POST_SALE_DISABLED` | The merchant turned this operation off in the post-sale policy. |\n| 422 | `POST_SALE_WINDOW_EXPIRED` | The order is older than the policy allows; `data.max_days` is the limit in days. |\n| 422 | `INVALID_USER` | The acting user is not valid. |\n| 422 | `REFUND_TENDER_NOT_ELIGIBLE` | A tender id is not eligible, or its group has `refundable: false`; the field is in `data.fields`. |\n| 422 | `PARTNER_REFUND_ORIGINAL_TENDER` | A partner order: a cash row is above the cash the order was paid with. |\n| 422 | `REFUND_TENDER_INVALID_AMOUNT` | A tender amount is not above 0. |\n| 422 | `REFUND_TENDER_DUPLICATE` | The same tender appears twice. |\n| 422 | `REFUND_MULTI_TENDER_REQUIRES_FULL` | More than one tender, but not a full refund of an untouched order. |\n| 422 | `REFUND_TENDER_SUM_MISMATCH` | The tender amounts do not add up to the refund. |\n| 422 | `REFUND_TENDER_EXCEEDS_REMAINING` | A Dine In round asks more of one non-cash tender than it can still take. |\n",
        "tags": [
          "Refunds"
        ],
        "x-mag-scope": "refund:create",
        "x-mag-description-en": "Create refunds",
        "x-mag-description-id": "Membuat pengembalian dana",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "name": "X-User-ID",
            "in": "header",
            "required": false,
            "description": "Optional here (MPOS-2389). The acting user (`id` from `GET /users`), checked with the cashier app permission; the user then needs an open shift at the outlet. Without it the call runs as your order source and needs no shift, but only on your own partner order refunded to your partner rail with no approval; anything else is `400 USER_ID_REQUIRED`.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": true,
            "description": "Unique per request; a retry with the same key and body returns the stored response for 7 days, also without `X-User-ID`. The acting user is part of the request: the same key sent once with and once without `X-User-ID` (or with another user) is `409 IDEMPOTENCY_KEY_REUSED`.",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 128
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RefundCreateRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The new refund, with the same body as `GET /refunds/{refund_id}`.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Refund"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/refunds/{refund_id}": {
      "get": {
        "operationId": "getRefund",
        "summary": "Get a refund",
        "description": "One refund by its `id`, with the same body as the answer of `POST /refunds`. A refund made in the cashier app can be read too. An unknown `id`, one that is not a UUID, and the `id` of a refund at another outlet all answer `404 NOT_FOUND`.",
        "tags": [
          "Refunds"
        ],
        "x-mag-scope": "refund:read",
        "x-mag-description-en": "View refund details",
        "x-mag-description-id": "Melihat detail pengembalian dana",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "name": "refund_id",
            "in": "path",
            "required": true,
            "description": "The `id` of the refund.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The refund",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Refund"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/exchanges/quote": {
      "post": {
        "operationId": "quoteExchange",
        "summary": "Quote an exchange",
        "description": "Prices an exchange: lines taken back and replacement lines sold. Nothing is recorded and the answer is not binding.\n\n### Before you call\n\n- The company needs a plan with exchanges.\n- An open shift is needed.\n- This call takes no `Idempotency-Key`.\n- An order of a kitchen sales type (`dine_in`, `takeaway`, `pickup`, `delivery`, `qr_ordering`) can be returned, refunded or exchanged only once its kitchen ticket is served or completed (`kitchen_status` on `GET /orders/{id}` is `served` or `completed`). Other sales types and the replacement order of an exchange are not affected.\n\n### Request\n\n- `tenders` is optional; without it `payment_fee` is not priced. The quote does not check that the tenders add up; `POST /exchanges` does (see its Request section for the three cases: pay the difference, refund the difference, keep the difference).\n- A tender names its payment method by `payment_method_id`: a `cash` or `custom` method from `GET /payment-methods`, the order's Terms Payment method, or the order source's own partner rail.\n- The own partner rail is the `Paid on <display_name>` method of the order source that sent the order with `POST /orders`. Only the app that owns that order source can use it.\n- `GET /payment-methods` does not list the rail. Its id is the `payment_method_id` on the order's tender (`GET /orders/{id}`), but only when the order was paid on that rail (an order sent without `tenders`; an order sent with its own `tenders` has no rail tender).\n- The rail of another order source, the order's rail named by another app, a rail on an order no source sent, and any other type (QRIS included) answer `400 VALIDATION_FAILED` on the field with code `NOT_ALLOWED`.\n\n### Behaviour\n\n- `refund_amount` is what goes back to the customer when the replacement costs less.\n- On a partner order (one created with `POST /orders`) Cash is not refundable unless the order was paid in cash: a cash tender on a refunded difference above the cash the order paid answers `422 PARTNER_REFUND_ORIGINAL_TENDER`, as `POST /exchanges` does.\n- A Dine In table bill is several orders, one per round: return, refund or exchange one round at a time. Reverse the rounds a table settlement paid instead of the settlement itself.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `VALIDATION_FAILED` | The body is not valid. A tender that is not allowed has code `NOT_ALLOWED` on the field. A returned line that is not part of the order has a field error on `returned_lines[i].order_line_id` with code `NOT_FOUND` (`INVALID_FORMAT` when the `order_line_id` is not a UUID). |\n| 400 | `USER_ID_REQUIRED` | `X-User-ID` is missing. |\n| 403 | `PLAN_FEATURE_REQUIRED` | The plan has no exchanges. `data.reason` is `not_in_plan` or `disabled` (the merchant can turn it on in Settings > Features), and `data.feature` is the feature key. A missing Public API add-on answers the same code without `data`. |\n| 403 | `PERMISSION_DENIED` | The acting user may not do this. |\n| 403 | `OUTLET_FORBIDDEN` | The outlet is not allowed. |\n| 404 | `NOT_FOUND` | Unknown original order, or an order of another outlet. |\n| 409 | `SHIFT_NOT_OPEN` | There is no open shift. |\n| 409 | `DINE_IN_ROUND_UNPAID` | The Dine In round's bill is not paid yet. |\n| 409 | `SETTLEMENT_SALE_NOT_REVERSIBLE` | The order is the payment record of a whole table (dine-in or online settlement). |\n| 409 | `KITCHEN_NOT_FULFILLED` | The kitchen ticket is not served or completed, or there is no ticket; see `data.kitchen_status` (`null` when there is no ticket). |\n| 422 | `UNPROCESSABLE` | A business rule failed; read `message`. |\n| 422 | `POST_SALE_DISABLED` | The merchant turned this operation off in the post-sale policy. |\n| 422 | `POST_SALE_WINDOW_EXPIRED` | The order is older than the policy allows; `data.max_days` is the limit in days. |\n| 422 | `INVALID_USER` | The acting user is not valid. |\n| 422 | `PARTNER_REFUND_ORIGINAL_TENDER` | A partner order: a cash tender on a refunded difference is above the cash the order was paid with. |\n| 422 | `PRICE_AMOUNT_OVERFLOW` | A replacement line's quantity, or the replacement total with its tax and fees, is too large to price. |\n",
        "tags": [
          "Exchanges"
        ],
        "x-mag-scope": "exchange:quote",
        "x-mag-description-en": "Quote an exchange",
        "x-mag-description-id": "Menghitung penukaran",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/UserID"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ExchangeQuoteRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The exchange quote",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/ExchangeQuote"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/exchanges": {
      "get": {
        "operationId": "listExchanges",
        "summary": "List exchanges",
        "description": "The exchanges recorded at the outlet in `X-Outlet-ID`, newest first, including those made in the cashier app. Pages are keyset-based.\n\n- Send `order_id` to list only the exchanges of one order (an order can have more than one).\n- A `cursor` is only valid with the same `limit`, the same `order_id` and the same endpoint.\n- Needs a plan with exchanges.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `INVALID_CURSOR` | The `cursor` came with another `limit`, another `order_id` or another endpoint. |\n| 400 | `VALIDATION_FAILED` | `order_id` is not a UUID. |\n| 403 | `PLAN_FEATURE_REQUIRED` | The plan has no exchanges. `data.reason` is `not_in_plan` or `disabled` (the merchant can turn it on in Settings > Features), and `data.feature` is the feature key. A missing Public API add-on answers the same code without `data`. |\n",
        "tags": [
          "Exchanges"
        ],
        "x-mag-scope": "exchange:list",
        "x-mag-description-en": "View the list of exchanges",
        "x-mag-description-id": "Melihat daftar penukaran",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/OrderIDFilter"
          }
        ],
        "responses": {
          "200": {
            "description": "Exchanges",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "required": [
                            "items"
                          ],
                          "properties": {
                            "items": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/Exchange"
                              }
                            },
                            "next_cursor": {
                              "type": "string",
                              "description": "Present only when more rows remain; pass it as `cursor` with the same `limit` and `order_id`."
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "operationId": "createExchange",
        "summary": "Create an exchange",
        "description": "Takes lines back and sells replacement lines in one step. A new exchange answers `201`; a retry with the same `Idempotency-Key` answers `201` with the same record.\n\n### Before you call\n\n- The company needs a plan with exchanges.\n- The acting user needs an open shift at the outlet.\n- Approval works as on `POST /returns`: send `approval_token` when the outlet needs an approval.\n- An order of a kitchen sales type (`dine_in`, `takeaway`, `pickup`, `delivery`, `qr_ordering`) can be returned, refunded or exchanged only once its kitchen ticket is served or completed (`kitchen_status` on `GET /orders/{id}` is `served` or `completed`). Other sales types and the replacement order of an exchange are not affected.\n\n### Request\n\n- What `tenders` must hold depends on the difference (`delta_amount`, the replacement total minus the return total) and on the merchant's exchange settings (the \"Tukar barang\" page in Pengaturan pesanan of the back office: \"Terima pembayaran selisih\" and \"Refund selisih negatif tukar barang\"):\n  - The replacement costs more: `tenders` must add up to `delta_amount`. When the merchant turned off \"Terima pembayaran selisih\", the call answers `422 UNPROCESSABLE`.\n  - The replacement costs less and the merchant refunds the difference (\"Refund selisih negatif tukar barang\" is on): send exactly one tender, equal to `refund_amount`. Two tenders answer `422 UNPROCESSABLE`, and so does a total other than `refund_amount` (\"tender total must equal the refund amount\"). QRIS is never accepted.\n  - The replacement costs less and the merchant keeps the difference (\"Refund selisih negatif tukar barang\" is off), or the totals are equal: `tenders` must total 0, so send none.\n  - On a partner order the refunded difference never goes back in cash above the cash the order was paid with: a Cash tender above that answers `422 PARTNER_REFUND_ORIGINAL_TENDER`.\n- A tender names its payment method by `payment_method_id`: a `cash` or `custom` method from `GET /payment-methods`, the order's Terms Payment method, or the order source's own partner rail.\n- The own partner rail is the `Paid on <display_name>` method of the order source that sent the order with `POST /orders`. Only the app that owns that order source can use it.\n- `GET /payment-methods` does not list the rail. Its id is the `payment_method_id` on the order's tender (`GET /orders/{id}`), but only when the order was paid on that rail (an order sent without `tenders`; an order sent with its own `tenders` has no rail tender).\n- The rail of another order source, the order's rail named by another app, a rail on an order no source sent, and any other type (QRIS included) answer `400 VALIDATION_FAILED` on the field with code `NOT_ALLOWED`.\n- When `expected_payment_fee` differs from the fee the server prices, the answer is `409 CART_REPRICED`: quote again.\n\n### Behaviour\n\n- When the replacement costs less, `refund_amount` on the record is the difference paid back to the customer (`variance_amount` when the merchant kept it).\n- A cash refund of a price difference larger than the shift's petty cash records nothing.\n- On a partner order (one created with `POST /orders`) the difference is paid back to the original tender.\n- A Dine In table bill is several orders, one per round: return, refund or exchange one round at a time. Reverse the rounds a table settlement paid instead of the settlement itself.\n- The kitchen check runs before the approval, the petty cash and the tenders, so an `approval_token` is not used up.\n\n### Retries and idempotency\n\n- After a timeout or a network error, retry with the same `Idempotency-Key` and the same body: the stored answer is replayed, or the write runs once.\n- An error from this call is stored under the `Idempotency-Key`: after you fix the cause, send a new key. But never send a new key after `409 DATA_CONFLICT` (see the next bullet).\n- On `409 DATA_CONFLICT` the same request is already recorded or still being committed, or the acting user's shift was closed while it ran.\n- Do not send the same request again with a new `Idempotency-Key`: a new key can create a second exchange. First read `GET /exchanges?order_id=...` for the order, wait a short while, and if the exchange is still absent and you still cannot tell, ask the merchant to check the back office before you create again.\n- `409 KITCHEN_NOT_FULFILLED` is not stored: once the kitchen has served or completed the order, retry with the same key and the same body.\n\n### Errors\n\n| Status | Code | When |\n|---|---|---|\n| 400 | `VALIDATION_FAILED` | The body is not valid. A tender that is not allowed has code `NOT_ALLOWED` on the field. A returned line that is not part of the order has a field error on `returned_lines[i].order_line_id` with code `NOT_FOUND` (`INVALID_FORMAT` when the `order_line_id` is not a UUID). |\n| 400 | `IDEMPOTENCY_KEY_REQUIRED` | `Idempotency-Key` is missing. |\n| 400 | `USER_ID_REQUIRED` | `X-User-ID` is missing. |\n| 403 | `PLAN_FEATURE_REQUIRED` | The plan has no exchanges. `data.reason` is `not_in_plan` or `disabled` (the merchant can turn it on in Settings > Features), and `data.feature` is the feature key. A missing Public API add-on answers the same code without `data`. |\n| 403 | `APPROVAL_REQUIRED` | The outlet needs an approval and no token was sent. |\n| 403 | `APPROVER_NOT_ELIGIBLE` | The approver is not allowed. |\n| 403 | `APPROVAL_TOKEN_INVALID` | The token is bad; see `data.reason`. |\n| 403 | `PERMISSION_DENIED` | The acting user may not do this. |\n| 403 | `OUTLET_FORBIDDEN` | The outlet is not allowed. |\n| 404 | `NOT_FOUND` | Unknown original order, or an order of another outlet. |\n| 409 | `SHIFT_NOT_OPEN` | The acting user has no open shift at the outlet. |\n| 409 | `CART_REPRICED` | `expected_payment_fee` differs from the fee the server prices; quote again. |\n| 409 | `INSUFFICIENT_PETTY_CASH` | A cash refund of a price difference is larger than the shift's petty cash. |\n| 409 | `IDEMPOTENCY_KEY_REUSED` | The key was used with another body. |\n| 409 | `IDEMPOTENCY_IN_PROGRESS` | The first request with this key is still running. |\n| 409 | `DATA_CONFLICT` | See Retries and idempotency. |\n| 409 | `DINE_IN_ROUND_UNPAID` | The Dine In round's bill is not paid yet. |\n| 409 | `SETTLEMENT_SALE_NOT_REVERSIBLE` | The order is the payment record of a whole table (dine-in or online settlement). |\n| 409 | `KITCHEN_NOT_FULFILLED` | The kitchen ticket is not served or completed, or there is no ticket; see `data.kitchen_status` (`null` when there is no ticket). |\n| 422 | `UNPROCESSABLE` | A business rule failed; read `message`. |\n| 422 | `POST_SALE_DISABLED` | The merchant turned this operation off in the post-sale policy. |\n| 422 | `POST_SALE_WINDOW_EXPIRED` | The order is older than the policy allows; `data.max_days` is the limit in days. |\n| 422 | `INVALID_USER` | The acting user is not valid. |\n| 422 | `PARTNER_REFUND_ORIGINAL_TENDER` | A partner order: a cash tender is above the cash the order was paid with. |\n| 422 | `PRICE_AMOUNT_OVERFLOW` | A replacement line's quantity, or the replacement total with its tax and fees, is too large to price. Nothing is recorded. |\n",
        "tags": [
          "Exchanges"
        ],
        "x-mag-scope": "exchange:create",
        "x-mag-description-en": "Create exchanges",
        "x-mag-description-id": "Membuat penukaran",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "$ref": "#/components/parameters/UserID"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ExchangeCreateRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The new exchange, with the same body as `GET /exchanges/{exchange_id}`.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Exchange"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/exchanges/{exchange_id}": {
      "get": {
        "operationId": "getExchange",
        "summary": "Get an exchange",
        "description": "One exchange by its `id`, with the same body as the answer of `POST /exchanges`. `lines` come ordered by leg (the replacement leg first). An exchange made in the cashier app can be read too. An unknown `id`, one that is not a UUID, and the `id` of an exchange at another outlet all answer `404 NOT_FOUND`.",
        "tags": [
          "Exchanges"
        ],
        "x-mag-scope": "exchange:read",
        "x-mag-description-en": "View exchange details",
        "x-mag-description-id": "Melihat detail penukaran",
        "parameters": [
          {
            "$ref": "#/components/parameters/OutletID"
          },
          {
            "name": "exchange_id",
            "in": "path",
            "required": true,
            "description": "The `id` of the exchange.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The exchange",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Exchange"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/webhooks/endpoints": {
      "get": {
        "operationId": "listWebhookEndpoints",
        "summary": "List webhook endpoints",
        "description": "Your webhook endpoints, one per key you have set. An endpoint you deleted is still listed, with status `disabled`. The signing secret is never part of this list.",
        "tags": [
          "Webhooks"
        ],
        "x-mag-scope": "webhook:endpoint:list",
        "x-mag-description-en": "View webhook endpoints",
        "x-mag-description-id": "Melihat endpoint webhook",
        "responses": {
          "200": {
            "description": "Your endpoints",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "required": [
                            "items"
                          ],
                          "properties": {
                            "items": {
                              "type": "array",
                              "items": {
                                "$ref": "#/components/schemas/WebhookEndpoint"
                              }
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/webhooks/endpoints/orders": {
      "put": {
        "operationId": "setOrdersWebhookEndpoint",
        "summary": "Set the orders webhook endpoint",
        "description": "Creates or updates your `orders` endpoint, which receives order and kitchen-status events. Answers `201` when the endpoint is new and `200` when it existed.\n\n### Request\n\n- Send the whole definition every time: `events` replaces the previous list, and an empty list answers `400 VALIDATION_FAILED` (use `DELETE` to stop notifications).\n- Each event must belong to this endpoint and duplicates are collapsed.\n- `payload` is `thin` (default, the event only names the order) or `full` (the order as `GET /orders/{id}` shows it at send time, but with empty image URLs; see the Webhooks section for the other differences).\n- The `url` must be an absolute `https` address on port 443, without user info or fragment, and not `localhost` or a private or loopback IP address.\n\n### Behaviour\n\n- Events are sent with `X-Mekari-Signature`, `X-Mekari-Timestamp`, `X-Mekari-Key-Version`, `X-Mekari-Delivery-ID` and `X-Mekari-Event-ID` headers.\n- The endpoint is active at once, without a challenge; use `test` to check it.\n- The response holds a `secret` object only when a signing secret was issued: the endpoint is new, its `url` changed, or it is set again after a `DELETE`. Store the secret then, it is never shown again.\n- After a `url` change the new secret signs every request at once; keep the previous one for 24 hours only to verify requests already on their way.\n- A change of `events` or `payload` alone returns no secret.\n- A paused endpoint becomes active again and its failure count is reset.\n",
        "tags": [
          "Webhooks"
        ],
        "x-mag-scope": "webhook:orders:subscribe",
        "x-mag-description-en": "Receive order events",
        "x-mag-description-id": "Menerima kejadian pesanan",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpsertOrdersWebhookEndpoint"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The endpoint, updated",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/WebhookEndpoint"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "201": {
            "description": "The endpoint, created, with its first signing secret",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/WebhookEndpoint"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/webhooks/endpoints/products": {
      "put": {
        "operationId": "setProductsWebhookEndpoint",
        "summary": "Set the products webhook endpoint",
        "description": "Creates or updates your `products` endpoint, which receives product and SKU events. Answers `201` when the endpoint is new and `200` when it existed.\n\n### Request\n\n- Send the whole definition every time: `events` replaces the previous list, and an empty list answers `400 VALIDATION_FAILED` (use `DELETE` to stop notifications).\n- Each event must belong to this endpoint and duplicates are collapsed.\n- `payload` is `thin` (default, the event only names the product) or `full` (the product as `GET /products/{id}` shows it at send time, but with empty image URLs; see the Webhooks section for the other differences).\n- The `url` must be an absolute `https` address on port 443, without user info or fragment, and not `localhost` or a private or loopback IP address.\n\n### Behaviour\n\n- Events are sent with `X-Mekari-Signature`, `X-Mekari-Timestamp`, `X-Mekari-Key-Version`, `X-Mekari-Delivery-ID` and `X-Mekari-Event-ID` headers.\n- The endpoint is active at once, without a challenge; use `test` to check it.\n- The response holds a `secret` object only when a signing secret was issued: the endpoint is new, its `url` changed, or it is set again after a `DELETE`. Store the secret then, it is never shown again.\n- After a `url` change the new secret signs every request at once; keep the previous one for 24 hours only to verify requests already on their way.\n- A change of `events` or `payload` alone returns no secret.\n- A paused endpoint becomes active again and its failure count is reset.\n",
        "tags": [
          "Webhooks"
        ],
        "x-mag-scope": "webhook:products:subscribe",
        "x-mag-description-en": "Receive product events",
        "x-mag-description-id": "Menerima kejadian produk",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpsertProductsWebhookEndpoint"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The endpoint, updated",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/WebhookEndpoint"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "201": {
            "description": "The endpoint, created, with its first signing secret",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/WebhookEndpoint"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/webhooks/endpoints/collections": {
      "put": {
        "operationId": "setCollectionsWebhookEndpoint",
        "summary": "Set the collections webhook endpoint",
        "description": "Creates or updates your `collections` endpoint, which receives collection events. Answers `201` when the endpoint is new and `200` when it existed.\n\n### Request\n\n- Send the whole definition every time: `events` replaces the previous list, and an empty list answers `400 VALIDATION_FAILED` (use `DELETE` to stop notifications).\n- Each event must belong to this endpoint and duplicates are collapsed.\n- `payload` is `thin` (default, the event only names the collection) or `full` (the collection as `GET /collections/{id}` shows it at send time, but with empty image URLs; see the Webhooks section for the other differences).\n- The `url` must be an absolute `https` address on port 443, without user info or fragment, and not `localhost` or a private or loopback IP address.\n\n### Behaviour\n\n- Events are sent with `X-Mekari-Signature`, `X-Mekari-Timestamp`, `X-Mekari-Key-Version`, `X-Mekari-Delivery-ID` and `X-Mekari-Event-ID` headers.\n- The endpoint is active at once, without a challenge; use `test` to check it.\n- The response holds a `secret` object only when a signing secret was issued: the endpoint is new, its `url` changed, or it is set again after a `DELETE`. Store the secret then, it is never shown again.\n- After a `url` change the new secret signs every request at once; keep the previous one for 24 hours only to verify requests already on their way.\n- A change of `events` or `payload` alone returns no secret.\n- A paused endpoint becomes active again and its failure count is reset.\n",
        "tags": [
          "Webhooks"
        ],
        "x-mag-scope": "webhook:collections:subscribe",
        "x-mag-description-en": "Receive collection events",
        "x-mag-description-id": "Menerima kejadian koleksi",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpsertCollectionsWebhookEndpoint"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The endpoint, updated",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/WebhookEndpoint"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "201": {
            "description": "The endpoint, created, with its first signing secret",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/WebhookEndpoint"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/webhooks/endpoints/inventory": {
      "put": {
        "operationId": "setInventoryWebhookEndpoint",
        "summary": "Set the inventory webhook endpoint",
        "description": "Creates or updates your `inventory` endpoint, which receives product availability events. Answers `201` when the endpoint is new and `200` when it existed.\n\n### Request\n\n- Send the whole definition every time: `events` replaces the previous list, and an empty list answers `400 VALIDATION_FAILED` (use `DELETE` to stop notifications).\n- Each event must belong to this endpoint and duplicates are collapsed.\n- `payload` must be `thin` (the default) for this endpoint; `full` answers `400 VALIDATION_FAILED`.\n- The `url` must be an absolute `https` address on port 443, without user info or fragment, and not `localhost` or a private or loopback IP address.\n\n### Behaviour\n\n- Events are sent with `X-Mekari-Signature`, `X-Mekari-Timestamp`, `X-Mekari-Key-Version`, `X-Mekari-Delivery-ID` and `X-Mekari-Event-ID` headers.\n- The endpoint is active at once, without a challenge; use `test` to check it.\n- The response holds a `secret` object only when a signing secret was issued: the endpoint is new, its `url` changed, or it is set again after a `DELETE`. Store the secret then, it is never shown again.\n- After a `url` change the new secret signs every request at once; keep the previous one for 24 hours only to verify requests already on their way.\n- A change of `events` or `payload` alone returns no secret.\n- A paused endpoint becomes active again and its failure count is reset.\n",
        "tags": [
          "Webhooks"
        ],
        "x-mag-scope": "webhook:inventory:subscribe",
        "x-mag-description-en": "Receive product availability events",
        "x-mag-description-id": "Menerima kejadian ketersediaan produk",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpsertInventoryWebhookEndpoint"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The endpoint, updated",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/WebhookEndpoint"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "201": {
            "description": "The endpoint, created, with its first signing secret",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/WebhookEndpoint"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/webhooks/endpoints/{key}": {
      "delete": {
        "operationId": "deleteWebhookEndpoint",
        "summary": "Delete a webhook endpoint",
        "description": "Stops the endpoint: it sends nothing more, its pending deliveries are cancelled and its signing secrets stop working. An unknown `key`, and an endpoint you have not set or have deleted, answer `404 NOT_FOUND`. Setting the endpoint again later issues a new secret.",
        "tags": [
          "Webhooks"
        ],
        "x-mag-scope": "webhook:endpoint:delete",
        "x-mag-description-en": "Delete a webhook endpoint",
        "x-mag-description-id": "Menghapus endpoint webhook",
        "parameters": [
          {
            "name": "key",
            "in": "path",
            "required": true,
            "description": "The endpoint key.",
            "schema": {
              "type": "string",
              "enum": [
                "orders",
                "products",
                "collections",
                "inventory"
              ]
            }
          }
        ],
        "responses": {
          "204": {
            "description": "The endpoint is deleted; no body"
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/webhooks/endpoints/{key}/rotate-secret": {
      "post": {
        "operationId": "rotateWebhookSecret",
        "summary": "Rotate a webhook signing secret",
        "description": "Issues a new signing secret for the endpoint.\n\n- The new secret signs every request from this moment, retries included.\n- Keep the previous secret for 24 hours only to verify requests that were already on their way; it is revoked after that.\n- Every call rotates again, and the secret is shown only in this response.\n- An unknown `key`, and an endpoint you have not set or have deleted, answer `404 NOT_FOUND`.\n",
        "tags": [
          "Webhooks"
        ],
        "x-mag-scope": "webhook:secret:rotate",
        "x-mag-description-en": "Rotate a webhook signing secret",
        "x-mag-description-id": "Mengganti kunci tanda tangan webhook",
        "parameters": [
          {
            "name": "key",
            "in": "path",
            "required": true,
            "description": "The endpoint key.",
            "schema": {
              "type": "string",
              "enum": [
                "orders",
                "products",
                "collections",
                "inventory"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The new signing secret",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/WebhookSecret"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/webhooks/endpoints/{key}/test": {
      "post": {
        "operationId": "testWebhookEndpoint",
        "summary": "Send a test webhook",
        "description": "Sends one signed `webhook.test` event to the endpoint now and waits up to 10 seconds for the answer. It signs the event the same way as real events.\n\n- A receiver that fails, times out or refuses the address is a normal `200` answer with `delivered: false`.\n- When the receiver answered with a non-success status, `http_status` carries that status and `error` is `null`.\n- `error` is set only when the receiver gave no answer, and then holds a short cause (`timeout`, `request failed` or `invalid webhook destination`) while `http_status` is `null`.\n- `error` is `null` when the event was delivered.\n- An unknown `key`, and an endpoint you have not set or have deleted, answer `404 NOT_FOUND`.\n",
        "tags": [
          "Webhooks"
        ],
        "x-mag-scope": "webhook:endpoint:test",
        "x-mag-description-en": "Send a test webhook",
        "x-mag-description-id": "Mengirim webhook uji coba",
        "parameters": [
          {
            "name": "key",
            "in": "path",
            "required": true,
            "description": "The endpoint key.",
            "schema": {
              "type": "string",
              "enum": [
                "orders",
                "products",
                "collections",
                "inventory"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "What the receiver answered",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/WebhookTestResult"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "hmacAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "Authorization",
        "description": "HMAC-SHA256 signature checked by Mekari API Gateway, sent together with a `Date` header. See the Authentication guide."
      }
    },
    "parameters": {
      "Limit": {
        "name": "limit",
        "in": "query",
        "required": false,
        "description": "Page size.",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 100,
          "default": 20
        }
      },
      "Cursor": {
        "name": "cursor",
        "in": "query",
        "required": false,
        "description": "Opaque cursor from `next_cursor` of the previous page. Only valid with the same `limit` and filters.",
        "schema": {
          "type": "string"
        }
      },
      "OrderIDFilter": {
        "name": "order_id",
        "in": "query",
        "required": false,
        "description": "Only the records of this order (the `id` of an order). A cursor is only valid with the same `order_id`.",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "OutletID": {
        "name": "X-Outlet-ID",
        "in": "header",
        "required": true,
        "description": "An outlet `id` from `GET /outlets`.",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "UserID": {
        "name": "X-User-ID",
        "in": "header",
        "required": true,
        "description": "The acting user (`id` from `GET /users`). The user's role must allow the action. Most endpoints use the back office permission; approvals, returns, refunds and exchanges use the cashier app permission.",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": true,
        "description": "Unique per request; a retry with the same key and body returns the stored response for 7 days.",
        "schema": {
          "type": "string",
          "minLength": 1,
          "maxLength": 128
        }
      },
      "IdempotencyKeyOptional": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Optional; same semantics as on POST.",
        "schema": {
          "type": "string",
          "minLength": 1,
          "maxLength": 128
        }
      },
      "ID": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      }
    },
    "schemas": {
      "Envelope": {
        "type": "object",
        "required": [
          "status",
          "message"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "success",
              "error"
            ]
          },
          "code": {
            "type": "string",
            "description": "Stable UPPER_SNAKE error code on errors."
          },
          "message": {
            "type": "string"
          },
          "meta": {
            "type": "object",
            "properties": {
              "request_id": {
                "type": "string"
              }
            }
          }
        }
      },
      "ErrorEnvelope": {
        "type": "object",
        "required": [
          "status",
          "code",
          "message"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "error"
            ]
          },
          "code": {
            "type": "string",
            "description": "Stable UPPER_SNAKE error code."
          },
          "message": {
            "type": "string",
            "description": "Human-readable English message."
          },
          "data": {
            "type": "object",
            "description": "Present on `VALIDATION_FAILED` when the error names fields, and on a few business errors:\n\n- `CART_REPRICED`: the POS amounts `total`, `subtotal`, `line_discount`, `tax`, `service_fee`, `payment_fee` and `rounding`.\n- `DUPLICATE_EXTERNAL_ORDER_ID`: `order_id`.\n- `KITCHEN_ACCEPT_STOCK_INSUFFICIENT`: `shortages`.\n- `PIN_LOCKED`: `unlock_at`.\n- `PIN_INVALID` and `OTP_INVALID`: `attempts_remaining`.\n- `OTP_RESEND_COOLDOWN`: `resend_after`.\n- `OTP_UNAVAILABLE`: `reason`, `fallback_authorized`, `fallback_expires_at`.\n- `APPROVAL_TOKEN_INVALID`: `reason`.\n- `KITCHEN_NOT_FULFILLED`: `kitchen_status`.\n",
            "properties": {
              "unlock_at": {
                "type": "string",
                "format": "date-time"
              },
              "attempts_remaining": {
                "type": "integer"
              },
              "resend_after": {
                "type": "string",
                "format": "date-time"
              },
              "reason": {
                "type": "string"
              },
              "fallback_authorized": {
                "type": "boolean"
              },
              "fallback_expires_at": {
                "type": "string",
                "format": "date-time"
              },
              "total": {
                "type": "integer",
                "format": "int64",
                "description": "On `CART_REPRICED`: the total POS priced, in whole units of `currency`."
              },
              "subtotal": {
                "type": "integer",
                "format": "int64"
              },
              "line_discount": {
                "type": "integer",
                "format": "int64",
                "description": "On `CART_REPRICED`: the sum of the line discounts, the same value as `line_discount` of an order."
              },
              "tax": {
                "type": "integer",
                "format": "int64"
              },
              "service_fee": {
                "type": "integer",
                "format": "int64"
              },
              "payment_fee": {
                "type": "integer",
                "format": "int64"
              },
              "rounding": {
                "type": "integer",
                "format": "int64"
              },
              "order_id": {
                "type": "string",
                "format": "uuid",
                "description": "On `DUPLICATE_EXTERNAL_ORDER_ID`: the `id` of the order your source already sent with this `external_order_id`."
              },
              "kitchen_status": {
                "type": "string",
                "nullable": true,
                "description": "On `KITCHEN_NOT_FULFILLED`: the status of the order's kitchen ticket, named as `kitchen_status` of an order (for example `new`, `accepted`, `preparing`, `ready`, `rejected` or `cancelled`); `null` when the order has no ticket."
              },
              "shortages": {
                "type": "array",
                "description": "On `KITCHEN_ACCEPT_STOCK_INSUFFICIENT`: the short items.",
                "items": {
                  "type": "object",
                  "properties": {
                    "kind": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    },
                    "required": {
                      "type": "integer",
                      "format": "int64"
                    },
                    "on_hand": {
                      "type": "integer",
                      "format": "int64"
                    },
                    "used_by": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "product_name": {
                            "type": "string"
                          },
                          "quantity": {
                            "type": "integer",
                            "format": "int64",
                            "description": "The quantity this order line needs of the short item, in the item's own unit."
                          }
                        }
                      }
                    }
                  }
                }
              },
              "fields": {
                "type": "array",
                "items": {
                  "type": "object",
                  "required": [
                    "field",
                    "code",
                    "message"
                  ],
                  "properties": {
                    "field": {
                      "type": "string",
                      "description": "The request field (JSON name)."
                    },
                    "code": {
                      "type": "string",
                      "description": "Field error code."
                    },
                    "message": {
                      "type": "string"
                    },
                    "param": {
                      "type": "string",
                      "description": "The rule's parameter, for example the maximum length."
                    }
                  }
                }
              }
            }
          },
          "meta": {
            "type": "object",
            "description": "Diagnostic values for support. `company_cid` and `user_id` are present only when the caller is known (a `401` carries only `request_id`).",
            "properties": {
              "request_id": {
                "type": "string",
                "description": "Identifies the request; usually the same value as the `X-Request-Id` response header."
              },
              "company_cid": {
                "type": "integer",
                "format": "int64",
                "description": "The merchant's Mekari POS company number."
              },
              "user_id": {
                "type": "string",
                "description": "The acting user (`X-User-ID`) when one is known."
              }
            }
          }
        }
      },
      "Outlet": {
        "type": "object",
        "required": [
          "id",
          "name"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "address": {
            "type": "string",
            "nullable": true
          },
          "timezone": {
            "type": "string",
            "nullable": true,
            "example": "Asia/Jakarta",
            "description": "The outlet's own IANA timezone. `null` means the outlet has no timezone of its own and uses the merchant's default timezone (Asia/Jakarta, Asia/Makassar or Asia/Jayapura), which this API does not return."
          }
        }
      },
      "User": {
        "type": "object",
        "required": [
          "id",
          "name",
          "role",
          "outlet_ids"
        ],
        "description": "Use `id` as `X-User-ID` on writes. `outlet_ids` empty means the user can access every outlet.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "role": {
            "type": "string",
            "description": "The role name."
          },
          "outlet_ids": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            }
          }
        }
      },
      "Shift": {
        "type": "object",
        "required": [
          "id",
          "outlet_id",
          "cashier_id",
          "cashier_name",
          "opened_at",
          "opening_float"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "outlet_id": {
            "type": "string",
            "format": "uuid"
          },
          "cashier_id": {
            "type": "string",
            "format": "uuid"
          },
          "cashier_name": {
            "type": "string"
          },
          "opened_at": {
            "type": "string",
            "format": "date-time"
          },
          "opening_float": {
            "type": "integer",
            "format": "int64",
            "description": "Cash in the drawer when the shift opened, in whole units of `currency`."
          }
        }
      },
      "CustomerSummary": {
        "type": "object",
        "required": [
          "id",
          "name",
          "phone",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "phone": {
            "type": "string"
          },
          "email": {
            "type": "string",
            "nullable": true
          },
          "card_number": {
            "type": "string",
            "nullable": true
          },
          "birthday": {
            "type": "string",
            "format": "date",
            "nullable": true
          },
          "address": {
            "type": "string",
            "nullable": true
          },
          "notes": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Customer": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CustomerSummary"
          },
          {
            "type": "object",
            "properties": {
              "member_tier_id": {
                "type": "string",
                "format": "uuid",
                "nullable": true,
                "description": "The customer's current member tier; null when none."
              },
              "member_tier_name": {
                "type": "string",
                "nullable": true
              },
              "origin_outlet_id": {
                "type": "string",
                "format": "uuid",
                "nullable": true,
                "description": "The outlet the customer was first registered at."
              }
            }
          }
        ]
      },
      "CustomerDetail": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Customer"
          },
          {
            "type": "object",
            "required": [
              "points_balance"
            ],
            "properties": {
              "points_balance": {
                "type": "integer",
                "format": "int64"
              },
              "store_credit": {
                "type": "integer",
                "format": "int64",
                "description": "Store credit in whole units of `currency`. Present only when the merchant's plan includes member balance."
              }
            }
          }
        ]
      },
      "Promotion": {
        "type": "object",
        "required": [
          "id",
          "name",
          "type",
          "status",
          "customer_group",
          "member_tier_ids",
          "outlet_ids",
          "min_purchase",
          "quota_type",
          "used_count",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "enum": [
              "percentage",
              "fixed_amount",
              "bundle_qty",
              "bundle_amount",
              "addon_deal",
              "cheapest_item",
              "gwp",
              "order_voucher",
              "product_voucher"
            ]
          },
          "status": {
            "type": "string",
            "description": "The effective status: `active` only inside its time range.",
            "example": "active"
          },
          "starts_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "ends_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "customer_group": {
            "type": "array",
            "description": "Who the promotion is for.",
            "items": {
              "type": "string",
              "enum": [
                "all",
                "member",
                "non_member"
              ]
            }
          },
          "member_tier_ids": {
            "type": "array",
            "description": "Member tiers the promotion is limited to, when `customer_group` has `member`.",
            "items": {
              "type": "string",
              "format": "uuid"
            }
          },
          "outlet_ids": {
            "type": "array",
            "description": "Outlets where the promotion applies.",
            "items": {
              "type": "string",
              "format": "uuid"
            }
          },
          "min_purchase": {
            "type": "integer",
            "format": "int64",
            "description": "Minimum purchase in whole units of `currency`."
          },
          "per_customer_limit": {
            "type": "integer",
            "nullable": true,
            "description": "Times one member may use the promotion."
          },
          "quota_type": {
            "type": "string",
            "enum": [
              "unlimited",
              "limited"
            ]
          },
          "quota": {
            "type": "integer",
            "format": "int64",
            "nullable": true
          },
          "used_count": {
            "type": "integer",
            "format": "int64"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PromotionDetail": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Promotion"
          },
          {
            "type": "object",
            "required": [
              "config",
              "products",
              "gwp_rules",
              "collections"
            ],
            "properties": {
              "config": {
                "type": "object",
                "additionalProperties": true,
                "description": "Promotion rules as configured in the back office; keys depend on `type` and may grow. Money amounts there are integers in whole units of `currency`. It does not repeat `products`, `gwp_rules` or `collections` (read them at the top level). `main_skus` and `addon_skus` stay: they say which sku is the main item and which is the add-on."
              },
              "products": {
                "type": "array",
                "description": "Products the promotion covers. Empty for `gwp`; a `gwp` promotion uses `gwp_rules`.",
                "items": {
                  "$ref": "#/components/schemas/PromotionProduct"
                }
              },
              "gwp_rules": {
                "type": "array",
                "description": "For `gwp` only (empty otherwise) - each rule's trigger product and its gifts.",
                "items": {
                  "type": "object",
                  "required": [
                    "trigger",
                    "gifts"
                  ],
                  "properties": {
                    "trigger": {
                      "allOf": [
                        {
                          "$ref": "#/components/schemas/PromotionProduct"
                        }
                      ],
                      "nullable": true
                    },
                    "gifts": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PromotionProduct"
                      }
                    }
                  }
                }
              },
              "collections": {
                "type": "array",
                "description": "Collections the promotion covers.",
                "items": {
                  "type": "object",
                  "required": [
                    "collection_id",
                    "collection_name"
                  ],
                  "properties": {
                    "collection_id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "collection_name": {
                      "type": "string"
                    },
                    "discount_pct": {
                      "type": "integer",
                      "format": "int64",
                      "nullable": true,
                      "description": "Percentage off for this collection (percentage promotions only)."
                    }
                  }
                }
              }
            }
          }
        ]
      },
      "PromotionProduct": {
        "type": "object",
        "required": [
          "product_id",
          "product_name",
          "variants"
        ],
        "properties": {
          "product_id": {
            "type": "string",
            "format": "uuid"
          },
          "product_name": {
            "type": "string"
          },
          "variants": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "variant_id",
                "sku",
                "name",
                "barcode",
                "price"
              ],
              "properties": {
                "variant_id": {
                  "type": "string",
                  "format": "uuid"
                },
                "sku": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "barcode": {
                  "type": "string"
                },
                "price": {
                  "type": "integer",
                  "format": "int64",
                  "description": "Variant price in whole units of `currency`."
                },
                "discount_pct": {
                  "type": "integer",
                  "format": "int64",
                  "nullable": true
                },
                "discount_amount": {
                  "type": "integer",
                  "format": "int64",
                  "nullable": true,
                  "description": "Amount off in whole units of `currency`."
                },
                "min_qty": {
                  "type": "integer",
                  "format": "int64",
                  "nullable": true
                }
              }
            }
          }
        }
      },
      "Voucher": {
        "type": "object",
        "required": [
          "valid"
        ],
        "description": "`valid` is true when the code is live: it exists, is unused, its promotion is active and inside its dates, and quota is left. It does not mean the code works for a given order; outlet, customer and minimum purchase rules are checked when the order is created. When `valid` is false, only `valid` and `reason` are present. When `valid` is true, `discount_pct` is set for a `percentage` discount, `discount_amount` for a `fixed_amount` discount, and `restrictions` shows the promotion's rules.",
        "properties": {
          "valid": {
            "type": "boolean"
          },
          "reason": {
            "type": "string",
            "enum": [
              "not_found",
              "redeemed",
              "inactive",
              "expired",
              "quota_exhausted"
            ],
            "description": "Present only when `valid` is false. `not_found`: no such code for this merchant. `redeemed`: the code was already used. `inactive`: its promotion is not active. `expired`: now is outside the promotion's dates (before the start or after the end). `quota_exhausted`: the promotion has no uses left."
          },
          "promotion_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "discount_type": {
            "type": "string",
            "enum": [
              "percentage",
              "fixed_amount"
            ]
          },
          "discount_pct": {
            "type": "integer",
            "format": "int64"
          },
          "discount_amount": {
            "type": "integer",
            "format": "int64",
            "description": "Amount off in whole units of `currency`."
          },
          "restrictions": {
            "type": "object",
            "description": "The promotion's rules, present only when `valid` is true. This check does not apply them; the order does.",
            "required": [
              "outlet_ids",
              "customer_group",
              "member_tier_ids",
              "per_customer_limit",
              "min_purchase"
            ],
            "properties": {
              "outlet_ids": {
                "type": "array",
                "description": "Outlets where the promotion applies. An outlet that is not listed gets no discount from it. The list is never empty for a promotion made in the back office; an empty list would mean no outlet, not all outlets.",
                "items": {
                  "type": "string",
                  "format": "uuid"
                }
              },
              "customer_group": {
                "type": "array",
                "description": "Who the promotion is for.",
                "items": {
                  "type": "string",
                  "enum": [
                    "all",
                    "member",
                    "non_member"
                  ]
                }
              },
              "member_tier_ids": {
                "type": "array",
                "description": "Member tiers the promotion is limited to, when `customer_group` has `member`.",
                "items": {
                  "type": "string",
                  "format": "uuid"
                }
              },
              "per_customer_limit": {
                "type": "integer",
                "nullable": true,
                "description": "Times one member may use the promotion; null for no limit."
              },
              "min_purchase": {
                "type": "integer",
                "format": "int64",
                "description": "Minimum purchase in whole units of `currency`."
              }
            }
          }
        }
      },
      "Category": {
        "type": "object",
        "required": [
          "id",
          "parent_id",
          "name",
          "depth",
          "path"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "parent_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "name": {
            "type": "string"
          },
          "depth": {
            "type": "integer",
            "description": "0 for a top-level category."
          },
          "path": {
            "type": "array",
            "description": "From the top-level category down to this one.",
            "items": {
              "type": "object",
              "required": [
                "id",
                "name"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid"
                },
                "name": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "CategoryItem": {
        "type": "object",
        "required": [
          "id",
          "parent_id",
          "name",
          "depth"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "parent_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "name": {
            "type": "string"
          },
          "depth": {
            "type": "integer",
            "description": "0 for a top-level category."
          }
        }
      },
      "Collection": {
        "type": "object",
        "required": [
          "id",
          "parent_id",
          "name",
          "depth",
          "sort_order"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "parent_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "name": {
            "type": "string"
          },
          "depth": {
            "type": "integer",
            "description": "0 for a top-level collection."
          },
          "sort_order": {
            "type": "integer",
            "description": "Position among the collections with the same parent."
          }
        }
      },
      "Attachment": {
        "type": "object",
        "required": [
          "url"
        ],
        "properties": {
          "filename": {
            "type": "string"
          },
          "content_type": {
            "type": "string",
            "example": "image/png"
          },
          "size_bytes": {
            "type": "integer",
            "format": "int64"
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Permanent anonymous public CDN URL. It contains no expiry query parameters and can be fetched directly by any internet user while the underlying asset remains available."
          }
        }
      },
      "VariantValue": {
        "type": "object",
        "required": [
          "group_name",
          "value"
        ],
        "description": "The option a variant selects in one variant group, identified by name.",
        "properties": {
          "group_name": {
            "type": "string",
            "example": "Size"
          },
          "value": {
            "type": "string",
            "example": "Large"
          }
        }
      },
      "VariantGroup": {
        "type": "object",
        "required": [
          "name",
          "values"
        ],
        "description": "A product-level dimension, derived from the variants' `variant_values` in order of first appearance.",
        "properties": {
          "name": {
            "type": "string"
          },
          "values": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "value"
              ],
              "properties": {
                "value": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "ProductVariant": {
        "type": "object",
        "required": [
          "variant_id",
          "sku",
          "barcode",
          "variant_values",
          "price",
          "cost",
          "attachment"
        ],
        "properties": {
          "variant_id": {
            "type": "string",
            "format": "uuid"
          },
          "sku": {
            "type": "string"
          },
          "barcode": {
            "type": "string"
          },
          "variant_values": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/VariantValue"
            }
          },
          "price": {
            "type": "integer",
            "format": "int64",
            "description": "Base price in whole units of `currency`. For products with canonical base prices and Price Management enabled, product availability saves refresh the stored compatibility price to the maximum permanent price across active assigned outlets (0 for an unpriced SKU). Existing outlet-specific read projections still apply. Products awaiting initial backfill retain legacy pricing."
          },
          "cost": {
            "type": "integer",
            "format": "int64",
            "description": "Cost in whole units of `currency`."
          },
          "attachment": {
            "nullable": true,
            "allOf": [
              {
                "$ref": "#/components/schemas/Attachment"
              }
            ]
          }
        }
      },
      "Product": {
        "type": "object",
        "required": [
          "id",
          "name",
          "status",
          "category_id",
          "collection_id",
          "outlet_ids",
          "attachments",
          "variants",
          "variant_groups"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "archived"
            ]
          },
          "category_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "collection_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "outlet_ids": {
            "type": "array",
            "description": "Outlets that sell the product; empty means every outlet.",
            "items": {
              "type": "string",
              "format": "uuid"
            }
          },
          "attachments": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Attachment"
            }
          },
          "variants": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ProductVariant"
            }
          },
          "variant_groups": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/VariantGroup"
            }
          }
        }
      },
      "ImageInput": {
        "type": "object",
        "required": [
          "url"
        ],
        "description": "An image POS copies into its own storage. The URL must be public HTTPS (port 443) and must not carry credentials or point to a private address. The file type is read from the downloaded bytes; the size is capped by the storage limit.",
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "maxLength": 2048
          }
        }
      },
      "VariantGroupInput": {
        "type": "object",
        "required": [
          "name",
          "values"
        ],
        "description": "A variant dimension by name, such as Size with S and M. POS reuses or creates it.",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100
          },
          "values": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "items": {
              "type": "object",
              "required": [
                "value"
              ],
              "properties": {
                "value": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 100
                }
              }
            }
          }
        }
      },
      "VariantValueInput": {
        "type": "object",
        "required": [
          "group_name",
          "value"
        ],
        "description": "The option a variant selects in one of the `variant_groups`, by name.",
        "properties": {
          "group_name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100
          },
          "value": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100
          }
        }
      },
      "CreateProductVariant": {
        "type": "object",
        "required": [
          "sku",
          "price"
        ],
        "properties": {
          "sku": {
            "type": "string",
            "minLength": 1,
            "maxLength": 64
          },
          "barcode": {
            "type": "string",
            "maxLength": 64
          },
          "variant_values": {
            "type": "array",
            "description": "Needs `variant_groups`.",
            "items": {
              "$ref": "#/components/schemas/VariantValueInput"
            }
          },
          "price": {
            "type": "integer",
            "format": "int64",
            "minimum": 0,
            "description": "Required. Base price in whole units of currency. Leaving it out or sending `null` answers `400 VALIDATION_FAILED` on `variants[i].price`; `0` is allowed. With Price Management enabled, the supplied amount including 0 initializes inherited outlet bases only for this newly generated product/SKU. Maximum is 999999999999 in enabled mode. Every active assigned target must be in the current actor's outlet grants. catalog.product.create permits this initialization without granting existing-price editing. Final scalar responses are compatibility maxima; the canonical command joins metadata and product.created outbox in the same transaction. This retains the company-wide base-price meaning; X-Outlet-ID selects product visibility/read context."
          },
          "cost": {
            "type": "integer",
            "format": "int64",
            "minimum": 0,
            "description": "Cost in whole units of `currency`."
          },
          "attachment": {
            "nullable": true,
            "description": "The variant's image.",
            "allOf": [
              {
                "$ref": "#/components/schemas/ImageInput"
              }
            ]
          }
        }
      },
      "CreateProduct": {
        "type": "object",
        "required": [
          "name",
          "variants"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255
          },
          "category_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true,
            "description": "A leaf category `id` from `GET /categories`."
          },
          "collection_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true,
            "description": "A collection `id` from `GET /collections`."
          },
          "outlet_ids": {
            "type": "array",
            "maxItems": 100,
            "description": "Outlets that sell the product. Empty or left out means every outlet.",
            "items": {
              "type": "string",
              "format": "uuid"
            }
          },
          "attachments": {
            "type": "array",
            "maxItems": 10,
            "items": {
              "$ref": "#/components/schemas/ImageInput"
            }
          },
          "variant_groups": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/VariantGroupInput"
            }
          },
          "variants": {
            "type": "array",
            "minItems": 1,
            "maxItems": 50,
            "items": {
              "$ref": "#/components/schemas/CreateProductVariant"
            }
          }
        }
      },
      "UpdateProductVariant": {
        "type": "object",
        "required": [
          "price"
        ],
        "description": "An existing variant has `variant_id` (an `id` key is ignored). Leave `variant_id` out and send `sku` to add a variant. Sending `sku` with `variant_id` renames the variant's SKU.",
        "properties": {
          "variant_id": {
            "type": "string",
            "format": "uuid"
          },
          "sku": {
            "type": "string",
            "maxLength": 64,
            "description": "Required for a new variant. Left out on an existing variant, the SKU is kept."
          },
          "barcode": {
            "type": "string",
            "nullable": true,
            "maxLength": 64,
            "description": "On an existing variant, left out keeps the barcode; `null` or an empty string clears it. A new variant has none."
          },
          "variant_values": {
            "type": "array",
            "nullable": true,
            "description": "A list needs `variant_groups`. On an existing variant, left out keeps its option values and a list replaces them. `null` or `[]` removes them, but only for all variants together and with `variant_groups: []` (or no `variant_groups` key), so no variant keeps options; otherwise `400 VALIDATION_FAILED` (`INVALID_VALUE`). A new variant has none.",
            "items": {
              "$ref": "#/components/schemas/VariantValueInput"
            }
          },
          "price": {
            "type": "integer",
            "format": "int64",
            "minimum": 0,
            "description": "Base price in whole units of currency. Required on every variant; 0 is valid. With Price Management enabled, scalar echoes create no amount intent. A real existing price change or initial price for a new SKU on an existing product additionally requires catalog.price.edit and authorization for every target outlet. Changed existing values use inherited canonical bases only; explicit, missing, suspended and permanent-owned cells are protected. New SKU inputs bind only newly generated IDs, with one mixed command for existing edits plus initialization. Maximum is 999999999999 in enabled mode. Price/history/receipt/feed/final maxima and product.updated outbox share the caller transaction and existing HTTP idempotency. Missing targets and protected ownership return 409; missing price permission returns 403. Source audit records the actual local user; MAG client outbox metadata and echo suppression remain. Feature-off preserves scalar compatibility."
          },
          "cost": {
            "type": "integer",
            "format": "int64",
            "nullable": true,
            "minimum": 0,
            "description": "Cost in whole units of `currency`. On an existing variant, left out keeps the cost; `null` sets it to 0. A new variant has cost 0."
          },
          "attachment": {
            "nullable": true,
            "description": "Left out keeps the image; `null` removes it; a value replaces it.",
            "allOf": [
              {
                "$ref": "#/components/schemas/ImageInput"
              }
            ]
          }
        }
      },
      "UpdateProduct": {
        "type": "object",
        "required": [
          "name",
          "variants"
        ],
        "description": "Replaces the fields it names. A field that is absent keeps its value; `null` clears it (`collection_id`, `outlet_ids`, `attachments`, and on a variant `attachment`, `barcode`, `cost`). A variant's `variant_values` left out are kept and a list replaces them; `null` or `[]` removes them only when every variant ends with no options and `variant_groups` is empty. List every variant: a variant not listed is removed.",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255
          },
          "collection_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true,
            "description": "Left out keeps the collection; `null` removes the product from its collection."
          },
          "outlet_ids": {
            "type": "array",
            "nullable": true,
            "maxItems": 100,
            "description": "Replaces the outlets that sell the product. Left out keeps them; `null` or `[]` means every outlet.",
            "items": {
              "type": "string",
              "format": "uuid"
            }
          },
          "attachments": {
            "type": "array",
            "nullable": true,
            "maxItems": 10,
            "description": "Left out keeps the product images; `null` or `[]` removes them; a list replaces them.",
            "items": {
              "$ref": "#/components/schemas/ImageInput"
            }
          },
          "variant_groups": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/VariantGroupInput"
            }
          },
          "variants": {
            "type": "array",
            "minItems": 1,
            "maxItems": 50,
            "description": "The full set of variants to keep.",
            "items": {
              "$ref": "#/components/schemas/UpdateProductVariant"
            }
          }
        }
      },
      "UpdateProductStatus": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "active",
              "archived"
            ]
          }
        }
      },
      "InventoryStock": {
        "type": "object",
        "required": [
          "item_type",
          "item_id",
          "sku",
          "name",
          "quantity",
          "unit"
        ],
        "properties": {
          "item_type": {
            "type": "string",
            "enum": [
              "product",
              "raw_material"
            ]
          },
          "item_id": {
            "type": "string",
            "format": "uuid"
          },
          "sku": {
            "type": "string",
            "nullable": true,
            "description": "The item's SKU; `null` when it has none."
          },
          "name": {
            "type": "string",
            "description": "The product name, or the raw material name."
          },
          "quantity": {
            "type": "integer",
            "format": "int64",
            "description": "On-hand quantity at the outlet, in the item's own unit."
          },
          "unit": {
            "type": "string",
            "nullable": true,
            "description": "The raw material's unit, for example `gram`; `null` for a product."
          }
        }
      },
      "InventoryAdjustment": {
        "type": "object",
        "description": "The answer of a stock adjustment and of a stock snapshot.",
        "required": [
          "id",
          "item_type",
          "item_id",
          "sku",
          "system_quantity",
          "delta",
          "quantity"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "The adjustment or count document id."
          },
          "item_type": {
            "type": "string",
            "enum": [
              "product",
              "raw_material"
            ]
          },
          "item_id": {
            "type": "string"
          },
          "sku": {
            "type": "string",
            "nullable": true,
            "description": "The item's SKU; `null` when it has none."
          },
          "system_quantity": {
            "type": "integer",
            "format": "int64",
            "description": "On-hand quantity before the adjustment or count."
          },
          "delta": {
            "type": "integer",
            "format": "int64",
            "description": "The change made. For a count, `quantity` minus `system_quantity`."
          },
          "quantity": {
            "type": "integer",
            "format": "int64",
            "description": "On-hand quantity after the adjustment or count."
          }
        }
      },
      "InventoryMovement": {
        "type": "object",
        "required": [
          "id",
          "item_type",
          "item_id",
          "sku",
          "delta",
          "type",
          "occurred_at"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "item_type": {
            "type": "string",
            "description": "The kind of item moved, for example `product`."
          },
          "item_id": {
            "type": "string"
          },
          "sku": {
            "type": "string",
            "nullable": true,
            "description": "The item's SKU; `null` when it has none."
          },
          "delta": {
            "type": "integer",
            "format": "int64",
            "description": "Signed change in quantity; negative for stock out."
          },
          "type": {
            "type": "string",
            "description": "The kind of movement, for example `sale` or `adjustment`."
          },
          "occurred_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "RawMaterial": {
        "type": "object",
        "required": [
          "id",
          "name",
          "unit",
          "status",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "sku": {
            "type": "string",
            "nullable": true
          },
          "barcode": {
            "type": "string",
            "nullable": true
          },
          "category_name": {
            "type": "string",
            "nullable": true,
            "description": "Free-text category name."
          },
          "unit": {
            "type": "string",
            "description": "The unit of measurement, for example `gram`."
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "inactive"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Recipe": {
        "type": "object",
        "required": [
          "id",
          "name",
          "status",
          "line_count",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "inactive"
            ]
          },
          "line_count": {
            "type": "integer",
            "description": "Number of ingredient lines."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "RecipeLine": {
        "type": "object",
        "required": [
          "raw_material_id",
          "raw_material_name",
          "quantity",
          "unit"
        ],
        "properties": {
          "raw_material_id": {
            "type": "string",
            "format": "uuid"
          },
          "raw_material_name": {
            "type": "string"
          },
          "quantity": {
            "type": "integer",
            "format": "int64",
            "description": "Quantity in the raw material's own unit."
          },
          "unit": {
            "type": "string"
          }
        }
      },
      "RecipeDetail": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Recipe"
          },
          {
            "type": "object",
            "required": [
              "lines"
            ],
            "properties": {
              "lines": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/RecipeLine"
                }
              }
            }
          }
        ]
      },
      "OrderLine": {
        "type": "object",
        "required": [
          "id",
          "line_type",
          "variant_id",
          "sku",
          "product_name",
          "variant_name",
          "image_url",
          "quantity",
          "unit_price",
          "discount",
          "total"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "line_type": {
            "type": "string",
            "enum": [
              "catalog",
              "manual"
            ],
            "description": "A `manual` line was typed in at the till and has no variant."
          },
          "variant_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true,
            "description": "Null on a manual line."
          },
          "sku": {
            "type": "string"
          },
          "product_name": {
            "type": "string"
          },
          "variant_name": {
            "type": "string"
          },
          "image_url": {
            "type": "string",
            "description": "The product's current image; empty when it has none or was deleted."
          },
          "quantity": {
            "type": "integer",
            "format": "int64"
          },
          "unit_price": {
            "type": "integer",
            "format": "int64",
            "description": "In whole units of `currency`."
          },
          "discount": {
            "type": "integer",
            "format": "int64",
            "description": "In whole units of `currency`."
          },
          "total": {
            "type": "integer",
            "format": "int64",
            "description": "In whole units of `currency`."
          }
        }
      },
      "OrderTender": {
        "type": "object",
        "required": [
          "payment_method_id",
          "payment_method_name",
          "amount",
          "payment_fee",
          "reference"
        ],
        "properties": {
          "payment_method_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true,
            "description": "The `id` of the payment method (see `GET /payment-methods`). Null only on an old tender recorded without one."
          },
          "payment_method_name": {
            "type": "string",
            "description": "The payment method's name at sale time."
          },
          "amount": {
            "type": "integer",
            "format": "int64",
            "description": "In whole units of `currency`."
          },
          "payment_fee": {
            "type": "integer",
            "format": "int64",
            "description": "The payment fee the customer paid for this tender, in whole units of `currency`. A fee absorbed by the business is not included."
          },
          "reference": {
            "type": "string"
          }
        }
      },
      "Order": {
        "type": "object",
        "required": [
          "id",
          "outlet_id",
          "document_number",
          "status",
          "sales_type",
          "currency",
          "subtotal",
          "line_discount",
          "order_discount",
          "tax",
          "service_fee",
          "payment_fee",
          "rounding",
          "total",
          "sold_at",
          "created_at",
          "lines",
          "tenders",
          "source",
          "external_order_id",
          "kitchen_order_id",
          "kitchen_status",
          "payment_status",
          "amount_owed"
        ],
        "description": "All amounts are integers in whole units of `currency`.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "outlet_id": {
            "type": "string",
            "format": "uuid"
          },
          "document_number": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "description": "The order status, for example `paid`."
          },
          "sales_type": {
            "type": "string",
            "enum": [
              "retail",
              "dine_in",
              "pickup",
              "takeaway",
              "qr_ordering",
              "delivery",
              "service"
            ],
            "description": "How the order was sold, for example `dine_in`."
          },
          "currency": {
            "type": "string",
            "example": "IDR"
          },
          "subtotal": {
            "type": "integer",
            "format": "int64"
          },
          "line_discount": {
            "type": "integer",
            "format": "int64",
            "description": "Sum of the line discounts (promotion, member tier, manual line discount) in whole units of `currency`. `order_discount` is not in it."
          },
          "order_discount": {
            "type": "integer",
            "format": "int64"
          },
          "tax": {
            "type": "integer",
            "format": "int64"
          },
          "service_fee": {
            "type": "integer",
            "format": "int64"
          },
          "payment_fee": {
            "type": "integer",
            "format": "int64"
          },
          "rounding": {
            "type": "integer",
            "format": "int64",
            "description": "Signed rounding amount."
          },
          "total": {
            "type": "integer",
            "format": "int64"
          },
          "sold_at": {
            "type": "string",
            "format": "date-time"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "lines": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OrderLine"
            }
          },
          "tenders": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OrderTender"
            }
          },
          "source": {
            "type": "string",
            "description": "Where the order came from: `pos` for an order made in Mekari POS, `grabfood` or `gofood` for a sales channel, or the `source_code` of the application that created it."
          },
          "external_order_id": {
            "type": "string",
            "nullable": true,
            "description": "The `external_order_id` the application gave the order. Null on an order that has none."
          },
          "kitchen_order_id": {
            "type": "string",
            "nullable": true,
            "description": "The kitchen ticket of the order. Null when the order has no ticket."
          },
          "kitchen_status": {
            "type": "string",
            "nullable": true,
            "enum": [
              "new",
              "accepted",
              "preparing",
              "ready",
              "served",
              "completed",
              "rejected",
              "cancelled"
            ],
            "description": "The status of the order on the outlet's kitchen board: `new`, `accepted`, `preparing`, `ready`, `served`, `completed`, `rejected` or `cancelled`. Null when the order has no ticket."
          },
          "payment_status": {
            "type": "string",
            "enum": [
              "unpaid",
              "partially_paid",
              "overdue",
              "settled"
            ],
            "description": "Where the payment of the order stands. Only a Terms (pay later) order can be `unpaid`, `partially_paid` or `overdue`; every other order is `settled`. `order.payment_updated` is sent on every Terms payment; a Terms order that becomes `overdue` sends no event."
          },
          "amount_owed": {
            "type": "integer",
            "format": "int64",
            "description": "What the buyer still owes on a Terms order, in whole units of `currency`. `0` when `payment_status` is `settled`."
          }
        }
      },
      "OrderSource": {
        "type": "object",
        "required": [
          "source_code",
          "display_name",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "source_code": {
            "type": "string",
            "description": "The code your orders carry in `source` (and in webhooks), for example `harvest`."
          },
          "display_name": {
            "type": "string",
            "description": "The name shown to the merchant. The payment method of your orders is named `Paid on` and this name."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PaymentMethod": {
        "type": "object",
        "required": [
          "id",
          "name",
          "type"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Send it as `payment_method_id` in a tender."
          },
          "name": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "enum": [
              "cash",
              "custom"
            ]
          }
        }
      },
      "OrderSourceRequest": {
        "type": "object",
        "required": [
          "source_code",
          "display_name"
        ],
        "properties": {
          "source_code": {
            "type": "string",
            "pattern": "^[a-z0-9-]{2,32}$",
            "description": "2 to 32 lowercase letters, digits or hyphens, unique within the merchant. `pos`, `grabfood`, `gofood`, `qr` and `open-api` are reserved. It cannot change after the source has an order (`409 SOURCE_CODE_LOCKED`).",
            "example": "harvest"
          },
          "display_name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 40,
            "description": "The name shown to the merchant, trimmed. It can change at any time.",
            "example": "Harvest"
          }
        }
      },
      "PartnerOrderRequest": {
        "type": "object",
        "required": [
          "external_order_id",
          "sales_type",
          "lines",
          "expected_total"
        ],
        "properties": {
          "external_order_id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 64,
            "description": "Your order id, unique per order source, 1 to 64 characters, without spaces at the start or the end. An id this source already sent answers `409 DUPLICATE_EXTERNAL_ORDER_ID` with `data.order_id`. Retry a request safely with the same `Idempotency-Key`.",
            "example": "HV-10021"
          },
          "external_ordered_at": {
            "type": "string",
            "format": "date-time",
            "description": "When your customer placed the order (RFC 3339)."
          },
          "sales_type": {
            "type": "string",
            "enum": [
              "delivery",
              "takeaway",
              "pickup"
            ]
          },
          "accepted_type": {
            "type": "string",
            "enum": [
              "auto",
              "manual"
            ],
            "default": "manual",
            "description": "`auto` accepts the ticket at once, so stock, points and the voucher are applied now. `manual` waits for the cashier to accept or reject it on the kitchen board."
          },
          "member_id": {
            "type": "string",
            "format": "uuid",
            "description": "A customer `id` from `GET /customers`. Points are awarded to this customer when the ticket is accepted."
          },
          "voucher_code": {
            "type": "string",
            "maxLength": 64,
            "description": "A voucher code of the merchant (see `GET /vouchers/{code}`). An unknown code answers `400 VALIDATION_FAILED` on `voucher_code`."
          },
          "lines": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "items": {
              "$ref": "#/components/schemas/PartnerOrderLine"
            }
          },
          "order_discount": {
            "type": "integer",
            "format": "int64",
            "minimum": 0,
            "description": "A discount on the whole order, in whole units of `currency`. Needs `X-User-ID`."
          },
          "tenders": {
            "type": "array",
            "maxItems": 10,
            "items": {
              "$ref": "#/components/schemas/PartnerOrderTender"
            },
            "description": "How the customer paid. Leave it out (or empty) to record the whole total on the `Paid on <display_name>` method of your source. An order with a total of 0 needs one explicit tender of 0 on a custom payment method."
          },
          "expected_total": {
            "type": "integer",
            "format": "int64",
            "minimum": 0,
            "description": "The total you expect, in whole units of `currency`. It must be the total POS prices, including the payment fee POS adds for a custom payment method that has a fee. A different total answers `409 CART_REPRICED`."
          }
        }
      },
      "PartnerOrderLine": {
        "type": "object",
        "required": [
          "quantity"
        ],
        "description": "A catalog line names `variant_id`. A manual line names a `manual_*` `sku` with `product_name` and `manual_price` and needs `X-User-ID`. Any other `sku` is `400`.",
        "properties": {
          "variant_id": {
            "type": "string",
            "format": "uuid",
            "description": "A variant `id` from `GET /products`."
          },
          "sku": {
            "type": "string",
            "maxLength": 100,
            "description": "Manual line only (`manual_*`)."
          },
          "product_name": {
            "type": "string",
            "maxLength": 255,
            "description": "The name of a manual SKU line. Required there, not used on other lines."
          },
          "quantity": {
            "type": "integer",
            "format": "int64",
            "minimum": 1
          },
          "modifiers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SelectedOption"
            }
          },
          "preferences": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SelectedOption"
            }
          },
          "note": {
            "type": "string",
            "maxLength": 200,
            "description": "A note for the kitchen."
          },
          "manual_price": {
            "type": "integer",
            "format": "int64",
            "minimum": 0,
            "description": "A price per unit that replaces the catalog price, in whole units of `currency`. Needs `X-User-ID`. The line cannot carry `modifiers` or `preferences`."
          },
          "discount": {
            "type": "integer",
            "format": "int64",
            "minimum": 0,
            "description": "A discount on the whole line (not per unit), in whole units of `currency`. Needs `X-User-ID`."
          }
        }
      },
      "PartnerOrderTender": {
        "type": "object",
        "required": [
          "payment_method_id",
          "amount"
        ],
        "description": "One payment. For a method with a fee, `amount` plus the fee POS adds must equal `expected_total` over all tenders.",
        "properties": {
          "payment_method_id": {
            "type": "string",
            "format": "uuid",
            "description": "An `id` from `GET /payment-methods` at the outlet. A method that list does not show (inactive, hidden, not offered at this outlet, or another type) is `400 VALIDATION_FAILED` with code `NOT_ALLOWED`. The cash method needs `X-User-ID` with an open shift."
          },
          "amount": {
            "type": "integer",
            "format": "int64",
            "minimum": 0,
            "description": "In whole units of `currency`, before the payment fee."
          },
          "reference": {
            "type": "string",
            "maxLength": 100,
            "description": "Your payment reference."
          }
        }
      },
      "OrderMember": {
        "type": "object",
        "required": [
          "id",
          "name",
          "email",
          "phone",
          "address",
          "member_tier_id",
          "member_tier_name"
        ],
        "description": "The buyer's current member profile, and the member tier whose discount applied to the sale.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "email": {
            "type": "string"
          },
          "phone": {
            "type": "string"
          },
          "address": {
            "type": "string"
          },
          "member_tier_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true,
            "description": "The member tier whose discount applied to the sale. Null when no tier discount applied."
          },
          "member_tier_name": {
            "type": "string",
            "nullable": true,
            "description": "The name of that tier. Null when no tier discount applied."
          }
        }
      },
      "OrderDetail": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Order"
          },
          {
            "type": "object",
            "required": [
              "member",
              "updated_at"
            ],
            "properties": {
              "member": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/OrderMember"
                  }
                ],
                "nullable": true,
                "description": "The buyer's current member profile; null for a walk-in order. This is personal data of the buyer (name, email, phone, address)."
              },
              "updated_at": {
                "type": "string",
                "format": "date-time",
                "description": "The last activity on the order; the sale time when there is none."
              }
            }
          }
        ]
      },
      "ReturnLine": {
        "type": "object",
        "required": [
          "order_line_id",
          "line_type",
          "variant_id",
          "sku",
          "product_name",
          "quantity",
          "refund_amount"
        ],
        "properties": {
          "order_line_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true,
            "description": "The order line this line returns (`lines[].id` of `GET /orders/{id}`). Null only for an old return made by sku before lines had ids."
          },
          "line_type": {
            "type": "string",
            "enum": [
              "catalog",
              "manual"
            ]
          },
          "variant_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true,
            "description": "Null on a manual line."
          },
          "sku": {
            "type": "string"
          },
          "product_name": {
            "type": "string"
          },
          "quantity": {
            "type": "integer",
            "format": "int64"
          },
          "refund_amount": {
            "type": "integer",
            "format": "int64",
            "description": "In whole units of `currency`."
          }
        }
      },
      "ReturnTender": {
        "type": "object",
        "required": [
          "payment_method_id",
          "payment_method_name",
          "amount",
          "reference"
        ],
        "description": "One tender a return or refund paid out to. The same shape in every answer.",
        "properties": {
          "payment_method_id": {
            "type": "string",
            "nullable": true,
            "description": "Null only on an old record made before tenders had a payment method."
          },
          "payment_method_name": {
            "type": "string"
          },
          "amount": {
            "type": "integer",
            "format": "int64",
            "description": "In whole units of `currency`."
          },
          "reference": {
            "type": "string"
          }
        }
      },
      "Return": {
        "type": "object",
        "required": [
          "id",
          "outlet_id",
          "document_number",
          "order_id",
          "reason",
          "currency",
          "refund_amount",
          "payment_fee",
          "cashier_id",
          "approver_id",
          "created_at",
          "lines",
          "refund_tenders"
        ],
        "description": "All amounts are integers in whole units of `currency`.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "outlet_id": {
            "type": "string",
            "format": "uuid"
          },
          "document_number": {
            "type": "string"
          },
          "order_id": {
            "type": "string",
            "format": "uuid"
          },
          "reason": {
            "type": "string"
          },
          "currency": {
            "type": "string",
            "example": "IDR"
          },
          "refund_amount": {
            "type": "integer",
            "format": "int64",
            "description": "What the customer got back: the line refunds plus `payment_fee` (on a full return) and the tip share."
          },
          "payment_fee": {
            "type": "integer",
            "format": "int64",
            "description": "The payment fee reversed in a full return."
          },
          "cashier_id": {
            "type": "string",
            "description": "The acting user (a user id). A return or refund your application made without `X-User-ID` on its own partner order shows `system:integration-<source_code>` instead (MPOS-2389)."
          },
          "approver_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true,
            "description": "Null when no approval was needed."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "lines": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ReturnLine"
            }
          },
          "refund_tenders": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ReturnTender"
            }
          }
        }
      },
      "ApprovalType": {
        "type": "string",
        "enum": [
          "refund",
          "return",
          "exchange",
          "order_cancel"
        ],
        "description": "The kind of action an approval covers."
      },
      "ApprovalGateReason": {
        "type": "string",
        "enum": [
          "refund_only",
          "cash_override",
          "threshold_exceeded",
          "exchange_above_original",
          "order_cancel"
        ],
        "description": "Why the approval is asked. Wrong PINs are counted per outlet, order and `gate_reason`, for all users together."
      },
      "EligibleApprover": {
        "type": "object",
        "required": [
          "approver_id",
          "name",
          "role_caption",
          "can_receive_otp",
          "otp_unavailable_reason"
        ],
        "description": "One person who may approve. The phone number is never returned.",
        "properties": {
          "approver_id": {
            "type": "string",
            "format": "uuid",
            "description": "Send it as `approver_id` in `POST /approvals/pin` and `POST /approvals/otp`."
          },
          "name": {
            "type": "string"
          },
          "role_caption": {
            "type": "string"
          },
          "can_receive_otp": {
            "type": "boolean",
            "description": "True when a one-time code can be sent to this approver."
          },
          "otp_unavailable_reason": {
            "type": "string",
            "nullable": true,
            "enum": [
              "no_phone",
              "phone_unverified"
            ],
            "description": "Why a code cannot be sent; null when it can."
          }
        }
      },
      "EligibleApprovers": {
        "type": "object",
        "required": [
          "is_enabled",
          "policy",
          "cancellation_types",
          "approvers"
        ],
        "properties": {
          "is_enabled": {
            "type": "boolean",
            "description": "False when no approval is needed for the asked types."
          },
          "policy": {
            "type": "string",
            "enum": [
              "pin",
              "otp_preferred",
              "otp_only"
            ],
            "description": "The strictest policy across the asked types; `pin` when `is_enabled` is false."
          },
          "cancellation_types": {
            "type": "array",
            "description": "The asked types that need an approval.",
            "items": {
              "$ref": "#/components/schemas/ApprovalType"
            }
          },
          "approvers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/EligibleApprover"
            }
          }
        }
      },
      "ApprovalPinRequest": {
        "type": "object",
        "required": [
          "pin",
          "order_id",
          "cancellation_types",
          "gate_reason"
        ],
        "properties": {
          "pin": {
            "type": "string",
            "minLength": 4,
            "maxLength": 8,
            "pattern": "^[0-9]+$",
            "description": "The manager's PIN. Never store or log it."
          },
          "approver_id": {
            "type": "string",
            "format": "uuid",
            "description": "An `approver_id` from `GET /approvals/eligible-approvers`. Check only this approver. Leave out to accept any approver whose PIN matches."
          },
          "order_id": {
            "type": "string",
            "format": "uuid",
            "description": "The `id` of the order the approval is for, an order of the outlet in `X-Outlet-ID`."
          },
          "cancellation_types": {
            "type": "array",
            "minItems": 1,
            "description": "The types the token will cover. Send the types of the action you will approve.",
            "items": {
              "$ref": "#/components/schemas/ApprovalType"
            }
          },
          "gate_reason": {
            "$ref": "#/components/schemas/ApprovalGateReason"
          }
        }
      },
      "ApprovalOtpRequest": {
        "type": "object",
        "required": [
          "approver_id",
          "order_id",
          "cancellation_types",
          "gate_reason"
        ],
        "properties": {
          "approver_id": {
            "type": "string",
            "format": "uuid",
            "description": "An `approver_id` from `GET /approvals/eligible-approvers`."
          },
          "order_id": {
            "type": "string",
            "format": "uuid",
            "description": "The `id` of the order the approval is for, an order of the outlet in `X-Outlet-ID`."
          },
          "cancellation_types": {
            "type": "array",
            "minItems": 1,
            "items": {
              "$ref": "#/components/schemas/ApprovalType"
            }
          },
          "gate_reason": {
            "$ref": "#/components/schemas/ApprovalGateReason"
          }
        }
      },
      "ApprovalOtpVerifyRequest": {
        "type": "object",
        "required": [
          "request_id",
          "code"
        ],
        "properties": {
          "request_id": {
            "type": "string",
            "format": "uuid",
            "description": "The `request_id` of `POST /approvals/otp`."
          },
          "code": {
            "type": "string",
            "minLength": 6,
            "maxLength": 6,
            "pattern": "^[0-9]{6}$",
            "description": "The code the approver received."
          }
        }
      },
      "ApprovalOtpSent": {
        "type": "object",
        "required": [
          "request_id",
          "approver_id",
          "expires_at",
          "resend_after",
          "sends_used",
          "sends_max"
        ],
        "description": "One sent code. It never carries the approver's phone number, not even masked.",
        "properties": {
          "request_id": {
            "type": "string",
            "format": "uuid"
          },
          "approver_id": {
            "type": "string",
            "format": "uuid"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "The code stops working at this time."
          },
          "resend_after": {
            "type": "string",
            "format": "date-time",
            "description": "Earliest time to send another code."
          },
          "sends_used": {
            "type": "integer"
          },
          "sends_max": {
            "type": "integer"
          }
        }
      },
      "ApprovalGrant": {
        "type": "object",
        "required": [
          "approval_token",
          "approver_id",
          "approver_name",
          "order_id",
          "expires_at",
          "method",
          "cancellation_types"
        ],
        "properties": {
          "approval_token": {
            "type": "string",
            "description": "Send it as `approval_token` on the return, refund or exchange. Valid for one order and one user, for the listed types, once."
          },
          "approver_id": {
            "type": "string",
            "format": "uuid"
          },
          "approver_name": {
            "type": "string"
          },
          "order_id": {
            "type": "string",
            "format": "uuid"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time"
          },
          "method": {
            "type": "string",
            "enum": [
              "pin",
              "otp",
              "pin_fallback"
            ],
            "description": "How the approval was given. `pin_fallback` is a PIN accepted because no code could be sent."
          },
          "cancellation_types": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ApprovalType"
            }
          }
        }
      },
      "PostSaleReason": {
        "type": "string",
        "enum": [
          "defective",
          "wrong_item",
          "customer_change_mind",
          "overcharge",
          "other"
        ]
      },
      "ReturnLineInput": {
        "type": "object",
        "required": [
          "order_line_id",
          "quantity"
        ],
        "description": "A line of the original order and how many units go back. It is the returned line of a return, a return quote and an exchange.",
        "properties": {
          "order_line_id": {
            "type": "string",
            "format": "uuid",
            "description": "The `id` of a line of the order (`lines[].id` of `GET /orders/{id}`)."
          },
          "quantity": {
            "type": "integer",
            "format": "int64",
            "minimum": 1
          }
        }
      },
      "RefundTenderInput": {
        "type": "object",
        "required": [
          "payment_method_id",
          "amount"
        ],
        "properties": {
          "payment_method_id": {
            "type": "string",
            "format": "uuid",
            "description": "A `payment_method_id` from `eligible_refund_tenders` of `POST /returns/quote`, or the cash method from `GET /payment-methods`."
          },
          "amount": {
            "type": "integer",
            "format": "int64",
            "description": "In whole units of `currency`. An amount that is not above 0 answers `422 REFUND_TENDER_INVALID_AMOUNT`."
          },
          "reference": {
            "type": "string",
            "maxLength": 100
          }
        }
      },
      "ReturnQuoteRequest": {
        "type": "object",
        "required": [
          "order_id",
          "lines"
        ],
        "properties": {
          "order_id": {
            "type": "string",
            "format": "uuid",
            "description": "The `id` of the paid order."
          },
          "refund_tenders": {
            "type": "array",
            "maxItems": 10,
            "description": "Optional. When sent, each tender must be an eligible tender of the order, else `422 REFUND_TENDER_NOT_ELIGIBLE`.",
            "items": {
              "$ref": "#/components/schemas/RefundTenderInput"
            }
          },
          "lines": {
            "type": "array",
            "minItems": 1,
            "items": {
              "$ref": "#/components/schemas/ReturnLineInput"
            }
          }
        }
      },
      "ReturnCreateRequest": {
        "type": "object",
        "required": [
          "order_id",
          "reason",
          "refund_tenders",
          "lines"
        ],
        "properties": {
          "order_id": {
            "type": "string",
            "format": "uuid",
            "description": "The `id` of the paid order."
          },
          "reason": {
            "$ref": "#/components/schemas/PostSaleReason"
          },
          "note": {
            "type": "string",
            "maxLength": 500
          },
          "refund_tenders": {
            "type": "array",
            "minItems": 1,
            "maxItems": 10,
            "description": "Where the money goes. Each tender names an eligible tender of the order.",
            "items": {
              "$ref": "#/components/schemas/RefundTenderInput"
            }
          },
          "approval_token": {
            "type": "string",
            "description": "From `POST /approvals/pin` or `POST /approvals/otp/verify`. Needed when the outlet requires an approval for returns."
          },
          "lines": {
            "type": "array",
            "minItems": 1,
            "items": {
              "$ref": "#/components/schemas/ReturnLineInput"
            }
          }
        }
      },
      "RefundCreateRequest": {
        "type": "object",
        "required": [
          "order_id",
          "refund_amount",
          "reason",
          "refund_tenders"
        ],
        "properties": {
          "order_id": {
            "type": "string",
            "format": "uuid",
            "description": "The `id` of the paid order."
          },
          "refund_amount": {
            "type": "integer",
            "format": "int64",
            "minimum": 1,
            "description": "The money to give back, in whole units of `currency`."
          },
          "reason": {
            "$ref": "#/components/schemas/PostSaleReason"
          },
          "note": {
            "type": "string",
            "maxLength": 500
          },
          "refund_tenders": {
            "type": "array",
            "minItems": 1,
            "maxItems": 10,
            "description": "Where the money goes. Each tender names an eligible tender of the order.",
            "items": {
              "$ref": "#/components/schemas/RefundTenderInput"
            }
          },
          "approval_token": {
            "type": "string",
            "description": "Needed when the outlet requires an approval for refunds."
          }
        }
      },
      "SelectedOption": {
        "type": "object",
        "required": [
          "group_id",
          "option_id"
        ],
        "properties": {
          "group_id": {
            "type": "string",
            "format": "uuid"
          },
          "option_id": {
            "type": "string",
            "format": "uuid"
          }
        }
      },
      "ExchangeReturnedLine": {
        "$ref": "#/components/schemas/ReturnLineInput"
      },
      "ExchangeReplacementLine": {
        "type": "object",
        "required": [
          "quantity"
        ],
        "description": "A line sold in place of the returned ones, shaped like a `POST /orders` line. A catalog line names `variant_id` and is sold at the catalog price. A manual line names a `manual_*` `sku` with `product_name` and `manual_price` and cannot carry `modifiers` or `preferences`. Any other `sku` is `400 VALIDATION_FAILED`.",
        "properties": {
          "variant_id": {
            "type": "string",
            "format": "uuid",
            "description": "A variant `id` from `GET /products`."
          },
          "sku": {
            "type": "string",
            "maxLength": 100,
            "description": "A `manual_*` sku, manual line only."
          },
          "product_name": {
            "type": "string",
            "maxLength": 255,
            "description": "The name of a manual line. Required there, not used on a catalog line."
          },
          "quantity": {
            "type": "integer",
            "format": "int64",
            "minimum": 1
          },
          "manual_price": {
            "type": "integer",
            "format": "int64",
            "minimum": 0,
            "description": "Manual line only; whole units of `currency`."
          },
          "modifiers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SelectedOption"
            }
          },
          "preferences": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SelectedOption"
            }
          }
        }
      },
      "ServiceFeeSelection": {
        "type": "object",
        "required": [
          "fee_category_id",
          "fee_option_id"
        ],
        "properties": {
          "fee_category_id": {
            "type": "string",
            "format": "uuid"
          },
          "fee_option_id": {
            "type": "string",
            "format": "uuid"
          }
        }
      },
      "ExchangeTenderInput": {
        "type": "object",
        "required": [
          "payment_method_id",
          "amount"
        ],
        "description": "Settles the difference.",
        "properties": {
          "payment_method_id": {
            "type": "string",
            "format": "uuid",
            "description": "A `cash` or `custom` method from `GET /payment-methods`; the Terms Payment method; or the order source's own partner rail.\n\nThe Terms Payment method: the `payment_method_id` of the order's own Terms Payment tender (`GET /orders/{id}`), because `GET /payment-methods` does not list Terms.\n\nThe order source's own partner rail: the `Paid on <display_name>` method of the order source that sent the order with `POST /orders`, usable only by the app that owns that order source. `GET /payment-methods` does not list it; its id is the `payment_method_id` on the order's tender, but only when the order was paid on that rail (sent without `tenders`; an order sent with its own `tenders` has no rail tender).\n\nThe rail of another order source, the order's rail named by another app, a rail on an order no source sent, and any other type are `400 VALIDATION_FAILED` with code `NOT_ALLOWED`.\n\nA method that is inactive, hidden or not offered at this outlet is `400 VALIDATION_FAILED` with code `NOT_ALLOWED`, Terms Payment included; the own partner rail is hidden by design and is not refused for that.\n"
          },
          "amount": {
            "type": "integer",
            "format": "int64",
            "minimum": 1,
            "description": "In whole units of `currency`."
          },
          "reference": {
            "type": "string"
          }
        }
      },
      "ExchangeQuoteRequest": {
        "type": "object",
        "required": [
          "order_id",
          "returned_lines",
          "replacement_lines"
        ],
        "properties": {
          "order_id": {
            "type": "string",
            "format": "uuid",
            "description": "The `id` of the paid order."
          },
          "returned_lines": {
            "type": "array",
            "minItems": 1,
            "items": {
              "$ref": "#/components/schemas/ExchangeReturnedLine"
            }
          },
          "replacement_lines": {
            "type": "array",
            "minItems": 1,
            "items": {
              "$ref": "#/components/schemas/ExchangeReplacementLine"
            }
          },
          "service_fees": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ServiceFeeSelection"
            }
          },
          "excluded_service_fee_category_ids": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            }
          },
          "tenders": {
            "type": "array",
            "description": "Optional. Without it the payment fee is not priced. The quote does not check the total; `POST /exchanges` does.",
            "items": {
              "$ref": "#/components/schemas/ExchangeTenderInput"
            }
          }
        }
      },
      "ExchangeCreateRequest": {
        "type": "object",
        "required": [
          "order_id",
          "reason",
          "returned_lines",
          "replacement_lines"
        ],
        "properties": {
          "order_id": {
            "type": "string",
            "format": "uuid",
            "description": "The `id` of the paid order."
          },
          "reason": {
            "$ref": "#/components/schemas/PostSaleReason"
          },
          "note": {
            "type": "string",
            "maxLength": 500
          },
          "approval_token": {
            "type": "string",
            "description": "Needed when the outlet requires an approval for exchanges."
          },
          "returned_lines": {
            "type": "array",
            "minItems": 1,
            "items": {
              "$ref": "#/components/schemas/ExchangeReturnedLine"
            }
          },
          "replacement_lines": {
            "type": "array",
            "minItems": 1,
            "items": {
              "$ref": "#/components/schemas/ExchangeReplacementLine"
            }
          },
          "service_fees": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ServiceFeeSelection"
            }
          },
          "excluded_service_fee_category_ids": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            }
          },
          "tenders": {
            "type": "array",
            "description": "Depends on `delta_amount` and the merchant's exchange settings. The replacement costs more: the tenders add up to `delta_amount`. The replacement costs less and the merchant refunds the difference: exactly one tender, equal to `refund_amount`, never QRIS. The merchant keeps the difference, or the totals are equal: no tenders (total 0). Any other total answers `422 UNPROCESSABLE`.\n",
            "items": {
              "$ref": "#/components/schemas/ExchangeTenderInput"
            }
          },
          "expected_payment_fee": {
            "type": "integer",
            "format": "int64",
            "minimum": 0,
            "description": "The `payment_fee` of your quote. A different fee answers `409 CART_REPRICED`."
          }
        }
      },
      "ReturnQuoteLine": {
        "type": "object",
        "required": [
          "order_line_id",
          "sku",
          "quantity",
          "refund_amount"
        ],
        "properties": {
          "order_line_id": {
            "type": "string",
            "format": "uuid"
          },
          "sku": {
            "type": "string"
          },
          "quantity": {
            "type": "integer",
            "format": "int64"
          },
          "refund_amount": {
            "type": "integer",
            "format": "int64",
            "description": "In whole units of `currency`."
          }
        }
      },
      "EligibleRefundTender": {
        "type": "object",
        "required": [
          "payment_method_id",
          "payment_method_name",
          "paid_amount",
          "refunded_amount",
          "refundable"
        ],
        "description": "One payment method of the original order and how much of it can still be refunded. The cash method is always in the list. On a Terms Payment order the list holds the tenders of its settlement payments in place of the Terms Payment line.",
        "properties": {
          "payment_method_id": {
            "type": "string",
            "nullable": true,
            "description": "The id to send in `refund_tenders`. Null only for a tender recorded without a payment method (`reason: legacy_tender`)."
          },
          "payment_method_name": {
            "type": "string"
          },
          "paid_amount": {
            "type": "integer",
            "format": "int64"
          },
          "refunded_amount": {
            "type": "integer",
            "format": "int64"
          },
          "refundable": {
            "type": "boolean"
          },
          "reason": {
            "type": "string",
            "enum": [
              "legacy_tender",
              "integration_qris",
              "points",
              "deposit",
              "customer_balance",
              "partner_original_tender"
            ],
            "description": "Present only when `refundable` is false. Why a group is not refundable: `legacy_tender` (recorded without a payment method id; refund it in POS, or to another eligible tender), `integration_qris`, `points`, `deposit`, `customer_balance` or `partner_original_tender` (the Cash group of a partner order that paid no cash: it is refunded to its original tender, never in cash)."
          },
          "settlement_document_numbers": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "SuggestedRefundTender": {
        "type": "object",
        "required": [
          "payment_method_id",
          "amount"
        ],
        "description": "One row of the suggested split of a full refund, shaped like an item of `refund_tenders`. Only groups with a `payment_method_id`. When the order has a `legacy_tender` group, the rows do not add up to `refund_amount`: add another eligible tender for the rest, or refund in POS.",
        "properties": {
          "payment_method_id": {
            "type": "string",
            "format": "uuid"
          },
          "amount": {
            "type": "integer",
            "format": "int64"
          }
        }
      },
      "ReturnQuote": {
        "type": "object",
        "required": [
          "refund_amount",
          "refund_rounding",
          "payment_fee",
          "tip_refund_amount",
          "is_full_return",
          "lines",
          "is_full_refund",
          "eligible_refund_tenders",
          "suggested_refund_tenders"
        ],
        "description": "All amounts are integers in whole units of `currency`. `refund_amount` is what the customer gets back, including `payment_fee`, `refund_rounding` and `tip_refund_amount`.",
        "properties": {
          "refund_amount": {
            "type": "integer",
            "format": "int64"
          },
          "refund_rounding": {
            "type": "integer",
            "format": "int64"
          },
          "payment_fee": {
            "type": "integer",
            "format": "int64"
          },
          "tip_refund_amount": {
            "type": "integer",
            "format": "int64"
          },
          "is_full_return": {
            "type": "boolean",
            "description": "This return takes back every unit still on the order (the payment fee comes back)."
          },
          "is_full_refund": {
            "type": "boolean",
            "description": "This is a full return of an order no return, refund or exchange touched yet. Only then is `suggested_refund_tenders` filled."
          },
          "lines": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ReturnQuoteLine"
            }
          },
          "eligible_refund_tenders": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/EligibleRefundTender"
            }
          },
          "suggested_refund_tenders": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SuggestedRefundTender"
            }
          }
        }
      },
      "Refund": {
        "type": "object",
        "required": [
          "id",
          "outlet_id",
          "document_number",
          "order_id",
          "currency",
          "refund_amount",
          "tip_refund_amount",
          "reason",
          "note",
          "approver_id",
          "cashier_id",
          "created_at",
          "refund_tenders",
          "is_full_refund"
        ],
        "description": "All amounts are integers in whole units of `currency`.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "outlet_id": {
            "type": "string",
            "format": "uuid"
          },
          "document_number": {
            "type": "string"
          },
          "order_id": {
            "type": "string",
            "format": "uuid"
          },
          "currency": {
            "type": "string",
            "example": "IDR"
          },
          "refund_amount": {
            "type": "integer",
            "format": "int64",
            "description": "The money given back."
          },
          "tip_refund_amount": {
            "type": "integer",
            "format": "int64"
          },
          "reason": {
            "type": "string"
          },
          "note": {
            "type": "string"
          },
          "approver_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true,
            "description": "Null when no approval was needed."
          },
          "cashier_id": {
            "type": "string",
            "description": "The acting user (a user id). A return or refund your application made without `X-User-ID` on its own partner order shows `system:integration-<source_code>` instead (MPOS-2389)."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "refund_tenders": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ReturnTender"
            }
          },
          "is_full_refund": {
            "type": "boolean"
          }
        }
      },
      "ServiceFeeLine": {
        "type": "object",
        "required": [
          "fee_category_id",
          "fee_category_name",
          "amount",
          "tax_position"
        ],
        "properties": {
          "fee_category_id": {
            "type": "string",
            "format": "uuid"
          },
          "fee_category_name": {
            "type": "string"
          },
          "amount": {
            "type": "integer",
            "format": "int64"
          },
          "tax_position": {
            "type": "string"
          }
        }
      },
      "ExchangeQuote": {
        "type": "object",
        "required": [
          "return_total",
          "tip_refund_amount",
          "replacement_subtotal",
          "tax",
          "service_fee",
          "service_fee_lines",
          "replacement_total",
          "delta_amount",
          "variance_amount",
          "refund_amount",
          "payment_fee",
          "grand_total"
        ],
        "description": "All amounts are integers in whole units of `currency`. `grand_total` is `delta_amount` plus `payment_fee`, what the customer pays. `refund_amount` is what goes back to the customer.",
        "properties": {
          "return_total": {
            "type": "integer",
            "format": "int64"
          },
          "tip_refund_amount": {
            "type": "integer",
            "format": "int64"
          },
          "replacement_subtotal": {
            "type": "integer",
            "format": "int64"
          },
          "tax": {
            "type": "integer",
            "format": "int64"
          },
          "service_fee": {
            "type": "integer",
            "format": "int64"
          },
          "service_fee_lines": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ServiceFeeLine"
            }
          },
          "replacement_total": {
            "type": "integer",
            "format": "int64"
          },
          "delta_amount": {
            "type": "integer",
            "format": "int64"
          },
          "variance_amount": {
            "type": "integer",
            "format": "int64"
          },
          "refund_amount": {
            "type": "integer",
            "format": "int64"
          },
          "payment_fee": {
            "type": "integer",
            "format": "int64"
          },
          "grand_total": {
            "type": "integer",
            "format": "int64"
          }
        }
      },
      "ExchangeOptionSelection": {
        "type": "object",
        "required": [
          "group_type",
          "group_id",
          "group_name",
          "option_id",
          "option_name",
          "unit_price",
          "extended_amount"
        ],
        "properties": {
          "group_type": {
            "type": "string"
          },
          "group_id": {
            "type": "string",
            "format": "uuid"
          },
          "group_name": {
            "type": "string"
          },
          "option_id": {
            "type": "string",
            "format": "uuid"
          },
          "option_name": {
            "type": "string"
          },
          "unit_price": {
            "type": "integer",
            "format": "int64"
          },
          "extended_amount": {
            "type": "integer",
            "format": "int64"
          }
        }
      },
      "ExchangeLine": {
        "type": "object",
        "required": [
          "id",
          "leg",
          "line_type",
          "variant_id",
          "sku",
          "product_name",
          "variant_name",
          "quantity",
          "unit_price",
          "option_total",
          "total",
          "order_line_id",
          "option_selections"
        ],
        "description": "One line of an exchange. `unit_price` includes the options; `option_total` is their share of it.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "leg": {
            "type": "string",
            "enum": [
              "returned",
              "replacement"
            ]
          },
          "line_type": {
            "type": "string",
            "enum": [
              "catalog",
              "manual"
            ]
          },
          "variant_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "sku": {
            "type": "string"
          },
          "product_name": {
            "type": "string"
          },
          "variant_name": {
            "type": "string"
          },
          "quantity": {
            "type": "integer",
            "format": "int64"
          },
          "unit_price": {
            "type": "integer",
            "format": "int64"
          },
          "option_total": {
            "type": "integer",
            "format": "int64"
          },
          "total": {
            "type": "integer",
            "format": "int64"
          },
          "order_line_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "option_selections": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ExchangeOptionSelection"
            }
          }
        }
      },
      "Exchange": {
        "type": "object",
        "required": [
          "id",
          "outlet_id",
          "document_number",
          "order_id",
          "replacement_order_id",
          "currency",
          "reason",
          "note",
          "return_total",
          "tip_refund_amount",
          "replacement_total",
          "delta_amount",
          "variance_amount",
          "refund_amount",
          "delta_payment_method_name",
          "approver_id",
          "cashier_id",
          "created_at",
          "lines"
        ],
        "description": "A recorded exchange. All amounts are integers in whole units of `currency`.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "outlet_id": {
            "type": "string",
            "format": "uuid"
          },
          "document_number": {
            "type": "string"
          },
          "order_id": {
            "type": "string",
            "format": "uuid"
          },
          "replacement_order_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true,
            "description": "Null when no replacement order was made."
          },
          "currency": {
            "type": "string",
            "example": "IDR"
          },
          "reason": {
            "type": "string"
          },
          "note": {
            "type": "string"
          },
          "return_total": {
            "type": "integer",
            "format": "int64"
          },
          "tip_refund_amount": {
            "type": "integer",
            "format": "int64"
          },
          "replacement_total": {
            "type": "integer",
            "format": "int64"
          },
          "delta_amount": {
            "type": "integer",
            "format": "int64"
          },
          "variance_amount": {
            "type": "integer",
            "format": "int64",
            "description": "The difference the merchant kept when the replacement cost less."
          },
          "refund_amount": {
            "type": "integer",
            "format": "int64",
            "description": "The difference paid back to the customer when the replacement cost less; 0 when it was kept or the customer paid."
          },
          "delta_payment_method_name": {
            "type": "string",
            "description": "How the difference was settled. When the difference was refunded to the customer it is the method code: `cash`, `transfer`, `terms_payment` or `ewallet` (`ewallet` when the app that owns the order source paid it back on its own partner rail). Otherwise it is the name of the payment method of the replacement order's tender (several names are joined with `, `). It is an empty string when no method was recorded. The real tenders of a paid difference are on `GET /orders/{replacement_order_id}`."
          },
          "approver_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true,
            "description": "Null when no approval was needed."
          },
          "cashier_id": {
            "type": "string",
            "format": "uuid",
            "description": "The acting user."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "lines": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ExchangeLine"
            }
          }
        }
      },
      "UpsertOrdersWebhookEndpoint": {
        "type": "object",
        "required": [
          "url",
          "events"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "example": "https://partner.example/hooks/pos",
            "description": "An absolute `https` address on port 443."
          },
          "events": {
            "type": "array",
            "minItems": 1,
            "description": "The events to receive. Replaces the previous list.",
            "items": {
              "type": "string",
              "enum": [
                "order.paid",
                "order.payment_updated",
                "order.returned",
                "order.refunded",
                "order.exchanged",
                "order.accepted",
                "order.rejected",
                "order.preparing",
                "order.ready",
                "order.served",
                "order.completed",
                "order.cancelled"
              ]
            }
          },
          "payload": {
            "type": "string",
            "enum": [
              "thin",
              "full"
            ],
            "default": "thin",
            "description": "Defaults to `thin`."
          }
        }
      },
      "UpsertProductsWebhookEndpoint": {
        "type": "object",
        "required": [
          "url",
          "events"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "example": "https://partner.example/hooks/pos",
            "description": "An absolute `https` address on port 443."
          },
          "events": {
            "type": "array",
            "minItems": 1,
            "description": "The events to receive. Replaces the previous list.",
            "items": {
              "type": "string",
              "enum": [
                "product.created",
                "product.updated",
                "product.deleted",
                "sku.changed"
              ]
            }
          },
          "payload": {
            "type": "string",
            "enum": [
              "thin",
              "full"
            ],
            "default": "thin",
            "description": "Defaults to `thin`."
          }
        }
      },
      "UpsertCollectionsWebhookEndpoint": {
        "type": "object",
        "required": [
          "url",
          "events"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "example": "https://partner.example/hooks/pos",
            "description": "An absolute `https` address on port 443."
          },
          "events": {
            "type": "array",
            "minItems": 1,
            "description": "The events to receive. Replaces the previous list.",
            "items": {
              "type": "string",
              "enum": [
                "collection.created",
                "collection.updated",
                "collection.deleted",
                "collection.reordered"
              ]
            }
          },
          "payload": {
            "type": "string",
            "enum": [
              "thin",
              "full"
            ],
            "default": "thin",
            "description": "Defaults to `thin`."
          }
        }
      },
      "UpsertInventoryWebhookEndpoint": {
        "type": "object",
        "required": [
          "url",
          "events"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "example": "https://partner.example/hooks/pos",
            "description": "An absolute `https` address on port 443."
          },
          "events": {
            "type": "array",
            "minItems": 1,
            "description": "The events to receive. Replaces the previous list.",
            "items": {
              "type": "string",
              "enum": [
                "inventory.availability_changed"
              ]
            }
          },
          "payload": {
            "type": "string",
            "enum": [
              "thin"
            ],
            "default": "thin",
            "description": "Only `thin` is accepted. Defaults to `thin`."
          }
        }
      },
      "WebhookEvent": {
        "type": "object",
        "description": "The body of every event Mekari POS sends. See the Webhooks section for headers, signing and retries.",
        "required": [
          "id",
          "type",
          "schema_version",
          "occurred_at",
          "company_id",
          "outlet_id",
          "data"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Identifies the change. Stable across retries and endpoints; dedupe on it."
          },
          "type": {
            "type": "string",
            "enum": [
              "order.paid",
              "order.payment_updated",
              "order.returned",
              "order.refunded",
              "order.exchanged",
              "order.accepted",
              "order.rejected",
              "order.preparing",
              "order.ready",
              "order.served",
              "order.completed",
              "order.cancelled",
              "product.created",
              "product.updated",
              "product.deleted",
              "sku.changed",
              "collection.created",
              "collection.updated",
              "collection.deleted",
              "collection.reordered",
              "inventory.availability_changed"
            ]
          },
          "schema_version": {
            "type": "integer",
            "example": 1
          },
          "occurred_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-10-12T03:15:00Z",
            "description": "Business time in UTC, whole seconds."
          },
          "company_id": {
            "type": "string",
            "description": "The merchant's company id, as known from Mekari SSO."
          },
          "outlet_id": {
            "type": "string",
            "nullable": true,
            "description": "The outlet of the change; null for company-wide events."
          },
          "data": {
            "description": "Thin by default. With `payload` set to `full`, the object `GET /orders/{id}`, `GET /products/{id}` or `GET /collections/{id}` returns in its `data` (an order of `order.returned`, `order.refunded` or `order.exchanged` also has `return_id`, `refund_id` or `exchange_id`), with empty image URLs and no stock per outlet.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/WebhookOrderData"
              },
              {
                "$ref": "#/components/schemas/WebhookKitchenData"
              },
              {
                "$ref": "#/components/schemas/WebhookProductData"
              },
              {
                "$ref": "#/components/schemas/WebhookSkuData"
              },
              {
                "$ref": "#/components/schemas/WebhookCollectionData"
              },
              {
                "$ref": "#/components/schemas/WebhookAvailabilityData"
              }
            ]
          }
        }
      },
      "WebhookOrderData": {
        "type": "object",
        "description": "Thin `data` of `order.paid`, `order.payment_updated`, `order.returned`, `order.refunded` and `order.exchanged`. A return, refund or exchange names the original order, its stored points and its own record id. `order.paid` and `order.payment_updated` add `payment_status` and `amount_owed`.",
        "required": [
          "order_id",
          "status",
          "sales_type",
          "source",
          "points_earned",
          "points_redeemed",
          "updated_at"
        ],
        "properties": {
          "order_id": {
            "type": "string",
            "description": "The id of the order. For a return, refund or exchange, the id of the original order."
          },
          "return_id": {
            "type": "string",
            "description": "The id of the return. Only on `order.returned`."
          },
          "refund_id": {
            "type": "string",
            "description": "The id of the refund. Only on `order.refunded`."
          },
          "exchange_id": {
            "type": "string",
            "description": "The id of the exchange. Only on `order.exchanged`."
          },
          "status": {
            "type": "string",
            "nullable": true,
            "description": "The sale status."
          },
          "sales_type": {
            "type": "string",
            "nullable": true
          },
          "source": {
            "type": "string",
            "nullable": true,
            "description": "Where the order came from: `pos`, `grabfood`, `gofood` or the `source_code` of an application that creates orders."
          },
          "points_earned": {
            "type": "integer",
            "format": "int64",
            "nullable": true,
            "description": "The points stored on the sale when the event was written."
          },
          "points_redeemed": {
            "type": "integer",
            "format": "int64",
            "nullable": true,
            "description": "The points stored on the sale when the event was written."
          },
          "payment_status": {
            "type": "string",
            "nullable": true,
            "enum": [
              "unpaid",
              "partially_paid",
              "overdue",
              "settled"
            ],
            "description": "Only on `order.paid` and `order.payment_updated`: where the payment stands when the event was written, as `payment_status` on the order. Null only on an event written before this field existed."
          },
          "amount_owed": {
            "type": "integer",
            "format": "int64",
            "nullable": true,
            "description": "Only on `order.paid` and `order.payment_updated`: what the buyer still owes, in whole units of `currency`; `0` when settled. Null only on an event written before this field existed."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "WebhookKitchenData": {
        "type": "object",
        "description": "Thin `data` of the kitchen events (`order.accepted`, `order.rejected`, `order.preparing`, `order.ready`, `order.served`, `order.completed`, `order.cancelled`). It reports the kitchen ticket. Read the order with `GET /orders/{id}`.",
        "required": [
          "order_id",
          "kitchen_order_id",
          "kitchen_status",
          "sales_type",
          "updated_at"
        ],
        "properties": {
          "order_id": {
            "type": "string",
            "nullable": true,
            "description": "Null until a POS order exists for the ticket, for example while a QR order is not paid. A set value does not mean the order is paid; `order.paid` says the sale is completed at the till, and for a Terms sale `payment_status` says whether it is paid."
          },
          "kitchen_order_id": {
            "type": "string"
          },
          "kitchen_status": {
            "type": "string",
            "enum": [
              "accepted",
              "rejected",
              "preparing",
              "ready",
              "served",
              "completed",
              "cancelled"
            ],
            "description": "Equal to the event suffix."
          },
          "sales_type": {
            "type": "string",
            "nullable": true
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "WebhookProductData": {
        "type": "object",
        "description": "Thin `data` of `product.created`, `product.updated` and `product.deleted`.",
        "required": [
          "product_id"
        ],
        "properties": {
          "product_id": {
            "type": "string"
          }
        }
      },
      "WebhookSkuData": {
        "type": "object",
        "description": "Thin `data` of `sku.changed`. This event is always thin.",
        "required": [
          "product_id",
          "variant_id",
          "sku"
        ],
        "properties": {
          "product_id": {
            "type": "string",
            "nullable": true,
            "description": "Null when the variant no longer exists."
          },
          "variant_id": {
            "type": "string"
          },
          "sku": {
            "type": "string"
          }
        }
      },
      "WebhookCollectionData": {
        "type": "object",
        "description": "Thin `data` of the collection events.",
        "required": [
          "collection_id"
        ],
        "properties": {
          "collection_id": {
            "type": "string"
          }
        }
      },
      "WebhookAvailabilityData": {
        "type": "object",
        "description": "`data` of `inventory.availability_changed`. Always thin, and never a quantity.",
        "required": [
          "product_id",
          "variant_id",
          "available"
        ],
        "properties": {
          "product_id": {
            "type": "string"
          },
          "variant_id": {
            "type": "string"
          },
          "available": {
            "type": "boolean",
            "description": "True when the item is in stock now."
          }
        }
      },
      "WebhookSecret": {
        "type": "object",
        "required": [
          "secret",
          "key_version"
        ],
        "properties": {
          "secret": {
            "type": "string",
            "description": "The signing secret, base64url. Shown only in the response that issues it."
          },
          "key_version": {
            "type": "integer",
            "description": "Sent as `X-Mekari-Key-Version` on deliveries signed with this secret."
          }
        }
      },
      "WebhookEndpoint": {
        "type": "object",
        "required": [
          "key",
          "url",
          "events",
          "payload",
          "status",
          "consecutive_failures",
          "active_key_version",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "key": {
            "type": "string",
            "enum": [
              "orders",
              "products",
              "collections",
              "inventory"
            ]
          },
          "url": {
            "type": "string",
            "format": "uri"
          },
          "events": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "payload": {
            "type": "string",
            "enum": [
              "thin",
              "full"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "paused",
              "disabled"
            ],
            "description": "`paused` after 10 consecutive failed deliveries, `disabled` after a `DELETE`; setting the endpoint again makes it `active`."
          },
          "consecutive_failures": {
            "type": "integer",
            "description": "Deliveries that failed for good since the last success."
          },
          "active_key_version": {
            "type": "integer",
            "description": "The version of the secret that signs new deliveries."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "secret": {
            "allOf": [
              {
                "$ref": "#/components/schemas/WebhookSecret"
              }
            ],
            "description": "Present only when a signing secret was issued by this `PUT`."
          }
        }
      },
      "WebhookTestResult": {
        "type": "object",
        "required": [
          "delivered",
          "http_status",
          "error"
        ],
        "properties": {
          "delivered": {
            "type": "boolean",
            "description": "True when the receiver answered with a success status."
          },
          "http_status": {
            "type": "integer",
            "nullable": true,
            "description": "The status the receiver sent; null when it sent none."
          },
          "error": {
            "type": "string",
            "nullable": true,
            "description": "A short cause, set only when the receiver gave no answer (refused destination, transport failure or timeout); null when the receiver answered, whether with success or not, because http_status then carries its status."
          }
        }
      }
    },
    "responses": {
      "Error": {
        "description": "Error envelope",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "CustomerDetail": {
        "description": "The customer",
        "content": {
          "application/json": {
            "schema": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/Envelope"
                },
                {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/CustomerDetail"
                    }
                  }
                }
              ]
            }
          }
        }
      },
      "Collection": {
        "description": "The collection",
        "content": {
          "application/json": {
            "schema": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/Envelope"
                },
                {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Collection"
                    }
                  }
                }
              ]
            }
          }
        }
      },
      "Product": {
        "description": "The product",
        "content": {
          "application/json": {
            "schema": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/Envelope"
                },
                {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Product"
                    }
                  }
                }
              ]
            }
          }
        }
      }
    }
  }
}
