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 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 ở /swaggerchỉ 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á

ordersCounttotalSpentCents đượ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: true cũ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.
  • publishlan 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, limitincoming 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ó bodyTypepage mang 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ận 409 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ệnh PUT sẽ 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 activedisabled. 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:

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

  1. 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;
  2. một bộ phân tích truy vấn theo danh sách cho phép;
  3. một nhánh route;
  4. 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 Page hay Article.
  • Xoá vĩnh viễn một tài nguyên media. DELETE bỏ 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/v1 là 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ờ qua Authorize vố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ới webhook, không bao giờ có trong một lệnh GET. Trên v1 hô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.
  • PUT là thay thế toàn bộ, không phải vá. Mọi trường của WebhookInput đều được gửi chứ không trộn — một description bị 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 được failing lên một endpoint đang khoẻ — làm vậy sẽ nhận 400 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ửa url củ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