BMWEB · Tài liệu
Tài liệu cho lập trình viên

Kết nối website với BMWEB

Website của khách hàng có thể viết bằng PHP, WordPress, Node.js, C#, Python hay công nghệ nào khác. Để app BMWEB hiện được sản phẩm, đơn hàng của website đó, lập trình viên chỉ cần làm một lớp nhỏ trên website gọi là connector: vài đường dẫn HTTPS trả về JSON theo một chuẩn chung. Tài liệu này mô tả chuẩn đó.

Tóm tắt trong 30 giây

1) Website mở các đường dẫn /v1/... trả JSON. 2) BMWEB gọi vào đó, mỗi yêu cầu có chữ ký HMAC-SHA256 tính bằng một khóa bí mật chung. 3) Website khai báo ở GET /v1/capabilities mình làm được những phần nào, nên có thể làm dần từng bước, bắt đầu chỉ với sản phẩm. 4) Có sẵn bản mẫu chạy được, bộ kiểm tra tự động và đặc tả OpenAPI để bắt đầu nhanh.

1. Bức tranh tổng thể

2. Chọn cách kết nối

CáchDùng khiTình trạng
A. Connector trên website
(tài liệu này)
Khách đã có website riêng với sản phẩm và đơn hàng, công nghệ gì cũng được.Đã có Chạy thật với website PHP của VIETFOOD.
B. BMWEB giữ dữ liệuKhách chưa có website, hoặc chỉ cần app.Đang xây Nơi lưu sản phẩm và đơn đã có; công cụ nhập sản phẩm trên trình duyệt chưa xong.
C. Plugin dựng sẵn
(WordPress/WooCommerce, các nền tảng bán hàng)
Cài là chạy, không cần lập trình.Chưa có Chỉ là ý định; hiện làm theo cách A.
Website bmweb (PHP)? Có bộ cài sẵn, không cần viết mã.

Nếu website của khách chạy khung bmweb (các bảng table_products/table_product, table_orders/table_order...), hãy tải connector-bmweb.zip: chép lên website, điền khóa bí mật, khai báo trong bảng điều khiển là chạy. Chạy được trên PHP 7.0, 7.4 và 8.x, tự nhận cả hai thế hệ bmweb (v1 và v2), có trang tự kiểm tra và hướng dẫn tiếng Việt trong gói (README.md).

Vậy câu trả lời cho "gửi tài liệu API cho bên họ rồi họ tự kết nối"?

Đúng, đó chính là cách A. Bạn gửi tài liệu này (kèm file đặc tả openapi.yaml, bản mẫu và bộ kiểm tra) cho lập trình viên của công ty/đại lý. Họ viết connector bằng ngôn ngữ họ quen, chạy bộ kiểm tra cho tới khi đạt, rồi đại lý nhập địa chỉ và khóa bí mật vào bảng điều khiển BMWEB. Không bên nào phải sửa app.

3. Yêu cầu tối thiểu

4. Khóa bí mật và chữ ký

Mọi yêu cầu BMWEB gửi đi đều có hai header. Connector phải kiểm cả hai trước khi làm bất cứ việc gì, kể cả đọc dữ liệu.

HeaderGiá trị
X-Connector-TimestampGiờ gửi, tính bằng giây từ 1970 (unix), ví dụ 1700000000.
X-Connector-SignatureChuỗi hex (chữ thường) của HMAC-SHA256(khóa bí mật, nội dung ký).
X-Marzi-Timestamp, X-Marzi-SignatureTên cũ của hai header trên, vẫn được gửi cùng giá trị để các website đã làm theo tên cũ chạy tiếp. Website mới chỉ cần đọc tên X-Connector-*.

Nội dung ký gồm bốn dòng nối bằng ký tự xuống dòng \n (không có dòng trống ở cuối):

{timestamp}
{METHOD}                       (GET hoặc POST, chữ hoa)
{đường dẫn + query}            (ví dụ /v1/products?page=1&limit=20)
{sha256 hex của body}          (GET không có body: dùng sha256 của chuỗi rỗng)

Bốn quy tắc dễ sai

  1. Đường dẫn ký là đường dẫn không có thư mục chứa connector. Nếu connector nằm ở https://site.com/marzi-connector thì website nhận /marzi-connector/v1/products nhưng BMWEB ký /v1/products. Hãy cắt tiền tố thư mục trước khi kiểm chữ ký và trước khi chọn route (bản mẫu có biến BASE_PATH, bản PHP có cấu hình base_path).
  2. Ký đúng nguyên văn query như nhận được (thứ tự tham số, mã hóa %), đừng sắp xếp lại.
  3. Hash body bằng byte gốc nhận được, đừng đọc JSON rồi dựng lại chuỗi.
  4. So sánh bằng hàm so sánh thời gian cố định (hash_equals, timingSafeEqual, hmac.compare_digest...), không dùng ==. Từ chối nếu |giờ hiện tại - timestamp| > 300 giây để chặn phát lại.
Giá trị mẫu để tự kiểm tra bản cài đặt của bạn
secret     = test-secret-key-0123456789abcdef
timestamp  = 1700000000
method     = POST
uri        = /v1/auth/verify?x=1
body       = {"username":"khach","password":"mat khau"}

signature  = 6555990f154cc9a91c0fb5182bfb991bebcca1fed0a1f137a11f0be74d5913d7

(GET /v1/products?page=1&limit=20, body rỗng, cùng khóa và giờ)
signature  = b0c9e960e037ae0e9266c7e7c1c20e45fa8e451ae84d83b586d8df671534adb7

Nếu hàm của bạn trả đúng hai chuỗi này thì phần chữ ký đã đúng.

Mã mẫu

Ba đoạn dưới đây đã được chạy thử và cho đúng giá trị mẫu. Đoạn C# viết theo cùng công thức nhưng chưa được biên dịch và chạy thử, hãy so với giá trị mẫu trước khi dùng.

Node.js

// Node.js 18+. Check or make a MARZI signature.
const crypto = require('crypto');

/** hex( HMAC-SHA256( secret, timestamp \n METHOD \n path?query \n sha256hex(body) ) ); body is the raw text ("" for GET). */
function sign(secret, timestamp, method, uri, body) {
  const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
  return crypto.createHmac('sha256', secret).update(`${timestamp}\n${method}\n${uri}\n${bodyHash}`).digest('hex');
}

/** True when the request really comes from MARZI. `uri` is the path + query WITHOUT the folder your connector lives in. */
function isFromMarzi(secret, headers, method, uri, body, maxSkewSeconds = 300) {
  const timestamp = String(headers['x-connector-timestamp'] || headers['x-marzi-timestamp'] || '');
  const given = String(headers['x-connector-signature'] || headers['x-marzi-signature'] || '').toLowerCase();
  if (!/^\d+$/.test(timestamp) || !given) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > maxSkewSeconds) return false; // too old: someone may be replaying it
  const expected = Buffer.from(sign(secret, timestamp, method, uri, body));
  const actual = Buffer.from(given);
  return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); // never compare with ===
}

module.exports = { sign, isFromMarzi };

if (require.main === module) {
  // Test vector from the documentation: must print 6555990f154cc9a91c0fb5182bfb991bebcca1fed0a1f137a11f0be74d5913d7
  console.log(sign('test-secret-key-0123456789abcdef', '1700000000', 'POST', '/v1/auth/verify?x=1', '{"username":"khach","password":"mat khau"}'));
}

Python

"""Python 3. Check or make a MARZI signature (standard library only)."""
import hashlib
import hmac
import time


def sign(secret: str, timestamp: str, method: str, uri: str, body: bytes) -> str:
    """hex( HMAC-SHA256( secret, timestamp \\n METHOD \\n path?query \\n sha256hex(body) ) ); body is the raw bytes (b"" for GET)."""
    body_hash = hashlib.sha256(body).hexdigest()
    payload = f"{timestamp}\n{method}\n{uri}\n{body_hash}".encode()
    return hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()


def is_from_marzi(secret: str, headers: dict, method: str, uri: str, body: bytes, max_skew_seconds: int = 300) -> bool:
    """True when the request really comes from MARZI. `uri` is the path + query WITHOUT the folder your connector lives in."""
    timestamp = str(headers.get("X-Connector-Timestamp") or headers.get("X-Marzi-Timestamp", ""))
    given = str(headers.get("X-Connector-Signature") or headers.get("X-Marzi-Signature", "")).lower()
    if not timestamp.isdigit() or not given:
        return False
    if abs(time.time() - int(timestamp)) > max_skew_seconds:
        return False  # too old: someone may be replaying it
    return hmac.compare_digest(sign(secret, timestamp, method, uri, body), given)  # never compare with ==


