FitUP Open API M2M gateway reference · v1.1.0 เอกสารอ้างอิง M2M gateway · v1.1.0
Download openapi.yaml ดาวน์โหลด openapi.yaml

FitUP Open API FitUP Open API

A machine-to-machine (M2M) JSON API for connecting your system — POS, e-commerce, partner app — to a FitUP tenant. Authenticate once, then call the scoped endpoints below. API แบบ machine-to-machine (M2M) รูปแบบ JSON สำหรับเชื่อมระบบของคุณ — POS, e-commerce หรือแอปพาร์ทเนอร์ — เข้ากับผู้เช่า (tenant) ของ FitUP: ยืนยันตัวตนครั้งเดียว แล้วเรียก endpoint ตามสิทธิ์ที่ได้รับ

Overviewภาพรวม

The Open API is a gateway. It authenticates your client, checks the scope for the operation, resolves your tenant from the token — never from anything you send — and forwards the call to the platform service that owns the data (members, catalog and stock, credit wallet, payments). Requests and responses are JSON throughout. Open API ทำหน้าที่เป็นเกตเวย์: ตรวจสอบตัวตนของ client, ตรวจสิทธิ์ (scope) ของงานนั้น, ระบุ tenant จากตัว token เท่านั้น (ไม่ใช้ค่าที่ส่งมาจากฝั่งคุณ) แล้วส่งต่อไปยังบริการที่เป็นเจ้าของ ข้อมูลจริง (สมาชิก, แคตตาล็อกและสต็อก, เครดิต, การชำระเงิน) — ทุก request/response เป็น JSON

Itemหัวข้อ Valueค่า
Base URLBase URL https://<your-open-api-host> — sandbox and production hosts are issued with your credentials. — โฮสต์ sandbox และ production จะให้มาพร้อมกับ credentials ของคุณ
Content-Type application/json on every request that has a body. สำหรับทุก request ที่มี body
Auth headerHeader ยืนยันตัวตน Authorization: Bearer <access_token>
CredentialsCredentials clientId + clientSecret issued by FitUP. Keep the secret server-side — never ship it in a browser or mobile app. ออกให้โดย FitUP — เก็บ secret ไว้ฝั่งเซิร์ฟเวอร์เท่านั้น ห้ามฝังในเว็บเบราว์เซอร์ หรือแอปมือถือ
TenantTenant Bound to your client at issuance. One client sees exactly one tenant's data. ผูกกับ client ตั้งแต่ตอนออก token — หนึ่ง client เห็นข้อมูลของ tenant เดียวเท่านั้น
Specสเปก openapi.yaml — OpenAPI 3.1, the machine-readable source of truth for every shape below. — OpenAPI 3.1 เป็นต้นฉบับที่เครื่องอ่านได้ของทุกโครงสร้างข้อมูลในหน้านี้

Authenticationการยืนยันตัวตน

Exchange your client credentials for a bearer token (client-credentials grant), then send that token on every subsequent call. Tokens are valid for expires_in seconds (default 86400 = 24 hours); request a new one when it expires — there is no refresh token. Cache it, do not mint one per request. แลก credentials ของคุณเป็น bearer token (client-credentials grant) แล้วแนบ token นั้น ไปกับทุก request ถัดไป — token มีอายุ expires_in วินาที (ค่าเริ่มต้น 86400 = 24 ชั่วโมง) หมดอายุแล้วขอใหม่ (ไม่มี refresh token) แนะนำให้ cache ไว้ ไม่ต้องขอใหม่ทุก request

POST/api/oauth/token publicไม่ต้องมี token

Issue an access token for your client. The returned JWT carries your client id, tenant and granted scopes. ออก access token ให้ client ของคุณ — JWT ที่ได้จะบรรจุ client id, tenant และ scope ที่ได้รับไว้ภายใน

Requestคำขอ

curl
curl -X POST "$BASE/api/oauth/token" \
  -H 'Content-Type: application/json' \
  -d '{"clientId":"YOUR_CLIENT_ID","clientSecret":"YOUR_CLIENT_SECRET"}'

Responseการตอบกลับ · 200

200 · application/json
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 86400
}
Bad credentials always return 401 { "error": "Invalid client credentials" } — the same message for an unknown client, a wrong secret, or a deactivated client, so the endpoint cannot be used to probe which clients exist. หาก credentials ไม่ถูกต้องจะได้ 401 { "error": "Invalid client credentials" } เสมอ — ข้อความเดียวกันทั้งกรณี client ไม่มีอยู่, secret ผิด หรือ client ถูกปิดใช้งาน เพื่อไม่ให้ใช้ endpoint นี้ สืบว่ามี client ใดอยู่ในระบบ
Want to check what a token actually grants? Call GET /api/me with it to inspect your token — client id, tenant, scopes and expiry — without decoding the JWT yourself. อยากรู้ว่า token ให้สิทธิ์อะไรบ้าง? เรียก GET /api/me ด้วย token นั้นเพื่อตรวจสอบ token ของคุณ — client id, tenant, scope และวันหมดอายุ — โดยไม่ต้องถอดรหัส JWT เอง
GET/api/me any tokentoken ใดก็ได้

Tells you what the token you just sent is: the client id it was issued to, the tenant it is bound to, the scopes it grants, and when it expires. Readable with any valid token (it only ever describes your own token) — use it to confirm your grants before investigating a 403 { "error": "Insufficient scopes" }, or to decide when to request a fresh token. บอกว่า token ที่คุณเพิ่งส่งมาเป็นของอะไร: client id ที่ออก token นี้, tenant ที่ผูกไว้, scope ที่ได้รับ และเวลาหมดอายุ · อ่านได้ด้วย token ที่ใช้ได้ทุกตัว (แสดงเฉพาะข้อมูลของ token ตัวคุณเอง) — ใช้ตรวจสิทธิ์ที่ได้รับก่อนไล่หาสาเหตุของ 403 { "error": "Insufficient scopes" } หรือใช้ดูว่าควรขอ token ใหม่เมื่อไร

curl
curl "$BASE/api/me" -H "Authorization: Bearer $TOKEN"

Responseการตอบกลับ · 200

200 · application/json
{
  "clientId": "acme-pos",
  "tenantId": "tenant-123",
  "scopes": ["read:members", "read:products", "write:purchases"],
  "vatRate": 0.07,
  "isVatRegistered": true,
  "expiresAt": "2026-07-29T10:00:00Z"
}
expiresAt is RFC3339 in UTC. scopes is always an array — [] if your client has no grants yet. An expired or malformed token gets 401 here just like anywhere else, so this is also a quick "is my token still good?" check. expiresAt เป็นรูปแบบ RFC3339 เวลา UTC · scopes เป็น array เสมอ — เป็น [] หาก client ของคุณยังไม่ได้รับสิทธิ์ใด ๆ · หาก token หมดอายุ หรือไม่ถูกต้องจะได้ 401 เช่นเดียวกับ endpoint อื่น จึงใช้เช็คเร็ว ๆ ว่า "token ยังใช้ได้อยู่ไหม" ได้ด้วย
Pricing a cart before checkout. Prices from GET /api/products are VAT-inclusive, so an order-summary screen can compute every total locally — no request per line item. Read vatRate once from this endpoint, then, in the smallest currency unit (satang) with half-up rounding: lineTotal = round(unitPrice × qty) − lineDiscount, total = Σ lineTotal, vat = round(total × vatRate ÷ (1 + vatRate)). The purchase response returns the authoritative vat / vatRate for the bill — if it ever disagrees with what you displayed, the tenant changed their VAT setting after your token was issued: request a new token to pick it up. การคำนวณยอดก่อนชำระเงิน ราคาจาก GET /api/products เป็นราคาที่รวม VAT แล้ว ดังนั้นหน้าสรุปยอดคำนวณเองได้ทั้งหมด ไม่ต้องยิง API ทุกครั้งที่หยิบสินค้า อ่าน vatRate จาก endpoint นี้ครั้งเดียว แล้วคำนวณใน หน่วยสตางค์ (ปัดครึ่งขึ้น): lineTotal = round(unitPrice × qty) − lineDiscount, total = Σ lineTotal, vat = round(total × vatRate ÷ (1 + vatRate)) ตัวเลขที่ถือเป็นทางการคือ vat / vatRate ที่ได้จากการสั่งซื้อจริง — หากไม่ตรงกับที่แสดงไว้ หมายความว่า tenant เปลี่ยนการตั้งค่า VAT หลังจากออก token ให้ขอ token ใหม่เพื่อรับค่าล่าสุด

Scopesสิทธิ์ (scopes)

Every protected operation requires exactly one scope, shown on each endpoint card below. Your token only carries the scopes granted to your client; calling an endpoint without its scope returns 403 { "error": "Insufficient scopes" }. ทุก endpoint ที่ต้องยืนยันตัวตนจะต้องมี scope เดียวที่กำหนดไว้ (แสดงบนการ์ดของแต่ละ endpoint) — token ของคุณมีเพียง scope ที่ client ได้รับ หากเรียกโดยไม่มี scope นั้นจะได้ 403 { "error": "Insufficient scopes" }

