Store Builder

Xây một app (/oauth + /api/v1)

Dựng thứ mà các chủ cửa hàng khác cài được: đăng ký app, đưa một cửa hàng đi qua OAuth, gọi API bằng token nhận được, rồi gửi một phiên bản đi duyệt.

App là một chương trình mà chủ cửa hàng cài lên cửa hàng của họ. Nó có bộ credential OAuth riêng, có quyền truy cập theo scope riêng vào dữ liệu của cửa hàng đó, và có một trang của riêng nó được nhúng khung bên trong các màn hình Quản lý của chủ cửa hàng.

Trang này dành cho lập trình viên đang dựng app. API công khai là nửa còn lại — nó mô tả khoá API wbk_ mà một chủ cửa hàng tự tạo cho công cụ của họ, một loại credential khác dành cho một đối tượng khác. Khi app của bạn đã cầm token wba_, mọi điều trang đó nói về /api/v1 đều áp dụng nguyên vẹn cho bạ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.

Toàn bộ luồng, theo thứ tự:

tạo một app            → client id wbc_ + client secret wbs_
tạo một phiên bản      → các scope, embed URL, redirect URI mà người duyệt xem xét
tự cài nó lên          → mã uỷ quyền wbo_   (sandbox: chạy được khi còn là bản nháp)
đổi mã lấy token       → access token wba_ + refresh token wbr_
gọi /api/v1            → dữ liệu của chủ cửa hàng, giới hạn trong scope họ đã cấp
mở trang nhúng         → frame token wbf_, phân giải ngược lại với chúng tôi
gửi đi duyệt           → được duyệt, và mọi chủ cửa hàng đều cài được

Có một CLI, và nó rút ngắn hai đầu của danh sách trên

Mọi bước ở trên đều là một request HTTP, và trang này mô tả tất cả — đó là hợp đồng, và đó là thứ bạn đọc khi một lời gọi trả về 401. Nhưng bạn không cần gõ tay đoạn đầu và đoạn cuối. Một công cụ nhỏ, sb, lo giúp:

Bước ở trên Làm tay Với sb
viết một app nhận được callback và render được trang nhúng §5 và §7, bạn tự viết sb init my-app — một app chạy được có cả hai, không phụ thuộc gì
tạo app, tạo phiên bản, và tự cài lên §2, §3, §4 vẫn làm taysb không gọi mạng cho tới khi đã có những id đó
đẩy scope, URL, block và mã island của một phiên bản §3 và Xây một block, mỗi cửa một lệnh curl sb deploy, theo thứ tự phụ thuộc
tải lại một island mỗi lần sửa mỗi lần lưu một lệnh curl sb dev
gửi đi duyệt §8 sb deploy --submit

Nó là đường đi nhanh hơn qua cùng một vùng đất, không phải thứ thay thế nó. Không việc gì sb làm mà curl không làm được, những request nó gửi chính là những request trang này in ra, và những lời từ chối của nó là lời từ chối của server có thêm hành động kế tiếp. Nếu bạn không muốn cài thêm công cụ, hoặc bạn đang dựng quy trình phát hành của riêng mình, cứ đọc thẳng tiếp — không có gì bên dưới giả định bạn đã có nó.

Lấy nó chỉ mất một lệnh: npx @sbuilder/cli --help. CLI sb nói về cách cài đặt tử tế, mọi lệnh, mọi cờ, và mỗi lệnh từ chối cái gì.


1. Trước khi bắt đầu

Bạn cần ba thứ, và thứ thứ ba là chỗ phần lớn các lần thử đầu tiên dừng lại.

  1. Một tài khoản và một tổ chức. Một app được phát hành bởi một tổ chức, chứ không phải bởi một cá nhân — hãy tạo một tổ chức ở /orgs nếu bạn chưa có. Cổng nhà phát triển nói điều đó ngay trên trạng thái rỗng của nó.
  2. Một cửa hàng bạn là thành viên, để cài lên trong lúc dựng.
  3. Hai URL: một redirect URI (nơi mã uỷ quyền được trả về) và một embed URL (trang được nhúng khung bên trong Quản lý). Cả hai đều bị kiểm tra. Cả hai đều phải là https công khai vào lúc bạn gửi duyệt — nhưng trong lúc phiên bản còn là bản nháp, cả hai đều được phép trỏ vào localhost, và đó là nội dung mục kế tiếp.

Phát triển với localhost — chạy được trên bản nháp, và dừng lại ở lúc gửi duyệt

Mọi URL bạn đăng ký đều đi qua đúng cái chốt chặn SSRF mà các endpoint webhook dùng: nó phân giải tên miền và từ chối các địa chỉ loopback, riêng tư, link-local, multicast và unspecified. Phép kiểm tra đó chạy khi một phiên bản nháp được tạo, chạy lại khi nó được sửa, và chạy lại lần nữa lúc gửi duyệt.

Có đúng một ngoại lệ, và đó chính là ngoại lệ bạn cần: trong lúc một phiên bản còn là bản nháp, một địa chỉ loopback không mã hoá được chấp nhận — cho cả redirect URI lẫn embed URL. Nên đây là một 201:

curl -X POST https://api.your-host/api/orgs/org_yourorg/apps/app_1a2b3c4d/versions \
  -H "Authorization: Bearer <user access token của bạn>" \
  -H "Content-Type: application/json" \
  -d '{"version":"1.0.0","scopes":["products.read"],"embedUrl":"http://localhost:5173/embed","redirectUri":"http://localhost:3000/callback"}'

và việc sửa bản nháp đó về sau, sang một cổng khác hay một đường dẫn khác, cũng vậy. Bạn có trọn vòng lặp phát triển chạy trên máy của chính mình: tự cài bản nháp của mình (§4), mã uỷ quyền đáp xuống server local của bạn, bạn đổi nó lấy token (§5), và server dev local của bạn chính là thứ trang nhúng tải lên (§7 — với một bản cài sandbox, tức là đúng cái phiên bản chưa được duyệt của bạn).

Hình dạng chính xác được phép, bởi nó hẹp hơn câu "giờ localhost chạy được rồi":

Đăng ký trên một bản nháp Kết quả
http://localhost:3000/callback chấp nhận
http://127.0.0.1:3000/callback (mọi địa chỉ trong 127.0.0.0/8) chấp nhận
http://[::1]:3000/callback chấp nhận
https://localhost:3000/callback từ chối — ngoại lệ này chỉ dành cho kết nối không mã hoá; không có câu chuyện chứng chỉ nào cho một tên loopback
http://apps.example.com/callback từ chối — không mã hoá tới một host công khai thì vẫn là không mã hoá
http://169.254.169.254/…, http://10.0.0.5/…, http://192.168.1.10/… từ chối — dịch vụ metadata và các dải riêng tư không phải loopback, và không có gì ở đây miễn trừ cho chúng

Không phần nào trong số này phụ thuộc vào WB_DEV. Cờ đó nới lỏng quy tắc chỉ-https trên một bản triển khai; còn ngoại lệ loopback là thuộc tính của trạng thái phiên bản và hành xử như nhau trên mọi bản triển khai, kể cả bản chạy thật.

Nó dừng ở đâu: lúc gửi duyệt. Gửi duyệt một bản nháp vẫn còn trỏ vào localhost sẽ bị từ chối với

{ "error": "the embed URL or redirect URI is not usable — it must be a public https address", "code": "invalid_url" }

400, ngay lúc gửi duyệt — đúng chỗ mà ngoại lệ kia lẽ ra phải kết thúc. Một phiên bản trong hàng chờ duyệt là phiên bản mà một người duyệt sẽ mở ra và một chủ cửa hàng sẽ cài, và cả hai người đó đều không ngồi ở máy tính của bạn. Nó cũng là thứ giữ embed URL của bạn tránh xa nơi duy nhất mà server này đi lấy nó: phép kiểm tra nhúng khung ở §8 chạy ngay sau phép kiểm tra này, nên một embed URL loopback bị từ chối trước khi có gì được tải về, không bao giờ sau đó.

Vậy lần sửa cuối cùng trước khi gửi duyệt là đổi cả hai URL sang https công khai — một bản nháp thì vẫn sửa được, nên đây là một lệnh PUT, không phải một phiên bản mới.

Một tunnel (ngrok, Cloudflare Tunnel, một máy trung gian bạn kiểm soát) thì vẫn đáng có, cho đúng thứ mà loopback không cho bạn được: thử đúng những URL bạn sẽ gửi duyệt, qua https thật, trước khi một người duyệt nhìn thấy chúng.

Hai bài test ghim mục này vào mã nguồn — TestQuickstartLocalhostSectionIsStillTrue ở đây và TestDeveloperLocalhostIsAcceptedOnADraftAndRefusedAtSubmit trong internal/apps/tests — nên nếu quy tắc đổi, trang này sẽ làm hỏng bản build thay vì lặng lẽ cũ đi.


2. Tạo app — wbc_wbs_

Trong app: Nhà phát triển (en: Developer) ở thanh bên trái, tại /developerTạo ứng dụng (en: Create an app). Đặt tên cho nó; ở bước này không hỏi gì thêm.

Lời gọi tương đương, nếu bạn muốn viết script — các route dành cho nhà phát triển nằm trên API riêng tư và xác thực bằng user access token của chính bạn (cái mà POST /api/auth/login trả về), không phải bằng credential của app:

curl -X POST https://api.your-host/api/orgs/org_yourorg/apps \
  -H "Authorization: Bearer <user access token của bạn>" \
  -H "Content-Type: application/json" \
  -d '{"name":"Shipping Helper"}'
{
  "app": {
    "id": "app_1a2b3c4d",
    "developerOrgId": "org_yourorg",
    "name": "Shipping Helper",
    "clientId": "wbc_9f8e7d6c",
    "status": "active"
  },
  "clientSecret": "wbs_Zm9vYmFyYmF6cXV4..."
}

201. Đây là phản hồi duy nhất từng mang theo clientSecret. Nó được lưu dưới 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 — đọc lại được, và không route nào khác trả nó về.

Hãy đối xử với wbs_… cho đúng: nó đi thẳng vào kho bí mật của bạn ngay khi ra khỏi phản hồi này, và không đi đâu khác.

Nếu nó lộ: hãy xoay khoá

Trong app: mở app → Đổi mã bí mật (en: Rotate secret).