if __name__ == "__main__":
    # Test vector from the documentation: must print 6555990f154cc9a91c0fb5182bfb991bebcca1fed0a1f137a11f0be74d5913d7
    print(sign("test-secret-key-0123456789abcdef", "1700000000", "POST", "/v1/auth/verify?x=1", '{"username":"khach","password":"mat khau"}'.encode()))

PHP

<?php
// PHP 7.4+. Check or make a MARZI signature.

/** hex( HMAC-SHA256( secret, timestamp \n METHOD \n path?query \n sha256hex(body) ) ); $body is the raw text ('' for GET). */
function marzi_sign(string $secret, string $timestamp, string $method, string $uri, string $body): string
{
    return hash_hmac('sha256', $timestamp . "\n" . $method . "\n" . $uri . "\n" . hash('sha256', $body), $secret);
}

/** True when the request really comes from MARZI. $uri is the path + query WITHOUT the folder your connector lives in. */
function marzi_is_genuine(string $secret, string $uri, int $maxSkewSeconds = 300): bool
{
    $timestamp = $_SERVER['HTTP_X_MARZI_TIMESTAMP'] ?? '';
    $given = strtolower($_SERVER['HTTP_X_MARZI_SIGNATURE'] ?? '');
    if (!ctype_digit($timestamp) || $given === '') {
        return false;
    }
    if (abs(time() - (int)$timestamp) > $maxSkewSeconds) {
        return false; // too old: someone may be replaying it
    }
    $body = file_get_contents('php://input') ?: '';
    return hash_equals(marzi_sign($secret, $timestamp, $_SERVER['REQUEST_METHOD'], $uri, $body), $given); // never compare with ==
}

if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) {
    // Test vector from the documentation: must print 6555990f154cc9a91c0fb5182bfb991bebcca1fed0a1f137a11f0be74d5913d7
    echo marzi_sign('test-secret-key-0123456789abcdef', '1700000000', 'POST', '/v1/auth/verify?x=1', '{"username":"khach","password":"mat khau"}'), "\n";
}

C# (.NET 6+)

// C# (.NET 6+). Check or make a MARZI signature.
// NOTE: this file was written to match the other languages and the test vector, but it has NOT been compiled or run by
// the MARZI team. Check it against the test vector in the documentation before you rely on it.
using System;
using System.Security.Cryptography;
using System.Text;

public static class MarziSignature
{
    /// hex( HMAC-SHA256( secret, timestamp \n METHOD \n path?query \n sha256hex(body) ) ); body is the raw bytes (empty for GET).
    public static string Sign(string secret, string timestamp, string method, string uri, byte[] body)
    {
        var bodyHash = Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant();
        var payload = $"{timestamp}\n{method}\n{uri}\n{bodyHash}";
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
        return Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant();
    }

    /// True when the request really comes from MARZI. `uri` is the path + query WITHOUT the folder your connector lives in.
    public static bool IsFromMarzi(string secret, string? timestamp, string? signature, string method, string uri, byte[] body, int maxSkewSeconds = 300)
    {
        if (string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(signature) || !long.TryParse(timestamp, out var seconds)) return false;
        if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - seconds) > maxSkewSeconds) return false; // too old: maybe a replay
        var expected = Encoding.ASCII.GetBytes(Sign(secret, timestamp, method, uri, body));
        var actual = Encoding.ASCII.GetBytes(signature.ToLowerInvariant());
        return CryptographicOperations.FixedTimeEquals(expected, actual); // never compare with ==
    }

    // Test vector from the documentation: Sign("test-secret-key-0123456789abcdef", "1700000000", "POST", "/v1/auth/verify?x=1",
    //   Encoding.UTF8.GetBytes("{\"username\":\"khach\",\"password\":\"mat khau\"}"))
    // must return 6555990f154cc9a91c0fb5182bfb991bebcca1fed0a1f137a11f0be74d5913d7
}

WordPress

Viết connector thành một plugin nhỏ: đăng ký route bằng register_rest_route('marzi/v1', ...) hoặc dùng một file PHP riêng ngoài WordPress. Lưu ý hai điều: (1) kiểm chữ ký trên đường dẫn đã bỏ tiền tố (/wp-json/marzi) và trên body thô ($request->get_body()); (2) tắt cache trang cho các đường dẫn này và cho phép chúng đi qua plugin bảo mật/tường lửa (Wordfence, Cloudflare...).

5. Làm theo từng giai đoạn

Connector khai báo ở GET /v1/capabilities những khả năng nó có. BMWEB chỉ bật tính năng tương ứng, nên bạn có thể giao từng phần. Khuyến nghị làm theo thứ tự:

#Khả năng (capabilities)Người dùng thấy gìĐường dẫn
1products.read, categories.readApp người mua xem sản phẩm, danh mục, tìm kiếm.3
2auth.verify, orders.readĐăng nhập bằng tài khoản website, giá thành viên, xem lịch sử đơn.4
3orders.createGiỏ hàng và đặt hàng ngay trong app.2 (cộng phương thức thanh toán)
4staff.verify, staff.orders, staff.customers, staff.productsApp quản lý: chủ cửa hàng đăng nhập, xem và lọc đơn, khách hàng, sản phẩm.11
5staff.orders.write, staff.products.writeĐổi trạng thái đơn, đánh dấu đã thanh toán, bật/tắt và sửa nhanh giá sản phẩm.4
6pages.readTrang giới thiệu, tin tức, khuyến mãi trong app người mua.4
7auth.qrNgười dùng đăng nhập trang web của khách bằng cách quét mã QR bằng app BMWEB (mục 6.6).3 + trang đăng nhập của website
8courses.readTab Khóa học trong app người mua: danh sách khóa, chương, bài, video, các khóa học viên đã đăng ký (mục 6.8). Cần auth.verify để học viên đăng nhập.5
9courses.enroll, staff.courses, staff.courses.writeHọc viên bấm "Đăng ký học"; chủ trung tâm xem đăng ký, mở khóa, khóa lại và tặng khóa trong app quản lý (cần staff.verify).4
10booking.readTab Đặt lịch: cơ sở, dịch vụ, sân hoặc nhân viên, lịch trống (mục 6.9).5
11booking.create, booking.paymentĐặt, hủy, lịch của tôi, đặt lặp lại; tiền cọc bằng mã QR rồi BMWEB báo về website.4 + 1
12booking.packages, booking.gamesThẻ nhiều buổi, hội viên giảm giá; trận mở, lớp, sự kiện để ghép kèo.8
13staff.booking, staff.booking.writeChủ cơ sở xem lịch từng ngày, xác nhận, hoàn thành, không đến, hủy, nhận tiền, đặt giúp khách, xác nhận thẻ.7
Giai đoạn 1 là đủ để có app chạy

Chỉ cần /v1/capabilities, /v1/products, /v1/products/{id} (và /v1/categories nếu muốn lọc theo danh mục). Phần còn lại thêm sau, mỗi lần thêm nhớ cập nhật danh sách capabilities.

6. Chi tiết từng đường dẫn

Quy ước chung: id luôn là chuỗi ("12", không phải số 12); tiền là số (không phải chuỗi) theo đơn vị nhỏ nhất của loại tiền cửa hàng đang dùng (VND, KRW không có số lẻ); thời gian theo ISO 8601 UTC; chữ nhiều ngôn ngữ ở dạng {"vi":..,"en":..,"kr":..} (tiếng Hàn là kr, không phải ko; chưa dịch thì lặp lại tiếng Việt); trường không có giá trị trả null, không bỏ trường. Phân trang bằng page (từ 1) và limit (tối đa 100). Các ví dụ dưới đây là câu trả lời thật của bản mẫu, đã rút gọn danh sách còn một phần tử.

6.1 Khả năng bắt buộc

GET /v1/capabilities

{
  "connector": "marzi-sample",
  "contract": 1,
  "capabilities": [
    "products.read",
    "categories.read",
    "auth.verify",
    "orders.read",
    "orders.create",
    "staff.verify",
    "staff.orders",
    "staff.orders.write",
    "staff.customers",
    "staff.products",
    "staff.products.write",
    "pages.read"
  ]
}

6.2 Sản phẩm: products.read, categories.read

GET /v1/products?page=1&limit=20&q=&category=&member=