Scope Grantsให้สิทธิ์
read:membersGET /api/members
read:productsGET /api/products, GET /api/product-categories
write:purchasesPOST /api/purchases
read:stockGET /api/stock, GET /api/products/{id}/stock, GET /api/products/{id}/stock/summary, GET /api/stock/lots, GET /api/stock/expiring
write:stockPOST /api/stock/sale, POST /api/stock/sale/{orderNo}/reverse
read:leadGET /api/interest-service
write:leadPOST /api/leads
write:creditPOST /api/pay-with-credit, POST /api/pay-with-qr
write:paymentsPOST /api/payments, POST /api/paylink, POST /api/recurring/schedules, POST /api/recurring/schedules/{scheduleId}/cancel
read:payments Status / lookup reads for payments, PayLink and recurring schedules. การอ่านสถานะ/ข้อมูลของ payments, PayLink และ recurring schedules
— GET /api/me and GET /api/branches need no extra scope: any valid token may read its own token details and its own tenant's branches. Call GET /api/me to see the scopes your token actually carries. GET /api/me และ GET /api/branches ไม่ต้องใช้ scope เพิ่ม — token ที่ใช้ได้ทุกตัวอ่านข้อมูลของ token ตัวเองและสาขาของ tenant ตัวเองได้ · เรียก GET /api/me เพื่อดูว่า token ของคุณมี scope อะไรบ้าง
Credit is the customer-facing name of the member's prepaid wallet (stored internally as points). Nothing else in the platform is called credit. เครดิต (credit) คือชื่อที่ใช้กับลูกค้าของกระเป๋าเงินเติมล่วงหน้าของสมาชิก (ระบบภายในเก็บเป็น points) — ไม่มีสิ่งอื่นในแพลตฟอร์มที่เรียกว่าเครดิต

Conventionsข้อกำหนดร่วม

The branch-id headerHeader branch-id

Several endpoints take a branch-id request header carrying a branch id from GET /api/branches. It is optional on members and products (omit it to span every branch), required on purchases, and defaults to 1 on leads. On products it does double duty: it filters the catalog and attaches that branch's on-hand stock to each item. หลาย endpoint รับ header branch-id ซึ่งใส่รหัสสาขาที่ได้จาก GET /api/branches — ไม่บังคับ สำหรับสมาชิกและสินค้า (ไม่ใส่ = ทุกสาขา), บังคับ สำหรับการขาย (purchases) และมีค่าเริ่มต้นเป็น 1 สำหรับ leads ส่วนกรณีสินค้า header นี้ทำสองหน้าที่: กรองแคตตาล็อก และ แนบ stock คงเหลือของสาขานั้นมาให้ในแต่ละรายการ

Paginationการแบ่งหน้า

List endpoints accept page (default 1) and pageSize (default 10) and answer with a pagination envelope: Endpoint ที่เป็นรายการรับ page (ค่าเริ่มต้น 1) และ pageSize (ค่าเริ่มต้น 10) และตอบกลับพร้อมส่วนหัว pagination:

application/json
{ "pagination": { "total": 100, "page": 1, "pageSize": 10 }, "data": [ ... ] }

IdempotencyIdempotency (เรียกซ้ำได้ปลอดภัย)

Every write that moves money or stock is idempotent by a key you supply, so a retry after a timeout never double-charges or double-deducts. Always reuse the same key when retrying the same logical operation. ทุก endpoint ที่เขียนข้อมูลเกี่ยวกับเงินหรือสต็อกเป็น idempotent ด้วยคีย์ที่ คุณ กำหนดเอง ดังนั้นการ retry หลัง timeout จะไม่ตัดเงินหรือตัดสต็อกซ้ำ — ให้ใช้คีย์เดิมทุกครั้งที่ retry งานเดียวกัน

EndpointsEndpoint Keyคีย์ On retryเมื่อ retry
Payments · PayLink · RecurringPayments · PayLink · Recurring orderNo Replays the original response with header Idempotent-Replay: true. A 409 means the first attempt is still in flight — retry shortly. ส่งผลลัพธ์เดิมกลับมาพร้อม header Idempotent-Replay: true — หากได้ 409 หมายความว่าคำขอแรกยังทำงานอยู่ ให้ลองใหม่อีกครั้งในอีกไม่นาน
Stock sale / reverseตัดสต็อก / คืนสต็อก orderNo Returns "applied": false (or "reversed": false) without touching stock again. ตอบ "applied": false (หรือ "reversed": false) โดยไม่แตะสต็อกอีก
Pay with credit / QRตัดเครดิต / QR receiptNo Returns success without deducting again (no 409). ตอบสำเร็จโดยไม่ตัดเครดิตซ้ำ (ไม่มี 409)

Tracing (X-Correlation-Id)การติดตามคำขอ (X-Correlation-Id)

Send an optional X-Correlation-Id header (any string; X-Request-Id is honored too) on any request. It is echoed on the response and propagated to every internal service, so one id stitches the whole chain in our logs — quote it to support and we find your exact request. Omit it and the gateway generates one for you and returns it. คุณสามารถแนบ header X-Correlation-Id (ข้อความอะไรก็ได้ และรองรับ X-Request-Id ด้วย) มากับ request ใดก็ได้ — ค่านี้จะถูกส่งกลับมาใน response และส่งต่อไปยังทุกบริการภายใน ทำให้ id เดียวเชื่อมทั้งสายใน log ของเรา แจ้ง id นี้กับทีมซัพพอร์ต แล้วเราจะหา request ของคุณได้ตรงตัว หากไม่ส่งมา เกตเวย์จะสร้างให้และส่งกลับใน response

Unitsหน่วย

Fieldฟิลด์ Unitหน่วย
price · amount · total Baht, major units — 89.90 means ฿89.90 (never satang). บาท (หน่วยใหญ่) — 89.90 คือ ฿89.90 ไม่ใช่สตางค์
credit Credit units (number), the member's wallet balance unit. หน่วยเครดิต (ตัวเลข) ตามยอดกระเป๋าเงินของสมาชิก
qty · quantity Units, decimals allowed up to 3 places — 2.5. จำนวนหน่วย รองรับทศนิยมไม่เกิน 3 ตำแหน่ง เช่น 2.5
createdAt · updatedAt ISO-8601 timestamps (UTC). เวลาแบบ ISO-8601 (UTC)

Errorsข้อผิดพลาด

Gateway errors are always { "error": "<message>" }. When an internal service rejects the call, its 4xx status and body are passed through unchanged. Internal 5xx, timeouts and transport failures are collapsed into 502 — that is our problem, not your payload's, and the request is safe to retry with the same idempotency key. ข้อผิดพลาดของเกตเวย์อยู่ในรูป { "error": "<message>" } เสมอ หากบริการภายในปฏิเสธคำขอ สถานะ 4xx และ body ของบริการนั้นจะถูกส่งผ่านกลับมาตรง ๆ ส่วน 5xx, timeout และปัญหาการเชื่อมต่อภายในจะถูกแปลงเป็น 502 ทั้งหมด — กรณีนี้เป็นปัญหาฝั่งเรา ไม่ใช่ข้อมูลที่คุณส่ง และปลอดภัยที่จะ retry ด้วยคีย์ idempotency เดิม

Status Meaningความหมาย Typical bodyตัวอย่าง body
400 Malformed body or an invalid field.body ผิดรูปแบบ หรือฟิลด์ไม่ถูกต้อง {"error":"invalid request body"}
401 Missing / expired token, or bad client credentials.ไม่มี token, token หมดอายุ หรือ credentials ไม่ถูกต้อง {"error":"Invalid or expired token"}
403 Token lacks the scope, or its tenant is no longer active.token ไม่มี scope ที่ต้องใช้ หรือ tenant ไม่ได้ใช้งานแล้ว {"error":"Insufficient scopes"}
404 Resource not found (passed through from the owning service).ไม่พบข้อมูล (ส่งผ่านมาจากบริการเจ้าของข้อมูล) {"error":"not found"}
409 Insufficient stock, or an idempotent request still in flight.สต็อกไม่พอ หรือคำขอ idempotent เดิมยังทำงานอยู่ {"error":"a request with this orderNo is already in progress"}
502 An internal service errored, timed out or was unreachable.บริการภายในผิดพลาด, timeout หรือติดต่อไม่ได้ {"error":"downstream unavailable"}

Membersสมาชิก

GET/api/members read:members

List the tenant's members, newest first, paginated. query is a free-text search over name, nickname, phone and member code. แสดงรายการสมาชิกของ tenant แบบแบ่งหน้า — query เป็นการค้นหาข้อความอิสระ จากชื่อ, ชื่อเล่น, เบอร์โทร และรหัสสมาชิก

Paramพารามิเตอร์ Inที่ Notesหมายเหตุ
pagequery Default 1.ค่าเริ่มต้น 1
pageSizequery Default 10.ค่าเริ่มต้น 10
queryquery Free text; trimmed and lower-cased server-side.ข้อความอิสระ ระบบตัดช่องว่างและแปลงเป็นตัวพิมพ์เล็กให้
branch-idheader Optional — only members of that branch. Omit for every branch.ไม่บังคับ — เฉพาะสมาชิกของสาขานั้น ไม่ใส่ = ทุกสาขา
curl
curl "$BASE/api/members?page=1&pageSize=10&query=somchai" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'branch-id: 2'
200 · application/json
{
  "pagination": { "total": 100, "page": 1, "pageSize": 10 },
  "data": [
    {
      "id": 123,
      "firstName": "Somchai",
      "lastName": "Jaidee",
      "nickName": "Chai",
      "phone": "+66812345678",
      "dateOfBirth": "1990-05-21T00:00:00.000Z",
      "memberCode": "M0001",
      "creditBalance": 500,
      "imageUrl": "https://cdn.example.com/avatars/123.jpg"
    }
  ]
}
imageUrl and dateOfBirth may be null. creditBalance is the member's current wallet balance. The old snake_case names (first_name, member_code, credit_balance, …) are gone — every field on this response is camelCase. imageUrl และ dateOfBirth อาจเป็น null ได้ creditBalance คือยอดกระเป๋าเงินคงเหลือปัจจุบัน — ชื่อฟิลด์แบบ snake_case เดิม (first_name, member_code, credit_balance ฯลฯ) ถูกยกเลิกแล้ว ทุกฟิลด์ใน response นี้เป็น camelCase

