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. 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. 2

    Gọi API kèm key trong header X-API-Key (hoặc Authorization: Bearer).

  3. 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.

ScopeMô tảNhóm
products:readLiệt kê & xem sản phẩmSản phẩm
products:writeTạo & cập nhật sản phẩmSản phẩm
batches:readLiệt kê & xem lôLô
batches:writeTạo & đổi trạng thái lôLô
items:readLiệt kê, xem, verify itemSản phẩm đơn vị
items:writeBáo hỏng itemSản phẩm đơn vị
inventory:readXem tồn khoTồn kho
containers:readLiệt kê & xem containerThùng/Pallet
containers:writeTạo & đổi trạng thái containerThùng/Pallet
shipments:readLiệt kê & xem shipmentVận chuyển
shipments:writeTạo & nhận shipmentVận chuyển
orders:readLiệt kê & xem đơn hàng (ERP đối chiếu trạng thái)Đơn hàng
orders:writeNhận đơn, sửa đơn chưa duyệt và huỷ đơn qua M2MĐơn hàng
qrcodes:readVerify mã QRMã QR
warranty:readTra cứu bảo hành & claimBảo hành
warranty:activateKích hoạt bảo hành cho sản phẩm (đường M2M của đối tác)Bảo hành
warranty:writeTạo & cập nhật yêu cầu bảo hành (claim)Bảo hành
services:writeGhi nhận kiểm định / thử áp lực / nạp lại cho một đơn vị hàngservices
trace:readTrace graph theo mãTruy xuất
epcis:readTruy vấn events (need-to-know)Sự kiện EPCIS
epcis:writeCapture eventsSự 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).

Sản phẩm

get/api/v1/productsproducts:readList products
post/api/v1/productsproducts:writeCreate a product
post/api/v1/products/bulkproducts:writeBulk upsert products by externalRef (create or update, up to 500 per call)
get/api/v1/products/{id}products:readGet a product
patch/api/v1/products/{id}products:writeUpdate a product

Lô

get/api/v1/batchesbatches:readList batches
post/api/v1/batchesbatches:writeCreate a batch
post/api/v1/batches/bulkbatches:writeBulk upsert batches by externalRef (create or update, up to 500 per call)
get/api/v1/batches/{id}batches:readGet a batch
patch/api/v1/batches/{id}/statusbatches:writeUpdate batch status

Sản phẩm đơn vị

get/api/v1/itemsitems:readList items
get/api/v1/items/{id}items:readGet an item
patch/api/v1/items/{id}/reportitems:writeReport an item damaged
post/api/v1/items/verifyitems:readVerify an item by serial number
post/api/v1/items/{id}/servicesservices:writeRecord an inspection / hydrostatic test / refill for one unit

Thùng/Pallet

get/api/v1/containerscontainers:readList containers
post/api/v1/containerscontainers:writeCreate a container
get/api/v1/containers/{id}containers:readGet a container
patch/api/v1/containers/{id}/statuscontainers:writeUpdate container status
post/api/v1/containers/{id}/contentscontainers:writeAdd contents to a container

orders

get/api/v1/ordersorders:readList orders
get/api/v1/orders/{id}orders:readGet an order
patch/api/v1/orders/{id}orders:writeUpdate an unapproved order
post/api/v1/orders/{id}/cancelorders:writeCancel an order (releases reserved stock)

Vận chuyển

get/api/v1/shipmentsshipments:readList shipments
post/api/v1/shipmentsshipments:writeCreate a shipment
get/api/v1/shipments/{id}shipments:readGet a shipment
post/api/v1/shipments/{id}/receiveshipments:writeReceive a shipment

Mã QR

post/api/v1/qrcodes/verifyqrcodes:readVerify a QR code

Tồn kho

get/api/v1/inventoryinventory:readList inventory

Bảo hành

get/api/v1/warrantieswarranty:readList warranties
post/api/v1/warrantieswarranty:activateActivate a warranty (by product code or by QR)
get/api/v1/warranties/{id}warranty:readGet a warranty
get/api/v1/warranties/{id}/claimswarranty:readList claims for a warranty
post/api/v1/warranties/{id}/claimswarranty:writeCreate a warranty claim
patch/api/v1/warranties/{id}/claims/{claimId}warranty:writeUpdate a warranty claim (status/priority/notes)

Truy xuất

get/api/v1/trace/{code}trace:readTrace 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].status là một trong "created", "updated", "failed"; index là vị trí trong items bạn gửi lên, để đối chiếu ngược.
  • summary gộp số lượng theo trạng thái — { created, updated, failed }.
  • Trùng externalRef trong cùng request: phần tử đầu tiên được xử lý, các phần tử trùng sau đó nhận failed / 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ể items có 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èm Retry-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)

codeHTTPÝ nghĩa
unauthorized401Thiếu API key hoặc key không hợp lệ/đã thu hồi.
forbidden_scope403Key hợp lệ nhưng thiếu scope bắt buộc cho endpoint này.
unsupported_key_type403Key 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_disabled403Tổ 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.)
forbidden403Key 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_found404Khô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_error400Tham số/payload không hợp lệ — xem `details` để biết trường cụ thể.
conflict409Xung độ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_conflict422Cù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_flight409Mộ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_limited429Vượt quá giới hạn request/phút cho key này — thử lại sau.
internal500Lỗ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ề EPCISDocument thô ở cấp cao nhất (không bọc trong data); riêng capture trả 202 kè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

EventMô tả
warranty.activated
Bả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.created
Yê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_changed
Claim đổ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.completed
Sử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.recorded
Ghi 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_changed
Lô đổ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.published
Lô đã 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.received
Lô 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.created
Cả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-Signaturet=<unix-seconds>,v1=<hex hmac-sha256> — xem mục xác thực chữ ký bên dưới.
X-VNC-EventTê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-AttemptSố 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.