VNCX Developer API
REST API cho hệ thống bên thứ ba (ERP, đối tác) — quản lý sản phẩm, lô, item, thùng/vận chuyển, tồn kho, bảo hành và truy xuất theo chuẩn tổ chức của bạn.
Bắt đầu nhanh
- 1
Vào Cài đặt → Tích hợp → Developer API, tạo API key và chọn các scope cần dùng (có thể giới hạn theo khu vực sản xuất).
- 2
Gọi API kèm key trong header
X-API-Key(hoặcAuthorization: Bearer). - 3
Ví dụ gọi bằng curl:
curl https://app.vncx.io/api/v1/products \ -H "X-API-Key: vncx_sk_..."
Xác thực
Mỗi request cần một API key hợp lệ, gửi qua một trong hai header: X-API-Key: <key> hoặc Authorization: Bearer <key>.
Key có định dạng vncx_sk_..., chỉ hiển thị đầy đủ một lần khi tạo/xoay vòng. Key điều hướng tổ chức của bạn và chỉ được phép gọi các endpoint nằm trong tập scope đã cấp — có thể giới hạn thêm theo khu vực sản xuất.
Rate limit: 600 request/phút cho mỗi key trên /api/v1. Mọi response đều kèm header RateLimit-* để theo dõi hạn mức còn lại.
Header X-EPCIS-API-Key cũng được chấp nhận (di sản của EPCIS partner API) — nhưng trên /api/v1 nó phải mang khoá vncx_sk_. Khoá EPCIS cũ dạng vncx_epc_... không gọi được /api/v1 (trả 403 unsupported_key_type); nó vẫn hoạt động y như cũ trên 3 route EPCIS v2 và các route M2M legacy (order-intake webhook, /api/public/warranty/*). Khoá EPCIS đã ngừng phát hành — để dùng /api/v1, hãy tạo khoá vncx_sk_ (có hạn dùng, xoay vòng và thu hồi) trong tab Developer API.
Các route EPCIS (GET /api/epcis/v2/events*) dùng chung mức 600 request/phút với key đó; riêng POST /api/epcis/v2/capture có mức riêng 3000 request/phút (nạp hàng loạt). Trước đây các route EPCIS không có giới hạn — một poller khối lượng lớn nay sẽ chạm ngưỡng 600/phút nếu dùng route query. Endpoint order-intake M2M POST /api/organization/{orgId}/orders/webhook (không thuộc /api/v1) cũng dùng chung ngưỡng 600/phút của key, tính gộp với mọi traffic /api/v1/EPCIS khác của cùng key — lưu ý khi đẩy đơn hàng loạt từ ERP.
Org phải bật module API cho /api/v1 — nếu chưa bật, endpoint trả 403 module_disabled theo envelope /api/v1. 3 route EPCIS v2 và order-intake webhook cũng cần module này, nhưng chúng dùng envelope khác: 403 { "error": "PREMIUM_REQUIRED", "message": "…" } (mã cần khớp là PREMIUM_REQUIRED, không phải module_disabled). Riêng 4 route /api/public/warranty/* không phụ thuộc module API — luồng đăng ký bảo hành của người tiêu dùng vẫn chạy khi module hết hạn.
Lưu ý về order-intake webhook: nó cần cả module order-fulfillment và module api.
Scopes
Mỗi API key được cấp một tập scope — chỉ endpoint khớp scope mới gọi được. Lưu ý: warranty:read và trace:read không giới hạn theo vùng — cấp thận trọng cho key đã bị giới hạn khu vực sản xuất.
| Scope | Mô tả | Nhóm |
|---|---|---|
products:read | Liệt kê & xem sản phẩm | Sản phẩm |
products:write | Tạo & cập nhật sản phẩm | Sản phẩm |
batches:read | Liệt kê & xem lô | Lô |
batches:write | Tạo & đổi trạng thái lô | Lô |
items:read | Liệt kê, xem, verify item | Sản phẩm đơn vị |
items:write | Báo hỏng item | Sản phẩm đơn vị |
inventory:read | Xem tồn kho | Tồn kho |
containers:read | Liệt kê & xem container | Thùng/Pallet |
containers:write | Tạo & đổi trạng thái container | Thùng/Pallet |
shipments:read | Liệt kê & xem shipment | Vận chuyển |
shipments:write | Tạo & nhận shipment | Vận chuyển |
orders:read | Liệt kê & xem đơn hàng (ERP đối chiếu trạng thái) | Đơn hàng |
orders:write | Nhận đơn, sửa đơn chưa duyệt và huỷ đơn qua M2M | Đơn hàng |
qrcodes:read | Verify mã QR | Mã QR |
warranty:read | Tra cứu bảo hành & claim | Bảo hành |
warranty:activate | Kích hoạt bảo hành cho sản phẩm (đường M2M của đối tác) | Bảo hành |
warranty:write | Tạo & cập nhật yêu cầu bảo hành (claim) | Bảo hành |
services:write | Ghi nhận kiểm định / thử áp lực / nạp lại cho một đơn vị hàng | services |
trace:read | Trace graph theo mã | Truy xuất |
epcis:read | Truy vấn events (need-to-know) | Sự kiện EPCIS |
epcis:write | Capture events | Sự kiện EPCIS |
orders:write chỉ dùng cho endpoint order-intake M2M (POST /api/organization/{orgId}/orders/webhook), không thuộc /api/v1. warranty:activate cấp quyền cho CẢ POST /api/v1/warranties (kích hoạt — có trong bảng Endpoints bên dưới) LẪN 2 endpoint xem trước M2M legacy /api/public/warranty/{product,dealers} và endpoint kích hoạt cũ /api/public/warranty/activate-by-product (đã deprecated, xem mục bên dưới) — ba route sau nằm ngoài /api/v1 nên không xuất hiện trong bảng Endpoints. warranty:activate (kích hoạt bảo hành) và warranty:write (tạo/cập nhật yêu cầu bảo hành) là hai scope tách biệt, không dùng thay cho nhau được.
warranty:write mở 2 endpoint thật: POST /api/v1/warranties/{id}/claims và PATCH /api/v1/warranties/{id}/claims/{claimId} — đây không còn là scope giữ chỗ. Xem mục Ghi bảo hành & Idempotency bên dưới để biết đầy đủ ngữ nghĩa (giới hạn vùng, consent, idempotency).
Endpoints
Xem đầy đủ trong OpenAPI →Sản phẩm
| get | /api/v1/products | products:read | List products |
| post | /api/v1/products | products:write | Create a product |
| post | /api/v1/products/bulk | products:write | Bulk upsert products by externalRef (create or update, up to 500 per call) |
| get | /api/v1/products/{id} | products:read | Get a product |
| patch | /api/v1/products/{id} | products:write | Update a product |
Lô
| get | /api/v1/batches | batches:read | List batches |
| post | /api/v1/batches | batches:write | Create a batch |
| post | /api/v1/batches/bulk | batches:write | Bulk upsert batches by externalRef (create or update, up to 500 per call) |
| get | /api/v1/batches/{id} | batches:read | Get a batch |
| patch | /api/v1/batches/{id}/status | batches:write | Update batch status |
Sản phẩm đơn vị
| get | /api/v1/items | items:read | List items |
| get | /api/v1/items/{id} | items:read | Get an item |
| patch | /api/v1/items/{id}/report | items:write | Report an item damaged |
| post | /api/v1/items/verify | items:read | Verify an item by serial number |
| post | /api/v1/items/{id}/services | services:write | Record an inspection / hydrostatic test / refill for one unit |
Thùng/Pallet
| get | /api/v1/containers | containers:read | List containers |
| post | /api/v1/containers | containers:write | Create a container |
| get | /api/v1/containers/{id} | containers:read | Get a container |
| patch | /api/v1/containers/{id}/status | containers:write | Update container status |
| post | /api/v1/containers/{id}/contents | containers:write | Add contents to a container |
orders
| get | /api/v1/orders | orders:read | List orders |
| get | /api/v1/orders/{id} | orders:read | Get an order |
| patch | /api/v1/orders/{id} | orders:write | Update an unapproved order |
| post | /api/v1/orders/{id}/cancel | orders:write | Cancel an order (releases reserved stock) |
Vận chuyển
| get | /api/v1/shipments | shipments:read | List shipments |
| post | /api/v1/shipments | shipments:write | Create a shipment |
| get | /api/v1/shipments/{id} | shipments:read | Get a shipment |
| post | /api/v1/shipments/{id}/receive | shipments:write | Receive a shipment |
Mã QR
| post | /api/v1/qrcodes/verify | qrcodes:read | Verify a QR code |
Tồn kho
| get | /api/v1/inventory | inventory:read | List inventory |
Bảo hành
| get | /api/v1/warranties | warranty:read | List warranties |
| post | /api/v1/warranties | warranty:activate | Activate a warranty (by product code or by QR) |
| get | /api/v1/warranties/{id} | warranty:read | Get a warranty |
| get | /api/v1/warranties/{id}/claims | warranty:read | List claims for a warranty |
| post | /api/v1/warranties/{id}/claims | warranty:write | Create a warranty claim |
| patch | /api/v1/warranties/{id}/claims/{claimId} | warranty:write | Update a warranty claim (status/priority/notes) |
Truy xuất
| get | /api/v1/trace/{code} | trace:read | Trace a product/batch/item by code |
Bulk sync (đa-ERP)
Đồng bộ hàng loạt sản phẩm hoặc lô từ hệ thống nguồn của bạn (ERP, WMS, ...) thay vì gọi từng request tạo/sửa một. Hai endpoint: POST /api/v1/products/bulk (scope products:write) và POST /api/v1/batches/bulk (scope batches:write).
Upsert theo externalRef + source
Body: { "source": "<tên hệ thống>", "items": [ ... ] }. Mỗi phần tử bắt buộc có externalRef — mã tham chiếu của chính hệ thống nguồn, không phải id nội bộ của VNCX. Hệ thống khớp theo bộ ba (tổ chức, source, externalRef): đã có bản ghi khớp thì cập nhật, chưa có thì tạo mới — bạn không cần biết trước id nội bộ, và có thể gọi lại nhiều lần với cùng dữ liệu nguồn mà không tạo bản ghi trùng.
Với lô: mỗi phần tử cần productId hoặc productExternalRef (tham chiếu sản phẩm bằng externalRef của cùng source thay vì id nội bộ).
curl -X POST https://app.vncx.io/api/v1/products/bulk \
-H "X-API-Key: vncx_sk_..." \
-H "Idempotency-Key: sap-sync-2026-07-13-01" \
-H "Content-Type: application/json" \
-d '{
"source": "sap-erp",
"items": [
{ "externalRef": "<externalRef-1>", "sku": "SKU-001", "name": "Sản phẩm A", "category": "food" },
{ "externalRef": "<externalRef-2>", "sku": "SKU-002", "name": "Sản phẩm B", "category": "food" }
]
}'Partial success — luôn 200, trừ lỗi toàn request
Một phần tử lỗi (externalRef trùng lặp trong cùng request, thiếu sản phẩm tham chiếu, lô ngoài vùng, ...) không làm hỏng cả request — response vẫn 200, chỉ riêng phần tử đó mang status: "failed". Chỉ khi cả request sai (body sai dạng, thiếu source, hoặc items vượt quá 500 phần tử) mới nhận 400 validation_error — không phần tử nào được xử lý.
{
"data": {
"results": [
{ "index": 0, "externalRef": "<externalRef-1>", "status": "created", "id": "prod_..." },
{ "index": 1, "externalRef": "<externalRef-2>", "status": "failed",
"error": { "code": "validation_error", "message": "..." } }
],
"summary": { "created": 1, "updated": 0, "failed": 1 }
},
"request_id": "..."
}results[i].statuslà một trong"created","updated","failed";indexlà vị trí trongitemsbạn gửi lên, để đối chiếu ngược.summarygộp số lượng theo trạng thái —{ created, updated, failed }.- Trùng
externalReftrong cùng request: phần tử đầu tiên được xử lý, các phần tử trùng sau đó nhậnfailed/validation_error. - Một lần gọi bulk chỉ tốn MỘT đơn vị rate-limit (600 request/phút của key), bất kể
itemscó bao nhiêu phần tử.
Idempotency-Key cho bulk: chỉ để retry y hệt
Cả hai endpoint bulk đều nhận Idempotency-Key (xem mục Idempotency bên dưới để biết cơ chế đầy đủ), nhưng key ở đây áp dụng cho cả request, không phải từng phần tử. Gửi lại đúng key + đúng body chỉ phát lại nguyên data.results đã lưu từ lần gọi trước — kể cả các phần tử "failed", hệ thống không tự thử lại chúng.
Muốn sửa/thử lại riêng các phần tử lỗi: gửi một request bulk mới (chỉ chứa các phần tử cần thử lại) kèm một Idempotency-Key mới — đừng tái sử dụng key cũ, vì key cũ chỉ phát lại kết quả cũ nguyên trạng.
Giới hạn vùng: sản phẩm KHÔNG, lô CÓ
products/bulk không giới hạn theo vùng — sản phẩm là tài nguyên cấp tổ chức, không gắn khu vực sản xuất. batches/bulk thì có: mỗi phần tử lô phải có productionAreaId nằm trong vùng được cấp cho key — lô ngoài vùng chỉ khiến riêng phần tử đó nhận status: "failed" với error.code: "forbidden", các phần tử khác trong cùng request không bị ảnh hưởng.
Đọc lại theo externalRef (GET filter)
Sau khi bulk sync, tra cứu lại đúng bản ghi ERP của bạn đã đẩy lên bằng GET /api/v1/products?externalRef=&source= hoặc GET /api/v1/batches?externalRef=&source= — không cần lưu id nội bộ của VNCX ở phía bạn, chỉ cần giữ đúng cặp source/externalRef hệ thống của bạn đã dùng khi bulk upsert.
Ghi bảo hành & Idempotency
Idempotency-Key
7 endpoint POST ghi trạng thái (sản phẩm, lô, thùng, vận chuyển + nhận vận chuyển, kích hoạt bảo hành, tạo yêu cầu bảo hành) nhận header Idempotency-Key. Gửi lại cùng key với cùng request (method, path, query string và body — nguyên văn) ⇒ hệ thống phát lại response cũ (kèm header Idempotent-Replay: true), không tạo bản ghi thứ hai.
- Cùng key + request KHÁC (body khác, hoặc cùng key dùng cho endpoint/query khác) ⇒
422 idempotency_conflict— vĩnh viễn, đổi key mới, đừng retry với key cũ. - Bản sao khác đang chạy song song dưới cùng key ⇒
409 idempotency_in_flight— tạm thời, thử lại sau (kèmRetry-After). - Key: 1–255 ký tự ASCII in được (không CR/LF/NUL). Sống 24 giờ.
- Chỉ lần ghi thành công (2xx) mới tiêu thụ key — nếu request bị từ chối (4xx), bạn dùng lại chính key đó sau khi sửa request.
- Hai endpoint xác thực (
/items/verify,/qrcodes/verify) không nhận header này — mỗi lần gửi mã ẩn là một lần đoán và phải được ghi nhật ký, replay sẽ che mất. PATCH cũng không nhận — đã idempotent theo ngữ nghĩa HTTP.
curl -X POST https://app.vncx.io/api/v1/warranties \
-H "X-API-Key: vncx_sk_..." \
-H "Idempotency-Key: 8f1c-order-99213" \
-H "Content-Type: application/json" \
-d '{
"mode": "product",
"gtin": "8935001234567",
"customerName": "Nguyễn Văn A",
"customerPhone": "0900000001",
"purchaseDate": "2026-07-10",
"sellingProductionAreaId": "area_...",
"consent": { "given": true, "collectedAt": "2026-07-10T03:00:00Z" }
}'Consent khi kích hoạt bảo hành
Khi bạn (đối tác) gọi thay người tiêu dùng, IP/User-Agent của request là của server bạn, không phải của khách — nên hệ thống không ghi chúng làm bằng chứng đồng ý. Thay vào đó, POST /api/v1/warranties yêu cầu bạn khẳng định:
"consent": { "given": true, "collectedAt": "2026-07-10T03:00:00Z" }given phải bằng true (thiếu hoặc false ⇒ 400 validation_error). collectedAt phải là thời điểm THẬT bạn thu đồng ý và không được ở tương lai. Bản ghi bảo hành lưu consentSource = "partner_api" cùng id của API key đã gọi — không lưu IP/UA của người tiêu dùng. (2 endpoint claim không có trường consent — consent chỉ áp dụng lúc kích hoạt.)
Phạm vi vùng & endpoint cũ
Ghi bảo hành (kích hoạt, tạo claim, cập nhật claim) CÓ giới hạn theo vùng: key bị giới hạn khu vực chỉ thao tác được với bảo hành có đại lý bán nằm trong vùng cho phép — ngoài vùng đó nhận 403 forbidden. (Trong khi warranty:read và trace:read vẫn KHÔNG giới hạn vùng.) Lưu ý endpoint PATCH /batches/{id}/status, PATCH /containers/{id}/status và POST /shipments/{id}/receive xử lý khác: ngoài vùng trả 404 not_found, không phải 403.
Deprecated: POST /api/public/warranty/activate-by-product (key vncx_epc_ hoặc vncx_sk_, scope warranty:activate) vẫn chạy nhưng không còn được khuyến nghị — chuyển sang POST /api/v1/warranties (cùng scope) để có idempotency, envelope chuẩn và provenance consent. Hai route xem trước cùng nhóm (GET /api/public/warranty/product, GET /api/public/warranty/dealers) không có thay thế và không bị deprecate. POST /api/public/warranty/activate (kích hoạt bằng QR, không cần API key — luồng người tiêu dùng tự quét mã) là một tính năng khác, ngoài partner API, và không bị ảnh hưởng.
Định dạng phản hồi
Phần này mô tả riêng envelope của /api/v1 (37 endpoint ở bảng trên). Hai nhóm route M2M còn lại — legacy warranty/order-intake và EPCIS v2 — dùng envelope khác, xem ghi chú ngay bên dưới bảng mã lỗi.
Danh sách (list)
Trả về data (mảng) kèm meta để phân trang cursor — truyền lại meta.nextCursor qua query ?cursor=&limit= để lấy trang kế tiếp.
{ "data": [ ... ], "meta": { "limit": 20, "nextCursor": "eyJpZCI6...", "hasMore": true } }Chi tiết (single)
{ "data": { ... } }Lỗi
Mọi response (kể cả thành công) đều có header X-Request-Id — gửi kèm khi liên hệ hỗ trợ để tra log.
{ "error": { "code": "forbidden_scope", "message": "...", "details": {} }, "request_id": "..." }Mã lỗi (/api/v1)
| code | HTTP | Ý nghĩa |
|---|---|---|
unauthorized | 401 | Thiếu API key hoặc key không hợp lệ/đã thu hồi. |
forbidden_scope | 403 | Key hợp lệ nhưng thiếu scope bắt buộc cho endpoint này. |
unsupported_key_type | 403 | Key EPCIS cũ (`vncx_epc_`) không gọi được /api/v1 — hãy phát hành key `vncx_sk_` ở tab Developer API. Key cũ vẫn dùng bình thường trên EPCIS v2 và các route M2M legacy. |
module_disabled | 403 | Tổ chức chưa bật module API — liên hệ quản trị viên để kích hoạt. (Chỉ /api/v1 trả mã này; xem ghi chú envelope bên dưới.) |
forbidden | 403 | Key có scope nhưng bản ghi nằm ngoài vùng sản xuất được cấp cho key. Áp dụng cho kích hoạt/ghi yêu cầu bảo hành (POST /warranties, POST và PATCH .../claims) và tạo lô/thùng/vận chuyển — khác `forbidden_scope` (thiếu scope hoàn toàn). |
not_found | 404 | Không tìm thấy tài nguyên, hoặc tài nguyên không thuộc tổ chức của key. |
validation_error | 400 | Tham số/payload không hợp lệ — xem `details` để biết trường cụ thể. |
conflict | 409 | Xung đột trạng thái nghiệp vụ — hiện chỉ xảy ra khi tạo yêu cầu bảo hành (claim) cho một bảo hành đã có claim đang mở (PENDING/IN_PROGRESS). |
idempotency_conflict | 422 | Cùng `Idempotency-Key` nhưng request khác lần trước (body khác, hoặc cùng key dùng cho endpoint/query khác). Vĩnh viễn — dùng key mới, đừng retry với key cũ. |
idempotency_in_flight | 409 | Một request khác cùng `Idempotency-Key` đang chạy (hoặc vừa được nhả). Tạm thời — thử lại sau (xem `Retry-After`). |
rate_limited | 429 | Vượt quá giới hạn request/phút cho key này — thử lại sau. |
internal | 500 | Lỗi phía máy chủ — nếu lặp lại, liên hệ hỗ trợ kèm `request_id`. |
Hai envelope khác (ngoài /api/v1)
Cùng một API key có thể gọi hai nhóm route khác — mỗi nhóm trả lỗi theo shape riêng, KHÔNG phải envelope { error: { code, message }, request_id } ở trên:
- 5 route M2M legacy — 4 route bảo hành công khai (
/api/public/warranty/{product,dealers,activate-by-product,lookup-by-phone}) và order-intake webhook (POST /api/organization/{orgId}/orders/webhook): lỗi trả về{ "error": "<chuỗi>" }— một chuỗi, không phải object{ code, message }. - 3 route EPCIS v2 (
GET /api/epcis/v2/events,GET /api/epcis/v2/events/{eventId},POST /api/epcis/v2/capture): thành công trả vềEPCISDocumentthô ở cấp cao nhất (không bọc trongdata); riêngcapturetrả202kèm{ success, captured, duplicates }. Lỗi cũng dùng{ "error": "<chuỗi>" }, không phải envelope của/api/v1.
Webhooks
Webhook đẩy sự kiện tới hệ thống của bạn theo thời gian thực — không cần poll. Đăng ký endpoint (URL https:// công khai) và chọn event cần nhận trong Cài đặt → Tích hợp → Webhooks.
Event
| Event | Mô tả |
|---|---|
warranty.activatedBảo hành được kích hoạt | Một bảo hành vừa được kích hoạt (qua QR, theo sản phẩm, hoặc qua API). |
warranty.claim.createdYêu cầu bảo hành mới | Một yêu cầu bảo hành (claim) vừa được tạo — bởi nhân viên hoặc bởi người tiêu dùng. |
warranty.claim.status_changedClaim đổi trạng thái | Trạng thái claim thay đổi (nhận xử lý, hoàn tất, từ chối, huỷ). |
warranty.repair.completedSửa chữa hoàn tất | Một ca sửa chữa vừa được ghi nhận, kèm danh sách linh kiện đã tiêu hao (GTIN, số lượng). VNCX KHÔNG quản tồn kho vật tư — phát sự kiện này để ERP/WMS của bạn tự trừ kho của chính nó. |
service.recordedGhi nhận dịch vụ | Một lần kiểm định / thử áp lực / nạp lại vừa được ghi cho một đơn vị hàng, kèm trạm thực hiện và các LÔ VẬT TƯ đã dùng. VNCX KHÔNG trừ tồn các lô đó — phát sự kiện này để ERP/WMS của bạn tự trừ kho của chính nó. |
batch.status_changedLô đổi trạng thái | Một trong các trục trạng thái của lô sản xuất thay đổi — lifecycle, quality, hoặc trạng thái thu hồi (recallStatus). Bao gồm cả thu hồi (recall), dù thu hồi chỉ đổi quality/recallStatus mà không đổi lifecycle. |
batch.publishedLô đã công bố | Lô được công bố cho đối tác — sự kiện EPCIS của lô đã có thể đọc. Không phát lại các thay đổi trạng thái xảy ra khi lô còn nháp. |
shipment.receivedLô hàng đã nhận | Một shipment chuyển sang RECEIVED (chỉ phát đúng một lần cho mỗi shipment). |
anticounterfeit.alert.createdCảnh báo hàng giả | Một cảnh báo an ninh MỚI được mở (không phát khi cảnh báo đang mở được nâng mức). |
type: "ping" cũng có thể xuất hiện — chỉ khi bạn bấm "Gửi thử" trong Cài đặt. Đây là probe kiểm tra endpoint, không phải event trong danh sách trên và không thể đăng ký nhận.
Header gửi kèm
X-VNC-Signature | t=<unix-seconds>,v1=<hex hmac-sha256> — xem mục xác thực chữ ký bên dưới. |
X-VNC-Event | Tên event, trùng với event.type trong body (vd warranty.activated). |
X-VNC-Delivery | == event.id — khoá dedupe cho giao at-least-once. |
X-VNC-Attempt | Số thứ tự lần gửi này, bắt đầu từ 1 (1-based). |
Xác thực chữ ký
Header X-VNC-Signature có dạng t=<unix-seconds>,v1=<hex hmac-sha256>, với v1 = hex(HMAC_SHA256(secret, `${t}.${rawBody}`)) tính trên raw body (không phải object đã parse rồi serialize lại). So sánh bằng timing-safe compare, và từ chối nếu |now - t| > 300s (chống replay — 300s là dung sai cố định, không cấu hình được).
// Node.js (Express) — verify chữ ký webhook VNC
const crypto = require("crypto");
// QUAN TRỌNG: HMAC tính trên RAW body. Đừng dùng express.json() rồi
// JSON.stringify() lại — thứ tự key / khoảng trắng khác đi là chữ ký sai.
app.post("/vnc-webhook", express.raw({ type: "application/json" }), async (req, res) => {
const raw = req.body.toString("utf8");
if (!verifyVncSignature(raw, req.get("X-VNC-Signature"), process.env.VNC_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(raw);
// Giao at-least-once: cùng một event có thể tới nhiều lần.
// Dedupe theo event.id (== header X-VNC-Delivery), KHÔNG theo nội dung.
if (await alreadyProcessed(event.id)) return res.sendStatus(200);
switch (event.type) {
case "warranty.activated":
// event.data = { warrantyId, warrantyCode, productId, ... } — không có
// tên/điện thoại người mua. Cần liên hệ thì gọi GET /warranties/{id}.
break;
case "batch.status_changed":
// event.data mang CẢ BA trục: previousLifecycle/lifecycle,
// previousQuality/quality, previousRecallStatus/recallStatus.
// Một đợt thu hồi (recall) chỉ đổi quality/recallStatus, KHÔNG đổi
// lifecycle — diff cả 3 cặp để biết trục nào vừa đổi.
break;
}
// Trả 2xx NGAY (VNC timeout sau 10s). Việc nặng thì đẩy sang hàng đợi.
res.sendStatus(200);
});
// Mirror của verifySignatureHeader() trong
// packages/shared/lib/webhooks/signature.ts — cùng logic, cùng hằng số.
function verifyVncSignature(rawBody, header, secret, toleranceSec = 300) {
if (!header) return false;
const parts = {};
for (const kv of header.split(",")) {
const i = kv.indexOf("=");
if (i > 0) parts[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
}
const t = Number(parts.t);
if (!Number.isFinite(t)) return false;
// Chống replay: t nằm TRONG phần được ký, nên kẻ tấn công bắt được request
// không thể phát lại với t mới (v1 sẽ sai) hay với t cũ (bị từ chối ở đây
// vì đã quá hạn).
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const provided = parts.v1 || "";
if (!/^[0-9a-f]{64}$/i.test(provided)) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`, "utf8")
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(provided, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Ngữ nghĩa giao
- At-least-once — một event có thể tới nhiều lần; dedupe theo header
X-VNC-Delivery. - Không đảm bảo thứ tự giữa các event.
- Retry: backoff mũ bắt đầu 2s (nhân đôi, giới hạn 1h), tối đa 10 lần gửi; mỗi lần gửi timeout sau 10s. Trả bất kỳ mã 2xx nào để xác nhận đã nhận — mã khác hoặc timeout sẽ được thử lại.
- Tự động tắt (auto-disable): 5 lượt giao liên tiếp cạn hết số lần thử sẽ khiến endpoint tự tắt — bật lại trong Cài đặt → Webhooks. Bật lại không phát lại các event đã bỏ lỡ trong lúc tắt.
- Endpoint phải là URL
https://công khai. VNC không đi theo redirect — endpoint trả 3xx sẽ bị tính là giao thất bại. - Webhook là tín hiệu, không phải nguồn sự thật: một sự cố hiếm có thể làm rơi một event. Với dữ liệu quan trọng, hãy đối soát định kỳ bằng REST API thay vì chỉ dựa vào webhook.
Không có PII người tiêu dùng
Payload không bao giờ chứa tên, số điện thoại, email hay địa chỉ người tiêu dùng — chỉ định danh tài nguyên (id, mã) và trạng thái vòng đời. Ví dụ warranty.activated mang warrantyId, warrantyCode, productId — không mang số điện thoại/tên người mua. Cần dữ liệu liên hệ thì gọi GET /api/v1/warranties/{id} với scope warranty:read.