curl -X POST https://api.your-host/api/orgs/org_yourorg/apps/app_1a2b3c4d/secret \
  -H "Authorization: Bearer <user access token của bạn>"
{
  "app": { "id": "app_1a2b3c4d", "clientId": "wbc_9f8e7d6c", "status": "active" },
  "clientSecret": "wbs_bmV3c2VjcmV0Zm9ydGhpc2FwcA..."
}

200, và cùng quy tắc như lúc tạo: phản hồi này là nơi duy nhất khoá bí mật mới tồn tại. clientId của bạn không đổi — bản cài của mọi chủ cửa hàng đều gọi tên nó.

Việc xoay khoá không vô hiệu hoá bất cứ thứ gì đã cấp trước đó. Không chủ cửa hàng nào bị đăng xuất, không bản cài nào phải uỷ quyền lại, và mọi token wba_ app bạn đang giữ vẫn chạy: client secret dùng để xác thực CHÍNH BẠN ở bước đổi token, còn access token thì được đối chiếu với bản cài. Thứ thay đổi là lần đổi token kế tiếp — khoá cũ hết tác dụng ở đó, nên hãy cập nhật cấu hình của bạn trước khi access token hiện tại hết hạn và app của bạn phải trình ra một refresh token.

Mọi route trong mục này đều từ chối một người gọi không phải thành viên của {orgId} bằng 403 not_a_member, trước khi có bất kỳ lần tra cứu nào — nên một người lạ cầm id của một tổ chức không thể từ mã trạng thái mà biết được một app có tồn tại hay không.


3. Tạo một phiên bản — thứ mà việc duyệt thật sự xem xét

Việc duyệt gắn vào một phiên bản, không bao giờ gắn vào app. Mọi thứ mà người duyệt và chủ cửa hàng đọc đều nằm trên đó: các scope nó xin, trang nó nhúng, địa chỉ duy nhất nó được nhận mã, và phần giới thiệu.

Trong app: mở app → Phiên bản mới (en: New version).

curl -X POST https://api.your-host/api/orgs/org_yourorg/apps/app_1a2b3c4d/versions \
  -H "Authorization: Bearer <user access token của bạn>" \
  -H "Content-Type: application/json" \
  -d '{"version":"1.0.0","scopes":["products.read","orders.read"],"embedUrl":"https://apps.example.com/embed","redirectUri":"https://apps.example.com/oauth/callback","summary":"Rate-shops carriers at checkout.","description":"Longer copy the merchant reads before consenting."}'
{ "version": { "id": "apv_5e6f7a8b", "appId": "app_1a2b3c4d", "status": "draft", "...": "..." } }

201, và trạng thái luôndraft — server tự đặt chứ không lấy từ bạn, bởi một client có thể gửi lên một phiên bản đã mang nhãn đã-duyệt thì đã bỏ qua hoàn toàn khâu duyệt.

Trường Những luật sẽ cắn bạn
scopes Ít nhất một, lấy từ chính bộ từ vựng <miền>.read / <miền>.write§4 của hướng dẫn API liệt kê. Hãy xin ít nhất có thể mà vẫn chạy.
embedUrl https công khai — hoặc localhost không mã hoá trong lúc phiên bản này còn là bản nháp (§1). Trang của nó phải cho phép nền tảng này nhúng khung — kiểm tra lúc gửi duyệt, xem §8.
redirectUri https công khai — hoặc localhost không mã hoá trong lúc phiên bản này còn là bản nháp (§1). Đúng một cái, khớp chính xác — không tiền tố, không ký tự đại diện.
summary, description Thứ chủ cửa hàng đọc trên màn hình đồng ý. Tuỳ chọn; một màn hình đồng ý trống rỗng thì vẫn là màn hình đồng ý, chỉ là tệ hơn.

Sửa: PUT …/versions/{versionId} chỉ viết lại được một bản nháp. Khi một phiên bản đã được gửi hoặc đã được xét duyệt, nó trả về 409 not_draft — việc duyệt ghim chặt một payload, nên sửa tại chỗ một phiên bản đã xét duyệt sẽ âm thầm làm mất hiệu lực quyết định mà nó mang theo. Hãy tạo một phiên bản mới.


4. Tự cài bản nháp của mình — vòng lặp phát triển

Bạn không phải chờ duyệt xong mới chạy được luồng này. Một lập trình viên tự cài app chưa duyệt của mình là một ngoại lệ được cho phép (bản cài sandbox), và nó bị thu hẹp hai lần: bạn phải vốn đã với tới được cửa hàng đó, bạn phải thuộc tổ chức phát hành app.

Trong cổng nhà phát triển: mở app, tìm phiên bản, và bấm Cài lên cửa hàng của tôi (en: Install on my store). Nó chọn một cửa hàng bạn thuộc về, ghép URL bên dưới từ chính client_id, version_idredirect_uri đã đăng ký của phiên bản, thêm một state, rồi cho bạn xem toàn bộ trước khi mở — để bạn có thể chép nó sang một hồ sơ trình duyệt khác nếu muốn. Phần còn lại của mục này là thứ mà cái nút đó dựng nên, tức là thứ bạn cần khi bạn tự viết script hoặc đang đọc một lời từ chối.

Đưa trình duyệt của chủ cửa hàng — lúc này là của bạn — tới /oauth/authorize:

https://api.your-host/oauth/authorize
  ?client_id=wbc_9f8e7d6c
  &version_id=apv_5e6f7a8b
  &redirect_uri=https://apps.example.com/oauth/callback
  &site_id=<id của cửa hàng>
  &state=<giá trị chống giả mạo của riêng bạn>
  • Trên thực tế site_id không phải tuỳ chọn. Màn hình đồng ý không phê duyệt được nếu thiếu nó và nói thẳng với chủ cửa hàng như vậy. Khi một chủ cửa hàng bắt đầu cài từ màn hình Ứng dụng (en: Apps) của chính cửa hàng họ, danh sách app điền sẵn giúp họ; còn khi app của bạn khởi động luồng này, bạn phải tự cung cấp.
  • state được trả nguyên vẹn về redirect URI của bạn. Hãy dùng nó.
  • Server kiểm tra trước khi chuyển hướng — một client lạ, một phiên bản thuộc app khác, hay một redirect_uri không phải cái đã đăng ký sẽ bị từ chối ngay tại đây dưới dạng JSON, không bao giờ bị đẩy sang địa chỉ bạn yêu cầu. Lời từ chối đó chính là lỗ hổng chuyển hướng mở mà endpoint này sinh ra để ngăn.

Trình duyệt đáp xuống màn hình đồng ý, nơi hiện tên app của bạn, tổ chức của bạn, và đúng những scope mà phiên bản này xin. Một phiên bản chưa duyệt được ghi nhãn là chưa qua xét duyệt — chủ cửa hàng được cho biết họ đang đồng ý với cái gì.

Khi họ chấp thuận, họ được đưa tới:

https://apps.example.com/oauth/callback?code=wbo_...&state=<của bạn>

Chủ cửa hàng có thể chỉ cấp một tập con những gì bạn xin — và không có gì báo cho bạn biết là tập con nào. Phản hồi token bên dưới không mang trường scope nào, và cũng không có endpoint nào để tra cứu, nên tín hiệu duy nhất là một lỗi 403 insufficient_scope trên đúng lời gọi cần cái quyền bạn không được cấp. Hãy thiết kế cho điều đó: xin ít nhất có thể, và hãy hạ cấp một tính năng thay vì cho rằng một scope bạn xin là một scope bạn có.


5. Đổi mã lấy token — wba_wbr_

Từ server tới server, bằng credential của bạn. Không trình duyệt, không phiên người dùng.

curl -X POST https://api.your-host/oauth/token \
  -H "Content-Type: application/json" \
  -d '{"grantType":"authorization_code","clientId":"wbc_9f8e7d6c","clientSecret":"wbs_...","code":"wbo_...","redirectUri":"https://apps.example.com/oauth/callback"}'
{
  "accessToken": "wba_ins_1a2b3c4d.site_9f8e.1771234567.AbCdEf...",
  "refreshToken": "wbr_...",
  "tokenType": "Bearer",
  "expiresAt": "2026-08-17T10:00:00Z"
}

Mã uỷ quyền sống 60 giây và chỉ dùng được một lần. redirectUri được đối chiếu như một phần của việc đổi mã — và một lần không khớp không tiêu mất mã, nên một lỗi gõ nhầm không huỷ hoại một cái mã mà lần thử lại lẽ ra dùng được.

Gia hạn, trước hoặc sau expiresAt:

curl -X POST https://api.your-host/oauth/token \
  -H "Content-Type: application/json" \
  -d '{"grantType":"refresh_token","clientId":"wbc_9f8e7d6c","clientSecret":"wbs_...","refreshToken":"wbr_..."}'

Refresh token có xoay vòng. Cái bạn gửi đi bị đốt và một cái mới quay về trong cùng phản hồi đó; hãy lưu cái mới, nếu không lần gia hạn kế tiếp sẽ nhận 400 invalid_refresh_token.

Tự gỡ mình khỏi một cửa hàng — phía app của một lần gỡ cài đặt:

curl -X POST https://api.your-host/oauth/revoke \
  -H "Content-Type: application/json" \
  -d '{"clientId":"wbc_9f8e7d6c","clientSecret":"wbs_...","accessToken":"wba_..."}'

204. Nó nhận một access token thay vì một id bản cài, một cách cố ý: token gọi tên bản cài và credential của bạn chứng minh nó là của bạn, nên không app nào gỡ cài đặt được app khác.


6. Gọi thử một endpoint

curl https://api.your-host/api/v1/products \
  -H "Authorization: Bearer wba_..."
{ "products": [ { "id": "prod_...", "name": "..." } ], "total": 42 }

Đó là toàn bộ khác biệt giữa một app và khoá của chính chủ cửa hàng: cái credential. Không có {siteId} ở bất cứ đâu trong một đường dẫn /api/v1 — cửa hàng được ngầm định bởi token. Mọi tài nguyên, phong bì, truy vấn và lỗi trên bề mặt đó được mô tả một lần duy nhất, tại API công khai, và áp dụng nguyên vẹn cho một token wba_.

Mỗi lời gọi bị chặn bởi hai giới hạn, và giới hạn thứ hai hay làm người ta bất ngờ:

  1. các scope mà chủ cửa hàng đã cấp,
  2. vai trò hiện tại của thành viên đã cài app.