Tham sốÝ nghĩa
qTìm trong tên và mã sản phẩm.
categoryLọc theo id danh mục.
memberCó khi người mua đã đăng nhập: trả giá thành viên cho họ (nếu website có).
{
  "items": [
    {
      "id": "1",
      "code": "BAN001",
      "names": {
        "vi": "Bánh su kem",
        "en": "Cream puffs",
        "kr": "슈크림"
      },
      "description": "Vỏ giòn, nhân kem vani béo nhẹ.",
      "price": 199000,
      "regularPrice": 35000,
      "variants": [
        {
          "id": "1-6",
          "name": "Hộp 6 cái",
          "price": 199000,
          "stock": 40
        },
        {
          "id": "1-12",
          "name": "Hộp 12 cái",
          "price": 359000,
          "stock": 25
        }
      ],
      "image": "p1.png",
      "stock": 60
    }
  ],
  "total": 9
}

Một sản phẩm có quy cách:

{
  "id": "1",
  "code": "BAN001",
  "names": {
    "vi": "Bánh su kem",
    "en": "Cream puffs",
    "kr": "슈크림"
  },
  "description": "Vỏ giòn, nhân kem vani béo nhẹ.",
  "price": 199000,
  "regularPrice": 35000,
  "variants": [
    {
      "id": "1-6",
      "name": "Hộp 6 cái",
      "price": 199000,
      "stock": 40
    },
    {
      "id": "1-12",
      "name": "Hộp 12 cái",
      "price": 359000,
      "stock": 25
    }
  ],
  "image": "p1.png",
  "stock": 60
}

GET /v1/products/{id}?member= trả một sản phẩm như trên; không có hoặc đã ẩn thì trả 404.

Sản phẩm có nhiều lựa chọn (không bắt buộc)

Có hai cách, website tự chọn cách hợp với dữ liệu của mình, không cần cài đặt gì ở bảng điều khiển; app nhận ra qua dữ liệu được trả về, và hai cách dùng lẫn trong một cửa hàng được.

{
  "id": "14",
  "code": "DRK104",
  "names": {
    "vi": "Trà trái cây",
    "en": "Fruit tea",
    "kr": "과일 티"
  },
  "description": "Trà thanh mát, chọn hương vị và size theo ý bạn.",
  "price": 39000,
  "regularPrice": 46000,
  "variants": [],
  "image": "tra-dau.png",
  "stock": 18,
  "group": {
    "id": "tra-trai-cay",
    "options": [
      {
        "name": "Hương vị",
        "value": "Dâu",
        "names": { "vi": "Hương vị", "en": "Flavor", "kr": "맛" },
        "values": { "vi": "Dâu", "en": "Strawberry", "kr": "딸기" }
      },
      { "name": "Size", "value": "M" }
    ]
  }
}

Bộ kiểm tra check.js tự kiểm thêm hai điểm trên khi website có trả group.

GET /v1/categories

{
  "items": [
    {
      "id": "banh",
      "names": {
        "vi": "Bánh ngọt",
        "en": "Pastries",
        "kr": "베이커리"
      }
    },
    {
      "id": "do-uong",
      "names": {
        "vi": "Đồ uống",
        "en": "Drinks",
        "kr": "음료"
      }
    },
    {
      "id": "qua-tang",
      "names": {
        "vi": "Quà tặng",
        "en": "Gifts",
        "kr": "선물"
      }
    }
  ]
}

6.3 Người mua: auth.verify, orders.read, orders.create

POST /v1/auth/verify, body {"username":"...","password":"..."}. Đúng thì trả thông tin người mua; sai trả 401 với "error":"invalid_credentials".

{
  "memberId": "1",
  "username": "khachmau",
  "name": "Nguyễn Thị Lan",
  "email": "lan@example.com",
  "phone": "0900000001"
}

GET /v1/members/{memberId}/orders?page=1&limit=20: đơn của người đó, mới nhất trước. GET /v1/members/{memberId}/orders/{id}: một đơn; đơn của người khác phải trả 404.

{
  "id": "1001",
  "code": "MAU1001",
  "status": "pending",
  "paid": false,
  "createdAt": "2026-10-17T03:08:45.000Z",
  "fullName": "Nguyễn Thị Lan",
  "phone": "0900000001",
  "address": "Số 12, đường Hoa Mai, Quận Mẫu",
  "note": "Giao giờ hành chính",
  "subtotal": 429000,
  "shippingFee": 0,
  "total": 429000,
  "lines": [
    {
      "productId": "1",
      "name": "Bánh su kem (Hộp 12 cái)",
      "unitPrice": 359000,
      "quantity": 1
    },
    {
      "productId": "4",
      "name": "Cà phê sữa đá (Size M)",
      "unitPrice": 35000,
      "quantity": 2
    }
  ]
}

status là trạng thái rút gọn hiển thị cho người mua: pending, confirmed, packing, shipping, delivered, cancelled. Website có trạng thái riêng thì ánh xạ về giá trị gần nhất (không rõ: pending).

GET /v1/payment-methods:

{
  "items": [
    {
      "id": "1",
      "names": {
        "vi": "Thanh toán khi nhận hàng",
        "en": "Cash on delivery",
        "kr": "현금 결제"
      }
    },
    {
      "id": "2",
      "names": {
        "vi": "Chuyển khoản",
        "en": "Bank transfer",
        "kr": "계좌 이체"
      }
    }
  ]
}

POST /v1/members/{memberId}/orders: đặt hàng. App chỉ gửi "mua gì, bao nhiêu", không bao giờ gửi giá. Website tự tính giá, kiểm tồn kho, tính phí giao hàng và tổng tiền, rồi trả đơn vừa tạo (giống GET .../orders/{id}).

{
  "items": [
    {
      "productId": "1",
      "variantId": "1-6",
      "quantity": 2
    },
    {
      "productId": "4",
      "variantId": "4-m",
      "quantity": 1
    }
  ],
  "fullName": "Nguyễn Thị Lan",
  "phone": "0900000001",
  "address": "Số 12, đường Hoa Mai, Quận Mẫu",
  "note": "Giao giờ hành chính",
  "paymentMethodId": "1"
}

Nếu không nhận đơn (hết hàng, thiếu thông tin...) trả 422 kèm message bằng tiếng Việt dễ hiểu, app sẽ hiện nguyên văn cho người mua:

{
  "error": "invalid_order",
  "message": "Giỏ hàng đang trống"
}
Đơn phải tạo trọn vẹn hoặc không tạo gì.

Nếu bảng đơn của website không hỗ trợ giao dịch (ví dụ MyISAM), hãy tự xóa đơn dở khi ghi dòng sản phẩm lỗi. Đừng để lại đơn thiếu sản phẩm.

6.4 App quản lý: staff.*

Chủ cửa hàng đăng nhập bằng tài khoản quản trị của website. Connector phải áp đúng phân quyền của website cho từng tài khoản.

POST /v1/staff/verify, body như auth/verify. Sai mật khẩu: 401. Đúng nhưng tài khoản không được xem đơn: 403. canEdit=false nghĩa là chỉ được xem, không được sửa.

{
  "staffId": "1",
  "username": "chushop",
  "name": "Chủ cửa hàng Mẫu",
  "canEdit": true
}

Mọi đường dẫn dưới /v1/staff/{staffId}/... phải kiểm lại quyền của staffId đó ở mỗi lần gọi (đừng mặc định đã kiểm lúc đăng nhập). Không đủ quyền: 403.

{
  "error": "forbidden",
  "message": "This account may not manage orders"
}

Đơn hàng

Yêu cầuViệc làm
GET .../order-statusesCác trạng thái đơn mà cửa hàng tự đặt (số id do website quy định).
GET .../ordersDanh sách đơn. Tham số: page, limit, status (id), q (tìm mã/tên/điện thoại), since (sau thời điểm, unix), from (từ, tính cả), to (đến, không tính), paid (1 hoặc 0).
GET .../orders/stats?since=Số đơn mới và số đơn theo từng trạng thái, để app báo "có đơn mới".
GET .../orders/{id}Một đơn (đầy đủ thông tin người đặt).
POST .../orders/{id}/statusBody {"statusId": 2}. Trả đơn sau khi đổi. Sai id: 422. Yêu cầu staff.orders.write.
POST .../orders/{id}/paymentBody {"paid": true} (phải là true/false). Trả đơn sau khi đổi. Yêu cầu staff.orders.write.
amount là tổng của mọi đơn khớp bộ lọc, không chỉ trang hiện tại

