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 tokenwba_, 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 tay — sb 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.
- 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 ở
/orgsnế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ó. - Một cửa hàng bạn là thành viên, để cài lên trong lúc dựng.
- 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à
httpscô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àolocalhost, 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àTestDeveloperLocalhostIsAcceptedOnADraftAndRefusedAtSubmittronginternal/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_ và wbs_
Trong app: Nhà phát triển (en: Developer) ở thanh bên trái, tại
/developer → Tạ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ôn là draft — 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 mà §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 đó, và 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_id và redirect_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_idkhô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_urikhô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_ và 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ờ:
- các scope mà chủ cửa hàng đã cấp, và
- 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ặclocalStorage. 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_token và wb_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
và 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ự. tone — error, 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ộthellonêu tên một phiên bản mà màn hình không nói được sẽ nhận lạiunsupportedcù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:
- Các URL được kiểm lại, và ngoại lệ
localhostbị 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àolocalhost(§1 — hãy đổi cả hai URL sanghttpscô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ới400 invalid_url. - 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: DENYhoặcSAMEORIGIN, hoặc một danh sáchContent-Security-Policy: frame-ancestorskhông cho phép nền tảng này, thì lần gửi duyệt bị từ chối với422 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ó
localhostsau 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ạnghttpsnà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à
clientIdcủa nó vẫn giữ chỗ. Xoay client secret là chuyện khác và việc đó thì có — §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à
freevà 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_scopelà 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
hellonhận về mộtwelcomemà danh sáchverbscủ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