Productsสินค้า

GET/api/products read:products

List the tenant's catalog, paginated. Send the branch-id header to scope to one branch (that branch's products plus the ones sold at every branch) and to receive each item's on-hand stock inline. แสดงแคตตาล็อกสินค้าของ tenant แบบแบ่งหน้า — ใส่ header branch-id เพื่อจำกัดเฉพาะสาขานั้น (สินค้าของสาขานั้น บวกสินค้าที่ขายทุกสาขา) และจะได้สต็อกคงเหลือของแต่ละรายการ แนบมาด้วย

Paramพารามิเตอร์ Inที่ Notesหมายเหตุ
page · pageSizequery Default 1 / 10.ค่าเริ่มต้น 1 / 10
queryquery Free-text search over title and SKU.ค้นหาข้อความอิสระจากชื่อสินค้าและ SKU
paymentTypequery CASH or CREDIT. Prefer this camelCase name; payment_type is a deprecated alias kept for old integrations. CASH หรือ CREDIT — ให้ใช้ชื่อแบบ camelCase นี้ ส่วน payment_type เป็นชื่อเดิมที่เลิกใช้แล้ว แต่ยังรับไว้เพื่อความเข้ากันได้
categoryIdquery Only products in this category (see below).เฉพาะสินค้าในหมวดนี้ (ดูหัวข้อถัดไป)
branch-idheader Optional; also attaches stock per item.ไม่บังคับ และจะแนบ stock ของแต่ละรายการมาให้
curl
curl "$BASE/api/products?page=1&pageSize=10&paymentType=CASH&categoryId=3" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'branch-id: 2'
200 · application/json
{
  "pagination": { "total": 42, "page": 1, "pageSize": 10 },
  "data": [
    {
      "id": "8f3a1c2e-6b4d-4c19-9f77-2a1e5c0d3b81",
      "title": "Protein Shake",
      "description": "Chocolate",
      "skuCode": "PS-001",
      "paymentType": "CASH",
      "price": 89.90,
      "unit": "bottle",
      "isActive": true,
      "trackStock": true,
      "branchIds": [],
      "branchId": null,
      "imageUrl": "https://cdn.example.com/products/ps-001.jpg",
      "categoryId": 3,
      "createdAt": "2026-01-10T08:00:00.000Z",
      "updatedAt": "2026-02-01T09:30:00.000Z",
      "stock": { "branchId": 2, "quantity": 24, "reorderLevel": 6, "lowStock": false }
    }
  ]
}
branchIds lists the branches that sell the product; an empty array means every branch. (branchId is the deprecated single-branch view: it is set only when the product sits on exactly one branch.) trackStock: true means stock is deducted when it is sold; false is catalog-only. The stock object appears only when you send the branch-id header. branchIds คือรายการสาขาที่ขายสินค้านั้น ถ้าเป็น array ว่าง หมายถึงขายทุกสาขา (branchId เป็นฟิลด์เดิมที่เลิกใช้แล้ว จะมีค่าเฉพาะกรณีที่สินค้า ผูกกับสาขาเดียวเท่านั้น) · trackStock: true หมายถึงระบบจะตัดสต็อกเมื่อขาย ส่วน false คือเก็บไว้ในแคตตาล็อกเท่านั้น · ออบเจ็กต์ stock จะมีมาเฉพาะเมื่อคุณส่ง header branch-id
GET/api/product-categories read:products

The tenant's product categories (flat, one level). Pass an id as ?categoryId= on GET /api/products. Query active=true for active categories only. หมวดสินค้าของ tenant (ชั้นเดียว ไม่มีหมวดย่อย) — นำ id ไปใส่เป็น ?categoryId= ใน GET /api/products · ใส่ active=true เพื่อเอาเฉพาะหมวดที่เปิดใช้งาน

curl
curl "$BASE/api/product-categories?active=true" \
  -H "Authorization: Bearer $TOKEN"
200 · application/json
[
  {
    "id": 3,
    "nameTh": "อาหารเสริม",
    "nameEn": "Supplements",
    "name": "อาหารเสริม",
    "sortOrder": 0,
    "isActive": true,
    "isDefault": false
  },
  {
    "id": 7,
    "nameTh": "อื่นๆ",
    "nameEn": "Other",
    "name": "อื่นๆ",
    "sortOrder": 9999,
    "isActive": true,
    "isDefault": true
  }
]
Category names are bilingual: nameTh and nameEn, at least one of which is non-empty — pick the language your UI needs. name is a read-only legacy field (nameTh when set, otherwise nameEn). Every tenant always has one isDefault: true category, อื่นๆ / Other: it sorts last, cannot be deleted, and holds products created without an explicit category. ชื่อหมวดเป็นสองภาษา: nameTh และ nameEn โดยมีอย่างน้อยหนึ่งค่าที่ไม่ว่าง — เลือกใช้ภาษาที่ UI ของคุณต้องการ · name เป็นฟิลด์เดิม แบบอ่านอย่างเดียว (เท่ากับ nameTh ถ้ามี ไม่มีก็ใช้ nameEn) · ทุก tenant จะมีหมวด isDefault: true ชื่อ อื่นๆ / Other อยู่หนึ่งหมวดเสมอ เรียงท้ายสุด ลบไม่ได้ และใช้เก็บสินค้าที่สร้างโดยไม่ระบุหมวด

Branchesสาขา

GET/api/branches any tokentoken ใดก็ได้

The tenant's active branches — reference data, readable with any valid token (you only ever see your own tenant's branches). Use an id from here as the branch-id header elsewhere. สาขาที่เปิดใช้งานของ tenant — เป็นข้อมูลอ้างอิงที่อ่านได้ด้วย token ที่ใช้ได้ทุกตัว (คุณเห็นเฉพาะสาขาของ tenant ตัวเอง) · นำ id จากที่นี่ไปใส่เป็น header branch-id ในการเรียกอื่น ๆ

curl
curl "$BASE/api/branches" -H "Authorization: Bearer $TOKEN"
200 · application/json
[
  { "id": 1, "code": "HQ",    "name": "Headquarters" },
  { "id": 2, "code": "BR-02", "name": "Sukhumvit" }
]
POST/api/branch-pos/activate any tokentoken ใดก็ได้

Check that a POS machine serial is registered to a branch before you start sending it as posId on sales. Send the serial printed on the machine with the branch-id header of the branch it stands in (required) and you get the machine back; send one that is not registered to that branch and you get 400 POS_NOT_FOUND. Readable with any valid token — it only confirms a machine that is already yours, so no extra scope. ใช้ตรวจว่าหมายเลขเครื่อง (serial) ของเครื่อง POS ลงทะเบียนไว้กับสาขานั้นจริง ก่อนเริ่มส่งเป็น posId ในการขาย · ส่ง serial ที่พิมพ์อยู่บนตัวเครื่อง พร้อม header branch-id ของสาขาที่เครื่องตั้งอยู่ (บังคับ) ระบบจะคืนข้อมูล เครื่องกลับมา · หากส่งค่าที่ไม่ได้ลงทะเบียนกับสาขานั้น จะได้ 400 POS_NOT_FOUND · เรียกได้ด้วย token ที่ใช้ได้ทุกตัว เพราะเป็นเพียงการยืนยันเครื่องที่เป็นของคุณอยู่แล้ว จึงไม่ต้องมี scope เพิ่ม

curl
curl -X POST "$BASE/api/branch-pos/activate" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'branch-id: 2' \
  -d '{ "posId": "E030230003A0050" }'
200 · application/json
{
  "id": "7c1f0a94-3b52-4de6-9f18-6c0b2d8ae431",
  "posId": "E030230003A0050",
  "posNumber": "NP01",
  "branchId": 2
}
posNumber is the machine number shown on the receipt, and the prefix of that machine's tax-invoice numbers. Machines are registered by the FitUP platform admin — contact the platform to have yours added; there is no self-service registration. posNumber คือหมายเลขเครื่องที่แสดงบนใบเสร็จ และเป็นคำนำหน้าของเลข ใบกำกับภาษีที่ออกจากเครื่องนั้น · การลงทะเบียนเครื่องทำโดยผู้ดูแลระบบของ FitUP — กรุณาติดต่อทีมงานเพื่อลงทะเบียนเครื่องของคุณ ไม่สามารถลงทะเบียนเองได้

Purchasesการขาย (Purchases)

POST/api/purchases write:purchases

Record a complete sale in one call: validates the products, deducts stock for trackStock products, deducts the member's credit wallet on CREDIT payments, writes the receipt and returns the totals. The branch-id header is required. บันทึกการขายให้ครบในคำขอเดียว: ตรวจสอบสินค้า, ตัดสต็อกสำหรับสินค้าที่ trackStock, ตัดเครดิตของสมาชิกเมื่อชำระแบบ CREDIT, บันทึกใบเสร็จ และคืนยอดรวมกลับมา · header branch-id บังคับ

