Store Builder

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: true cũng vậy — hạ bệ trang đang giữ vai trò đó.
  • settings truyền qua nguyên văn. Đọc nó, đổi khoá bạn biết, gửi lại cả cụm.
  • publish lan 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:

  1. scope của khoá phải chứa quyền đó, và
  2. 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. DELETE bỏ 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