App không bao giờ làm được nhiều hơn người đã cài nó, và điều đó được phân giải theo từng request — nên một bản cài do một quản trị viên thực hiện, người này về sau bị hạ xuống vai trò chỉ xem, sẽ mất quyền ghi ở lời gọi kế tiếp, không có gì phải quét dọn và bạn không phải ghi sổ gì. Lời từ chối là 403 insufficient_scope, và với một app thì nó nói đúng theo cách đó: chủ cửa hàng phải cài lại và chấp thuận quyền ấy.

# Gửi nhầm nửa kia của cặp token là lỗi đầu tiên rất hay gặp, và nó được gọi tên:
curl https://api.your-host/api/v1/products -H "Authorization: Bearer wbr_..."
# → 401 {"error":"that is a refresh token; exchange it at /oauth/token for an wba_ access token first",
#        "code":"refresh_token_not_accepted"}

7. Render trang nhúng của bạn — wbf_

Khi một chủ cửa hàng mở app của bạn bên trong Quản lý, nền tảng này cấp một frame token sống ngắn và tải embed URL của bạn trong một iframe, kèm hai tham số truy vấn:

https://apps.example.com/embed?wb_frame_token=wbf_...&wb_site_id=site_9f8e

Token đó không phải một sự uỷ quyền. Nó trả lời câu hỏi "ai đang xem" — một bản cài, một cửa hàng, và thành viên đang nhìn màn hình — và không gì khác. Việc app của bạn được làm gì thì vẫn bị giới hạn bởi chính token wba_ của nó.

Đừng bao giờ tin nó ở dạng vừa nhận được. Hãy phân giải nó từ server của chính bạn, bằng credential của bạn:

curl -X POST https://api.your-host/oauth/frame \
  -H "Content-Type: application/json" \
  -d '{"clientId":"wbc_9f8e7d6c","clientSecret":"wbs_...","token":"wbf_..."}'
{ "installId": "ins_1a2b3c4d", "siteId": "site_9f8e", "memberId": "8c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f" }

401 invalid_frame_token nếu token bị giả mạo hoặc hết hạn, và 401 unknown_client nếu nó được cấp cho một app khác với credential bạn trình ra — không app nào phân giải được frame token của app khác. Bản cài được đọc lại như một phần của việc trả lời, nên một chủ cửa hàng vừa tắt app của bạn mười giây trước sẽ nhận được một lời từ chối ở đây, chứ không phải một câu đồng ý đã cũ.

Ba ràng buộc trên chính trang được nhúng

  • Iframe bị sandbox mà không có allow-same-origin. Trang của bạn render được, chạy được script, gửi được form và mở được link — nhưng nó nằm trong một origin mờ đục, nên nó không đọc hay ghi được cookie hoặc localStorage. Hãy mang phiên làm việc của bạn trong cái token bạn được trao, đừng mang trong cookie.
  • Token sống ngắn (10 phút) và được cấp lại mỗi lần mở. Hãy đổi nó lấy phiên của riêng bạn ngay lập tức; đừng giữ nó như một phiên.
  • Trang của bạn phải cho phép được nhúng khung. Xem §8 — đây là thứ khiến các lần gửi duyệt bị từ chối.

Cầu nối app — nói chuyện với màn hình Quản lý bao quanh bạn

Trang của bạn nằm trong một iframe. Nó có thể hỏi màn hình đang bao quanh nó vài việc — hôm nay là chiều cao mà nó nên có — qua postMessage. Kênh này có đánh phiên bản, có xác thực, và cố ý rất nhỏ.

Không động từ nào với tới server của nền tảng này. Mỗi động từ đều chỉ đổi một thứ gì đó trên chính màn hình của chủ cửa hàng. Không có gì ở đây đọc dữ liệu cửa hàng, cấp một credential, hay gọi API thay bạn: token wba_ của bạn và /api/v1 mới là cách bạn với tới dữ liệu, trong giới hạn của sự đồng ý mà chủ cửa hàng thật sự đã cho. Nếu bạn thấy mình đang muốn một động từ cầu nối chạm tới dữ liệu cửa hàng, thì câu trả lời là một lời gọi API.

Mở đầu bằng một cái bắt tay. Màn hình bỏ qua mọi động từ cho tới khi bạn đã chào và đã được đón:

const params = new URLSearchParams(location.search);
const nonce = params.get('wb_bridge_nonce');       // xem bên dưới — luôn gửi lại
const CHANNEL = 'wb.app.bridge';
const ADMIN_ORIGIN = 'https://<origin quản trị bạn được cho lúc đăng ký>';

let ready = false;
window.addEventListener('message', (e) => {
  // Xác minh phía CHỦ. Origin quản trị là một hằng số bạn được cho biết một lần
  // — đừng bao giờ đọc nó từ một tham số URL: người nhúng khung bạn mới là
  // người cung cấp những tham số đó, nên một app "ghim" giá trị ấy thì đang
  // ghim đúng giá trị mà kẻ tấn công của nó chọn.
  if (e.origin !== ADMIN_ORIGIN) return;
  const m = e.data;
  if (!m || m.channel !== CHANNEL) return;
  if (m.type === 'welcome') { ready = true; resize(); }
  if (m.type === 'unsupported') {
    // Màn hình đang nhúng bạn nói m.supported, không phải thứ bạn hỏi.
    console.warn('app bridge versions supported here:', m.supported);
  }
});

parent.postMessage({ channel: CHANNEL, v: 1, nonce, type: 'hello' }, ADMIN_ORIGIN);