Fieldฟิลด์ Requiredบังคับ Notesหมายเหตุ
items[].productId✓ Product id from the catalog.รหัสสินค้าจากแคตตาล็อก
items[].qty✓ Up to 3 decimals.ทศนิยมไม่เกิน 3 ตำแหน่ง
items[].lineDiscount— Per-line discount in baht.ส่วนลดรายบรรทัด (บาท)
cashierId— Staff user id credited as the salesperson. Omit it and the sale is credited to the fitness's default cashier; send it to override that for one sale.รหัสผู้ใช้พนักงานที่บันทึกเป็นผู้ขาย — หากไม่ส่ง ระบบจะบันทึกเป็นพนักงานเริ่มต้น (default cashier) ของฟิตเนสนั้น · ส่งมาเมื่อต้องการระบุพนักงานเฉพาะรายการนั้น
posId— Serial of the POS machine ringing up the sale, as printed on the machine (e.g. E030230003A0050). Stamps that machine on the receipt and switches the tax-invoice number to the machine's own series. Omit it and the receipt carries no machine number (the branch's own series is used instead). A posId that is not a machine registered to this branch is rejected with 400 POS_NOT_FOUND. Machines are registered by the FitUP platform admin — contact the platform to have yours added; there is no self-service registration.หมายเลขเครื่อง (serial) ของเครื่อง POS ที่ใช้ขาย ตามที่พิมพ์ไว้บนตัวเครื่อง (เช่น E030230003A0050) — ระบบจะบันทึกเครื่องนี้ไว้บนใบเสร็จ และออกเลขใบกำกับภาษีจากชุดเลข ของเครื่องนั้น · หากไม่ส่ง ใบเสร็จจะไม่มีหมายเลขเครื่อง (ใช้ชุดเลขของสาขาแทน) · หากส่ง posId ที่ไม่ได้ลงทะเบียนไว้กับสาขานี้ จะได้ 400 POS_NOT_FOUND · การลงทะเบียนเครื่องทำโดยผู้ดูแล ระบบของ FitUP (commander) — กรุณาติดต่อทีมงานเพื่อลงทะเบียนเครื่องของคุณ ไม่สามารถลงทะเบียนเองได้
paymentType— CASH (default) or CREDIT.CASH (ค่าเริ่มต้น) หรือ CREDIT
memberId— Required when paymentType = CREDIT.บังคับเมื่อ paymentType = CREDIT
receiptPaymentMethod— Tender recorded on the receipt for CASH sales (default CASH); ignored for CREDIT.วิธีรับชำระที่บันทึกบนใบเสร็จสำหรับการขายแบบ CASH (ค่าเริ่มต้น CASH) — ไม่ใช้กับ CREDIT
paid— Amount tendered in baht; defaults to the total.จำนวนเงินที่รับมา (บาท) — ค่าเริ่มต้นเท่ากับยอดรวม
curl
curl -X POST "$BASE/api/purchases" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'branch-id: 2' \
  -d '{
    "items": [
      { "productId": "8f3a1c2e-6b4d-4c19-9f77-2a1e5c0d3b81", "qty": 2 },
      { "productId": "b1c2d3e4-7a88-4d0a-9c31-55e0a7f2c410", "qty": 1, "lineDiscount": 25.00 }
    ],
    "cashierId": 456,
    "paymentType": "CASH",
    "receiptPaymentMethod": "CASH",
    "paid": 250.00
  }'
Drop cashierId from that body and the sale is credited to the fitness's default cashier — you never need to know FitUP's internal staff ids. The fitness sets that staff member in FitUP's staff management, and product scopes are not granted until it is set, so in practice you can always omit cashierId. The default is per fitness, not per client. หากตัด cashierId ออกจาก body ระบบจะบันทึกการขายให้พนักงานเริ่มต้น (default cashier) ของฟิตเนสนั้น — คุณไม่จำเป็นต้องรู้รหัสพนักงานภายในของ FitUP · ฟิตเนสจะตั้งค่าพนักงานคนนี้ ในหน้าจัดการพนักงานของ FitUP และจะออก scope กลุ่มสินค้าให้ไม่ได้ถ้ายังไม่ได้ตั้ง ดังนั้นในทางปฏิบัติคุณ ไม่ต้องส่ง cashierId เลย · ค่าเริ่มต้นนี้เป็นระดับฟิตเนส ไม่ใช่ระดับ client
200 · application/json
{
  "success": true,
  "purchaseId": "3d9f8a5c-1e2b-47d6-8c90-af7b21d4e6c3",
  "receiptId": 1024,
  "receiptCode": "RCPT-2026-001024",
  "currency": "THB",
  "subtotal": 250.00,
  "discount": 25.00,
  "vat": 14.72,
  "vatRate": 0.07,
  "total": 225.00,
  "paid": 250.00,
  "change": 25.00,
  "receipt": {
    "fitness": { "name": "Fitup Fitness Co., Ltd.", "taxId": "0105561234567", "logoUrl": "https://.../logo.png" },
    "branch": {
      "name": "สาขาสยาม", "code": "BKK-01", "addressLine": "999 ถนนพระราม 1", "zipCode": "10330",
      "officePhone": "021234567", "subDistrict": "ปทุมวัน", "district": "ปทุมวัน", "province": "กรุงเทพมหานคร",
      "isHeadOffice": false, "label": "สาขา สาขาสยาม (BKK-01)"
    },
    "receipt": {
      "code": "RCPT-2026-001024", "issuedAt": "2026-08-04 10:30", "vatPercent": 7,
      "beforeVat": "210.28", "vat": "14.72", "total": "225.00",
      "totalInThai": "สองร้อยยี่สิบห้าบาทถ้วน"
    },
    "items": [
      { "no": 1, "name": "Protein Shake", "qty": "2.00", "unitPrice": "89.90", "lineDiscount": "0.00", "lineTotal": "179.80" },
      { "no": 2, "name": "Shaker Bottle", "qty": "1.00", "unitPrice": "70.20", "lineDiscount": "25.00", "lineTotal": "45.20" }
    ],
    "member": { "firstName": "สมชาย", "lastName": "ใจดี" },
    "cashier": { "firstName": "Staff", "lastName": "One" }
  }
}
The receipt object is everything printed on a FitUP receipt, so your POS can lay out the same slip we do — club and branch header, line items, VAT breakdown and the Thai baht text. Its money and quantity fields are pre-formatted strings ("1,920.00") on purpose: print them verbatim and your slip matches ours to the satang. Use the numeric fields above (total, vat, …) for your own bookkeeping. branch / member can be null (a walk-in sale has no member), and on the rare occasion the receipt cannot be read back the whole receipt key is absent — the sale still succeeded, so treat it as optional. For the branch line print branch.label as given (สำนักงานใหญ่ for the head office, else สาขา {name} ({code})) rather than building it from name / code. ออบเจ็กต์ receipt คือข้อมูลทุกอย่างที่พิมพ์อยู่บนใบเสร็จของ FitUP เพื่อให้ POS ของคุณจัดสลิปได้เหมือนกับที่เราออก — หัวใบเสร็จของสาขาและฟิตเนส, รายการสินค้า, การแยก VAT และตัวหนังสือ จำนวนเงินภาษาไทย · ฟิลด์จำนวนเงิน/จำนวนสินค้าเป็นสตริงที่จัดรูปแบบมาแล้ว ("1,920.00") โดยเจตนา — พิมพ์ตามนี้ตรงๆ สลิปจะตรงกับของเราถึงระดับสตางค์ · ถ้าจะเอาไปคำนวณ บัญชีเองให้ใช้ตัวเลขด้านบน (total, vat, …) · branch / member เป็น null ได้ (ขายหน้าร้านไม่มีสมาชิก) และในกรณีที่อ่านใบเสร็จกลับมาไม่ได้ คีย์ receipt จะไม่มีมาเลย แต่การขายสำเร็จแล้ว จึงควรมองว่าเป็นฟิลด์ที่อาจไม่มี · บรรทัดสาขาให้พิมพ์ branch.label ตามที่ได้รับ (สำนักงานใหญ่ สำหรับสำนักงานใหญ่ นอกนั้นเป็น สาขา {name} ({code})) ไม่ต้องประกอบเองจาก name / code
Do not also call POST /api/stock/sale or POST /api/pay-with-credit for the same sale — this endpoint already deducts both stock and credit, so pairing them double-deducts. Voiding a receipt is a staff-side operation in the FitUP back office; it is not exposed here. อย่าเรียก POST /api/stock/sale หรือ POST /api/pay-with-credit ซ้ำสำหรับการขายเดียวกัน — endpoint นี้ตัดทั้งสต็อกและเครดิต ให้แล้ว หากเรียกซ้ำจะถูกตัดสองครั้ง · การยกเลิก (void) ใบเสร็จเป็นงานฝั่งพนักงานในระบบหลังบ้านของ FitUP ไม่เปิดให้เรียกผ่าน API นี้

Stockสต็อก

On-hand quantities, lot visibility and the stock-first sale flow. Stock is consumed FEFO → FIFO: soonest expiry first, then oldest received. Quantities are decimals. ยอดคงเหลือ, การมองเห็นล็อต และการตัดสต็อกแบบ stock-first · การตัดสต็อกใช้ลำดับ FEFO → FIFO คือหมดอายุก่อนตัดก่อน แล้วจึงเรียงตามล็อตที่รับเข้ามาก่อน · จำนวนเป็นทศนิยมได้

GET/api/stock read:stock

On-hand stock across products, paginated. Query: branchId, lowStock (true = at or below the reorder level), page, pageSize. ยอดคงเหลือของสินค้าทั้งหมดแบบแบ่งหน้า · พารามิเตอร์: branchId, lowStock (true = ถึงหรือต่ำกว่าจุดสั่งซื้อ), page, pageSize

curl
curl "$BASE/api/stock?branchId=2&lowStock=true&page=1&pageSize=20" \
  -H "Authorization: Bearer $TOKEN"
200 · application/json
{
  "pagination": { "total": 12, "page": 1, "pageSize": 20 },
  "data": [
    {
      "productId": "8f3a1c2e-6b4d-4c19-9f77-2a1e5c0d3b81",
      "branchId": 2,
      "quantity": 4,
      "reorderLevel": 6,
      "lowStock": true,
      "updatedAt": "2026-07-20T04:10:00.000Z"
    }
  ]
}
GET/api/products/{id}/stock read:stock

On-hand for one product across every branch. {id} is the product id. Returns an array of the same stock rows as above. ยอดคงเหลือของสินค้าหนึ่งรายการในทุกสาขา — {id} คือรหัสสินค้า ผลลัพธ์เป็น array ของแถวสต็อกรูปแบบเดียวกับด้านบน

