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 sẽ dùng. Mọi thứ ở đây đều 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.
Tài liệu này trước đây không tồn tại. API đã ship mà không có nó, và đó chính là lý do không ai dùng được: một endpoint không ai tìm thấy thì không phải là một tính năng đã ship.
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), tại /manage/{siteId}/settings.
Bấm Tạo khoá (en: New key), đặt tên cho nó, rồi tích những scope nó cần.
Trang này viết bằng tiếng Việt, còn app thì có cả tiếng Việt lẫn tiếng Anh. Chỗ nào nêu tên một nút bạn phải bấm, nhãn tiếng Anh đi kèm trong
(en: …)— một đường dẫn bạn không tìm ra trong phiên làm việc của chính mình thì không phải là một hướng dẫn.
Phần bí mật chỉ hiện ra đúng một lần, lúc tạo. Nó được lưu ở dạng đã băm
(SHA-256), nên không ai — kể cả người vận hành nền tảng này — khôi phục lại được
sau đó. Nếu làm mất, hãy thu hồi khoá và tạo một khoá khác. Màn hình danh sách
chỉ luôn hiện tiền tố (wbk_a1b2c3…), đủ để phân biệt các khoá với nhau và
vô dụng khi dùng làm thông tin đăng nhập.
Một khoá thuộc về một cửa hàng. Không có khoá nào trải qua nhiều cửa hàng; một agency vận hành năm khách hàng thì giữ năm khoá.
1b. Hai trang, hai công việc
Trang này để đọc. Nó trả lời một khoá với tới được những gì, PUT thay thế
những 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.
/partner-docs/index.html là bảng điều khiển. Một Swagger UI trực tiếp của
đúng bề mặt này, sinh ra 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 kể từ
lúc bạn đã biết mình đang gửi gì, và nó chưa bao giờ chỉ cho ai cách bắt đầu —
một danh sách các thao tác thì không làm được việc đó.
Không cái nào thay được cái nào, nên cả hai đều được phục vụ và mỗi cái đều dẫn sang cái kia.
Xây một app là trang thứ ba, và nó dành cho một
người khá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 ở đó: nó nói về
luồng OAuth, cặp credential wbc_/wbs_, cái token wba_ mà bề mặt này sau đó
chấp nhận, và màn hình nhúng. Mọi thứ bên dưới áp dụng nguyên vẹn cho một app
khi nó đã cầm token; nhưng không có gì bên dưới chỉ bạn cách lấy được token.
Bảng điều khiển chỉ chứa bảy đường dẫn /api/v1. Đó là chủ ý: nó được dựng
với --tags v1, để một người tích hợp khi đọc có thể biết khoá của mình được
làm gì. Bản đặc tả nội bộ ĐẦY ĐỦ (hơn 100 đường dẫn, phần lớn là
/api/sites/...) nằm ở /swagger và chỉ có trong môi trường dev — trước
đây nó công khai, việc đó vừa để lộ hình dạng API nội bộ vừa khiến một agency
không phân biệt nổi route nào là của mình.
Sinh lại sau khi động vào bất kỳ annotation nào:
npm run docs:api # bản đặc tả nội bộ đầy đủ
npm run docs:partner # bản đặc tả đối tác, chỉ v1
Hai bài test sẽ hỏng nếu quên chạy lệnh thứ hai hoặc nếu một route riêng tư lọt
vào đó — xem internal/server/tests/public_api_boundary_test.go.
2. Gọi nó
curl https://api.your-host/api/v1/products \
-H "Authorization: Bearer wbk_your_secret_here"
Hai loại credential, và một loại vẫn bị từ chối
| Tiền tố | Nó là gì | Ai giữ |
|---|---|---|
wbk_ |
Một khoá API do 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_ |
Một app access token, cấp khi chủ cửa hàng cài một app từ chợ ứng dụng | Server của chính app đó |
wbr_ |
Một refresh token của app — KHÔNG được chấp nhận ở đây | App; hãy đổi nó ở /oauth/token trước |
Cả hai loại được chấp nhận đều thuộc về đúng một cửa hàng, đều có scope, và đều thu hồi được. Qua khỏi cửa rồi thì chúng hành xử y hệt nhau: cùng endpoint, cùng luật scope, cùng lỗi.
wba_ từ đâu ra: từ việc một chủ cửa hàng cài app của bạn, qua
/oauth/authorize → /oauth/token. Luồng đó, từ đầu tới cuối, nằm ở
Xây một app — trang này bắt đầu ngay sau nó một
bước.
Access token của một người dùng thì vẫn bị từ chối, và phần đó luôn là một
quyết định chứ không phải một lỗ hổng — 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 đó sẽ nhận 401 api_key_required.
Bề mặt này CHỈ chấp nhận khoá API cho tới khi chợ ứng dụng ra mắt. Quy tắc được ghi lại là đã THAY ĐỔI chứ không âm thầm viết lại, bởi quy tắc cũ là một quyết định có chủ ý mà một người tích hợp có thể đã đọc.
Ba quy tắc giải thích được phần lớn bề mặt này:
Cửa hàng được ngầm định bởi khoá. Không có {siteId} ở bất cứ đâu trong một
đường dẫn v1, không bao giờ. Một khoá mà chỉ cần sửa URL là trỏ được 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, để client render được số trang.
Lỗi luôn là JSON — không bao giờ là văn bản thuần — để một lần thất bại có thể
được rẽ nhánh chứ không chỉ đem ra hiển thị.
Phân trang là ?limit= + ?offset=. limit tối đa 200; xin nhiều hơn thì
bị kẹp lại chứ không bị từ chối.
3. Có những gì ở đó
Hôm nay có bảy tài nguyên: products, orders, customers, pages, media, blog (bài viết + chuyên mục) và webhooks.
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 sẽ lùi về mặc định chứ không báo lỗi: một bộ lọc sai
không nên biến một lần đọc thành một lần thất bại.
# Tất cả sản phẩm đang bán, mới nhất trước
curl "https://api.your-host/api/v1/products?status=active&sort=created&dir=desc&limit=20" \
-H "Authorization: Bearer $WB_KEY"
# Tạo một sản phẩm. GIÁ NẰM TRÊN BIẾN THỂ, không bao giờ nằm trên sản phẩm:
# priceCents của chính sản phẩm là giá biến thể thấp nhất đang hiển thị, được
# tính lúc ghi. Gửi nó ở mức ngoài cùng thì nó bị bỏ đi, và bạn có một sản phẩm
# không đáng giá gì.
curl -X POST https://api.your-host/api/v1/products \
-H "Authorization: Bearer $WB_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à phần dữ liệu dẫn xuất: id, dấu thời gian, số thứ tự tuần tự, giá thấp nhất, tổng tồn kho. Gửi chúng lên không phải là lỗi — chúng bị bỏ qua, bởi âm thầm nhận một nửa những gì người gọi gửi còn tệ hơn là có một hình dạng nói rõ nó nhận gì.
Orders — đọc, cộng thêm 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à một quyết định chứ không phải một chỗ bỏ sót. Tạo
một đơn hàng chính là thanh toán: nó tiêu một mã giảm giá dưới một khoá, áp
giới hạn số lần dùng, và tính tổng tiền từ một danh mục đang sống. Một lệnh tạo
CRUD trần trụi hoặc là bỏ qua tất cả những thứ đó — sinh ra các bản ghi doanh
thu mà không gì đối soát được — hoặc là âm thầm trở thành một quy trình thanh
toán thứ hai rồi trôi dạt khỏi quy trình thật. Một lệnh POST trả về 405 với
"code": "orders_are_read_only", để tác giả của một tích hợp biết ranh giới này
là cố ý chứ không phải một route bị thiếu.
Có một nếp gấp hay làm người ta bất ngờ, kể cả người viết client: một lệnh POST
từ một khoá không có orders.write sẽ trả 403 insufficient_scope, chứ
không phải 405. Phép kiểm tra scope chạy trước, một cách cố ý (xem §4) — bạn chỉ
gặp cái ranh giới chỉ-đọc kia khi bạn đã cầm đúng cái scope mà lẽ ra nó cho bạn
đi qua.
Lệnh PATCH chỉ nhận status, payment, fulfillment, và mọi trường đều
tuỳ chọn — một trường 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 thể xoá mất thông tin đó chỉ bằng cách không nhắc tới.
curl -X PATCH https://api.your-host/api/v1/orders/ord_123 \
-H "Authorization: Bearer $WB_KEY" \
-H "Content-Type: application/json" \
-d '{"fulfillment":"fulfilled"}'
Các dòng hàng, tổng tiền và bản chụp thông tin khách hàng là lịch sử và 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 được tính lúc đọc, nên một tích hợp khách
hàng thân thiết không phải kéo về mọi đơn hàng của từng khách.
Trường note riêng tư của chủ cửa hàng không được phơi ra. Ghi chú hỗ trợ
được viết cho đồng nghiệp, không phải cho bất cứ tích hợp đối tác nào đem hiển
thị ngược lại cho chính khách hàng.
Pages — CRUD phần siêu dữ liệu, cộng thêm 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 (patch thậ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 của 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: một đồ thị mà hình dạng của nó thuộc về hợp đồng render giữa trình sửa và bộ render, và nó đổi mỗi khi có thêm một phần tử. Công bố nó ra sẽ biến mỗi phần tử mới thành một thay đổi phá vỡ một hợp đồng đã đóng băng, và lần di trú schema đầu tiên sẽ làm hỏng mọi tích hợp cùng lúc.
Nên ở đây một trang được nhìn như một thứ có URL — liệt kê, tạo, đổi tên, đổi slug, sắp xếp lại, xoá, xuất bản. Hãy dàn trang trong trình sửa; còn vòng đời thì điều khiển từ đây.
# Tạo một trang rồi đưa nó lên, bằng hai lời gọi.
curl -X POST https://api.your-host/api/v1/pages \
-H "Authorization: Bearer wbk_…" -H 'Content-Type: application/json' \
-d '{"name":"Autumn sale","settings":{"seoTitle":"Autumn sale"}}'
curl -X POST https://api.your-host/api/v1/pages/pg_123/publish \
-H "Authorization: Bearer wbk_…"
Ba điều đáng biết trước lời gọi đầu tiên:
- Trang đầu tiên của một site tự động trở thành trang chủ, và bất kỳ trang
nào gửi lên kèm
isHomepage: truecũng vậy — việc đó hạ bệ trang đang giữ vai trò đó. Đây là trường duy nhất ở đây có tác dụng phụ lên một trang khác. settingsđược truyền qua nguyên văn. Các khoá bên trong nó cố ý không nằm trong hợp đồng đóng băng; trường này chỉ hứa rằng thứ bạn lưu sẽ quay về. Hãy đọc nó, đổi những khoá bạn biết, rồi gửi lại nguyên cả cụm.publishcó lan truyền: một trang dùng chung một global section với các trang khác sẽ khiến chúng cũng được xuất bản lại, bởi một header sửa một lần không được phép lên sóng ở một trang mà vẫn cũ ở những trang còn lại.
Media — thư viện, kể cả việc tải lên
GET /api/v1/media liệt kê tài nguyên
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 nó 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
Lệnh tải lên là request duy nhất trên bề mặt này không phải JSON:
curl -X POST https://api.your-host/api/v1/media \
-H "Authorization: Bearer wbk_…" \
-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 của cửa hàng được đo từ chính phần dữ liệu tải lên — không có trường nào
bạn gửi lên tác động được vào nó. Vượt hạn mức sẽ nhận 413 kèm used,
limit và incoming tính bằng byte, bị từ chối trước khi có gì được lưu, nên
nó không tốn của cửa hàng đồng nào.
DELETE bỏ vào thùng rác; nó không huỷ, và nó không giải phóng hạn mức. Một
trang đã xuất bản có thể vẫn đang phục vụ tệp đó và không có cơ chế đếm tham
chiếu, nên một API xoá cứng được sẽ là một API có thể chọc thủng một storefront
đang sống chỉ bằng một script. Lệnh xoá vĩnh viễn nằm lại trong app, ngay cạnh
màn hình có thể cho thấy một lần xoá sắp làm hỏng cái gì.
Tài nguyên mang theo một url, không bao giờ mang một khoá object: phần tiền tố
của URL là cấu hình lưu trữ, được ghép lúc đọc, và chính điều đó cho phép đổi
CDN mà không phải viết lại các dòng dữ liệu. Đây là trường duy nhất ở đây mà 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 nằm dưới cùng một cặp quyền, blog.read / blog.write.
content được làm sạch lúc ghi — thẻ script và các trình xử lý sự kiện bị
tước bỏ, bởi phần thân này được render nguyên văn vào một trang storefront. Hãy
đọc phản hồi thay vì cho rằng thứ bạn gửi lên chính là thứ sẽ được phục vụ.
Hai chỗ bất đối xứng mà nếu không nói thì sẽ làm bạn bất ngờ:
- Slug của một bài viết được suy ra từ tiêu đề khi bạn không đưa, và được khử trùng lặp khi đụng nhau. Slug của một chuyên mục thì bắt buộc, và không có cả hai điều trên. Đó là luật của miền nghiệp vụ chứ không phải của API này: slug chuyên mục là một đoạn URL do tác giả chọn.
- Một bài viết có
bodyTypelàpagemang theo một tài liệu của trình dựng thay vì HTML. Đọc nó thì không sao; ghi nó sẽ nhận409 unsupported_body_type, bởi phần thân đó không đi trong trường nào của hình dạng ghi — một lệnhPUTsẽ không sửa nó, mà xoá nó, trong một request trông y như một lần chỉnh sửa bình thường.
Các categoryIds không nhận ra sẽ bị bỏ đi chứ không bị từ chối, để một tham
chiếu cũ không làm hỏng cả một lần nhập liệu. Hãy so với phản hồi để biết cái
nào đã dính.
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ỡ bỏ
GET /api/v1/webhooks/{id}/deliveries lịch sử gửi — xem §8
Đây mới là lý do một cửa hàng với tới được qua API này thay vì bằng một vòng lặp
hỏi liên tục: đăng ký một URL một lần, rồi nền tảng này gọi vào đó — có ký —
ngay khi order.created, product.updated và phần còn lại của danh mục xảy ra.
Nó cũng là toàn bộ 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 sẽ đăng ký năm mươi lần qua route này, mỗi lần một lời gọi,
thay vì bấm qua năm mươi màn hình cài đặt.
curl -X POST https://api.your-host/api/v1/webhooks \
-H "Authorization: Bearer $WB_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/webbuilder","events":["order.created","order.updated"]}'
Phản hồi mang theo khoá bí mật dùng để ký, và nó là phản hồi duy nhất trên
toàn bộ bề mặt này làm việc đó. Một bên nhận không thể xác minh chữ ký của một
lần gửi nếu thiếu nó, nên nó được trao đúng một lần, như một trường ngang hàng
tường minh với webhook trong phản hồi lúc tạo:
{ "webhook": { "id": "whe_…", "url": "…", "events": [...], "status": "active", … },
"secret": "whsec_…" }
Trên bề mặt này không có route nào đọc lại được nó — GET, danh sách và PUT đều
trả về cùng hình dạng webhook mà hoàn toàn không có trường secret, kể cả một
trường rỗng. Mất nó thì cách sửa là xoá endpoint đi và đăng ký một cái mới; ranh
giới đó tồn tại vì webhooks.read với tới được vai trò thấp nhất trên một cửa
hàng, và một lệnh GET có thể trả về khoá ký sẽ cho phép vai trò đó giả mạo các
lần gửi vào chính bên nhận của chủ cửa hàng.
URL được kiểm tra trước khi có gì được lưu, cả lúc tạo lẫn lúc thay thế. Một
địa chỉ loopback, một địa chỉ link-local (dịch vụ metadata của đám mây nằm ở
một địa chỉ như vậy), hoặc một tên miền phân giải ra một trong hai, sẽ nhận 400 blocked_url — đây đúng là chỗ mà một kẻ tấn công muốn trỏ một lệnh fetch phía
server vào, nên cùng cái chốt chặn mà màn hình cài đặt của app chạy cũng áp dụng
ở đây. events phải nêu tên ít nhất một loại mà nền tảng này phát ra; một
endpoint không đăng ký gì sẽ không bao giờ kích hoạt, mà cũng chẳng có lỗi nào
để bạn nhận ra.
PUT là một lệnh thay thế, giống endpoint sản phẩm: hãy gửi mọi trường bạn
muốn giữ, bởi một trường bị bỏ sót sẽ bị đặt lại chứ không sống sót.
status luôn nhận active và disabled. failing là nhãn do server đặt
(nền tảng này dựng nó lên sau ba lần gửi thất bại liên tiếp) và một lệnh PUT
cố ĐẶT nó sẽ bị từ chối với 400 invalid_status — với đúng một ngoại lệ: nếu
endpoint bạn đang thay thế VỐN ĐÃ ở trạng thái failing, thì gửi lại
"status":"failing" nguyên như vậy sẽ được chấp nhận. Chính điều đó khiến quy
trình đọc-sửa-ghi mà cả mục này khuyến nghị — GET endpoint, đổi đúng thứ bạn
muốn đổi, rồi PUT trả lại — chạy được trên đúng cái endpoint mà bạn nhiều khả
năng đang nhìn nhất: cái mà nền tảng đã gắn cờ là đang hỏng.
4. Scope
Một khoá giữ một tập các quyền <miền>.read / <miền>.write. Các miền là
products, orders, customers, discounts, blog, integrations, pages,
theme, media, codefiles, webhooks — cùng bộ từ vựng mà các vai trò trong
app dùng, nên một khoá không bao giờ được cấp thứ mà sản phẩm không mô hình hoá.
Mỗi request đều bị chặn bởi hai giới hạn, và scope được kiểm tra trước:
- bản thân scope của khoá phải chứa quyền đó, và
- thành viên đã tạo ra khoá vẫn phải còn quyền đó qua vai trò của họ.
Điều thứ hai là thứ giữ cho một khoá an toàn theo thời gian: một khoá do một quản trị viên tạo ra, người này về sau bị hạ xuống vai trò chỉ xem, sẽ mất quyền ghi ngay ở request kế tiếp, không cần một đợt thu hồi nào và không cần bên tích hợp phải ghi sổ gì. Quyền được phân giải trực tiếp, không bao giờ chụp lại lúc tạo khoá.
Một khoá thiếu scope sẽ nhận 403 với "code": "insufficient_scope" — không
phải 404, để người gọi phân biệt được "bạn không được phép" với "nó không tồn
tại".
5. Những lỗi đáng rẽ nhánh
| Status | code |
Nghĩa là |
|---|---|---|
| 401 | missing_credential |
Hoàn toàn không có header Authorization |
| 401 | api_key_required |
Có header, nhưng không phải khoá wbk_ cũng không phải app token wba_ (ví dụ token phiên của một người dùng) |
| 401 | refresh_token_not_accepted |
Một refresh token wbr_. Hãy đổi nó ở /oauth/token lấy một access token wba_ trước |
| 401 | invalid_credential |
Trông như một khoá, nhưng không khớp cái nào |
| 401 | key_revoked |
Nó từng tồn tại và đã bị tắt |
| 401 | app_token_invalid |
Một app token bị giả mạo, hết hạn, hoặc app của nó không còn được cài trên cửa hàng đó. Ba trường hợp là MỘT câu trả lời, một cách cố ý: cách sửa giống nhau — cài lại — và tách chúng ra sẽ để lộ việc một bản cài nhất định có tồn tại hay không |
| 503 | resource_unavailable |
Một app token trên một server không có chợ ứng dụng nào được cấu hình. Cả route lẫn credential đều có thể ổn; chỉ là bản triển khai này không kiểm được |
| 403 | insufficient_scope |
Khoá chưa từng được cấp quyền này |
| 403 | key_unusable |
Các scope đã lưu của khoá không còn hợp lệ nữa — hãy tạo khoá mới. Không phải lỗi của người gọi, và câu trả lời nói rõ như vậy |
| 404 | not_found |
Không có dòng dữ liệu 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ố ý, không phải route bị thiếu |
| 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à một tài liệu của trình dựng; hãy sửa nó trong trình sửa |
| 409 | nothing_to_publish |
Trang chưa có bản nháp nào được lưu |
| 409 | cycle_detected |
Một chuyên mục bị chuyển vào dưới chính hậu duệ của nó |
| 413 | quota_exceeded |
Hết dung lượng. Body mang theo used, limit, incoming tính bằng byte |
| 429 | rate_limited |
Chậm lại; giới hạn tính theo từng khoá |
| 503 | resource_unavailable |
Route là có thật; server này chưa cấu hình tài nguyên đó (ví dụ media mà không có object store). Không phải 404 — thử lại sau là hành động đúng |
| 400 | invalid_body / invalid_status / invalid_price / invalid_slug |
Về request |
| 400 | missing_file / empty_file / unsupported_type |
Về tệp tải lên |
| 400 | blocked_url |
Một URL webhook phân giải tới nơi mà nền tảng này không được phép trỏ vào (chốt chặn SSRF) |
| 400 | no_events / invalid_event |
Một webhook không đăng ký gì, hoặc một webhook nêu tên một loại sự kiện nền tảng này không phát ra |
Các mã 401 được tách ra một cách cố ý: "bạn không gửi gì", "bạn gửi sai loại
thứ", "bạn gửi sai MỘT NỬA trong cặp credential của app", "khoá này không có
thật" và "khoá này đã bị thu hồi" cần những cách sửa khác nhau, và một mã
unauthorized chung chung sẽ bắt một tích hợp phải đoán giữa chúng.
6. Những gì chưa có ở đây
Bảy tài nguyên phủ được phần lõi headless — một storefront, một luồng đồng bộ ERP, một công cụ khách hàng thân thiết, một lần di trú nội dung và một agency dựng trang, cộng lại thì cần products, orders, customers, pages, media, blog và webhooks.
Đây vẫn là một tập đang lớn dần, không phải một ranh giới đã thiết kế xong.
Bốn miền (discounts, theme, integrations, codefiles) đã tồn tại trong
API riêng tư của app và trong bộ từ vựng scope; thứ chúng chưa có là một DTO v1.
Đó mới là phần việc thật cho mỗi tài nguyên:
- một hình dạng công khai đã đóng băng — API công khai không bao giờ được để lọt một struct của miền nghiệp vụ, bởi một struct hôm nay serialise được thì ngày mai sẽ công bố mọi trường nó mọc thêm;
- một bộ phân tích truy vấn theo danh sách cho phép;
- một nhánh route;
- một bài test tập-khoá sẽ hỏng ngay khi một trường xuất hiện hoặc biến mất, và một bài test giải mã nghiêm ngặt sẽ hỏng ngay khi tài liệu và phản hồi bất đồng.
internal/publicapi/products.go là bản tham chiếu cho một tài nguyên CRUD đầy
đủ, pages.go cho một tài nguyên mà miền của nó không phơi ra truy vấn nào, và
media.go cho một tài nguyên mà lệnh ghi mang theo dữ liệu nhị phân.
Có hai thứ vắng mặt do quyết định, không phải vì thiếu DTO, và cả hai được nhắc lại ở đây vì chúng là câu hỏi đầu tiên của người tích hợp:
- Tài liệu của trang, và phần thân của 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 vào một hợp đồng đối tác
sẽ đóng băng luôn hợp đồng render cùng với chúng, và mỗi phần tử canvas mới sẽ
thành một thay đổi phá vỡ API. Nếu ranh giới đó có ngày dịch chuyển thì nó sẽ
dịch qua một endpoint tài liệu được đánh phiên bản riêng — chứ không phải bằng
cách nới rộng
PagehayArticle. - Xoá vĩnh viễn một tài nguyên media.
DELETEbỏ vào thùng rác. Xoá sạch sẽ chạm tới tầng lưu trữ và có thể làm hỏng một trang đã xuất bản đang sống, mà lại không có cơ chế đếm tham chiếu nào cho bạn biết là trang nào — nên nó nằm lại trong app, cạnh màn hình có thể cho thấy cái gì sắp hỏng.
7. Những ranh giới mà các bài test canh giữ
Đây không phải quy ước; internal/server/tests/public_api_boundary_test.go sẽ
làm hỏng bản build nếu chúng bị vi phạm.
- API riêng tư không đánh phiên bản, và sẽ giữ như vậy.
/api/v1là bề mặt đối tác;/api/sites/...là app đang nói chuyện với chính nó và được phép đổi bất cứ khi nào app đổi. - Mọi handler v1 đều phân quyền qua
AuthorizePrincipal, không bao giờ quaAuthorizevốn có hình dạng của người dùng — một người gọi được uỷ quyền phải đi qua con đường có kiểm tra scope. - Không route v1 nào nhận
{siteId}. Cửa hàng đến từ khoá.
8. Webhooks
Mục 3 nói về việc đăng ký một endpoint. Mục này nói về thứ thật sự đáp xuống đó khi bạn đã có một cái — hợp đồng giao nhận, tức là toàn bộ lý do API này tồn tại thay vì một vòng lặp hỏi liên tục.
Danh mục sự kiện
Tám loại sự kiện, và danh sách này là một hợp đồng công khai đã đóng băng:
thêm một loại thì miễn phí, nhưng đổi tên hay gỡ bỏ một loại sẽ làm hỏng mọi bên
nhận cùng lúc mà không có lối gỡ dần nào — câu lệnh switch trên type của một
bên nhận nằm ở phía bên kia của thứ mà nền tảng này không kiểm soát gì 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 một đơn thay đổi |
customer.created |
Một khách hàng được thêm |
customer.updated |
Một 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, §3) |
Trường events của một endpoint đã đăng ký (§3) phải nêu tên ít nhất một trong
số này; bất cứ thứ gì khác sẽ nhận 400 invalid_event.
Phong bì
Mỗi lần gửi là một lệnh POST với body sau, dù là 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 — nó cũng chính là header
X-WB-Event-Id bên dưới, và nó là trường để bạn đánh khoá bảng khử trùng lặp
(xem phần Bảo đảm, ngay sau).
Có năm header đi kèm body:
| Header | Mang theo |
|---|---|
X-WB-Signature |
sha256=<hex> — xem phần Xác minh, bên dưới |
X-WB-Timestamp |
Thời điểm gửi, RFC3339 — cũng là giá trị được gấp vào chữ ký |
X-WB-Event-Id |
Cùng chuỗi với id trong phong bì |
X-WB-Event-Type |
Cùng chuỗi với type trong phong bì |
X-WB-Attempt |
1 ở lần đầu, tăng dần ở mỗi lần thử lại |
Bảo đảm giao nhận, nói thẳng như những lời bảo đảm
Ít nhất một lần, không bao giờ đúng một lần. Một lần gửi mà nền tảng này tin
là đã thất bại — timeout, đứt kết nối, một mã 5xx — sẽ được gửi lại toàn bộ, kể
cả khi bên nhận thật ra đã xử lý xong và chỉ có phản hồi quay về phía chúng
tôi là bị mất. Trùng lặp sẽ xảy ra. Một bên nhận biến thẳng order.created
thành một lần tính tiền, một email, hay một lần trừ kho mà không kiểm id trước
thì sớm muộn cũng sẽ làm việc đó hai lần. id (bằng với X-WB-Event-Id) là
khoá khử trùng lặp: hãy ghi lại những cái đã xử lý, và cho một lần lặp lại đi
qua mà không làm gì.
Không có thứ tự. Các lần gửi đi ra theo thứ tự chúng đến hạn, không phải
theo thứ tự các sự kiện đã xảy ra. Một lần thử lại phải nằm chờ sau khoảng lùi
của nó, nên một sự kiện mới của cùng tài nguyên có thể được gửi — và đáp xuống —
trước nó. Đừng suy ra trình tự từ thứ tự đến; nếu bạn cần trình tự, hãy đọc
updatedAt của chính tài nguyên đó.
Lịch thử lại
Bảy lần tất cả, lần đầu ngay lập tức, mỗi lần sau lại 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ờ |
Tức là khoảng 32,6 giờ từ lần thử đầu tới lần cuối — đủ dài để sống qua một lần
deploy, một chứng chỉ hết hạn hay một đêm sự cố, và đủ ngắn để tuần sau không
còn gì đang được thử lại. Sau khi lần thứ 7 thất bại, lần gửi đó bị đánh dấu
dead và không có gì được gửi thêm cho nó nữa. Việc này không vô hiệu hoá
endpoint — nó vẫn tiếp tục nhận các sự kiện mới, bởi một tích hợp tự nó quay
lại được thì nên tự bắt kịp, chứ không nên bị âm thầm tắt đi.
Xác minh một lần gửi đúng là từ chúng tôi
Đây là đoạn quan trọng: một cơ chế ký mà không ai cài đặt nổi là một cơ chế ký không ai dùng.
Chuỗi được ký là <giây-unix>.<body thô> — và nó không phải là
X-WB-Timestamp nguyên văn. Header đó ở dạng RFC3339
(2026-08-14T10:00:00Z); chuỗi được HMAC là cùng thời điểm đó nhưng tính bằng
giây Unix. Hãy phân tích header, lấy số giây epoch của nó, 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
// khi gặp chỗ lệch đầu tiên, nên thời gian chạy của nó rò rỉ việc kẻ tấn công
// đã đoán đúng bao nhiêu ký tự đầu — đó là một đòn tấn công có thật, không
// phải lý thuyết. Luôn dùng hàm so sánh thời gian cố định của ngôn ngữ bạn.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
rawBody phải là đúng chuỗi byte đã nhận, trước mọi lần phân tích JSON — một
framework tự serialise lại body trước khi handler của bạn nhìn thấy (khác thứ tự
khoá, khác khoảng trắng) sẽ tạo ra một 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 bên trong chuỗi được ký là có chủ ý. Một 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 một lần, và nó phát lại được mãi mãi, không phân biệt nổi với bản gốc. Gấp dấu thời gian vào cho phép bên nhận từ chối luôn bất cứ thứ gì cũ hơn vài phút, đóng cửa sổ đó lại. Nền tảng này không áp đặt một mức dung sai thay bạn — quyết định đó, và độ rộng của nó, là của bạn.
Quản lý endpoint — ba điều sẽ làm bạn bất ngờ
Cả ba đã nói ở §3 và nhắc lại ở đây, vì đó là những câu hỏi đầu tiên của bất cứ ai dựng đồ trên cái này:
- Khoá bí mật chỉ quay về đúng một lần — trong phản hồi của
POST /api/v1/webhooks, như một trường ngang hàng tường minh vớiwebhook, không bao giờ có trong một lệnhGET. Trênv1hôm nay không có route nào hé lộ lại nó; mất khoá nghĩa là xoá endpoint đi và đăng ký một cái mới. PUTlà thay thế toàn bộ, không phải vá. Mọi trường củaWebhookInputđều được gửi chứ không trộn — mộtdescriptionbị bỏ sót sẽ bị xoá trắng, đúng quy tắc mà endpoint sản phẩm tuân theo.status: "failing"chỉ sống sót qua một lệnh PUT khi nó vốn đã ở đó. Bạn không ĐẶT đượcfailinglên một endpoint đang khoẻ — làm vậy sẽ nhận400 invalid_status— nhưng đọc một endpoint đang hỏng rồi PUT trả lại với đúng trạng thái đó trong lúc bạn sửaurlcủa nó thì được, và chính điều đó khiến quy trình đọc-sửa-ghi dùng được trên đúng cái endpoint mà bạn đang thật sự sửa.
Kiểm tra xem nó có thật sự tới nơi không
GET /api/v1/webhooks/{id}/deliveries là câu trả lời cho câu hỏi mà việc đăng
ký một endpoint lập tức làm nảy ra: cái này có đang chạy không? Một trang các
lần thử của endpoint đó, mới nhất trước — không đưa ?limit thì bạn nhận 50,
đúng mặc định mà mọi danh sách trên bề mặt này áp dụng, chứ không phải toàn bộ
lịch sử được giữ lại của endpoint:
{ "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 — đó
chính là lý do webhooks mang scope của riêng nó thay vì đi ké orders.read — 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ả nó về ở đây sẽ biến sự độc lập kia thành chuyện hư cấu. Nếu bạn cần biết
chính xác cái gì đã được gửi, thì cái phong bì mà bên nhận của bạn đã có (§8,
phần Phong bì) chính là payload đó, được xác minh bằng đúng chữ ký bạn đã kiểm
lúc nó tới.
Route này không có ?offset, và đó là vĩnh viễn chứ không phải làm dở. Mọi
danh sách khác trên bề mặt này phân trang bằng limit + offset; riêng cái này
chỉ nhận limit. Bạn luôn nhận được các lần gửi GẦN ĐÂY NHẤT của endpoint —
những lần cũ hơn không với tới được qua API này một khi một endpoint bận rộn đã
sinh ra nhiều hơn limit lần kể từ lúc bạn kiểm tra lần trước. Nếu bạn cần cả
lịch sử, hãy theo dõi id (X-WB-Event-Id) ngay trên bên nhận của mình khi các
lần gửi tới; route này để trả lời "nó có đang chạy không", không phải để làm kho
lưu trữ.
Lời từ chối kiểu SSRF
Một URL webhook được kiểm tra trước khi được lưu, cả lúc tạo lẫn lúc thay thế:
các địa chỉ riêng tư, loopback và link-local đều bị từ chối, và — bởi câu trả
lời DNS của một tên miền có thể đổi sau khi phép kiểm tra đó chạy — địa chỉ đã
phân giải được kiểm lại một lần nữa vào lúc mở kết nối, và không bao giờ đi theo
chuyển hướng. Trỏ một URL vào địa chỉ nội bộ thì câu trả lời là 400 blocked_url ngay lập tức; không có gì ở phía bạn để mà gỡ rối mất cả buổi
chiều.
Cập nhật 22/8/2026