function resize() {
  if (!ready) return;
  parent.postMessage(
    { channel: CHANNEL, v: 1, nonce, type: 'resize', height: document.body.scrollHeight },
    ADMIN_ORIGIN,
  );
}
new ResizeObserver(resize).observe(document.body);

Hãy gửi lại wb_bridge_nonce trong mọi thông điệp. Nó là tham số truy vấn thứ ba, bên cạnh wb_frame_tokenwb_site_id, và nó là một credential khác với frame token: nó không bao giờ rời khỏi trình duyệt, nó được cấp mới mỗi lần mở, và nó không uỷ quyền cho việc gì cả. Nó tồn tại vì ràng buộc đầu tiên ở §7 — iframe của bạn bị sandbox mà không có allow-same-origin, nên nó có một origin mờ đục, và mọi thông điệp bạn gửi đều tới màn hình với event.origin === "null", đúng cái chuỗi mà mọi iframe bị sandbox trên internet đều gửi. Một phép kiểm tra origin ở phía đó chỉ là trang trí. Thứ màn hình kiểm tra thay vào đó là thông điệp có đến từ đúng cửa sổ nó đã nhúng có mang cái nonce mà chỉ tài liệu của bạn từng nhìn thấy. Mất nonce là cầu nối im lặng.

Phía kiểm tra của bạn thì lại làm việc đúng cách: màn hình không bị sandbox, nên các câu trả lời của nó tới bạn kèm origin thật của nó.

Động từ Payload Nó làm gì
resize height — một số, tính bằng pixel CSS Đặt chiều cao iframe của bạn. Kẹp trong khoảng 200–4000; một giá trị ngoài khoảng bị kéo vào trong, không bị từ chối. Một giá trị không phải số hữu hạn thì bị bỏ qua.
toast message — một dòng, tối đa 200 ký tự. toneerror, hoặc bất cứ gì khác cho sắc thái trung tính Hiện một dòng trong khung giao diện của chủ cửa hàng, có tiêu đề là tên app của bạn. Khoảng trắng bị gộp thành một dòng; một thông điệp rỗng hoặc quá dài bị từ chối chứ không bị cắt bớt. Tối đa một lần mỗi giây — phần dư bị bỏ, không xếp hàng.
confirm id — khoá đối chiếu của riêng bạn, tối đa 64 ký tự. message — tối đa 300 ký tự Hỏi chủ cửa hàng một câu có/không trong một hộp thoại có tiêu đề là tên app của bạn, rồi trả lời { type: 'confirm-result', id, confirmed }.
navigate to — một đường dẫn Đưa trang quản trị của chủ cửa hàng tới to, bên trong khu quản lý của chính cửa hàng này.

Chi tiết về confirm. Mỗi lần một câu hỏi: gửi câu thứ hai trong lúc câu đầu còn mở thì nó quay về ngay với { confirmed: false, refused: 'busy' } — đó là một lời từ chối, không phải chủ cửa hàng nói không, và đọc nó thành "không" chính là sai lầm mà trường này sinh ra để ngăn. Bấm huỷ, bấm Escape và bấm ra ngoài đều trả confirmed: false. Nếu chủ cửa hàng đóng màn hình trong lúc câu hỏi của bạn còn mở, sẽ không có câu trả lời nào cả: hãy gắn trạng thái của bạn vào cái id và để nó hết hạn, thay vì đứng chờ một câu trả lời có thể không bao giờ tới.

