API công khai (/api/v1)
Xác thực bằng một khoá API, đọc và ghi cửa hàng của bạn qua HTTP, và hiểu hình dạng của mọi phản hồi cũng như mọi lỗi.
API mà một đối tác, một app hay một tích hợp của agency dùng. Mọi thứ ở đây với tới được bằng một khoá API hoặc một token app đã cài — không session, không cookie, không đăng nhập người dùng.
Trang này để đọc. Nó trả lời một khoá với tới được gì, PUT thay thế gì,
và giá thật ra nằm ở đâu — những câu hỏi bạn có trước khi lời gọi đầu tiên
chạy được. Bảng điều khiển API
là Swagger UI trực tiếp của đúng bề mặt này, sinh từ chính các handler, có nút
thử ngay: dán một khoá, bắn một request vào server thật, đọc phản hồi thật.
Nó là công cụ tốt hơn từ lúc bạn đã biết mình gửi gì, và nó không chỉ ai cách
bắt đầu — một danh sách thao tác không làm được việc đó.
Nếu bạn đang dựng một app để chủ cửa hàng cài — chứ không phải tích hợp
với một cửa hàng bạn vốn kiểm soát — hãy bắt đầu ở
Xây một app: nó nói về luồng OAuth và token
wba_ mà bề mặt này sau đó chấp nhận. Mọi thứ bên dưới áp dụng nguyên vẹn cho
một app khi nó đã cầm token.
1. Lấy một khoá
Trong app: Quản lý cửa hàng → Cài đặt → Khoá API (en: Manage store → Settings → API keys). Bấm Tạo khoá (en: New key), đặt tên, rồi tích những scope nó cần.
Phần bí mật chỉ hiện ra đúng một lần, lúc tạo. Nó được lưu băm SHA-256, nên
không ai — kể cả người vận hành nền tảng — khôi phục được. Làm mất thì thu hồi
khoá và tạo khoá khác. Màn hình danh sách chỉ hiện tiền tố (wbk_a1b2c3…),
đủ để phân biệt và vô dụng khi dùng làm credential.
Một khoá thuộc về một cửa hàng. Không có khoá trải qua nhiều cửa hàng; một agency vận hành năm khách hàng giữ năm khoá.
2. Gọi nó
curl https://api.sbuilder.io.vn/api/v1/products \
-H "Authorization: Bearer wbk_your_secret_here"
Hai loại credential, và một loại bị từ chối
| Tiền tố | Nó là gì | Ai giữ |
|---|---|---|
wbk_ |
Khoá API chủ cửa hàng tự tạo cho công cụ của mình (§1) | Chủ cửa hàng, hoặc người họ đưa cho |
wba_ |
App access token, cấp khi chủ cửa hàng cài một app từ chợ ứng dụng | Server của app đó |
wbr_ |
Refresh token của app — KHÔNG được chấp nhận ở đây | App; đổi nó ở /oauth/token trước |
Hai loại được chấp nhận đều thuộc đúng một cửa hàng, có scope, thu hồi được.
Qua khỏi cửa thì hành xử y hệt: cùng endpoint, cùng luật scope, cùng lỗi.
Access token của một người dùng bị từ chối — chấp nhận nó sẽ khiến bề mặt
này mạnh ngang bất cứ ai dán nó vào. Gửi loại đó nhận 401 api_key_required.
Ba quy tắc giải thích phần lớn bề mặt
Cửa hàng được ngầm định bởi khoá. Không có {siteId} ở đâu trong đường
dẫn v1. Một khoá chỉ cần sửa URL là trỏ sang cửa hàng khác thì bán kính thiệt
hại của nó phụ thuộc vào sự trung thực của người gọi.
Mọi phản hồi đều có phong bì.
| Hình dạng | Body |
|---|---|
| Một danh sách | {"products": [...], "total": 42} |
| Một mục | {"product": {...}} |
| Bất kỳ lỗi nào | {"error": "câu cho người đọc", "code": "mã_cho_máy"} |
total là số lượng trước khi phân trang. Lỗi luôn là JSON — không bao giờ
văn bản thuần — để một lần thất bại rẽ nhánh được.
Phân trang là ?limit= + ?offset=. limit mặc định 50, tối đa 200; xin
nhiều hơn bị kẹp lại chứ không bị từ chối.
3. Có những gì ở đó
products, orders, customers, pages, media, blog (bài
viết + chuyên mục), webhooks, translations — và app-data, dành
riêng cho token wba_.
Products — CRUD đầy đủ
GET /api/v1/products danh sách
POST /api/v1/products tạo
GET /api/v1/products/{id} đọc
PUT /api/v1/products/{id} thay thế
DELETE /api/v1/products/{id} xoá
Tham số: ?q= (tên, slug, SKU, thẻ) · ?status=draft|active|archived ·
?sort=number|name|price|stock|created|updated (mặc định number) ·
?dir=asc|desc · ?limit= · ?offset=. Một sort không nhận ra lùi về mặc
định chứ không báo lỗi.
# Tạo một sản phẩm. GIÁ NẰM TRÊN BIẾN THỂ, không bao giờ trên sản phẩm:
# priceCents của sản phẩm là giá biến thể thấp nhất, tính lúc ghi. Gửi nó ở mức
# ngoài cùng thì bị bỏ đi.
curl -X POST https://api.sbuilder.io.vn/api/v1/products \
-H "Authorization: Bearer $SB_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Cotton T-shirt","status":"active","variants":[{"sku":"TS-M","priceCents":250000,"stock":10}]}'
Server sở hữu phần định danh và dẫn xuất: id, dấu thời gian, số thứ tự, giá thấp nhất, tổng tồn kho. Gửi chúng lên không phải lỗi — chúng bị bỏ qua.
Orders — đọc, cộng một lệnh vá trạng thái
GET /api/v1/orders danh sách
GET /api/v1/orders/{id} đọc
PATCH /api/v1/orders/{id} chỉ cập nhật trạng thái
Không có POST, và đó là quyết định. Tạo một đơn hàng chính là thanh
toán: nó tiêu mã giảm giá dưới một khoá, áp giới hạn số lần dùng, tính tổng từ
danh mục đang sống. Một POST trả 405 orders_are_read_only, để tác giả tích
hợp biết ranh giới này là cố ý. Một nếp gấp hay bất ngờ: POST từ khoá không
có orders.write trả 403 insufficient_scope, không phải 405 — scope kiểm
trước (§4).
PATCH chỉ nhận status, payment, fulfillment, mọi trường tuỳ chọn —
vắng mặt nghĩa là giữ nguyên:
# Một kho đánh dấu kiện hàng đã gửi. Nó không biết gì về thanh toán,
# và không xoá mất thông tin đó chỉ bằng cách không nhắc tới.
curl -X PATCH https://api.sbuilder.io.vn/api/v1/orders/ord_123 \
-H "Authorization: Bearer $SB_KEY" \
-H "Content-Type: application/json" \
-d '{"fulfillment":"fulfilled"}'
Dòng hàng, tổng tiền và bản chụp khách hàng là lịch sử, không viết lại được qua route này.
Customers — CRUD đầy đủ
GET /api/v1/customers danh sách
POST /api/v1/customers tạo
GET /api/v1/customers/{id} đọc
PUT /api/v1/customers/{id} thay thế
DELETE /api/v1/customers/{id} xoá
ordersCount và totalSpentCents tính lúc đọc. Trường note riêng của chủ
cửa hàng không phơi ra — ghi chú hỗ trợ viết cho đồng nghiệp, không cho
tích hợp nào hiển thị ngược lại khách.
Pages — CRUD siêu dữ liệu, cộng xuất bản
GET /api/v1/pages danh sách
POST /api/v1/pages tạo
GET /api/v1/pages/{id} đọc
PATCH /api/v1/pages/{id} cập nhật (vắng mặt nghĩa là "giữ nguyên")
DELETE /api/v1/pages/{id} xoá
POST /api/v1/pages/{id}/publish biên dịch bản nháp thành trang đang chạy
Phần thân trang không có ở đây, và sẽ không có. Tài liệu của một trang là cây node của trình dựng, hình dạng của nó thuộc hợp đồng render và đổi mỗi khi thêm một phần tử. Công bố nó sẽ biến mỗi phần tử mới thành một thay đổi phá vỡ API. Ở đây một trang là một thứ có URL — liệt kê, tạo, đổi tên, đổi slug, sắp xếp, xoá, xuất bản. Dàn trang trong trình sửa; điều khiển vòng đời từ đây.
Ba điều đáng biết:
- Trang đầu tiên của site tự thành trang chủ, và bất kỳ trang nào gửi kèm
isHomepage: truecũng vậy — hạ bệ trang đang giữ vai trò đó. settingstruyền qua nguyên văn. Đọc nó, đổi khoá bạn biết, gửi lại cả cụm.publishlan truyền: trang dùng chung global section với trang khác khiến chúng cũng được xuất bản lại.
Media — thư viện, kể cả tải lên
GET /api/v1/media liệt kê
POST /api/v1/media tải lên (multipart/form-data)
GET /api/v1/media/{id} đọc
PATCH /api/v1/media/{id} đổi tên và/hoặc chuyển thư mục
DELETE /api/v1/media/{id} chuyển vào thùng rác
POST /api/v1/media/{id}/restore lấy ra khỏi thùng rác
GET /api/v1/media-folders cây thư mục
POST /api/v1/media-folders tạo thư mục
Tải lên là request duy nhất không phải JSON:
curl -X POST https://api.sbuilder.io.vn/api/v1/media \
-H "Authorization: Bearer $SB_KEY" \
-F file=@hero.jpg -F folderId=mdf_123 -F name="Autumn hero"
Ảnh, video và font (woff2/woff/ttf/otf), tối đa 25 MiB. Dung lượng tính vào
hạn mức đo từ chính dữ liệu tải lên. Vượt hạn mức nhận 413 kèm used,
limit, incoming tính bằng byte, từ chối trước khi lưu.
DELETE bỏ vào thùng rác, không huỷ, và không giải phóng hạn mức. Một
trang đã xuất bản có thể vẫn phục vụ tệp đó, nên xoá vĩnh viễn nằm lại trong
app, cạnh màn hình cho thấy xoá sắp làm hỏng cái gì.
Tài nguyên mang url, không bao giờ mang khoá object — tiền tố ghép lúc đọc,
nên đổi CDN không phải viết lại dữ liệu. Đây là trường duy nhất bạn không nên
cache vĩnh viễn.
Blog — bài viết và chuyên mục, CRUD đầy đủ
GET /api/v1/articles danh sách
POST /api/v1/articles tạo
GET /api/v1/articles/{id} đọc
PUT /api/v1/articles/{id} thay thế
DELETE /api/v1/articles/{id} xoá
GET /api/v1/blog-categories danh sách
POST /api/v1/blog-categories tạo
GET /api/v1/blog-categories/{id} đọc
PUT /api/v1/blog-categories/{id} thay thế
DELETE /api/v1/blog-categories/{id} xoá
Cả hai dưới cùng cặp quyền blog.read / blog.write.
content được làm sạch lúc ghi — thẻ script và trình xử lý sự kiện bị
tước, vì thân này render nguyên văn vào storefront. Đọc phản hồi thay vì cho
rằng thứ gửi lên là thứ được phục vụ.
Hai chỗ bất đối xứng: slug bài viết suy từ tiêu đề khi không đưa, và khử
trùng lặp; slug chuyên mục bắt buộc và không có cả hai. Bài viết có
bodyType là page mang tài liệu của trình dựng thay vì HTML — đọc thì được,
ghi nhận 409 unsupported_body_type, vì một PUT sẽ không sửa nó mà xoá nó.
categoryIds không nhận ra bị bỏ đi chứ không từ chối; so với phản hồi để biết
cái nào đã dính.
Translations — nội dung của cửa hàng bằng ngôn ngữ thứ hai
GET /api/v1/translations?locale=&entityType=&entityId= bản dịch của một thực thể
GET /api/v1/translations?locale=&entityType=&entityIds=a,b nhiều thực thể một lần
PUT /api/v1/translations ghi
DELETE /api/v1/translations?locale=&entityType=&entityId=&field= xoá một trường
GET /api/v1/translations/progress?locale= đã dịch được bao nhiêu
entityType là một trong product, category, article, blogCategory,
node. Đọc một thực thể trả danh sách translations; đọc nhiều trả
byEntity, đánh khoá theo id — một app đồng bộ danh mục hỏi một trang id mỗi
lần, thay vì một lời gọi mỗi sản phẩm.
curl -X PUT https://api.sbuilder.io.vn/api/v1/translations \
-H "Authorization: Bearer $SB_KEY" -H "Content-Type: application/json" \
-d '{"entries":[{"locale":"en","entityType":"product","entityId":"prd_1a2b","field":"name","value":"Cotton T-shirt","source":"human"}]}'
# → {"written": 1}
source chỉ thành human khi bạn nói rõ như vậy; bỏ trống hay viết khác đều
là machine. Đó là cố ý: bản dịch máy đội lốt người là thứ khó phát hiện
nhất. Miền quyền riêng, translations.read / translations.write, vì dịch
danh mục là việc chủ cửa hàng giao cho một người dịch hay một app, và người đó
không nên được quyền sửa sản phẩm họ đang dịch. Thân đầy đủ ở
Bán hàng đa ngôn ngữ.
Webhooks — đăng ký một URL để được gọi
GET /api/v1/webhooks danh sách
POST /api/v1/webhooks đăng ký (trả về endpoint VÀ khoá bí mật để ký)
GET /api/v1/webhooks/{id} đọc
PUT /api/v1/webhooks/{id} thay thế
DELETE /api/v1/webhooks/{id} gỡ
GET /api/v1/webhooks/{id}/deliveries lịch sử gửi — §7
Đăng ký một URL một lần, rồi nền tảng gọi vào đó — có ký — khi
order.created, product.updated và phần còn lại của danh mục xảy ra. Đây
cũng là lý do một khoá thuộc về một cửa hàng: một agency vận hành năm mươi cửa
hàng đăng ký năm mươi lần qua route này thay vì bấm qua năm mươi màn hình.
curl -X POST https://api.sbuilder.io.vn/api/v1/webhooks \
-H "Authorization: Bearer $SB_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/sbuilder","events":["order.created","order.updated"]}'
Phản hồi mang khoá bí mật để ký, và là phản hồi duy nhất trên bề mặt này làm
vậy — một trường ngang hàng với webhook:
{ "webhook": { "id": "whe_…", "url": "…", "events": [...], "status": "active", … },
"secret": "whsec_…" }
Không route nào đọc lại được nó — GET, danh sách và PUT trả webhook không có
secret. Mất thì xoá endpoint và đăng ký cái mới: webhooks.read với tới
được vai trò thấp nhất, và một GET trả khoá ký sẽ cho vai trò đó giả mạo các
lần gửi.
URL được kiểm trước khi lưu, cả lúc tạo lẫn thay thế. Loopback, link-local
(dịch vụ metadata của đám mây nằm ở đó), hoặc tên miền phân giải ra một trong
hai nhận 400 blocked_url. events phải nêu ít nhất một loại nền tảng phát
ra — endpoint không đăng ký gì không bao giờ kích hoạt, và cũng không có lỗi
nào để nhận ra.
PUT là thay thế: gửi mọi trường bạn muốn giữ. status nhận active và
disabled; failing là nhãn server đặt sau ba lần gửi thất bại liên tiếp, và
PUT cố đặt nó bị từ chối 400 invalid_status — trừ khi endpoint bạn đang thay
thế vốn đã failing, để quy trình đọc-sửa-ghi chạy được trên đúng endpoint bạn
đang sửa.
App data — dành cho token wba_
GET /api/v1/app-data các giá trị của app này trên cửa hàng này
PUT /api/v1/app-data đặt một giá trị, gọi tên bằng key
DELETE /api/v1/app-data/{key} xoá
Giá trị mà một app đã cài lưu trên cửa hàng, để trang của chủ cửa hàng ràng buộc vào — một app đánh giá lưu xếp hạng ở đây, và server in nó thẳng vào HTML. Vùng tên đến từ token nên không có app id trong body. Toàn bộ mô hình, giới hạn và cách một block đọc nó ở Xây một block.
4. Scope
Một khoá giữ một tập quyền <miền>.read / <miền>.write. Các miền: products,
orders, customers, discounts, blog, integrations, pages, theme,
media, codefiles, webhooks, forms, translations, appdata — cùng bộ
từ vựng các vai trò trong app dùng, nên một khoá không bao giờ được cấp thứ
sản phẩm không mô hình hoá.
Mỗi request bị chặn bởi hai giới hạn, và scope kiểm trước:
- scope của khoá phải chứa quyền đó, và
- thành viên đã tạo khoá vẫn phải còn quyền đó qua vai trò của họ.
Điều thứ hai giữ khoá an toàn theo thời gian: khoá do một quản trị viên tạo, người này về sau bị hạ xuống chỉ xem, mất quyền ghi ngay ở request kế tiếp, không cần thu hồi. Quyền phân giải trực tiếp, không bao giờ chụp lại lúc tạo.
Khoá thiếu scope nhận 403 insufficient_scope — không phải 404, để người
gọi phân biệt "không được phép" với "không tồn tại".
5. Những lỗi đáng rẽ nhánh
| Status | code |
Nghĩa là |
|---|---|---|
| 401 | missing_credential |
Không có header Authorization |
| 401 | api_key_required |
Có header, nhưng không phải wbk_ cũng không phải wba_ (ví dụ token phiên của người dùng) |
| 401 | refresh_token_not_accepted |
Một wbr_. Đổi nó ở /oauth/token lấy wba_ trước |
| 401 | invalid_credential |
Trông như khoá, nhưng không khớp cái nào |
| 401 | key_revoked |
Từng tồn tại và đã bị tắt |
| 401 | app_token_invalid |
App token giả mạo, hết hạn, hoặc app không còn cài trên cửa hàng đó. Ba trường hợp là MỘT câu trả lời, cố ý: cách sửa giống nhau — cài lại |
| 403 | insufficient_scope |
Khoá chưa được cấp quyền này |
| 403 | key_unusable |
Scope đã lưu của khoá không còn hợp lệ — tạo khoá mới |
| 404 | not_found |
Không có dòng nào như vậy trong cửa hàng của khoá này |
| 404 | unknown_resource |
Không có tài nguyên v1 nào như vậy |
| 405 | orders_are_read_only |
Ranh giới cố ý |
| 409 | duplicate_email / duplicate_sku / duplicate_slug / duplicate_name |
Trường nào bị đụng |
| 409 | unsupported_body_type |
Thân bài viết này là tài liệu của trình dựng; sửa trong trình sửa |
| 409 | nothing_to_publish |
Trang chưa có bản nháp nào |
| 409 | cycle_detected |
Chuyên mục bị chuyển vào dưới chính hậu duệ của nó |
| 409 | too_many_keys |
App đã lưu đủ 500 giá trị trên cửa hàng này |
| 413 | quota_exceeded |
Hết dung lượng. Body mang used, limit, incoming |
| 429 | rate_limited |
Chậm lại; giới hạn theo từng khoá |
| 503 | resource_unavailable |
Route có thật; server này chưa cấu hình tài nguyên đó (media không có object store, app token không có chợ ứng dụng). Thử lại sau |
| 400 | invalid_body / invalid_status / invalid_price / invalid_slug / invalid_translation / invalid_owner / invalid_key |
Về request |
| 400 | missing_file / empty_file / unsupported_type |
Về tệp tải lên |
| 400 | blocked_url |
URL webhook phân giải tới nơi nền tảng không được trỏ vào |
| 400 | no_events / invalid_event |
Webhook không đăng ký gì, hoặc nêu loại sự kiện nền tảng không phát |
Các mã 401 tách ra cố ý: "bạn không gửi gì", "bạn gửi sai loại", "bạn gửi sai MỘT NỬA cặp credential của app", "khoá không có thật" và "khoá đã thu hồi" cần cách sửa khác nhau.
6. Những gì chưa có
Bốn miền (discounts, theme, integrations, codefiles) đã tồn tại trong
bộ từ vựng scope; thứ chúng chưa có là một hình dạng công khai đã đóng băng.
Hai thứ vắng mặt do quyết định:
- Tài liệu của trang, và thân bài viết kiểu trang. Chúng là cây node của trình dựng; đóng băng hình dạng của chúng sẽ đóng băng luôn hợp đồng render. Nếu ranh giới đó dịch chuyển thì qua một endpoint tài liệu đánh phiên bản riêng.
- Xoá vĩnh viễn một tài nguyên media.
DELETEbỏ vào thùng rác; xoá sạch có thể làm hỏng một trang đang sống mà không có cơ chế đếm tham chiếu, nên nó nằm lại trong app.
7. Webhooks: những gì thật sự đáp xuống
Danh mục sự kiện
Mười loại, và danh sách này là hợp đồng công khai đã đóng băng: thêm thì miễn phí, đổi tên hay gỡ sẽ làm hỏng mọi bên nhận cùng lúc.
| Sự kiện | Kích hoạt khi |
|---|---|
order.created |
Một đơn hàng được đặt |
order.updated |
Trạng thái, thanh toán hoặc giao hàng của đơn thay đổi |
customer.created |
Một khách hàng được thêm |
customer.updated |
Hồ sơ khách hàng thay đổi |
product.created |
Một sản phẩm được tạo |
product.updated |
Một sản phẩm thay đổi |
product.deleted |
Một sản phẩm bị gỡ |
page.published |
Một trang lên sóng (POST /pages/{id}/publish) |
course.enrolled |
Ứng dụng Khoá học ghi danh một học viên |
course.revoked |
Ứng dụng Khoá học thu lại quyền truy cập — cả cặp, để một LMS phản chiếu quyền truy cập không giữ mãi một học viên đã hoàn tiền |
events của endpoint phải nêu ít nhất một trong số này; khác đi nhận
400 invalid_event.
Phong bì
Mỗi lần gửi là một POST với body sau, dù sự kiện nào:
{
"id": "evt_01h...",
"type": "order.created",
"createdAt": "2026-08-14T10:00:00Z",
"data": { "...": "payload riêng của sự kiện" }
}
id giữ nguyên qua mọi lần thử của cùng một lần gửi — cũng là header
X-WB-Event-Id — và là khoá để bạn khử trùng lặp.
| Header | Mang theo |
|---|---|
X-WB-Signature |
sha256=<hex> — xem Xác minh |
X-WB-Timestamp |
Thời điểm gửi, RFC3339 — cũng là giá trị gấp vào chữ ký |
X-WB-Event-Id |
Cùng chuỗi với id |
X-WB-Event-Type |
Cùng chuỗi với type |
X-WB-Attempt |
1 lần đầu, tăng dần mỗi lần thử lại |
Bảo đảm giao nhận
Ít nhất một lần, không bao giờ đúng một lần. Một lần gửi nền tảng tin là
thất bại — timeout, đứt kết nối, 5xx — được gửi lại toàn bộ, kể cả khi bên
nhận đã xử lý xong và chỉ phản hồi bị mất. Trùng lặp sẽ xảy ra. Bên nhận
biến thẳng order.created thành một lần tính tiền mà không kiểm id sớm muộn
làm việc đó hai lần. Ghi lại các id đã xử lý, và cho lần lặp đi qua.
Không có thứ tự. Các lần gửi ra theo thứ tự chúng đến hạn, không theo
thứ tự sự kiện xảy ra: một lần thử lại nằm chờ sau khoảng lùi, nên sự kiện
mới hơn của cùng tài nguyên có thể đáp xuống trước. Cần trình tự thì đọc
updatedAt của chính tài nguyên.
Lịch thử lại
Bảy lần, lần đầu ngay lập tức, mỗi lần sau xa hơn:
| Lần | Gửi vào |
|---|---|
| 1 | Ngay lập tức |
| 2 | +1 phút |
| 3 | +5 phút |
| 4 | +30 phút |
| 5 | +2 giờ |
| 6 | +6 giờ |
| 7 | +24 giờ |
Khoảng 32,6 giờ từ lần đầu tới lần cuối — đủ để sống qua một lần deploy, một
chứng chỉ hết hạn hay một đêm sự cố. Sau lần thứ 7 thất bại, lần gửi bị đánh
dấu dead. Việc này không vô hiệu hoá endpoint — nó vẫn nhận sự kiện
mới.
Xác minh một lần gửi đúng là từ chúng tôi
Chuỗi được ký là <giây-unix>.<body thô> — không phải X-WB-Timestamp
nguyên văn. Header ở dạng RFC3339; chuỗi được HMAC là cùng thời điểm tính bằng
giây Unix. Phân tích header, lấy số giây epoch, rồi tự dựng chuỗi:
const crypto = require('crypto');
function isGenuine(secret, timestampHeader, rawBody, signatureHeader) {
const unixSeconds = Math.floor(new Date(timestampHeader).getTime() / 1000);
const signedString = `${unixSeconds}.${rawBody}`;
const expected = 'sha256=' +
crypto.createHmac('sha256', secret).update(signedString).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
// So sánh thời gian hằng, không dùng `===`: một phép so từng byte trả về ngay
// ở chỗ lệch đầu tiên, và thời gian chạy của nó rò rỉ kẻ tấn công đã đoán
// đúng bao nhiêu ký tự.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
rawBody phải là đúng byte đã nhận, trước mọi lần phân tích JSON — framework
tự serialise lại body trước khi handler nhìn thấy sẽ tạo chuỗi không bao giờ
khớp, trên một chữ ký vốn chưa từng sai.
Dấu thời gian nằm trong chuỗi được ký là chủ ý. Chữ ký chỉ ký lên body thì không bao giờ hết hạn: chụp lại một lần gửi thật, phát lại được mãi. Gấp dấu thời gian vào cho phép bên nhận từ chối thứ cũ hơn vài phút. Nền tảng không áp mức dung sai thay bạn — độ rộng của nó là của bạn.
Kiểm tra nó có thật sự tới không
GET /api/v1/webhooks/{id}/deliveries trả một trang các lần thử của endpoint
đó, mới nhất trước — không đưa ?limit thì 50:
{ "deliveries": [
{ "id": "whd_…", "endpointId": "whe_…", "eventId": "evt_…",
"eventType": "order.created", "attempt": 2, "status": "delivered",
"nextAttemptAt": "…", "lastStatusCode": 200, "lastError": "",
"createdAt": "…", "deliveredAt": "…" }
],
"total": 1 }
payload không bao giờ được đưa vào. webhooks.read cấp riêng được, và
payload của một lần gửi chính là bản ghi đơn hàng hay khách hàng đã kích hoạt
nó; trả về ở đây sẽ biến sự độc lập kia thành hư cấu. Cần biết chính xác cái
gì đã gửi thì phong bì bên nhận của bạn đã có chính là nó.
Route này không có ?offset, và đó là vĩnh viễn. Bạn luôn nhận các lần
gửi GẦN ĐÂY NHẤT; lịch sử cũ hơn không với tới được qua API một khi endpoint
bận rộn sinh ra nhiều hơn limit lần. Cần cả lịch sử thì theo dõi id ngay
trên bên nhận; route này để trả lời "nó có đang chạy không".
Lời từ chối kiểu SSRF
URL webhook được kiểm trước khi lưu, cả lúc tạo lẫn thay thế: địa chỉ riêng
tư, loopback và link-local bị từ chối, và — vì câu trả lời DNS có thể đổi sau
đó — địa chỉ đã phân giải được kiểm lại lúc mở kết nối, không bao giờ đi theo
chuyển hướng. Trỏ vào địa chỉ nội bộ thì 400 blocked_url ngay lập tức.
Cập nhật 26/9/2026