Màn đơn hàng của app hiện "số đơn" và "doanh thu" của bộ lọc đang chọn. total là số đơn khớp, amount là tổng doanh thu của chúng (bỏ qua đơn đã hủy). Tính trong cơ sở dữ liệu, đừng cộng các dòng của một trang.

{
  "items": [
    {
      "id": "1001",
      "code": "MAU1001",
      "status": "pending",
      "paid": false,
      "createdAt": "2026-10-17T03:08:45.000Z",
      "fullName": "Nguyễn Thị Lan",
      "phone": "0900000001",
      "address": "Số 12, đường Hoa Mai, Quận Mẫu",
      "note": "Giao giờ hành chính",
      "subtotal": 429000,
      "shippingFee": 0,
      "total": 429000,
      "lines": [
        {
          "productId": "1",
          "name": "Bánh su kem (Hộp 12 cái)",
          "unitPrice": 359000,
          "quantity": 1
        },
        {
          "productId": "4",
          "name": "Cà phê sữa đá (Size M)",
          "unitPrice": 35000,
          "quantity": 2
        }
      ],
      "statusId": 1,
      "statusNames": {
        "vi": "Mới đặt",
        "en": "New",
        "kr": "신규"
      },
      "paymentMethodNames": {
        "vi": "Thanh toán khi nhận hàng",
        "en": "Cash on delivery",
        "kr": "현금 결제"
      },
      "memberId": "1",
      "email": "lan@example.com",
      "introCode": null
    }
  ],
  "total": 12,
  "amount": 3656000
}
{
  "items": [
    {
      "id": 1,
      "names": {
        "vi": "Mới đặt",
        "en": "New",
        "kr": "신규"
      }
    },
    {
      "id": 2,
      "names": {
        "vi": "Đã xác nhận",
        "en": "Confirmed",
        "kr": "확인됨"
      }
    },
    {
      "id": 3,
      "names": {
        "vi": "Đang đóng gói",
        "en": "Packing",
        "kr": "포장 중"
      }
    },
    {
      "id": 4,
      "names": {
        "vi": "Đang giao",
        "en": "Shipping",
        "kr": "배송 중"
      }
    },
    {
      "id": 5,
      "names": {
        "vi": "Đã giao",
        "en": "Delivered",
        "kr": "배송 완료"
      }
    },
    {
      "id": 6,
      "names": {
        "vi": "Đã hủy",
        "en": "Cancelled",
        "kr": "취소됨"
      }
    }
  ]
}
{
  "newSince": 11,
  "byStatus": {
    "1": 2,
    "2": 1,
    "3": 1,
    "4": 1,
    "5": 6,
    "6": 1
  },
  "now": 1792210127
}

Khách hàng

GET .../customers?page=&limit=&q=&sort= với sort là spent (chi tiêu), orders (số đơn), recent (mua gần đây) hoặc joined (mới tham gia). Số điện thoại trong danh sách phải che bớt (090****001). GET .../customers/{id} trả đầy đủ thông tin kèm 5 đơn gần nhất.

{
  "items": [
    {
      "id": "2",
      "name": "Trần Văn Minh",
      "phoneMasked": "090****002",
      "orders": 2,
      "spent": 1486000,
      "lastOrderAt": "2026-10-16T08:08:45.000Z",
      "joinedAt": "2026-08-18T04:08:45.000Z"
    }
  ],
  "total": 6
}

Sản phẩm (cho chủ cửa hàng)

GET .../products?page=&limit=&q=&visible= (visible là 1 hoặc 0; ở đây trả cả sản phẩm đang ẩn), GET .../products/{id}.

{
  "items": [
    {
      "id": "1",
      "code": "BAN001",
      "names": {
        "vi": "Bánh su kem",
        "en": "Cream puffs",
        "kr": "슈크림"
      },
      "image": "p1.png",
      "visible": true,
      "regularPrice": 35000,
      "salePrice": 0,
      "variants": [
        {
          "id": "1-6",
          "name": "Hộp 6 cái",
          "price": 199000,
          "salePrice": 0
        },
        {
          "id": "1-12",
          "name": "Hộp 12 cái",
          "price": 389000,
          "salePrice": 359000
        }
      ],
      "stock": 60
    }
  ],
  "total": 10
}

POST .../products/{id}/visibility với {"visible": false} để ẩn/hiện. POST .../products/{id} để sửa nhanh: chỉ trường nào có mặt mới đổi; kiểm tra hết trước rồi mới ghi, một trường sai thì không đổi gì (422). Giá trong khoảng 0 đến 1.000.000.000; tên 1 đến 255 ký tự. Cả hai đều yêu cầu staff.products.write.

{
  "names": {
    "vi": "Bánh su kem vani"
  },
  "regularPrice": 36000,
  "salePrice": 0,
  "variants": [
    {
      "id": "1-6",
      "price": 199000,
      "salePrice": 189000
    }
  ]
}

6.5 Trang nội dung: pages.read

Các phần của website có thể hiện thành trang trong app (Giới thiệu, Tin tức, Khuyến mãi...). kind là single (một bài duy nhất) hoặc list (danh sách bài). key chỉ gồm chữ thường và dấu gạch ngang. count = 0 nghĩa là chưa có gì để hiện, app sẽ ẩn mục đó.

Yêu cầuViệc làm
GET /v1/pagesDanh sách các mục.
GET /v1/pages/{key}Bài duy nhất của mục single, kèm contents (HTML).
GET /v1/pages/{key}/articles?page=&limit=Danh sách bài của mục list, không kèm contents.
GET /v1/pages/{key}/articles/{id}Một bài kèm contents.
{
  "items": [
    {
      "key": "about",
      "kind": "single",
      "names": {
        "vi": "Giới thiệu",
        "en": "About us",
        "kr": "회사 소개"
      },
      "count": 1
    },
    {
      "key": "news",
      "kind": "list",
      "names": {
        "vi": "Tin tức",
        "en": "News",
        "kr": "뉴스"
      },
      "count": 2
    },
    {
      "key": "promotion",
      "kind": "list",
      "names": {
        "vi": "Khuyến mãi",
        "en": "Promotions",
        "kr": "프로모션"
      },
      "count": 1
    }
  ]
}
{
  "id": "1",
  "names": {
    "vi": "Khai trương chi nhánh mới",
    "en": "Khai trương chi nhánh mới",
    "kr": "Khai trương chi nhánh mới"
  },
  "summaries": {
    "vi": "Chúng tôi vừa mở thêm một cửa hàng.",
    "en": "Chúng tôi vừa mở thêm một cửa hàng.",
    "kr": "Chúng tôi vừa mở thêm một cửa hàng."
  },
  "image": "upload/news/n2.png",
  "publishedAt": "2026-10-12T04:08:45.000Z",
  "contents": {
    "vi": "<p>Nội dung bài viết mẫu.</p>",
    "en": "<p>Nội dung bài viết mẫu.</p>",
    "kr": "<p>Nội dung bài viết mẫu.</p>"
  }
}

image của bài viết là đường dẫn trên website (ví dụ upload/news/x.jpg), khác với ảnh sản phẩm chỉ là tên file; BMWEB ghép với "Địa chỉ website" ở bảng điều khiển.

6.6 Đăng nhập website bằng mã QR: auth.qr

Người mua hoặc chủ cửa hàng đã đăng nhập trong app BMWEB có thể đăng nhập trang web của khách bằng cách quét một mã QR hiện trên trang đăng nhập (giống đăng nhập web của Zalo, WhatsApp hay Naver). Phần "tạo phiên đăng nhập" nằm trên website của bạn; BMWEB chỉ báo lại "người này đã đồng ý".

Mã trong QR có dạng:

{
  "note": "what the QR code says",
  "link": "marzi://login?shop=mau-demo&sid=r-MGJd0ElohtJMPzHHH29xTaUGudxGbX"
}

GET /v1/qr-login/{sid}: mô tả mã (404 nếu không có, đã hết hạn, đã dùng hoặc bị từ chối). Việc xem không được làm ai đăng nhập.