Chi tiết về navigate. Giới hạn là /manage/<wb_site_id>/… — đúng cửa hàng mà chủ cửa hàng đã mở bạn lên. Bị từ chối, im lặng ở phía bạn và có tiếng trong console của chủ cửa hàng: URL tuyệt đối, đường dẫn không nêu giao thức (//host), màn hình của một cửa hàng khác, trình sửa trang, các đoạn .., và bất cứ gì dài quá 512 ký tự. Không có gì trong đó vượt qua được một phép kiểm tra quyền — route mà chủ cửa hàng đáp xuống sẽ kiểm lại vai trò của họ y hệt như khi họ tự bấm vào.

Giới hạn đó là chủ ý chứ không phải tạm thời: một app được cài trên một cửa hàng thì không nên dời chủ của nó sang màn hình của một cửa hàng khác, và trình sửa trang không phải chỗ để thả ai đó xuống giữa chừng công việc. Nếu app của bạn cần tới một nơi mà hiện chưa với tới được, hãy nói rõ màn hình nào và vì sao — câu trả lời có thể là nới rộng nó, nhưng nới rộng là một quyết định, không phải một chỗ bỏ sót đang chờ ai đó để ý.

Hãy hạ cấp một cách nhẹ nhàng. Nếu không có welcome nào tới, thì bạn đang bị nhúng bởi một thứ không nói giao thức này — một bản triển khai cũ hơn, hoặc một host khác. Cứ chạy tiếp: render ở một chiều cao cố định hợp lý và đi tiếp. Đừng bao giờ để trang của bạn treo chờ một cái bắt tay.

Phiên bản giao thức là 1, và nó được thương lượng chứ không phải mặc định: một hello nêu tên một phiên bản mà màn hình không nói được sẽ nhận lại unsupported cùng danh sách những phiên bản nó nói được. Các động từ mới sẽ tới mà không cần tăng số phiên bản; còn bất cứ thay đổi nào làm đổi ý nghĩa của một động từ đã có thì sẽ không như vậy.


8. Gửi đi duyệt

Trong app: mở phiên bản nháp → Gửi duyệt (en: Submit for review). Gửi duyệt sẽ khoá phiên bản; muốn đổi gì sau đó thì phải tạo một phiên bản mới.

curl -X POST https://api.your-host/api/orgs/org_yourorg/apps/app_1a2b3c4d/versions/apv_5e6f7a8b/submit \
  -H "Authorization: Bearer <user access token của bạn>"

200, và phiên bản chuyển sang pending. Có hai phép kiểm tra chạy ở đây, và phép thứ hai không chạy ở đâu khác:

  1. Các URL được kiểm lại, và ngoại lệ localhost bị rút đi — đúng phép kiểm tra §1 mô tả, chạy lần thứ ba và chặt hơn một nấc. Có hai thứ hỏng ở đây: một bản nháp vẫn còn trỏ vào localhost (§1 — hãy đổi cả hai URL sang https công khai trước), và một tên miền vốn công khai lúc bạn lưu bản nháp nhưng phân giải ra một địa chỉ riêng tư vào lúc bạn gửi duyệt, bởi DNS phân giải tại một thời điểm cụ thể. Cả hai đều thất bại với 400 invalid_url.
  2. Trang nhúng của bạn được tải về, và các header về nhúng khung được đọc. Nếu nó trả X-Frame-Options: DENY hoặc SAMEORIGIN, hoặc một danh sách Content-Security-Policy: frame-ancestors không cho phép nền tảng này, thì lần gửi duyệt bị từ chối với 422 embed_refuses_framing.

Phép kiểm tra thứ hai là thứ đáng chuẩn bị trước, bởi phương án còn lại tệ hơn: một chủ cửa hàng phát hiện ra nó dưới dạng một khung trắng vĩnh viễn, vài ngày sau, mà không có gì trong bất kỳ log nào. Hãy phục vụ trang nhúng của bạn với một frame-ancestors nêu tên origin Quản lý của nền tảng này (một ký tự đại diện * cũng qua được, và một danh sách có giới hạn mà nêu tên nền tảng này cũng qua — bạn không phải làm yếu chính sách của mình để đi lọt). Một lỗi mạng hay một mã 4xx/5xx từ chính server của bạn thì không bị coi là một lời từ chối, nên một server sập tạm thời không làm một phiên bản vĩnh viễn không gửi duyệt được.

Sau đó một người duyệt sẽ chấp thuận, hoặc từ chối kèm lý do — một lời từ chối trống bị server chặn, và lý do đó đi kèm trong lần đọc app mà màn hình cổng nhà phát triển của bạn vốn đã gọi, nên bạn thấy được vì sao.

Khi đã được duyệt, phiên bản đó cài được bởi bất kỳ chủ cửa hàng nào, và phần quy tắc URL chỉ-dành-cho-bản-nháp cũng chấm dứt với nó: redirect_uri của một app đã duyệt phải là https, không có ngoại lệ.


9. Gỡ rối: nhật ký request

Mọi lời gọi /api/v1 mà các bản cài của bạn thực hiện đều được ghi lại, theo route — không bao giờ theo URL, nên không có gì trong đó mang định danh của một chủ cửa hàng khác.

curl "https://api.your-host/api/orgs/org_yourorg/apps/app_1a2b3c4d/requests?limit=50" \
  -H "Authorization: Bearer <user access token của bạn>"
{
  "requests": [
    { "id": "…", "method": "GET", "path": "/api/v1/products/{id}", "status": 403, "code": "insufficient_scope", "at": "2026-08-17T09:41:00Z" }
  ],
  "total": 1
}

limit mặc định là 50 và tối đa 200. Nhật ký giữ 7 ngày theo mặc định (người vận hành có thể cấu hình ngắn hơn hoặc dài hơn). Một bản ghi được đưa vào một kênh có bộ đệm sau khi phản hồi của bạn đã được viết ra, và bị bỏ đi chứ không xếp hàng khi kênh đó đầy — việc ghi log không bao giờ được làm chậm hay làm hỏng lời gọi của bạn. Cũng vì lý do đó, một server chưa nối nhật ký sẽ trả 503 request_log_unavailable thay vì một danh sách rỗng, bởi danh sách rỗng sẽ đọc thành "app của bạn chưa gọi lần nào".


10. Những lỗi đáng rẽ nhánh

Bề mặt OAuth tách các lời từ chối ra thay vì gộp chúng vào một invalid_request. Mỗi dòng bên dưới có một cách sửa khác nhau, và một mã chung chung sẽ bắt bạn phải đoán giữa chúng.

Status code Nghĩa là
401 unknown_client client_id không rõ, client_secret sai, hoặc một credential không sở hữu thứ mà nó đang hỏi tới
400 redirect_uri_mismatch Không đúng chính xác URI đã đăng ký trên phiên bản này, hoặc không đúng cái mà mã đã được cấp cho
400 insecure_redirect_uri http thuần trên một phiên bản đã rời trạng thái nháp
400 code_already_used Mã uỷ quyền đã được dùng rồi — đây là một sự kiện an ninh, không phải chuyện thời điểm
400 code_expired Cũ hơn 60 giây. Hãy bắt đầu lại luồng
400 invalid_refresh_token Không rõ, đã bị xoay vòng, hoặc đã bị thu hồi. Xoay vòng nghĩa là cái trước đó đã chết
400 unsupported_grant_type grantType phải là authorization_code hoặc refresh_token
400 scope_not_requested Một sự đồng ý cấp thứ mà phiên bản này chưa từng xin
400 no_scopes Một sự đồng ý không cấp gì cả
403 forbidden Người đang đồng ý không cài được lên cửa hàng đó
409 not_installable Phiên bản chưa được duyệt (và không có ngoại lệ sandbox nào áp dụng), hoặc app đang bị đình chỉ
401 install_unavailable Bản cài đã biến mất hoặc đã bị tắt
401 invalid_frame_token Một token wbf_ sai định dạng, bị giả mạo hoặc hết hạn. Một token cấp cho một app khác thì lại là unknown_client
404 unknown_endpoint Không có route /oauth nào như vậy

Trên các route dành cho nhà phát triển:

Status code Nghĩa là
403 not_a_member Bạn không phải thành viên của tổ chức đó
404 (không có code) Không có app nào như vậy trong tổ chức này — cố ý trả lời y hệt như với một id không tồn tại
400 invalid_url Embed URL hoặc redirect URI không phải một địa chỉ công khai dùng được — hoặc là một địa chỉ localhost đang được gửi duyệt, thứ mà chỉ bản nháp mới được giữ (§1)
400 no_scopes / invalid_name / no_developer Phiên bản không xin gì cả / app không có tên / không có tổ chức sở hữu
409 not_draft Phiên bản đó đã được gửi hoặc đã được xét duyệt. Hãy tạo một cái mới
422 embed_refuses_framing Trang nhúng của bạn không cho nền tảng này nhúng khung (§8)
503 request_log_unavailable Nhật ký request chưa được cấu hình trên server này

Mọi thứ một token wba_ chạm tới được trên /api/v1 đều dùng bảng của chính bề mặt đó — §5 của hướng dẫn API.


11. Thời hạn, và những con số đằng sau chúng

Thứ Sống Vì sao
Mã uỷ quyền wbo_ 60 giây, dùng một lần Người giữ hợp pháp duy nhất sẽ đổi nó ngay lập tức, từ server tới server
Access token wba_ 1 giờ Không thu hồi riêng lẻ được, nên nó ngắn; bản cài được đọc lại ở mỗi lời gọi, nên gỡ cài đặt cũng có hiệu lực ngay
Refresh token wbr_ 60 ngày, có xoay vòng Quá hạn đó thì chủ cửa hàng đồng ý lại
Frame token wbf_ 10 phút Cấp lại mỗi lần mở; dài hơn nữa là phát lại được sau khi tab đã đóng
Nhật ký request mặc định 7 ngày Đủ dài để gỡ rối, đủ ngắn để không thành một cơ sở dữ liệu thứ hai

12. Những gì chưa có

Nói ra để bạn liệu đường mà tính, thay vì tự phát hiện.

  • Không có localhost sau giai đoạn bản nháp. §1. Phát triển trên máy của bạn thì chạy; gửi duyệt từ đó thì không, và không có dạng https nào của ngoại lệ đó — một tunnel vẫn là cách duy nhất để thử đúng những URL bạn sẽ gửi duyệt.
  • Không xoá được app. Không có route nào cho việc đó: một app bạn không muốn nữa sẽ bị một quản trị viên đình chỉ, chứ không phải bị bạn gỡ bỏ, và clientId của nó vẫn giữ chỗ. Xoay client secret là chuyện khác và việc đó thì — §2.
  • Chỉ có app miễn phí. Không có đường thanh toán nào, nên không có gì để thu tiền; giá của một phiên bản là free và server tự đặt.
  • Một redirect URI cho mỗi phiên bản, khớp chính xác. Nên một môi trường thứ hai (staging bên cạnh production) cần một phiên bản thứ hai — bạn có thể giữ nó làm một bản nháp vĩnh viễn và cài qua đường sandbox — hoặc một app thứ hai. Không có danh sách URI được phép.
  • Không tra cứu được scope. Không có gì cho app của bạn biết nó thật sự đang giữ những scope nào; một lỗi 403 insufficient_scope là tín hiệu duy nhất (§4).
  • Không có danh bạ nhà phát triển công khai, và không chuyển được một app giữa các tổ chức.
  • Cầu nối app có bốn động từ, và chỉ bốn (§7). Nó không phải một SDK và không có ý định trở thành một SDK: không có gì trên đó với tới server của nền tảng này. Một hello nhận về một welcome mà danh sách verbs của nó mới là sự thật về những gì bản triển khai đang nhúng bạn chấp nhận — hãy đọc nó thay vì phỏng đoán.
  • Không bài test nào trong repository này chạy trang của một app. Nửa phía chủ của cầu nối thì có test phủ; còn nửa phía lập trình viên ở §7 là một ví dụ đã dựng sẵn, do một con người chạy trên một bản triển khai thật, không phải do một fixture. Hãy coi nó là điểm khởi đầu để tự kiểm chứng, không phải là sản phẩm đã được chứng nhận.

Tài liệu tham chiếu cho mọi thứ một token với tới được khi bạn đã có nó: API công khai để đọc, bảng điều khiển API để bắn một request vào server thật.

Cập nhật 22/8/2026