API reference

The Malapos REST API. All resources are scoped to your workspace (accountId, derived from your Huudis identity) — you never pass it yourself.

Base URL

https://malapos.com/api/v1

Authentication

The API accepts two auth paths:

  • Bearer token — a Huudis-issued JWT:

    Authorization: Bearer <jwt>
    
  • Browser session cookie — the dashboard's BFF cookie, set on sign-in. Server-side calls from the app use this path.

Either way the request resolves to your workspace. There are no separate per-workspace API keys in v1; programmatic callers use a Huudis Bearer token (see SDKs & CLI). Requests without a valid credential get 401 AUTH_REQUIRED.

GET /api/v1/health is the one unauthenticated endpoint (service status + dependency checks).

Response envelope

Every response uses the family-standard envelope:

{
  "data": { },
  "error": null,
  "meta": { "requestId": "req_...", "timestamp": "2026-01-01T00:00:00Z" }
}

On error, data is null and error carries an UPPER_SNAKE_CASE code plus a human-readable message (and sometimes param):

{
  "data": null,
  "error": { "code": "VALIDATION_ERROR", "message": "..." },
  "meta": { "requestId": "req_...", "timestamp": "..." }
}

Common codes: VALIDATION_ERROR (400), AUTH_REQUIRED (401), FORBIDDEN (403), NOT_FOUND (404), CONFLICT (409), IDEMPOTENCY_KEY_IN_USE (409), INTERNAL_ERROR (500). A paid-plan limit returns LIMIT_REACHED.

Pagination

List endpoints that grow unbounded (e.g. sales) are cursor-paginated. Pass ?limit= (1–100, default 20) and ?cursor=. The response's meta carries the next page:

"meta": { "requestId": "...", "timestamp": "...", "cursor": "<next>", "hasMore": true }

When hasMore is false, cursor is null. Smaller collections (outlets, categories, etc.) return the full set under a named key.

IDs

IDs are ULIDs with a type prefix: out_ (outlet), cat_ (category), prd_ (product), var_ (variant), mdg_/mod_ (modifier group / modifier), txn_ (transaction), shf_ (shift), sup_ (supplier), pur_ (purchase order), cus_ (customer). Money is whole IDR integers (no decimals).

Idempotency

Mutating requests accept an Idempotency-Key header. Re-sending the same key returns the original result instead of creating a duplicate; reusing a key with a different body returns IDEMPOTENCY_KEY_IN_USE.

Endpoints

Outlets — /outlets

Method Path Description
GET /outlets List outlets in the workspace
POST /outlets Create an outlet (name, optional address, phone, timezone, taxRateBps, taxInclusive, receiptHeader, receiptFooter)
GET /outlets/:id Get one outlet
PATCH /outlets/:id Update an outlet
DELETE /outlets/:id Deactivate an outlet

taxRateBps is basis points of 10000 (so 11% PPN = 1100).

Categories — /categories

Method Path Description
GET /categories List categories
POST /categories Create a category
PATCH /categories/:id Update a category
DELETE /categories/:id Delete / deactivate a category

Products — /products

Method Path Description
GET /products List products with variants
GET /products/lookup Sell-screen lookup: ?barcode= (single variant) or ?q= (name/SKU search)
POST /products Create a product with one or more variants (kind, trackStock, requiresBatch, categoryId, imageUrl)
GET /products/:id Get one product
PATCH /products/:id Update a product
DELETE /products/:id Deactivate a product
POST /products/:id/variants Add a variant
PATCH /products/:id/variants/:vid Update a variant
DELETE /products/:id/variants/:vid Deactivate a variant

A variant carries name, price, cost, sku, barcode, sortOrder.

Modifiers — /modifiers

Method Path Description
GET /modifiers List modifier groups (with their modifiers)
POST /modifiers Create a group (name, minSelect, maxSelect)
GET /modifiers/:id Get one group
PATCH /modifiers/:id Update a group
DELETE /modifiers/:id Delete a group
POST /modifiers/:id/items Add a modifier (name, price) to a group
PATCH /modifiers/:id/items/:modId Update a modifier
DELETE /modifiers/:id/items/:modId Delete a modifier
GET /modifiers/product/:productId Groups attached to a product
PUT /modifiers/product/:productId Set which groups attach to a product