{
  "sid": "r-MGJd0ElohtJMPzHHH29xTaUGudxGbX",
  "expiresAt": "2026-10-17T05:38:14.969Z",
  "site": "Cửa hàng Mẫu",
  "requester": {
    "ip": "127.0.0.1",
    "device": "Trình duyệt trên máy tính",
    "place": null,
    "startedAt": "2026-10-17T05:36:14.969Z"
  },
  "roles": [
    "member",
    "staff"
  ]
}
TrườngÝ nghĩa
expiresAtHết hạn (ISO 8601 UTC). Nên chỉ khoảng 2 phút.
siteTên website hiện trên màn xác nhận.
requesterAi đang mở trang đăng nhập: ip, device (ví dụ "Chrome trên Windows"), place (nơi, nếu biết), startedAt. App hiện nguyên văn để người dùng nhận ra có đúng là mình không. Trường không biết thì trả null.
rolesLoại tài khoản trang này nhận: member (người mua), staff (quản trị). Trang đăng nhập khách chỉ để ["member"]; trang quản trị chỉ để ["staff"].

POST /v1/qr-login/{sid}/approve:

{
  "role": "member",
  "userId": "1"
}
{
  "ok": true
}

Mã không còn: 404. Không muốn cho tài khoản này đăng nhập bằng cách này (ví dụ quản trị bị cấm): 422 kèm lời giải thích; BMWEB hiện lời đó cho người dùng.

Điều bắt buộc để không bị chiếm phiên
  • Trình duyệt nhận phiên bằng token riêng (không có trong mã QR). Nếu để ai biết sid cũng nhận được phiên, một người lạ nhìn thấy mã QR có thể đăng nhập thay người đã quét.
  • sid ngẫu nhiên, dùng một lần, ngắn hạn (khoảng 2 phút). Sau khi đồng ý, từ chối hoặc hết hạn: xóa.
  • Trả thật requester (địa chỉ IP và trình duyệt của người mở trang). Đây là thứ duy nhất giúp người dùng phát hiện một trang giả mạo hiện hộ mã QR của bạn rồi dụ họ quét.
  • Chỉ tin lời gọi có chữ ký BMWEB (mục 4). Mọi đường dẫn /v1/qr-login/... cũng phải qua bước kiểm chữ ký như các đường dẫn khác.
  • Giới hạn tạo mã theo địa chỉ IP trên trang đăng nhập (chống tạo hàng loạt) và giới hạn số lần hỏi trạng thái.
  • Tài khoản quản trị có quyền cao: cân nhắc chỉ cho đăng nhập bằng mã QR nếu quản trị đó có thêm bước xác nhận, hoặc chỉ dùng cho người mua.

Website bmweb v2 không cần tự làm: bộ cài connector-bmweb.zip có sẵn trang qr-login.php và các đường dẫn trên (xem mục "Đăng nhập website bằng mã QR" trong README.md của gói).

Trang đăng nhập mẫu của bản mẫu chạy tại http://127.0.0.1:8083/demo/login (mở trong trình duyệt, quét bằng app đã nối cửa hàng mau-demo). Mã nguồn nằm trong server.js (phần "QR sign-in") và demo/login.html.

Việc của app và BMWEB

App chỉ hiện nút "Đăng nhập website bằng mã QR" khi website khai báo auth.qr. BMWEB chặn mã của cửa hàng khác, giới hạn 20 lần thử mỗi phút cho mỗi tài khoản, và luôn dùng danh tính trong token của người dùng (app không thể nói "đăng nhập tôi thành người khác"). Website không cần làm gì thêm cho các việc này.

6.7 Đơn từ sàn BMWEB (không cần làm thêm gì)

Sàn BMWEB Market lấy sản phẩm của cửa hàng qua đúng những đường dẫn đã có (products.read, categories.read) và, với cửa hàng đã ký thỏa thuận phí sàn, gửi đơn qua POST /v1/members/{memberId}/orders (khả năng orders.create) như một đơn của một thành viên. Không có đường dẫn hay trường mới.

6.8 Khóa học và lớp học: courses.*

Cho website bán khóa học. App người mua có tab Khóa học; chủ trung tâm duyệt đăng ký trong app quản lý (hướng dẫn dùng ở Khóa học và lớp học). Website chỉ cần trả khóa, bài và ai được học gì; những thứ website hiếm khi có (học đến đâu, thời gian học, bình luận, giới hạn thiết bị) do BMWEB tự giữ, bạn không phải làm.

Khả năngĐường dẫnTrả về
courses.readGET /v1/courses?page&limit&q&member=<id>Danh sách khóa: id, names, description, price (giá phải trả), regularPrice (giá niêm yết), image (tên tệp hoặc địa chỉ), lessons (số bài) và, khi có member, access: none, pending (đã đăng ký, chờ), active (được học).
courses.readGET /v1/courses/{id}?member=Một khóa kèm sections: chương, mỗi chương có bài {id, names, free, durationSeconds, hasVideo}.
courses.readGET /v1/courses/{courseId}/lessons/{lessonId}?member=Nội dung bài: content (HTML, đã bỏ phần nhúng video) và video. 403 mã not_enrolled nếu học viên chưa được học khóa này và bài không miễn phí.
courses.readGET /v1/members/{memberId}/coursesCác đăng ký của học viên: courseId, status (pending hoặc active), createdAt, activatedAt, expiresAt hoặc null.
courses.enrollPOST /v1/members/{memberId}/courses/{courseId}/enroll{status, price}. Website tự quyết: đã trả tiền thì active, chưa thì pending; đăng ký lại một khóa đã có chỉ trả trạng thái hiện tại.
staff.coursesGET /v1/staff/{id}/enrollments?status=pending|active|all&q&page&limitDanh sách đăng ký cho nhân viên: id, courseId, courseName, memberId, memberName, phone, price, status, createdAt.
staff.courses.writePOST /v1/staff/{id}/enrollments/{enrollmentId}/status {"active": true}Mở (true) hoặc khóa lại (false) một đăng ký; trả đăng ký sau khi đổi.
staff.courses.writePOST /v1/staff/{id}/courses/{courseId}/grant {"memberId": "..."}Tặng khóa: tạo đăng ký (nếu chưa có) và mở luôn.

Một khóa trong danh sách:

{
  "items": [
    {
      "id": "1",
      "names": {
        "vi": "Lập trình PLC cơ bản",
        "en": "PLC programming basics",
        "kr": "PLC 프로그래밍 기초"
      },
      "description": "Từ con số 0 đến tự viết được chương trình điều khiển băng tải, đèn giao thông bằng PLC. Có bài tập sau mỗi chương.",
      "price": 590000,
      "regularPrice": 790000,
      "image": "khoa-plc.png",
      "lessons": 5,
      "access": "active"
    }
  ],
  "total": 3
}

Một khóa với chương và bài (rút gọn):

{
  "id": "1",
  "names": {
    "vi": "Lập trình PLC cơ bản",
    "en": "PLC programming basics",
    "kr": "PLC 프로그래밍 기초"
  },
  "description": "Từ con số 0 đến tự viết được chương trình điều khiển băng tải, đèn giao thông bằng PLC. Có bài tập sau mỗi chương.",
  "price": 590000,
  "regularPrice": 790000,
  "image": "khoa-plc.png",
  "lessons": 5,
  "access": "active",
  "sections": [
    {
      "id": "11",
      "names": {
        "vi": "Chương 1: Làm quen với PLC",
        "en": "Chapter 1: Meet the PLC",
        "kr": "Chương 1: Làm quen với PLC"
      },
      "lessons": [
        {
          "id": "101",
          "names": {
            "vi": "Bài 1: PLC là gì, dùng để làm gì",
            "en": "Lesson 1: What a PLC is",
            "kr": "Bài 1: PLC là gì, dùng để làm gì"
          },
          "free": true,
          "durationSeconds": 596,
          "hasVideo": true
        },
        {
          "id": "102",
          "names": {
            "vi": "Bài 2: Cấu tạo và đấu nối",
            "en": "Lesson 2: Parts and wiring",
            "kr": "Bài 2: Cấu tạo và đấu nối"
          },
          "free": false,
          "durationSeconds": 653,
          "hasVideo": true
        }
      ]
    }
  ]
}

Một bài học; video.kind là youtube, vimeo, mp4, hls hoặc embed (trang nhúng của dịch vụ video như Bunny Stream):

{
  "id": "102",
  "courseId": "1",
  "names": {
    "vi": "Bài 2: Cấu tạo và đấu nối",
    "en": "Lesson 2: Parts and wiring",
    "kr": "Bài 2: Cấu tạo và đấu nối"
  },
  "free": false,
  "content": "<h3>Cấu tạo</h3><p>Nguồn, CPU, ngõ vào, ngõ ra. Lưu ý đấu nối an toàn: luôn ngắt nguồn trước khi đấu dây.</p>",
  "video": {
    "kind": "mp4",
    "url": "https://example.com/video/ed.mp4"
  }
}