curl
curl "$BASE/api/products/8f3a1c2e-6b4d-4c19-9f77-2a1e5c0d3b81/stock" \
  -H "Authorization: Bearer $TOKEN"
GET/api/products/{id}/stock/summary read:stock

What one product holds at one branch and what it is worth: stockValue is the batches still on hand valued at what each actually cost (FIFO), not quantity × today's price. branchId is required — stock, and so its value, only means anything per branch. ยอดคงเหลือและมูลค่าของสินค้าหนึ่งรายการที่สาขาหนึ่ง — stockValue คิดจากล็อตที่เหลืออยู่ตามต้นทุนจริงของแต่ละล็อต (FIFO) ไม่ใช่จำนวน × ราคาขายวันนี้ · ต้องระบุ branchId เพราะสต็อกและมูลค่ามีความหมายแยกตามสาขาเท่านั้น

curl
curl "$BASE/api/products/8f3a1c2e-6b4d-4c19-9f77-2a1e5c0d3b81/stock/summary?branchId=2" \
  -H "Authorization: Bearer $TOKEN"
200 · application/json
{
  "productId": "8f3a1c2e-6b4d-4c19-9f77-2a1e5c0d3b81",
  "branchId": 2,
  "quantity": 15,
  "quantityCosted": 15,
  "stockValue": 2775,
  "avgCost": 185,
  "avgSalePrice": 249
}
avgCost and avgSalePrice are null, never 0, when there is nothing to average. quantityCosted below quantity means some stock was received before a cost was recorded, so stockValue covers only part of it. avgCost และ avgSalePrice จะเป็น null ไม่ใช่ 0 เมื่อไม่มีข้อมูลให้เฉลี่ย · หาก quantityCosted น้อยกว่า quantity แปลว่ามีสต็อกบางส่วนที่รับเข้ามาโดยไม่ได้บันทึกต้นทุน stockValue จึงครอบคลุมเพียงบางส่วน
GET/api/stock/lots read:stock

Open lots in consume order (FEFO → FIFO), paginated. Query: productId, branchId, page, pageSize. ล็อตที่ยังมีของเหลือ เรียงตามลำดับที่จะถูกตัด (FEFO → FIFO) แบบแบ่งหน้า · พารามิเตอร์: productId, branchId, page, pageSize

curl
curl "$BASE/api/stock/lots?productId=8f3a1c2e-6b4d-4c19-9f77-2a1e5c0d3b81&branchId=2" \
  -H "Authorization: Bearer $TOKEN"
200 · application/json
{
  "pagination": { "total": 3, "page": 1, "pageSize": 10 },
  "data": [
    {
      "id": "0f21a7c4-52d8-4a1e-b6d3-9c8f10a4e772",
      "productId": "8f3a1c2e-6b4d-4c19-9f77-2a1e5c0d3b81",
      "branchId": 2,
      "lotNo": "LOT-2026-07",
      "qtyReceived": 24,
      "qtyRemaining": 4,
      "receivedAt": "2026-07-01T02:00:00.000Z",
      "expiryDate": "2026-09-30",
      "refNo": "PO-8891",
      "note": ""
    }
  ]
}
GET/api/stock/expiring read:stock

Open lots expiring within N days, soonest first — already-expired lots are included with a negative daysUntilExpiry. Query: days (default 30), branchId. ล็อตที่จะหมดอายุภายใน N วัน เรียงจากใกล้หมดอายุที่สุด — รวมล็อตที่หมดอายุแล้ว โดย daysUntilExpiry จะเป็นค่าติดลบ · พารามิเตอร์: days (ค่าเริ่มต้น 30), branchId

curl
curl "$BASE/api/stock/expiring?days=30&branchId=2" \
  -H "Authorization: Bearer $TOKEN"
200 · application/json
[
  {
    "id": "0f21a7c4-52d8-4a1e-b6d3-9c8f10a4e772",
    "productId": "8f3a1c2e-6b4d-4c19-9f77-2a1e5c0d3b81",
    "branchId": 2,
    "lotNo": "LOT-2026-07",
    "qtyRemaining": 4,
    "expiryDate": "2026-08-05",
    "daysUntilExpiry": 8,
    "expired": false
  }
]
POST/api/stock/sale write:stock

Deduct stock for an order's line items — atomic across items and idempotent by orderNo. If any tracked item lacks stock the whole call fails with 409 and nothing is deducted; the message names the product (insufficient stock for product <productId>). A replay of an applied orderNo returns "applied": false. ตัดสต็อกตามรายการของออร์เดอร์ — ทำเป็นหน่วยเดียว (atomic) ทุกบรรทัด และเป็น idempotent ด้วย orderNo · หากมีรายการที่นับสต็อกใดสต็อกไม่พอ ทั้งคำขอจะล้มเหลวด้วย 409 และไม่มีการตัดสต็อกเลย โดยข้อความจะระบุสินค้าที่ไม่พอ (insufficient stock for product <productId>) · การเรียกซ้ำด้วย orderNo ที่ตัดไปแล้วจะได้ "applied": false

Only meaningful for products with "trackStock": true. A catalog-only product ("trackStock": false) has no on-hand by design and is always sellable, so its line moves no stock and is listed in untracked on the response instead of failing the order — you never need to know which products the tenant tracks before selling them. ใช้ได้จริงกับสินค้าที่ "trackStock": true เท่านั้น · สินค้าที่ไม่นับสต็อก ("trackStock": false) ไม่มียอดคงเหลือโดยการออกแบบและขายได้เสมอ ดังนั้นบรรทัดนั้น จะไม่ตัดสต็อกและจะถูกแจ้งกลับใน untracked แทนที่จะทำให้ออร์เดอร์ล้มเหลว — คุณไม่จำเป็นต้องรู้ล่วงหน้าว่าสินค้าใดของร้านนับสต็อกอยู่

curl
curl -X POST "$BASE/api/stock/sale" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'X-Correlation-Id: my-trace-0001' \
  -d '{
    "orderNo": "SO-1001",
    "branchId": 2,
    "contact": "Somchai P.",
    "actor": "Cashier 01",
    "items": [ { "productId": "8f3a1c2e-6b4d-4c19-9f77-2a1e5c0d3b81", "qty": 2, "unitPrice": 199 } ]
  }'
200 · application/json
{
  "success": true,
  "applied": true,
  "orderNo": "SO-1001",
  "untracked": ["4c8b0d71-2f19-4a53-8e6c-9b7d1a2f3e40"]
}
contact, actor and per-line unitPrice are optional. They are snapshotted onto the stock ledger so the tenant's stock report can show who sold what and at what price; unitPrice is the gross Baht figure your receipt prints (no VAT handling), and a line without one is simply left out of the average-sale-price figure. contact, actor และ unitPrice ของแต่ละบรรทัด เป็นค่าเสริม · ระบบจะบันทึกลงในบัญชีเคลื่อนไหวสต็อกเพื่อให้รายงานสต็อกแสดงได้ว่าใครขายอะไรในราคาเท่าใด · unitPrice คือราคาต่อหน่วยแบบรวม (ตามที่พิมพ์บนใบเสร็จของคุณ ไม่มีการคำนวณ VAT ซ้ำ) และบรรทัดที่ไม่ส่งมาจะไม่ถูกนำไปคิดราคาขายเฉลี่ย
untracked appears only when the order contained catalog-only products; those lines moved no stock. untracked จะมีเฉพาะเมื่อออร์เดอร์มีสินค้าที่ไม่นับสต็อก — บรรทัดเหล่านั้นไม่ถูกตัดสต็อก
POST /api/purchases already deducts stock. Use the stock sale endpoints only when you keep your own billing/receipts and just need FitUP's stock ledger moved. POST /api/purchases ตัดสต็อกให้แล้ว — ใช้ endpoint ตัดสต็อกนี้เฉพาะกรณีที่คุณออกบิล/ใบเสร็จเองในระบบของคุณ และต้องการเพียงให้สต็อกใน FitUP เดินตาม
POST/api/stock/sale/{orderNo}/reverse write:stock

Compensate a prior stock sale — e.g. your order failed after the deduction. The exact lots are restored; idempotent, so a second reverse returns "reversed": false. No body needed. คืนสต็อกที่ตัดไปก่อนหน้า เช่น ออร์เดอร์ของคุณล้มเหลวหลังตัดสต็อกไปแล้ว — ระบบคืนเข้าล็อตเดิมทุกล็อต และเป็น idempotent ดังนั้นเรียกซ้ำจะได้ "reversed": false · ไม่ต้องส่ง body

curl
curl -X POST "$BASE/api/stock/sale/SO-1001/reverse" \
  -H "Authorization: Bearer $TOKEN"
200 · application/json
{ "success": true, "reversed": true, "orderNo": "SO-1001" }

Leadsลูกค้ามุ่งหวัง (Leads)

GET/api/interest-service read:lead

The interested-service options to offer on your form. Use each slug in the lead's interestedService array; show the label. ตัวเลือก "บริการที่สนใจ" สำหรับใส่ในฟอร์มของคุณ — ใช้ค่า slug ในอาร์เรย์ interestedService ของ lead และแสดงข้อความจาก label

curl
curl "$BASE/api/interest-service" -H "Authorization: Bearer $TOKEN"
200 · application/json
[
  { "slug": "personal-training", "label": "Personal Training" },
  { "slug": "yoga",              "label": "Yoga" }
]
POST/api/leads write:lead

