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

Kết nối website với MARZI

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 MARZI 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) MARZI 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. MARZI 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 marzi-bmweb-connector.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 MARZI. 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 MARZI 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-Marzi-TimestampGiờ gửi, tính bằng giây từ 1970 (unix), ví dụ 1700000000.
X-Marzi-SignatureChuỗi hex (chữ thường) của HMAC-SHA256(khóa bí mật, nội dung ký).

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 MARZI 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-marzi-timestamp'] || '');
  const given = String(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-Marzi-Timestamp", ""))
    given = str(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ó. MARZI 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 MARZI (mục 6.6).3 + trang đăng nhập của website
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; MARZI 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 MARZI 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; MARZI 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; MARZI 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ý MARZI (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.

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à MARZI

App chỉ hiện nút "Đăng nhập website bằng mã QR" khi website khai báo auth.qr. MARZI 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 MARZI (không cần làm thêm gì)

Sàn Marzi 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.

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àoMARZI 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ì MARZI 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ư MARZI 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 MARZI (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ừ MARZI 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 MARZI đị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 (27 đường dẫn, 29 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/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).
marzi-connector-mau.zipTất cả các file trên trong một gói.
marzi-bmweb-connector.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ạ MARZI sẽ bỏ qua.