Học viên chưa được học bài (HTTP 403; app hiện nguyên message):

{
  "error": "not_enrolled",
  "message": "This course is not unlocked for this account"
}

Đăng ký của một học viên, và việc đăng ký một khóa:

{
  "items": [
    {
      "id": "1",
      "courseId": "1",
      "status": "active",
      "price": 590000,
      "createdAt": "2026-10-14T09:10:00+07:00",
      "activatedAt": "2026-10-14T10:00:00+07:00",
      "expiresAt": null
    },
    {
      "id": "2",
      "courseId": "2",
      "status": "pending",
      "price": 390000,
      "createdAt": "2026-10-20T20:30:00+07:00",
      "activatedAt": null,
      "expiresAt": null
    }
  ]
}
{
  "status": "pending",
  "price": 390000
}

Danh sách đăng ký cho nhân viên, và kết quả khi mở khóa:

{
  "items": [
    {
      "id": "101",
      "courseId": "2",
      "courseName": "Excel cho người đi làm",
      "memberId": "3",
      "memberName": "Lê Thu Trang",
      "phone": "0900000013",
      "price": 390000,
      "status": "pending",
      "createdAt": "2026-10-21T07:59:40.984Z"
    },
    {
      "id": "3",
      "courseId": "1",
      "courseName": "Lập trình PLC cơ bản",
      "memberId": "2",
      "memberName": "Trần Quốc Huy",
      "phone": "0900000012",
      "price": 590000,
      "status": "pending",
      "createdAt": "2026-10-21T08:15:00+07:00"
    }
  ],
  "total": 3
}
{
  "id": "3",
  "courseId": "1",
  "courseName": "Lập trình PLC cơ bản",
  "memberId": "2",
  "memberName": "Trần Quốc Huy",
  "phone": "0900000012",
  "price": 590000,
  "status": "active",
  "createdAt": "2026-10-21T08:15:00+07:00"
}

6.9 Đặt lịch: booking.*

Một động cơ đặt lịch dùng chung cho sân thể thao, spa, massage, cắt tóc, khách sạn, tour. Tài liệu thiết kế đầy đủ nằm ở docs/DAT_LICH.md; hướng dẫn dùng, có hình động, ở Đặt lịch. Website là nguồn sự thật: lịch trống, giá, giờ mở cửa, tiền cọc, chính sách hủy và việc không để hai người đặt trùng một giờ đều do website quyết định. BMWEB chỉ hỏi, hiện ra, nhận tiền cọc bằng mã QR và báo lại cho website.

Ba loại dịch vụ, cùng các màn hình
kindVí dụNgười đặt chọnLựa chọn trống (offers)
slotsân, spa, massage, cắt tócngày, thời lượng, khung giờ, sân hoặc nhân viênmỗi phần tử là một giờ bắt đầu trên một sân/nhân viên, còn trống
staykhách sạnngày nhận, số đêm, số kháchmỗi phần tử là một loại phòng còn phòng cho cả kỳ (left = số phòng còn); nhận phòng 14:00, trả phòng 12:00
departuretour, xe, tàungày, số ngườimỗi phần tử là một chuyến (resourceId = mã chuyến, left = số chỗ còn, price = giá cho đúng số người hỏi)

Vé máy bay thương mại không nằm trong chuẩn này (cần hệ thống đặt chỗ hàng không); đơn vị tự bán chỗ trên chuyến của mình dùng departure.

Khả năngĐường dẫnViệc
booking.readGET /v1/venuesCơ sở: id, names, address, phone, image, hours (giờ mở cửa từng thứ, hoặc null).
booking.readGET /v1/booking/services?venue=Dịch vụ của cơ sở: kind, price (giá từ) và priceUnit, durations (phút), minGuests/maxGuests, pickResource, repeat, packages.
booking.readGET /v1/booking/resources?service=Sân, nhân viên, loại phòng: names, tags (ví dụ covered, glass), capacity.
booking.readGET /v1/booking/availability?service=&date=&duration=&guests=&resource= (hoặc from=&to= cho stay){kind, offers:[{start, end, resourceId, resourceNames, tags, price, left, image}]}. Chỉ trả những lựa chọn còn trống; giá đã tính theo giờ cao điểm, cuối tuần, số đêm, số người.
booking.createPOST /v1/members/{memberId}/bookingsĐặt. Trả {items:[Booking]} (nhiều phần tử khi repeat). 409 mã slot_taken khi giờ đã có người đặt (đặt lặp lại: không đặt buổi nào); 422 khi sai luật (ngoài giờ mở cửa, quá hạn mức, sai số khách...).
booking.createGET /v1/members/{memberId}/bookings?scope=upcoming|past|all và .../{id}Lịch của tôi, một lịch (404 nếu không phải của họ).
booking.createPOST /v1/members/{memberId}/bookings/{id}/cancel {"all": false}Hủy một lịch hoặc cả chuỗi từ buổi này. 422 khi không hủy được. Hủy muộn có cancelFee (phí website tự tính).
booking.paymentPOST /v1/bookings/{id}/payment {amount, reference}BMWEB báo đã nhận cọc. Website đặt deposit.paid và đổi pending_payment thành confirmed. Gọi lại nhiều lần vẫn an toàn.
booking.packagesGET /v1/booking/packages, GET /v1/members/{memberId}/packages, POST /v1/members/{memberId}/packages/{id}/buy, POST /v1/member-packages/{id}/paymentThẻ sessions (số buổi), hội viên membership (giảm %), thẻ tiền credit. Mua xong ở pending_payment đến khi nhận tiền. Đặt lịch kèm memberPackageId thì trừ một buổi.
booking.gamesGET /v1/games, POST /v1/members/{memberId}/games, .../games/{id}/join, .../leaveTrận mở, lớp, sự kiện để ghép kèo: trình độ (level), danh sách người chơi, giá mỗi người. Mở trận từ một lịch đã đặt. 409 khi đầy, 422 khi trình độ không hợp.
staff.bookingGET /v1/staff/{id}/bookings?from=&to=&venue=&status=&q=, .../bookings/{id}, GET /v1/staff/{id}/packages?status=pending_payment|active|allLịch cho chủ cơ sở (kèm customer) và các thẻ khách đã mua.
staff.booking.writePOST /v1/staff/{id}/bookings, .../bookings/{id}/status, .../bookings/{id}/payment, POST /v1/staff/{id}/packages/{id}/paymentĐặt giúp khách, đổi trạng thái (confirmed, completed, no_show, cancelled), xác nhận đã nhận tiền cọc hoặc tiền thẻ. Website kiểm quyền nhân viên mỗi lần gọi (403).

Cơ sở và dịch vụ:

{
  "items": [
    {
      "id": "1",
      "names": {
        "vi": "Câu lạc bộ Padel Mẫu",
        "en": "Sample Padel Club",
        "kr": "Câu lạc bộ Padel Mẫu"
      },
      "address": "12 đường Thảo Điền, Quận Mẫu",
      "phone": "0281234001",
      "image": "dl-padel.png",
      "hours": [
        {
          "day": 0,
          "open": "06:00",
          "close": "22:00"
        },
        {
          "day": 1,
          "open": "06:00",
          "close": "22:00"
        },
        {
          "day": 2,
          "open": "06:00",
          "close": "22:00"
        },
        {
          "day": 3,
          "open": "06:00",
          "close": "22:00"
        },
        {
          "day": 4,
          "open": "06:00",
          "close": "22:00"
        },
        {
          "day": 5,
          "open": "06:00",
          "close": "22:00"
        },
        {
          "day": 6,
          "open": "06:00",
          "close": "22:00"
        }
      ]
    }
  ]
}
{
  "items": [
    {
      "id": "1",
      "venueId": "1",
      "names": {
        "vi": "Thuê sân padel",
        "en": "Padel court",
        "kr": "Thuê sân padel"
      },
      "description": "Sân tiêu chuẩn, giờ cao điểm 17:00-21:00 và cuối tuần giá cao hơn. Đặt 30% để giữ sân; hủy miễn phí trước 6 giờ.",
      "category": {
        "vi": "Thể thao",
        "en": "Sports",
        "kr": "Thể thao"
      },
      "kind": "slot",
      "image": "dl-padel.png",
      "priceUnit": "hour",
      "durations": [
        60,
        90,
        120
      ],
      "minGuests": 1,
      "maxGuests": 4,
      "pickResource": true,
      "repeat": true,
      "packages": true,
      "price": 250000,
      "depositPercent": 30
    }
  ]
}