Capture an incomplete lead, e.g. from a website or campaign form. Optional branch-id header (default 1) attaches the lead to a branch. All five body fields are required. บันทึกลูกค้ามุ่งหวังที่ยังไม่สมัคร เช่น จากฟอร์มบนเว็บไซต์หรือแคมเปญ · header branch-id (ไม่บังคับ ค่าเริ่มต้น 1) ใช้ผูก lead กับสาขา · ฟิลด์ทั้งห้าใน body บังคับทั้งหมด

curl
curl -X POST "$BASE/api/leads" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'branch-id: 1' \
  -d '{
    "firstName": "Nok",
    "lastName": "Suksawat",
    "phone": "0812345678",
    "email": "nok@example.com",
    "interestedService": ["personal-training", "yoga"]
  }'
200 · application/json
{ "success": true }

Credit (wallet)เครดิต (กระเป๋าเงิน)

Credit is the member's prepaid wallet. Both endpoints deduct it and are idempotent by receiptNo — reuse the same receiptNo on a retry and the wallet is never charged twice. เครดิตคือกระเป๋าเงินเติมล่วงหน้าของสมาชิก · ทั้งสอง endpoint เป็นการตัดเครดิต และเป็น idempotent ด้วย receiptNo — ใช้ receiptNo เดิมตอน retry แล้วเครดิตจะไม่ถูก ตัดซ้ำ

POST/api/pay-with-credit write:credit

Deduct credit from a member identified by userId. credit must be ≥ 0. description is optional and lands on the wallet ledger entry. ตัดเครดิตจากสมาชิกที่ระบุด้วย userId · credit ต้องมีค่า ≥ 0 · description ไม่บังคับ และจะถูกบันทึกลงรายการเดินบัญชีของกระเป๋าเงิน

curl
curl -X POST "$BASE/api/pay-with-credit" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"userId":123,"credit":150,"receiptNo":"RCPT-1001","description":"POS sale"}'
200 · application/json
{ "success": true }
POST/api/pay-with-qr write:credit

Deduct credit from the member identified by a scanned QR payload. The QR is single-use — its nonce is consumed on first redemption, so the same QR cannot be charged twice — and the deduction is additionally idempotent by receiptNo. ตัดเครดิตจากสมาชิกที่ระบุด้วย payload จากการสแกน QR · QR ใช้ได้ครั้งเดียว (nonce ถูกใช้ไปเมื่อแลกครั้งแรก จึงไม่สามารถตัดเงินด้วย QR เดิมซ้ำได้) และการตัดยังเป็น idempotent ด้วย receiptNo อีกชั้นหนึ่ง

curl
curl -X POST "$BASE/api/pay-with-qr" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"payload":"<scanned-qr-string>","credit":150,"receiptNo":"RCPT-1002","description":"POS sale"}'
200 · application/json
{ "success": true }

Paymentsการชำระเงิน

Cash payments through ChillPay: the Payment Gateway (QR PromptPay, card, installments), hosted PayLinks, and recurring subscriptions. Amounts are in baht. Every create is idempotent by orderNo, and tenantId is always taken from your token — any value you send for it is ignored. การรับชำระเงินสดผ่าน ChillPay: Payment Gateway (QR PromptPay, บัตร, ผ่อนชำระ), ลิงก์ชำระเงิน (PayLink) และการตัดรอบอัตโนมัติ (recurring) · จำนวนเงินเป็นบาท · ทุกการสร้างเป็น idempotent ด้วย orderNo และ tenantId จะถูกกำหนดจาก token ของคุณเสมอ ค่าที่ส่งมาจะถูกละเว้น

POST/api/payments write:payments

Create a one-time Payment Gateway transaction and redirect the customer to the returned paymentUrl. Required: branchId, orderNo, amount. สร้างรายการชำระเงินแบบครั้งเดียว แล้วพาลูกค้าไปที่ paymentUrl ที่ได้กลับมา · ฟิลด์บังคับ: branchId, orderNo, amount

Fieldฟิลด์ Notesหมายเหตุ
channel card · qr_promptpay · installment_kbank · installment_scb · installment_krungsri · installment_ktc_flexi · installment_firstchoice (a raw ChillPay code is also accepted). (ส่งรหัสช่องทางของ ChillPay ตรง ๆ ก็ได้)
creditMonth Required for installment channels — the number of months. บังคับสำหรับช่องทางผ่อนชำระ — จำนวนเดือน
shopId Installments only: "01" = the merchant pays the interest, "02" = the customer does. Default "01". ใช้กับผ่อนชำระเท่านั้น: "01" = ร้านรับภาระดอกเบี้ย, "02" = ลูกค้ารับภาระ · ค่าเริ่มต้น "01"
langCode TH (default) or EN — the language of the hosted payment page. TH (ค่าเริ่มต้น) หรือ EN — ภาษาของหน้าชำระเงิน
customerId · customerEmail · phoneNumber · description Optional; carried onto the transaction for reconciliation. ไม่บังคับ — จะถูกบันทึกไว้กับรายการเพื่อใช้กระทบยอด
curl
curl -X POST "$BASE/api/payments" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "branchId": 1,
    "orderNo": "ORD-1001",
    "customerId": "CUST-9",
    "amount": 89.92,
    "currency": "THB",
    "channel": "qr_promptpay",
    "customerEmail": "cust@example.com",
    "langCode": "TH"
  }'
200 · application/json
{
  "orderNo": "ORD-1001",
  "transactionId": 550231,
  "channelCode": "bank_qrcode",
  "paymentUrl": "https://sandbox-cashier.chillpay.co/...",
  "expiredDate": "2026-07-28T10:15:00.000Z"
}
GET/api/payments/{transactionId}/status read:payments

Poll a payment's status with the transactionId from the create call. Returns the transaction as recorded by the payment service; 404 when the id is unknown to your tenant. ตรวจสถานะการชำระเงินด้วย transactionId ที่ได้จากตอนสร้างรายการ · ผลลัพธ์คือรายการตามที่บริการชำระเงินบันทึกไว้ และจะได้ 404 หากไม่พบ id นั้นใน tenant ของคุณ

curl
curl "$BASE/api/payments/550231/status" -H "Authorization: Bearer $TOKEN"

Responseการตอบกลับ · 200

200 · application/json
{
  "transactionId": 550231,
  "orderNo": "ORD-1001",
  "paymentStatus": 0,
  "statusText": "Complete",
  "bankCode": "SCB",
  "bankRefCode": "0043215",
  "amount": 250.00,
  "currency": 764
}

Status codesรหัสสถานะ

paymentStatuspaymentStatus statusTextstatusText Final?สิ้นสุด? Meaningความหมาย
9Pendingno — keep pollingยัง — ให้ poll ต่อAwaiting the customer's payment.รอลูกค้าชำระเงิน
0Completeyesใช่Paid. Fulfil the order.ชำระเงินสำเร็จ — ส่งมอบสินค้า/บริการได้
23Settledyesใช่Paid and settled to the merchant — treat exactly like 0.ชำระเงินแล้วและโอนเข้าบัญชีร้านค้าแล้ว — ถือว่าสำเร็จเช่นเดียวกับ 0
1Failyesใช่Rejected (e.g. by the issuer). Ask the customer to try again with a new order.ถูกปฏิเสธ (เช่น จากธนาคารผู้ออกบัตร) — ให้ลูกค้าลองใหม่ด้วยรายการใหม่
2Cancelyesใช่The customer abandoned the payment page.ลูกค้ายกเลิก/ออกจากหน้าชำระเงิน
3Erroryesใช่The payment could not be processed. Do not fulfil; start a new order.ประมวลผลการชำระเงินไม่สำเร็จ — อย่าส่งมอบสินค้า ให้เริ่มรายการใหม่
20 / 24VoidSuccess / VoidFailyesใช่Outcome of a void requested by the merchant.ผลของการยกเลิกรายการ (void) ที่ร้านค้าร้องขอ
21 / 25RefundSuccess / RefundFailyesใช่Outcome of a refund.ผลของการคืนเงิน (refund)
22RefundRequestednoยังA refund is being processed.อยู่ระหว่างดำเนินการคืนเงิน
Only 0 and 23 mean the money is yours — never fulfil an order on any other code. statusText is a convenience label derived from paymentStatus; branch on the number, and treat an unrecognised code as not-paid (statusText will read Unknown). While polling a Pending payment, a few seconds between calls is plenty — the customer still has to finish on the bank's page. มีเพียง 0 และ 23 ที่หมายถึงได้รับเงินแล้ว — อย่าส่งมอบสินค้า เมื่อได้รหัสอื่น · statusText เป็นเพียงข้อความอธิบายที่แปลงมาจาก paymentStatus ให้ตัดสินใจจากตัวเลข และหากพบรหัสที่ไม่รู้จักให้ถือว่ายังไม่ชำระเงิน (statusText จะเป็น Unknown) · ระหว่าง poll สถานะ Pending เว้นระยะไม่กี่วินาทีต่อครั้งก็เพียงพอ เพราะลูกค้ายังต้องทำรายการบนหน้าเว็บของธนาคารให้เสร็จก่อน
POST/api/paylink write:payments

Create a hosted payment link (and QR image) you can send to a customer. Required: orderNo, productName, amount. Optional: branchId, productDescription (defaults to productName), startDate (default now), expiredDate (default +24h), paymentLimit (default 1; 0 = unlimited). สร้างลิงก์ชำระเงิน (พร้อมภาพ QR) เพื่อส่งให้ลูกค้า · ฟิลด์บังคับ: orderNo, productName, amount · ไม่บังคับ: branchId, productDescription (ค่าเริ่มต้นเท่ากับ productName), startDate (ค่าเริ่มต้น = ตอนนี้), expiredDate (ค่าเริ่มต้น +24 ชม.), paymentLimit (ค่าเริ่มต้น 1; ใส่ 0 = ไม่จำกัดจำนวนครั้ง)