Sales — /sales

Method Path Description
POST /sales Ring up a sale
GET /sales List sales (cursor-paginated); ?outletId= ?status= ?shiftId=
GET /sales/:id Full receipt (items + payments + customer)
POST /sales/:id/void Void a sale (returns stock); optional reason

POST /sales body:

{
  "outletId": "out_...",
  "shiftId": "shf_...",
  "customerId": "cus_...",
  "items": [
    {
      "variantId": "var_...",
      "quantity": 2,
      "unitPrice": 15000,
      "discount": 0,
      "modifiers": [{ "name": "Less sugar", "price": 0 }]
    }
  ],
  "orderDiscount": 0,
  "payments": [
    { "method": "CASH", "amount": 30000, "tendered": 50000 }
  ],
  "status": "COMPLETED",
  "note": null
}

status is COMPLETED or PARKED (a held bill). Payment method is one of CASH, QRIS, CARD, OTHER; for cash, tendered drives the change calculation. unitPrice is optional (defaults to the variant's catalog price). The transaction totals (subtotal, taxTotal, total, changeTotal) are computed server-side.

Inventory — /inventory

Method Path Description
GET /inventory/levels On-hand levels per outlet/variant
POST /inventory/adjust Manual stock adjustment (writes a movement)
PUT /inventory/reorder Set a variant's reorder point
POST /inventory/transfer Transfer stock between outlets
GET /inventory/movements The stock movement ledger
GET /inventory/batches List pharmacy stock batches
POST /inventory/batches Create a dated batch
GET /inventory/expiring Batches expiring soon

Shifts — /shifts

Method Path Description
GET /shifts/current The open shift for an outlet, if any
POST /shifts/open Open a shift with an openingFloat
POST /shifts/:id/close Close a shift with countedCash (reconciliation)
GET /shifts List shifts
GET /shifts/:id Get one shift

Suppliers — /suppliers

Method Path Description
GET /suppliers List suppliers
POST /suppliers Create a supplier
GET /suppliers/:id Get one supplier
PATCH /suppliers/:id Update a supplier
DELETE /suppliers/:id Deactivate a supplier

Purchase orders — /purchase-orders

Method Path Description
GET /purchase-orders List purchase orders
POST /purchase-orders Create a draft PO
GET /purchase-orders/:id Get one PO
PATCH /purchase-orders/:id Update a draft PO
POST /purchase-orders/:id/order Mark a PO as ordered
POST /purchase-orders/:id/receive Receive lines (stocks goods in; batch/expiry for pharmacy)
POST /purchase-orders/:id/cancel Cancel a PO

Customers — /customers

Method Path Description
GET /customers List / search customers
POST /customers Create a customer
GET /customers/:id Get one customer
PATCH /customers/:id Update a customer
DELETE /customers/:id Delete a customer
GET /customers/:id/loyalty A customer's loyalty ledger
POST /customers/:id/loyalty/adjust Manually adjust points
POST /customers/:id/loyalty/redeem Redeem points

Reports — /reports

Method Path Description
GET /reports/summary Sales summary for a period
GET /reports/top-products Top products (?limit=, 1–100, default 10)
GET /reports/sales-by-day Daily sales series (?days=, 1–365, default 30)
GET /reports/low-stock Variants at or below reorder point (?outletId=)

Settings — /settings

Method Path Description
GET /settings The workspace business profile (auto-created on first read)
PUT /settings Update businessName, businessType, currency

Billing — /billing

Method Path Description
GET /billing/tiers The plan catalog (public)
GET /billing Your workspace's current plan
POST /billing/checkout Start a Plugipay checkout for a paid tier
POST /billing/cancel Cancel the subscription (keeps the paid period, then lapses to free)

Tiers are Free (Rp 0), Starter (Rp 99.000/mo), Growth (Rp 199.000/mo), and Business (Rp 449.000/mo) — see the pricing page for what each includes. Paid plans are billed through Plugipay.