Sân (tags để người đặt lọc theo có mái, có kính...) và những giờ còn trống của một sân:

{
  "items": [
    {
      "id": "1",
      "serviceIds": [
        "1"
      ],
      "names": {
        "vi": "Sân 1 (có mái)",
        "en": "Court 1 (covered)",
        "kr": "Sân 1 (có mái)"
      },
      "tags": [
        "covered"
      ],
      "capacity": 4,
      "image": null
    },
    {
      "id": "2",
      "serviceIds": [
        "1"
      ],
      "names": {
        "vi": "Sân 2 (có mái, kính)",
        "en": "Court 2 (covered, glass)",
        "kr": "Sân 2 (có mái, kính)"
      },
      "tags": [
        "covered",
        "glass"
      ],
      "capacity": 4,
      "image": null
    }
  ]
}
{
  "kind": "slot",
  "offers": [
    {
      "start": "2026-10-24T17:30",
      "end": "2026-10-24T19:00",
      "resourceId": "2",
      "resourceNames": {
        "vi": "Sân 2 (có mái, kính)",
        "en": "Court 2 (covered, glass)",
        "kr": "Sân 2 (có mái, kính)"
      },
      "tags": [
        "covered",
        "glass"
      ],
      "price": 525000,
      "left": null,
      "image": null
    },
    {
      "start": "2026-10-24T18:00",
      "end": "2026-10-24T19:30",
      "resourceId": "2",
      "resourceNames": {
        "vi": "Sân 2 (có mái, kính)",
        "en": "Court 2 (covered, glass)",
        "kr": "Sân 2 (có mái, kính)"
      },
      "tags": [
        "covered",
        "glass"
      ],
      "price": 525000,
      "left": null,
      "image": null
    }
  ]
}

Gửi yêu cầu đặt, và một lịch đang chờ cọc (pending_payment: website giữ chỗ đến deposit.dueBy, quá hạn thì tự hủy):

{
  "serviceId": "1",
  "resourceId": "2",
  "start": "2026-10-24T18:00",
  "end": "2026-10-24T19:30",
  "guests": 4,
  "note": "Sinh nhật nhóm bạn"
}
{
  "id": "5001",
  "code": "DL5001",
  "status": "pending_payment",
  "venueId": "1",
  "venueName": "Câu lạc bộ Padel Mẫu",
  "serviceId": "1",
  "serviceNames": {
    "vi": "Thuê sân padel",
    "en": "Padel court",
    "kr": "Thuê sân padel"
  },
  "resourceId": "2",
  "resourceNames": {
    "vi": "Sân 2 (có mái, kính)",
    "en": "Court 2 (covered, glass)",
    "kr": "Sân 2 (có mái, kính)"
  },
  "start": "2026-10-24T18:00",
  "end": "2026-10-24T19:30",
  "guests": 4,
  "price": 525000,
  "paidAmount": 0,
  "deposit": {
    "amount": 158000,
    "paid": false,
    "dueBy": "2026-10-21T16:50:00+07:00"
  },
  "policy": {
    "freeUntil": "2026-10-24T12:00:00+07:00",
    "lateFee": 100000
  },
  "canCancel": true,
  "cancelFee": null,
  "seriesId": null,
  "note": "Sinh nhật nhóm bạn",
  "createdAt": "2026-10-21T09:35:07.827Z"
}

Người thứ hai đặt đúng giờ đó (HTTP 409; app hiện nguyên message và tải lại giờ trống):

{
  "error": "slot_taken",
  "message": "Giờ này vừa có người đặt, hãy chọn giờ khác"
}

BMWEB báo đã nhận cọc, website xác nhận lịch:

{
  "id": "5001",
  "code": "DL5001",
  "status": "confirmed",
  "venueId": "1",
  "venueName": "Câu lạc bộ Padel Mẫu",
  "serviceId": "1",
  "serviceNames": {
    "vi": "Thuê sân padel",
    "en": "Padel court",
    "kr": "Thuê sân padel"
  },
  "resourceId": "2",
  "resourceNames": {
    "vi": "Sân 2 (có mái, kính)",
    "en": "Court 2 (covered, glass)",
    "kr": "Sân 2 (có mái, kính)"
  },
  "start": "2026-10-24T18:00",
  "end": "2026-10-24T19:30",
  "guests": 4,
  "price": 525000,
  "paidAmount": 158000,
  "deposit": {
    "amount": 158000,
    "paid": true,
    "dueBy": null
  },
  "policy": {
    "freeUntil": "2026-10-24T12:00:00+07:00",
    "lateFee": 100000
  },
  "canCancel": true,
  "cancelFee": null,
  "seriesId": null,
  "note": "Sinh nhật nhóm bạn",
  "createdAt": "2026-10-21T09:35:07.827Z"
}

Thẻ, mua thẻ, thẻ của khách (nhân viên xem), và trận mở:

{
  "items": [
    {
      "id": "1",
      "names": {
        "vi": "Thẻ 10 buổi sân padel",
        "en": "10 court sessions",
        "kr": "Thẻ 10 buổi sân padel"
      },
      "description": "10 lượt thuê sân 60-90 phút, dùng trong 6 tháng.",
      "type": "sessions",
      "price": 2000000,
      "sessions": 10,
      "validDays": 180,
      "discountPercent": null,
      "serviceIds": [
        "1"
      ]
    },
    {
      "id": "2",
      "names": {
        "vi": "Hội viên Bạc (giảm 15%)",
        "en": "Silver membership (15% off)",
        "kr": "Hội viên Bạc (giảm 15%)"
      },
      "description": "Giảm 15% mọi lượt thuê sân trong 3 tháng.",
      "type": "membership",
      "price": 600000,
      "sessions": null,
      "validDays": 90,
      "discountPercent": 15,
      "serviceIds": [
        "1"
      ]
    }
  ]
}
{
  "id": "MP5002",
  "status": "pending_payment",
  "price": 2000000
}
{
  "items": [
    {
      "id": "MP5002",
      "packageId": "1",
      "names": {
        "vi": "Thẻ 10 buổi sân padel",
        "en": "10 court sessions",
        "kr": "Thẻ 10 buổi sân padel"
      },
      "type": "sessions",
      "remaining": 10,
      "expiresAt": null,
      "status": "pending_payment",
      "memberName": "Nguyễn Hoàng Nam",
      "phone": "0900000021",
      "price": 2000000,
      "createdAt": "2026-10-21T09:35:07.865Z"
    }
  ],
  "total": 1
}
{
  "items": [
    {
      "id": "101",
      "kind": "game",
      "venueId": "1",
      "venueName": "Câu lạc bộ Padel Mẫu",
      "resourceNames": {
        "vi": "Sân 2 (có mái, kính)",
        "en": "Court 2 (covered, glass)",
        "kr": "Sân 2 (có mái, kính)"
      },
      "start": "2026-10-22T18:00",
      "end": "2026-10-22T19:30",
      "title": "Kèo tối thứ Tư",
      "level": {
        "from": 2,
        "to": 3.5,
        "kind": "match"
      },
      "capacity": 4,
      "players": [
        {
          "name": "Trần Mai Phương",
          "level": 3.5,
          "me": false
        },
        {
          "name": "Phạm Quang",
          "level": 3,
          "me": false
        },
        {
          "name": "Võ Thanh",
          "level": 2.5,
          "me": false
        }
      ],
      "pricePerPlayer": 120000,
      "status": "open",
      "joined": false,
      "host": false
    }
  ],
  "total": 5
}

Lịch của một ngày cho chủ cơ sở:

{
  "items": [
    {
      "id": "5001",
      "code": "DL5001",
      "status": "confirmed",
      "venueId": "1",
      "venueName": "Câu lạc bộ Padel Mẫu",
      "serviceId": "1",
      "serviceNames": {
        "vi": "Thuê sân padel",
        "en": "Padel court",
        "kr": "Thuê sân padel"
      },
      "resourceId": "2",
      "resourceNames": {
        "vi": "Sân 2 (có mái, kính)",
        "en": "Court 2 (covered, glass)",
        "kr": "Sân 2 (có mái, kính)"
      },
      "start": "2026-10-24T18:00",
      "end": "2026-10-24T19:30",
      "guests": 4,
      "price": 525000,
      "paidAmount": 158000,
      "deposit": {
        "amount": 158000,
        "paid": true,
        "dueBy": null
      },
      "policy": {
        "freeUntil": "2026-10-24T12:00:00+07:00",
        "lateFee": 100000
      },
      "canCancel": true,
      "cancelFee": null,
      "seriesId": null,
      "note": "Sinh nhật nhóm bạn",
      "createdAt": "2026-10-21T09:35:07.827Z",
      "customer": {
        "name": "Nguyễn Hoàng Nam",
        "phone": "0900000021"
      }
    }
  ],
  "total": 1
}