curl
curl -X POST "$BASE/api/paylink" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "branchId": 1,
    "orderNo": "PL-2001",
    "productName": "Monthly membership",
    "amount": 1290.00,
    "currency": "THB",
    "expiredDate": "2026-08-01T17:00:00Z",
    "paymentLimit": 1
  }'
200 · application/json
{
  "orderNo": "PL-2001",
  "payLinkId": 88134,
  "paymentUrl": "https://sandbox-paylink.chillpay.co/...",
  "qrImage": "https://sandbox-paylink.chillpay.co/qr/...",
  "status": "ACTIVE"
}
GET/api/paylink/transactions/{transactionId} read:payments

Fetch one PayLink transaction — whether it was paid, when, and for how much. ดึงข้อมูลรายการของ PayLink หนึ่งรายการ — ชำระแล้วหรือไม่ ชำระเมื่อไร และเป็นจำนวนเท่าไร

curl
curl "$BASE/api/paylink/transactions/88134" -H "Authorization: Bearer $TOKEN"
POST/api/recurring/schedules write:payments

Create a subscription schedule. Required: orderNo, amount (per cycle), billingDueDate, billingCycle (day | month | year) and billingRound (the interval multiplier, e.g. 1). Omit totalBilling for an open-ended subscription. Send the customer to verifyPaymentUrl once to register their card. สร้างรอบตัดเงินอัตโนมัติ (subscription) · ฟิลด์บังคับ: orderNo, amount (ต่อรอบ), billingDueDate, billingCycle (day | month | year) และ billingRound (ตัวคูณช่วงเวลา เช่น 1) · ไม่ใส่ totalBilling = ตัดเรื่อยไปไม่จำกัดรอบ · พาลูกค้าไปที่ verifyPaymentUrl หนึ่งครั้งเพื่อผูกบัตร

curl
curl -X POST "$BASE/api/recurring/schedules" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "branchId": 1,
    "customerId": "CUST-9",
    "customerEmail": "cust@example.com",
    "orderNo": "SUB-3001",
    "amount": 1290.00,
    "currency": "THB",
    "description": "Gold membership",
    "billingDueDate": "2026-08-01",
    "billingCycle": "month",
    "billingRound": 1,
    "totalBilling": 12
  }'
200 · application/json
{
  "recurringScheduleId": "b7c1f0d2-4a55-4e9b-9c31-0a8d7e6f5b42",
  "verifyPaymentUrl": "https://sandbox-cashier.chillpay.co/verify/...",
  "status": "PENDING_VERIFY"
}
GET/api/recurring/schedules/{scheduleId} read:payments

Fetch a schedule's current state — verification status, next billing date and rounds billed so far. ดึงสถานะปัจจุบันของรอบตัดเงิน — สถานะการยืนยันบัตร, วันตัดรอบถัดไป และจำนวนรอบที่ตัดไปแล้ว

curl
curl "$BASE/api/recurring/schedules/b7c1f0d2-4a55-4e9b-9c31-0a8d7e6f5b42" \
  -H "Authorization: Bearer $TOKEN"
POST/api/recurring/schedules/{scheduleId}/cancel write:payments

Stop future billing for a schedule. The body is optional: { "reason": "..." } is stored with the cancellation. Already-billed rounds are not refunded. หยุดการตัดเงินรอบถัดไปของ schedule นั้น · body ไม่บังคับ — ส่ง { "reason": "..." } เพื่อบันทึกเหตุผลไว้กับการยกเลิก · รอบที่ตัดเงินไปแล้วจะไม่ถูกคืนเงิน

curl
curl -X POST "$BASE/api/recurring/schedules/b7c1f0d2-4a55-4e9b-9c31-0a8d7e6f5b42/cancel" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"reason":"customer request"}'

System & docsระบบและเอกสาร

These three routes need no token — they are served by the gateway itself. สาม route นี้ไม่ต้องใช้ token — เกตเวย์เป็นผู้ให้บริการเองโดยตรง

RouteRoute Purposeใช้ทำอะไร
GET /health Liveness probe — {"status":"ok"}. Use it for uptime checks, not as a dependency health report. ตรวจว่าบริการยังมีชีวิต — ตอบ {"status":"ok"} · ใช้สำหรับเช็ก uptime ไม่ใช่รายงานสุขภาพของบริการเบื้องหลัง
GET /doc.html This page. GET / redirects here. หน้านี้เอง · เรียก GET / จะถูกส่งต่อมาที่นี่
GET /openapi.yaml The OpenAPI 3.1 spec for this exact deployment — feed it to your client generator or Postman. สเปก OpenAPI 3.1 ของเวอร์ชันที่ deploy อยู่จริง — นำไปสร้าง client หรือ import เข้า Postman ได้

Quick start (curl)เริ่มใช้เร็ว (curl)

Set BASE to your host, get a token, then work through a branch, the catalog and a sale. Every example above uses these two shell variables. ตั้งค่า BASE เป็นโฮสต์ของคุณ, ขอ token, แล้วไล่ตามลำดับ: เลือกสาขา, ดูแคตตาล็อก, บันทึกการขาย · ตัวอย่างทั้งหมดด้านบนใช้ตัวแปรเชลล์สองตัวนี้

bash
BASE="https://<your-open-api-host>"

# 1) Token (cache it — valid for expires_in seconds)
TOKEN=$(curl -s -X POST "$BASE/api/oauth/token" \
  -H 'Content-Type: application/json' \
  -d '{"clientId":"YOUR_CLIENT_ID","clientSecret":"YOUR_CLIENT_SECRET"}' | jq -r .access_token)

# 2) Which branches can I sell at?
curl -s "$BASE/api/branches" -H "Authorization: Bearer $TOKEN"

# 3) Catalog for branch 2, with on-hand stock attached
curl -s "$BASE/api/products?pageSize=50" \
  -H "Authorization: Bearer $TOKEN" -H 'branch-id: 2'

# 4) Record a sale (deducts stock + credit, writes the receipt)
#    No cashierId -> credited to the fitness's default cashier
curl -s -X POST "$BASE/api/purchases" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -H 'branch-id: 2' -H 'X-Correlation-Id: my-trace-0001' \
  -d '{"items":[{"productId":"<product-id>","qty":2}],"paymentType":"CASH"}'

# 5) Or take a QR PromptPay payment instead
curl -s -X POST "$BASE/api/payments" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"branchId":2,"orderNo":"ORD-1001","amount":89.92,"channel":"qr_promptpay"}'

Changesสิ่งที่เปลี่ยนแปลง

What changed on this API, newest first. Dates are when the change went live on our side. This page and openapi.yaml are served by the deployment you are calling, so they always describe the behaviour you actually get. รายการสิ่งที่เปลี่ยนแปลงของ API นี้ เรียงจากใหม่ไปเก่า · วันที่คือวันที่การเปลี่ยนแปลงนั้น ขึ้นใช้งานฝั่งเรา · หน้านี้และ openapi.yaml ให้บริการจาก deployment ตัวเดียวกับที่คุณเรียกอยู่ จึงตรงกับพฤติกรรมที่คุณได้รับจริงเสมอ

What counts as safe for you: we add response fields and optional request fields without a heads-up, so ignore fields you do not recognise rather than failing on them. Anything renamed or removed is listed here, and the old name keeps working for a transition period — a field marked deprecated still responds today. สิ่งที่ถือว่าปลอดภัยสำหรับคุณ: เราเพิ่มฟิลด์ใน response และฟิลด์ ที่ไม่บังคับใน request ได้โดยไม่แจ้งล่วงหน้า ดังนั้นให้ข้ามฟิลด์ที่ไม่รู้จักไป อย่าให้ระบบพังเพราะเจอฟิลด์ใหม่ · ส่วนการเปลี่ยนชื่อหรือถอดฟิลด์จะถูกบันทึกไว้ที่นี่ และชื่อเดิมจะยัง ใช้ได้ต่ออีกระยะ — ฟิลด์ที่เขียนว่า deprecated วันนี้ยังตอบกลับปกติ
Dateวันที่ Changeการเปลี่ยนแปลง
2026-10-14 Added — the receipt.branch object on POST /api/purchases now carries isHeadOffice and label, the branch line exactly as FitUP prints it (สำนักงานใหญ่ for the head office, else สาขา {name} ({code})). Print label in place of your own name + code line. A branch can now also issue receipts under its own company name, tax id and address; those arrive in the same fitness / branch fields, so nothing else changes for you. See Purchases. เพิ่ม — ออบเจ็กต์ receipt.branch ของ POST /api/purchases มี isHeadOffice และ label เพิ่มมา คือบรรทัดสาขา ตามที่ FitUP พิมพ์จริง (สำนักงานใหญ่ สำหรับสำนักงานใหญ่ นอกนั้นเป็น สาขา {name} ({code})) · ให้พิมพ์ label แทนบรรทัดชื่อ + รหัสสาขาที่ประกอบเอง · ตอนนี้สาขาออกใบเสร็จในนามบริษัท เลขผู้เสียภาษี และที่อยู่ของตัวเองได้ด้วย ค่าพวกนี้มาในฟิลด์ fitness / branch เดิม ฝั่งคุณจึงไม่ต้องแก้อะไรเพิ่ม · ดู การขาย
2026-09-09 Fixed — POST /api/stock/sale no longer rejects an order that contains catalog-only products. A product with "trackStock": false has no on-hand by design and is always sellable; its line now moves no stock and comes back in the new untracked array on the response instead of failing the whole call with 409 insufficient stock. You do not need to know which products the club tracks before selling them.
Changed — a genuine 409 now names the product that ran short: insufficient stock for product <productId>. See Stock.
แก้ไข — POST /api/stock/sale จะไม่ปฏิเสธออร์เดอร์ ที่มีสินค้าซึ่งไม่นับสต็อกอีกต่อไป · สินค้าที่ "trackStock": false ไม่มียอดคงเหลือ โดยการออกแบบและขายได้เสมอ ดังนั้นบรรทัดนั้นจะไม่ตัดสต็อกและจะถูกส่งกลับใน array ใหม่ untracked แทนที่จะทำให้ทั้งคำขอล้มเหลวด้วย 409 insufficient stock · คุณไม่จำเป็นต้องรู้ล่วงหน้าว่าสินค้าใดของคลับนับสต็อกอยู่
เปลี่ยน — กรณีสต็อกไม่พอจริง 409 จะระบุสินค้าที่ไม่พอด้วย: insufficient stock for product <productId> · ดู สต็อก
2026-08-05 Added — POST /api/purchases now returns a receipt object with everything printed on a FitUP receipt (club and branch header, line items, VAT breakdown, Thai baht text), so your POS can lay out the same slip we do. See Purchases.
Changed — a product can now be sold at several branches, so GET /api/products returns branchIds (an array; [] = every branch). branchId is deprecated: it still comes back, but only holds an id when the product sits on exactly one branch — it is null for an all-branch and a multi-branch product. Read branchIds.
เพิ่ม — POST /api/purchases ตอบออบเจ็กต์ receipt ที่มีข้อมูลทุกอย่างบนใบเสร็จของ FitUP (หัวใบเสร็จของฟิตเนสและสาขา, รายการสินค้า, การแยก VAT, ตัวหนังสือจำนวนเงินภาษาไทย) เพื่อให้ POS ของคุณจัดสลิปได้เหมือนกับที่เราออก · ดู การขาย
เปลี่ยน — สินค้า 1 ตัวขายได้หลายสาขาแล้ว ดังนั้น GET /api/products จะส่ง branchIds มา (เป็น array · [] หมายถึงขายทุกสาขา) · branchId กลายเป็น deprecated — ยังส่งมาให้ แต่จะมีค่าเฉพาะ กรณีที่สินค้าผูกกับสาขาเดียว และเป็น null ทั้งกรณีขายทุกสาขาและกรณีขายหลายสาขา จึงควรอ่าน branchIds
2026-08-04 Added — optional posId on POST /api/purchases: the serial of the POS machine ringing up the sale, as printed on the machine. It stamps that machine on the receipt and switches the tax-invoice number to the machine's own series. Omit it and the branch's own series is used, exactly as before. An unregistered serial is rejected with 400 POS_NOT_FOUND. เพิ่ม — ฟิลด์ posId (ไม่บังคับ) ใน POST /api/purchases คือหมายเลขเครื่อง POS ที่ใช้ขาย ตามที่พิมพ์อยู่บนเครื่อง · ระบบจะบันทึกเครื่องนั้นบนใบเสร็จและใช้ชุดเลขใบกำกับภาษีของเครื่องนั้น · ถ้าไม่ส่งมาจะใช้ชุดเลขของสาขา เหมือนเดิมทุกอย่าง · หากส่งหมายเลขที่ไม่ได้ลงทะเบียนไว้จะได้ 400 POS_NOT_FOUND
2026-07-31 Changed — cashierId on POST /api/purchases became optional: omit it and the sale is credited to the fitness's default cashier, so you never need FitUP's internal staff ids. Send it only to override that for one sale.
Changed — every request and response field on this API is now camelCase. Member fields were renamed (member_code → memberCode, credit_balance → creditBalance, image_url → imageUrl) and the product filter ?payment_type= became ?paymentType=. The OAuth token response keeps its RFC 6749 names (access_token, token_type, expires_in) and ChillPay channel values are unchanged. ?payment_type= is still accepted.
เปลี่ยน — cashierId ใน POST /api/purchases กลายเป็น ไม่บังคับ · ถ้าไม่ส่ง ระบบจะบันทึกการขาย ให้พนักงานเริ่มต้นของฟิตเนสนั้น คุณจึงไม่จำเป็นต้องรู้รหัสพนักงานภายในของ FitUP · ส่งมาเมื่อต้องการ ระบุเป็นรายครั้งเท่านั้น
เปลี่ยน — ฟิลด์ทุกตัวทั้ง request และ response ของ API นี้เป็น camelCase ทั้งหมด · ฟิลด์ของสมาชิกถูกเปลี่ยนชื่อ (member_code → memberCode, credit_balance → creditBalance, image_url → imageUrl) และตัวกรองสินค้า ?payment_type= เปลี่ยนเป็น ?paymentType= · ส่วน response ของ OAuth token ยังใช้ชื่อตาม RFC 6749 (access_token, token_type, expires_in) และค่า channel ของ ChillPay ไม่เปลี่ยน · ?payment_type= ยังใช้ได้อยู่
2026-07-29 Added — your token now carries the fitness's VAT settings, and GET /api/me reports them (vatRate, isVatRegistered), so you can price a cart on your side without a call per line item. vatRate is the effective rate — 0 when the fitness is not VAT-registered. Because it is baked into the token, a rate change reaches you when you next request a token.
Added — POST /api/purchases returns vat and vatRate for the sale. Prices are VAT-inclusive: vat is the portion already contained in total.
เพิ่ม — token ของคุณมีค่า VAT ของฟิตเนสติดมาด้วย และ GET /api/me แสดงค่าเหล่านั้น (vatRate, isVatRegistered) ทำให้คุณคำนวณราคาตะกร้าฝั่งคุณได้เองโดยไม่ต้องยิงถามทีละรายการ · vatRate เป็นอัตราที่ใช้จริง — จะเป็น 0 ถ้าฟิตเนสไม่ได้จด VAT · เนื่องจากค่านี้ฝังอยู่ใน token การแก้อัตราจะมีผลกับคุณเมื่อขอ token ใหม่ครั้งถัดไป
เพิ่ม — POST /api/purchases ส่ง vat และ vatRate ของการขายนั้นกลับมา · ราคารวม VAT อยู่แล้ว ดังนั้น vat คือส่วนที่ รวมอยู่ใน total แล้ว
2026-07-27 Added — GET /api/branches (which branch ids you may sell at; no extra scope) and POST /api/purchases, which records a whole sale in one call: it validates products, deducts stock and the member's credit wallet, and writes the receipt. Do not pair it with /api/stock/sale or /api/pay-with-credit for the same sale — that double-deducts.
Added — products carry categoryId and GET /api/product-categories lists the categories (filter with ?categoryId=).
เพิ่ม — GET /api/branches (ดูว่าขายที่สาขาไหนได้ ไม่ต้องใช้ scope เพิ่ม) และ POST /api/purchases ที่บันทึกการขายจบในครั้งเดียว — ตรวจสอบสินค้า, ตัดสต็อก, ตัดเครดิตของสมาชิก และออกใบเสร็จ · อย่าเรียกคู่กับ /api/stock/sale หรือ /api/pay-with-credit สำหรับการขายเดียวกัน เพราะจะถูกตัดสองครั้ง
เพิ่ม — สินค้ามีฟิลด์ categoryId และมี GET /api/product-categories ให้ดูรายการหมวดหมู่ (กรองด้วย ?categoryId=)
2026-07-18 Added — the Stock section: on-hand per branch (GET /api/stock, GET /api/products/{id}/stock), lot visibility in consume order (GET /api/stock/lots, GET /api/stock/expiring) and the stock-first sale flow (POST /api/stock/sale plus its reversal), under the new read:stock / write:stock scopes. Sending the branch-id header to GET /api/products also attaches that branch's on-hand to every row. เพิ่ม — หมวด สต็อก ทั้งหมด: ยอดคงเหลือ แยกตามสาขา (GET /api/stock, GET /api/products/{id}/stock), การดูล็อตตาม ลำดับการตัด (GET /api/stock/lots, GET /api/stock/expiring) และการตัดสต็อก แบบ stock-first (POST /api/stock/sale และการย้อนกลับ) ภายใต้ scope ใหม่ read:stock / write:stock · และถ้าส่ง header branch-id ไปกับ GET /api/products ทุกรายการจะมียอดคงเหลือของสาขานั้นแนบมาด้วย
2026-06-27 Added — the Payments section: card and QR PromptPay charges (POST /api/payments), PayLink and recurring schedules, with status reads under read:payments. Only paymentStatus 0 (Complete) and 23 (Settled) mean the money is collected — never fulfil an order on any other code. เพิ่ม — หมวด ชำระเงิน: รับชำระด้วยบัตร และ QR PromptPay (POST /api/payments), PayLink และการตัดเงินแบบรายรอบ (recurring) พร้อมการอ่านสถานะภายใต้ read:payments · มีเพียง paymentStatus 0 (Complete) และ 23 (Settled) ที่หมายถึงได้รับเงินแล้ว — อย่าส่งของหรือ ยืนยันคำสั่งซื้อจากโค้ดอื่น
2026-06-25 Changed — the member's prepaid wallet is called credit everywhere (it was "points"): scope write:credit, endpoints POST /api/pay-with-credit and POST /api/pay-with-qr, field creditBalance, body field credit. Both wallet deductions are idempotent by receiptNo — a retry with the same receiptNo does not deduct twice. เปลี่ยน — กระเป๋าเงินเติมล่วงหน้าของสมาชิกเรียกว่า เครดิต (credit) ทั้งหมด (เดิมคือ "points"): scope write:credit, endpoint POST /api/pay-with-credit และ POST /api/pay-with-qr, ฟิลด์ creditBalance, ฟิลด์ใน body ชื่อ credit · การตัดเครดิตทั้งสองเส้นเป็น idempotent ด้วย receiptNo — ยิงซ้ำด้วย receiptNo เดิม จะไม่ถูกตัดสองครั้ง