7. Mã lỗi

Khi lỗi, trả mã HTTP phù hợp và JSON {"error": "mã_ngắn", "message": "giải thích"}:

MãKhi nàoBMWEB làm gì
200Thành công.Dùng dữ liệu.
400Body POST không phải JSON hợp lệ.Báo lỗi tạm thời.
401Chữ ký sai/thiếu/quá cũ (unauthorized), hoặc sai tên đăng nhập/mật khẩu ở auth/verify và staff/verify (invalid_credentials).Với hai đường dẫn đăng nhập: coi là "sai mật khẩu". Với đường dẫn khác: báo cửa hàng tạm thời không dùng được.
403Tài khoản không đủ quyền (forbidden).Hiện message cho chủ cửa hàng.
404Không có thứ được hỏi, hoặc là của người khác (not_found).Coi là "không tồn tại".
422Yêu cầu không hợp lệ về nghiệp vụ (hết hàng, trạng thái lạ, giá sai...).Hiện message nguyên văn cho người dùng, nên viết bằng tiếng Việt dễ hiểu.
501Tính năng ghi chưa bật.Báo "cửa hàng tạm thời không dùng được". Vì vậy đừng khai báo khả năng ghi (orders.create, staff.*.write) khi chưa bật.
5xx, quá 10 giây, JSON hỏngLỗi của website.Báo "cửa hàng tạm thời không dùng được".
{
  "error": "unauthorized",
  "message": "Bad signature"
}
Đừng lẫn hai loại 401

Vì BMWEB coi mọi 401 ở đường dẫn đăng nhập là "sai mật khẩu", một connector ký sai khóa sẽ khiến mọi người dùng thấy "sai mật khẩu". Khi gặp tình huống này, đừng kiểm tài khoản trước: dùng nút Thử kết nối hoặc bộ kiểm tra (mục 8) để thấy lỗi chữ ký.

8. Kiểm thử

  1. Chạy bản mẫu để xem đúng chuẩn trông thế nào.
    cd mau
    MARZI_SECRET=khoa-thu-nghiem-dai-it-nhat-16-ky-tu node server.js
    # mở cổng 8083, dữ liệu giả, tài khoản: khachmau / chushop (mật khẩu Mau@12345)
  2. Chạy bộ kiểm tra tự động (chỉ cần Node.js 18 trở lên, không cài gì thêm). Nó gọi connector đúng như BMWEB gọi, kiểm chữ ký, kiểm hình dạng JSON từng đường dẫn, và chỉ đọc, không đặt đơn hay sửa gì.
    node check.js https://www.tenmien.com/marzi-connector KHOA_BI_MAT \
      --member tai-khoan-nguoi-mua:mat-khau --staff tai-khoan-quan-tri:mat-khau

    Kết quả mẫu (rút gọn):

    Chữ ký và an toàn
      [đạt] Từ chối yêu cầu ký sai khóa
      [đạt] Từ chối yêu cầu quá cũ (chống phát lại)
      [đạt] Từ chối yêu cầu không có chữ ký
    Sản phẩm (products.read)
      [đạt] GET /v1/products
      [LỖI] GET /v1/products/{id} - product.price: là string ("35000"), cần number
    Kết quả: 24 đạt, 1 lỗi.
  3. Nhập vào bảng điều khiển BMWEB (mục "Nguồn dữ liệu" của khách hàng): địa chỉ connector, khóa bí mật, bấm Thử kết nối. Thành công sẽ liệt kê các khả năng website cung cấp.
  4. Thử bằng tay trong app những việc bộ kiểm tra không tự làm vì có ghi dữ liệu: đặt một đơn thử, đổi trạng thái đơn, đánh dấu đã thanh toán, bật/tắt và sửa giá một sản phẩm thử.

Với website thật, nên tạo tài khoản thử riêng cho người mua và cho quản trị để dùng khi kiểm tra.

9. Lỗi thường gặp

Triệu chứngNguyên nhân thường gặp
"Website từ chối khóa bí mật" trong bảng điều khiểnKhóa hai bên khác nhau (thừa dấu cách/xuống dòng khi dán); connector nằm trong thư mục con mà chưa cắt tiền tố; đồng hồ máy chủ lệch quá 5 phút; ký trên query đã bị sắp xếp lại; hash body sau khi đã dựng lại JSON.
Bộ kiểm tra báo "chuyển hướng 301/302"Địa chỉ nhập là http:// trong khi website ép https://, hoặc thiếu/thừa www, hoặc thiếu dấu / cuối. Dùng đúng địa chỉ cuối cùng.
"không phải JSON hợp lệ"PHP/ASP in cảnh báo hay lỗi ra trước JSON; file có BOM; trang lỗi HTML của website; ký tự lạ khi xuất tiếng Việt (cần UTF-8).
"là string, cần number"Giá hoặc số lượng bị trả thành chuỗi ("35000"). Trả số. id thì ngược lại, phải là chuỗi.
Gọi từ máy bạn được, từ BMWEB thì khôngTường lửa, Cloudflare ("Under Attack", kiểm tra bot) hay plugin bảo mật chặn lời gọi giữa hai máy chủ. Cho phép các đường dẫn /v1/... của connector đi qua. Hãy hỏi chủ hệ thống BMWEB địa chỉ IP máy chủ khi cần đưa vào danh sách cho phép.
Ảnh sản phẩm không hiệnConnector trả cả đường dẫn thay vì chỉ tên file; hoặc "Thư mục ảnh" ở bảng điều khiển sai; hoặc ảnh chỉ mở được khi đã đăng nhập website.
Chậm hoặc báo tạm thời không dùng đượcTruy vấn không có chỉ mục; không giới hạn limit; trả quá 5 MB; quá 10 giây.
Giờ đơn lệch nhiều tiếngTrả giờ địa phương không kèm múi giờ. Dùng ISO 8601 UTC (kết thúc bằng Z).

10. Bảo mật cho connector

11. Tải về

FileNội dung
openapi.yamlĐặc tả OpenAPI 3 của toàn bộ chuẩn (57 đường dẫn, 61 thao tác). Dùng để sinh khung máy chủ (ví dụ openapi-generator cho ASP.NET, FastAPI, Express) hoặc để xem trong Swagger/Postman. Đã được đối chiếu với bản mẫu: mọi câu trả lời của bản mẫu đều khớp đặc tả.
mau/server.jsConnector mẫu bằng Node.js (không cần thư viện), dữ liệu giả: sản phẩm, đơn, khách hàng, trang nội dung. Đọc mã để xem cách kiểm chữ ký, định tuyến và trả lời.
mau/booking.jsPhần đặt lịch của connector mẫu (chạy với SAMPLE=booking, mục 6.9): sân, spa, tiệm tóc, khách sạn, tour, thẻ, trận mở.
mau/school.jsPhần dữ liệu và đường dẫn khóa học của connector mẫu (chạy với SAMPLE=school, mục 6.8).
mau/check.jsBộ kiểm tra tự động (mục 8), có cả phần đăng nhập bằng mã QR.
mau/demo/login.htmlTrang đăng nhập mẫu có mã QR (dùng thư viện tạo QR qrcode.js, giấy phép MIT).
sign.js · sign.py · sign.php · MarziSignature.csHàm ký và kiểm chữ ký bằng Node.js, Python, PHP, C#.
mau/test-vector.jsonGiá trị mẫu để thử hàm ký của bạn (mục 4).
connector-mau.zipTất cả các file trên trong một gói.
connector-bmweb.zipConnector PHP dựng sẵn cho website bmweb (PHP 7.0, 7.4, 8.x; bmweb v1 và v2): chép lên website, điền một tệp cấu hình, khai báo trong bảng điều khiển. Có trang tự kiểm tra (selftest.php) và hướng dẫn cài đặt tiếng Việt (README.md) trong gói.
Về hợp đồng

Chuẩn hiện tại là phiên bản 1 (khai báo ở trường contract của /v1/capabilities). Hãy cứ khai báo đúng danh sách khả năng website làm được; khả năng lạ BMWEB sẽ bỏ qua.