#Tích hợp Stripe Shield SDK
#Tổng quan v1
SDK JavaScript phía parent cho checkout bằng wallet Stripe (Apple Pay / Google Pay).
Store nhúng một script và gọi StripeShield.init(config); SDK hiển thị
các nút wallet trực tiếp trên trang (không dùng iframe) và tùy chọn
thêm một card form với các tab.
Không bao giờ nạp cả SDK legacy và SDK v1 trên cùng một trang. Cả hai đều định nghĩa
window.StripeShield; script nào nạp sau cùng sẽ thắng (SDK v1 ghi cảnh báo console khi phát hiện SDK khác). Chuyển đổi toàn bộ một trang: URL script, keyshpk_và server verifier cùng lúc.
#Bắt đầu nhanh
<div id="ece-apple"></div>
<div id="ece-google"></div>
<div id="stripe-card-form"></div>
<!-- Host mặc định: live.vkl-shield.com (production) / sandbox.vkl-shield.com (testing, minh họa dưới đây).
Một đối tác có thể được cấp một shield host riêng — load script từ host đó
(hoặc truyền selectBase) thay cho host mặc định. -->
<script src="https://sandbox.vkl-shield.com/assets/v1/stripe-shield-sdk.js"></script>
<script>
window.StripeShield.init({
container: { applePay: '#ece-apple', googlePay: '#ece-google' },
shieldKey: 'shpk_test_xxxxxxxxxxxxxxxxxxxxxxxx_xxxxxx',
getOrderData: function () { return fetchYourOrderBody(); },
cardForm: { container: '#stripe-card-form' },
onCompleted: function (envelope, orderId) {
// Xác minh envelope ở phía server trước khi hoàn tất đơn hàng
console.log(envelope);
}
});
</script>#Tham chiếu các tùy chọn init
| Field | Type | Bắt buộc | Mặc định | Mô tả |
|---|---|---|---|---|
container |
{applePay: string|Element, googlePay: string|Element} |
✅ | — | Điểm gắn nút wallet — mỗi giá trị là một chuỗi CSS selector HOẶC một Element bạn đã có sẵn (ví dụ một React ref). |
shieldKey |
string | ✅ | — | Public key cấp cho store của bạn (định dạng v1: shpk_live_* hoặc shpk_test_*). |
getOrderData |
async () => đơn hàng dạng PayPal |
✅ | — | Trả về {purchase_units:[{amount:{value,currency_code:'USD',breakdown?}, invoice_id, reference_id, custom_id, items}]}. Được gọi một lần lúc init; cố định sau đó. |
slug |
string | ❌ | — | Định tuyến pool (cho phép nhiều slug phân tách bằng dấu |). |
selectBase |
string | ❌ | origin của script (ghi nhận lúc nạp; host mặc định là https://live.vkl-shield.com hoặc https://sandbox.vkl-shield.com) |
Ghi đè base cho select/create-intent/complete/beacon/card-form.js. Mặc định là origin mà SDK <script> được nạp từ — được ghi nhận một lần khi file được evaluate, không đọc lại từ document.currentScript tại thời điểm init() (giá trị đó đã là null lúc này với một lời gọi deferred, ví dụ onLoad của next/script, <script>.onload thuần, hoặc một SPA mount sau hydration). Truyền selectBase rõ ràng khi bundling/proxy SDK qua asset pipeline riêng của bạn, hoặc khi store của bạn được cấp một shield host riêng. |
cardForm |
object | ❌ | — | Bật card form + các tab. Xem bên dưới. |
clientStatic |
object | ❌ | — | Ghi đè việc thu thập fingerprint. |
#Tùy chọn card form
| Field | Type | Bắt buộc | Mô tả |
|---|---|---|---|
container |
string | Element | ✅ | Nơi gắn form (dưới các wallet) — chuỗi CSS selector hoặc một Element. |
submitButton |
{text?, class?} |
❌ | Nhãn & CSS class của nút thanh toán. |
appearance |
object | ❌ | Các tùy chọn Stripe Appearance API. |
fonts |
array | ❌ | Font web tùy chỉnh cho iframe Stripe. |
#Giá trị trả về & teardown
StripeShield.init(config) trả về một handle: {destroy()}. destroy() unmount bất kỳ thứ gì instance này đã mount (wallet ECE, card form), xóa listener/timer của nó, và biến các callback/telemetry của nó thành no-op; an toàn khi gọi nhiều lần (idempotent).
Tránh gọi destroy() khi một thanh toán đang diễn ra (in-flight) nếu có thể (xem khoảng in-flight bên dưới) — unmount nút Pay giữa lúc thanh toán là UX tệ dù vẫn an toàn về mặt kỹ thuật. Nếu bạn vẫn gọi (ví dụ một cleanup vô điều kiện trong effect của framework), destroy() sẽ bị TRÌ HOÃN: instance vẫn tiếp tục chạy — callback, telemetry và backoff retry /complete vẫn sống — cho đến khi thanh toán đạt onCompleted/onFailed cuối cùng, rồi mới thực sự teardown. Một destroy() được gọi khi không có gì đang diễn ra vẫn chạy ngay lập tức như trước.
Gọi init() lại trên cùng trang sẽ destroy instance đang sống trước đó trước tiên — ngoại trừ khi instance đó đang "in-flight": một thanh toán đã bắt đầu (pay() đã được gọi, TRƯỚC CẢ khi create-intent được gửi đi — không chỉ khi đã đến /complete) và chưa đạt callback cuối cùng, bao gồm cả một hoàn tất redirect-return (Klarna/Amazon) vẫn đang chạy sau khi người mua quay lại. Trong trường hợp đó init() KHÔNG destroy nó: việc destroy sẽ âm thầm làm rơi mất onCompleted/onFailed(charged_but_unconfirmed) đang chờ mà người mua có thể đang mong đợi, trong khi một nút Pay mới xuất hiện bên dưới — gây rủi ro người mua nghĩ bị charge hai lần. Thay vào đó init() ghi cảnh báo console và trả về CHÍNH instance in-flight đó không đổi (config mới của bạn bị bỏ qua).
Nếu một destroy() đã bị trì hoãn trên instance in-flight đó (theo đoạn trên — ví dụ cleanup của một effect framework fire giữa lúc thanh toán) và sau đó bạn gọi init() lại trước khi thanh toán settle, init() coi đây là store đang yêu cầu lấy lại một instance sống: nó hủy lệnh destroy đang chờ (cảnh báo console, trả về cùng handle) thay vì để nó chạy. Điều này giữ nút Pay/card form được mount để người mua có thể thử lại sau một lỗi có thể retry (bị từ chối, rate limit, …) thay vì bị bỏ lại không có checkout nào trên màn hình. Khi thanh toán đạt callback cuối cùng mà không có init() nào hủy lệnh destroy đang chờ trong lúc đó, teardown thực sự sẽ chạy (hoặc, nếu không có lệnh destroy đang chờ nào, lần gọi init() tiếp theo sẽ destroy và thay thế instance đã settle như thường).
var handle = StripeShield.init({ /* ... */ });
// Sau đó (ví dụ SPA chuyển route ra khỏi checkout):
handle.destroy();#Callback & event
| Callback | Signature | Khi nào | Bạn cần làm gì |
|---|---|---|---|
onReady |
({wallets}) => void |
Các wallet đã hiển thị & sẵn sàng | Có thể hiển thị thông tin phụ |
onClicked |
({walletType}) => void |
Người mua click một nút wallet | Ghi log event, disable retry |
onProcessing |
({walletType}) => void |
Capture thanh toán đang diễn ra | Disable các nút, hiển thị loading |
onCompleted |
(envelope, orderId) => void |
Capture thành công (người mua đã bị charge) | XÁC MINH envelope ở phía server, hoàn tất đơn hàng |
onCancel |
() => void |
Người mua hủy | Hiển thị lựa chọn thử lại |
onFailed |
({message, code, walletType, channel?, paymentIntentId?, reason?}) => void |
Lỗi thanh toán | Kiểm tra code: charged_but_unconfirmed (đã charge) vs redirect_failed/redirect_not_succeeded (chưa charge); channel='card_form' = lỗi form (hiển thị inline); reason:'redirect_state_missing' = trường hợp cụ thể của charged_but_unconfirmed khi trạng thái redirect đã lưu bị mất |
#Sơ đồ chuỗi (sequence diagram)
sequenceDiagram
participant Store as Store Page
participant SDK as StripeShield SDK
participant Shield as Central Shield<br/>(selectBase)
participant Stripe as Stripe API
Store->>SDK: init(config)
SDK->>Shield: GET /v1/stripe/select?shield_key=shpk_...
Shield-->>SDK: {publishable_key, form_methods, card_enabled}
SDK->>SDK: Nạp Stripe.js (v3)<br/>với publishable_key
SDK->>SDK: Hiển thị nút wallet<br/>(detectWallet: iOS→Apple,<br/>Android→Google, còn lại→none)
SDK-->>Store: onReady({wallets})
Store->>SDK: (Người mua click Apple/Google Pay)
SDK-->>Store: onClicked({walletType})
Note over SDK,Stripe: Người mua xác nhận qua wallet sheet ECE —<br/>ĐÂY là điều fire event 'confirm' của ECE dưới đây<br/>(không phải lúc click trước đó), nên create-intent chỉ<br/>bắt đầu khi người mua đã chấp thuận sheet
SDK-->>Store: onProcessing({walletType})
SDK->>Shield: POST /v1/stripe/create-intent<br/>(order_data + wallet_type)
Shield->>Stripe: POST /v1/payment_intents
Stripe-->>Shield: {id, client_secret}
Shield-->>SDK: {id, client_secret}
SDK->>Stripe: confirmPayment(client_secret)<br/>(redirect:'if_required')
Stripe-->>SDK: {status: succeeded} hoặc {error}
SDK->>Shield: POST /v1/stripe/complete<br/>(pi_id + wallet_type) — gọi trực tiếp, không postMessage
Shield->>Stripe: GET /v1/payment_intents/{pi_id}
Stripe-->>Shield: {id, status, amount_received, contact}
Shield->>Shield: Dựng envelope v1<br/>(ký bằng Ed25519)
Shield-->>SDK: {signed_payload, sig, kid, contact}
SDK-->>Store: onCompleted(envelope, orderId)
Note over Store: Xác minh ở phía server
Store->>Store: Fetch JWKS từ<br/>keys.vkl-shield.com/v1/keys
Store->>Store: Xác minh sig dựa trên các byte signed_payload
Store->>Store: Parse signed_payload
Store->>Store: Assert typ, v, env, partner_id<br/>key_id, reference_id, invoice_id<br/>amount_received, status, iat, replay
Store->>Store: Hoàn tất đơn hàng + giao hàng
Note over Store: Vì sao cần các kiểm tra này?
Note right of Store: reference_id, invoice_id,<br/>amount_received: client cung cấp<br/>partner_id, key_id, env, typ:<br/>do shield đóng dấu → ngăn<br/>nhầm lẫn giữa tenant/phiên bản
#Luồng event
| Event | Được fire bởi | Bạn cần làm gì |
|---|---|---|
onReady({wallets}) |
SDK lúc load | Hiển thị nút wallet (thường tự động render) |
onClicked({walletType}) |
SDK lúc click | Ghi log event, tránh double-click |
onProcessing({walletType}) |
SDK ngay sau khi người mua xác nhận wallet sheet, TRƯỚC khi create-intent/confirmPayment chạy | Hiển thị "Đang xử lý...", disable các nút |
onCompleted(envelope, orderId) |
SDK sau capture | Xác minh chữ ký ở phía server, hoàn tất đơn hàng |
onCancel() |
SDK khi người mua hủy (wallet sheet bị đóng) | Hiển thị thông báo "Đơn hàng đã hủy" |
onFailed({message, code, walletType, channel?, paymentIntentId?, reason?}) |
SDK khi lỗi | Kiểm tra code — charged_but_unconfirmed (đối soát) vs redirect_failed/redirect_not_succeeded/bị từ chối (retry) |
#Không có iframe, không có postMessage
Khác với PayPal v1 SDK, Stripe v1 SDK render trực tiếp trên trang store — không dùng iframe. Stripe Express Checkout Elements (wallet) và Payment/Address Elements của card form mount trực tiếp vào container của bạn (đây là điều cho phép Apple Pay hoạt động được; một iframe cross-origin sẽ chặn nó). Các callback của bạn (onReady, onClicked, onProcessing, onCompleted, onCancel, onFailed) được gọi trực tiếp bởi các event handler riêng của SDK (ECE ready/click/cancel, và chuỗi promise pay()/completeIntent()) — không có lớp postMessage nội bộ nào cần ghi lại. Không cần thêm công việc tích hợp ngoài việc nối các callback trên.
#Envelope bằng chứng (chỉ v1)
onCompleted(proof, orderId) nhận envelope được trả về từ /v1/stripe/complete:
Cấu trúc envelope đầy đủ (từ tests/fixtures/v1-ed25519-test-vector-stripe.json):
{
"error": false,
"data": { "...": "các field đã ký từ signed_payload (id, amount_received, status, ship_*, bill_*, meta_*, v.v.); chỉ mang tính tiện lợi" },
"signed_payload": "<chuỗi UTF-8 nén — phải xác minh sig dựa trên các byte chính xác>",
"sig": "rl2KaWx1alC6quN5wOxZ4wzrawAV_2WfpJHImwBVxSqu4EiHuCL6IcPQtoOToY1omp5Tex1c9QVZrC4mpUOFDA",
"kid": "shsig_TESTVECTORSTRIPE00000000",
"alg": "Ed25519",
"v": 1,
"contact": {
"name": "Nguyễn Văn A",
"address": { "line1": "12/3 Rue de l’Église", "line2": "Apt 4.5", "city": "Hồ Chí Minh", "state": "HCM", "postal_code": "700000", "country": "VN" },
"email": "buyer@example.com",
"phone": "+84901234567",
"wallet_type": "apple_pay"
}
}
kid/sigở trên lấy từtests/fixtures/v1-ed25519-test-vector-stripe.json— một test vector mà private key của nó được công khai có chủ ý (có thể suy ra từ một seed label đã công bố) để đảm bảo tái lập được.kidnày KHÔNG BAO GIỜ xuất hiện trong JWKS thật; không xử lý đặc biệt hoặc hardcode nó. Luôn resolvekidbằng cách tra cứu trong response từ JWKS URL CHUẨN (keys.vkl-shield.com, xem dòngkidbên dưới) — không bao giờ pin việc xác minh vào một giá trị kid cụ thể.
| Field envelope | Type | Hành động của bạn |
|---|---|---|
signed_payload |
string (UTF-8) | Xác minh sig dựa trên các byte UTF-8 chính xác, rồi JSON.parse nó. Dữ liệu duy nhất đáng tin. |
sig |
string (base64url) | Chữ ký Ed25519; xác minh bằng crypto.createPublicKey({key: jwk, format: 'jwk'}) |
kid |
string (shsig_ + 24 ký tự base62) |
Tra cứu trong JWKS (refetch một lần nếu chưa biết, rate-limit 1 lần/phút cho mỗi JWKS URL, không phải cho mỗi kid). |
alg / v |
"Ed25519" / 1 |
Assert. |
data |
object | Bản copy tiện lợi không đáng tin — không bao giờ ủy quyền trực tiếp từ nó. |
contact |
object | Không được ký, chỉ là gợi ý hiển thị (name, address, email, phone, wallet_type). |
signed_payload (đã parse) — thứ tự field theo chuẩn bảng chữ cái, chỉ gồm các giá trị scalar đơn giản:
{
"amount_received": 450,
"bill_city": "Hồ Chí Minh",
"bill_country": "VN",
"bill_email": "buyer@example.com",
"bill_line1": "12/3 Rue de l’Église",
"bill_line2": "",
"bill_name": "Nguyễn Văn A",
"bill_phone": "+84901234567",
"bill_postal": "700000",
"bill_state": "HCM",
"env": "sandbox",
"iat": 1793500000,
"id": "pi_TESTVECTOR000000000000",
"invoice_id": "1001",
"key_id": "key_EXAMPLE000000000",
"meta_amount_cents": "450",
"meta_channel": "wallet",
"meta_invoice_id": "1001",
"meta_items_json": "[{\"n\":\"Áo thun 1/2 tay – Nguyễn\",\"q\":1,\"p\":4.5,\"s\":\"\"}]",
"meta_items_total": "4.5",
"meta_key_id": "key_EXAMPLE000000000",
"meta_partner_id": "acme",
"meta_reference_id": "partner-code-example",
"meta_shield": "shield-sandbox.example",
"meta_shipping_fee": "0",
"meta_store": "shop.example.com",
"partner_id": "acme",
"reference_id": "partner-code-example",
"ship_city": "Hồ Chí Minh",
"ship_country": "VN",
"ship_line1": "12/3 Rue de l’Église",
"ship_line2": "Apt 4.5",
"ship_name": "Nguyễn Văn A",
"ship_phone": "+84901234567",
"ship_postal": "700000",
"ship_state": "HCM",
"status": "succeeded",
"store": "shop.example.com",
"store_domain": "shop.example.com",
"typ": "stripe_complete",
"v": 1
}| Field | Type | Kiểm tra của bạn |
|---|---|---|
typ |
"stripe_complete" |
Assert. |
id |
string (PaymentIntent id) | Key chống replay — từ chối nếu đã settle PI này rồi. |
amount_received |
integer (cents) | Assert === tổng tiền đơn hàng của bạn tính bằng cent. |
status |
"succeeded" |
Assert. |
invoice_id |
string | Assert === id đơn hàng của bạn. |
reference_id |
string | TÙY CHỌN; client cung cấp khi tạo đơn hàng. Chỉ assert nếu bạn tự đặt nó. |
store |
string | location.hostname của trang store lúc create-intent (client cung cấp, không chuẩn hóa; chỉ mang tính thông tin). |
bill_name, bill_email, bill_phone, bill_line1, bill_line2, bill_city, bill_state, bill_postal, bill_country |
string ("" nếu không có) |
Chi tiết billing đã ký từ lần charge. |
ship_name, ship_phone, ship_line1, ship_line2, ship_city, ship_state, ship_postal, ship_country |
string ("" nếu không có) |
Địa chỉ giao hàng đã ký — địa chỉ cần giao hàng tới. Dùng cái này để giao hàng. |
meta_<key> |
string | Bản copy đã ký của metadata PI (meta_invoice_id, meta_reference_id, meta_amount_cents, meta_items_json, meta_items_total, meta_shipping_fee, meta_channel, meta_shield, meta_store, meta_key_id, meta_partner_id). Mỗi giá trị đều là string — bao gồm cả meta_items_json, mà nội dung sau khi parse là [{n, q, p, s}] với p là một SỐ (không phải string) được làm tròn 2 chữ số thập phân, và s luôn có mặt ("" khi không có SKU); meta_items_total/meta_shipping_fee là các chuỗi decimal không có số 0 ở cuối (ví dụ "4.5", "0", không phải "4.50"/"0.00"). meta_key_id/meta_partner_id LUÔN có mặt trên một PI được tạo bởi v1 (complete assert metadata.key_id === keyId) — chúng do server đặt, không phải "chỉ khi xác thực bằng key". settle_ref cố tình không được ký. |
partner_id |
string ([a-z0-9]{2,32}) |
Assert === partner id của bạn. |
key_id |
string (key_ + 16 ký tự base62) |
Assert nó là một trong các key id của BẠN. |
env |
"live" | "sandbox" |
Assert === "live" trên production. |
iat |
integer (unix seconds) | Assert now − 600 ≤ iat ≤ now + 300. |
store_domain |
string | Chỉ mang tính thông tin. |
v |
1 |
Assert. |
Không có field currency: Stripe v1 hiện chỉ hỗ trợ USD, nên hãy ràng buộc số tiền theo cent và coi mọi đơn hàng không phải USD là không khớp.
Contact (không được ký): Object
contactchứa{name, address, email, phone, wallet_type}chỉ để gợi ý hiển thị/giao hàng. Các field này KHÔNG được ký và KHÔNG được dùng để validate. Địa chỉ giao hàng đã ký nằm trong các fieldship_*ở trên.
Bạn PHẢI xác minh sig dựa trên signed_payload ở phía server trước khi tin tưởng bất kỳ điều gì.
#Mã lỗi & cách xử lý
#Tham chiếu lỗi theo từng bước
| Code | HTTP | Bước | Hiển thị dưới dạng | Code của bạn |
|---|---|---|---|---|
SHIELD_KEY_INVALID, SHIELD_KEY_ENV_MISMATCH, SHIELD_KEY_EXPIRED, SHIELD_KEY_REVOKED |
401 | select | Im lặng (không callback); container wallet vẫn ẩn | Kiểm tra định dạng key, hạn dùng, và trạng thái thu hồi |
SCOPE_DENIED |
403 | select | Im lặng (không callback) | Xác minh key có scope stripe |
ORIGIN_NOT_ALLOWED |
403 | select | Im lặng (không callback) | Thêm origin của store vào allowlist của key |
RATE_LIMITED |
429 | create-intent | onFailed({code:'RATE_LIMITED'}), người mua KHÔNG bị charge |
Quá nhiều request từ IP này; chờ ~60 giây |
TEMPORARILY_LOCKED |
429 | create-intent | onFailed({code:'TEMPORARILY_LOCKED'}), người mua KHÔNG bị charge |
IP đang bị decline lock (5 lần thanh toán bị từ chối trong 30 phút → khóa 15 phút), không phải một lần vượt rate-limit thông thường; chờ 15+ phút. Lock này CHỈ được NẠP bởi các lần PayPal capture bị từ chối — một lần Stripe bị từ chối trên cùng IP này KHÔNG cộng vào counter, nhưng một lần PayPal bị từ chối ở nơi khác CÓ THỂ khóa create-intent Stripe này (lock tính theo IP, chia sẻ giữa cả hai nhà cung cấp). |
POOL_MISMATCH |
403 | complete | onFailed({code:'charged_but_unconfirmed'}) — người mua ĐÃ bị charge; confirmPayment đã thành công trước khi /complete chạy |
Key không được gán cho tài khoản Stripe của PaymentIntent này; đối soát qua webhook |
SIGNING_UNAVAILABLE, SHIELD_REGISTRY_UNAVAILABLE, SHIELD_REGISTRY_STALE |
503 | complete | onFailed({code:'charged_but_unconfirmed'}) |
Tạm thời; SDK tự retry (x3); fallback qua webhook |
/v1/stripe/select KHÔNG bị rate-limit (enforcePaymentRate chỉ bảo vệ create-intent) và không bao giờ tự retry — khi select thất bại (network timeout, 530, non-2xx, v.v.) SDK gửi beacon một lần và resolve null. Không có error callback nào fire. Phát hiện trạng thái này: onReady không bao giờ fire; container wallet vẫn ẩn (CSS: display:none theo mặc định). Người mua thấy UI thanh toán gốc của store không đổi. Đây là thiết kế có chủ ý — một shield chậm/chết không bao giờ chặn checkout.
#Các trạng thái đã charge nhưng chưa xác nhận
Khi /v1/stripe/complete trả về bất kỳ lỗi nào ngoại trừ NOT_SUCCEEDED, người mua đã bị charge nhưng bằng chứng không thể xác nhận được. Các trạng thái này có mã lỗi riêng biệt:
onFailed: function (error) {
if (error.code === 'charged_but_unconfirmed') {
// Người mua ĐÃ bị charge. Hiển thị thông báo này:
showMessage('Đã nhận thanh toán! Đang xác nhận đơn hàng của bạn...');
// Đối soát qua webhook AIO transaction.completed, khớp theo settle_ref (custom_id)
// ID PaymentIntent: error.paymentIntentId
} else if (error.code === 'redirect_failed' || error.code === 'redirect_not_succeeded') {
// Người mua KHÔNG bị charge; thanh toán bị từ chối hoặc hủy
showError('Thanh toán chưa hoàn tất. Vui lòng thử lại.');
} else {
// Lỗi create-intent (RATE_LIMITED, TEMPORARILY_LOCKED, một mã từ chối/
// validation của Stripe, v.v.) — confirmPayment chưa từng chạy, người
// mua KHÔNG bị charge. Mọi lỗi /complete NGOẠI TRỪ NOT_SUCCEEDED đã
// được xử lý ở nhánh charged_but_unconfirmed trên, nên đến được nhánh
// này nghĩa là chưa có charge nào xảy ra.
showError('Thanh toán thất bại. Vui lòng thử lại.');
}
}Hành vi retry:
- Timeout network / 502/503/504 với mã issue tạm thời (SIGNING_UNAVAILABLE, SHIELD_REGISTRY_UNAVAILABLE, SHIELD_REGISTRY_STALE) trên
/v1/stripe/complete→ SDK tự retry tối đa 3 lần (backoff 1s/3s/9s) /v1/stripe/selectvà/v1/stripe/create-intentkhông bao giờ được SDK retry- Sau khi retry
/completehết lượt mà không có kết quả →charged_but_unconfirmed(người mua đã bị charge) - Chỉ
NOT_SUCCEEDEDnghĩa là người mua KHÔNG bị charge (thanh toán bị từ chối/hủy trước khi charge) - Mọi lỗi
/v1/stripe/completekhác → người mua ĐÃ bị charge; đối soát qua webhook fallback
Khôi phục:
- Đối soát qua webhook AIO
transaction.completed. AIO nhận các webhook từ nhà cung cấp PayPal/Stripe cho các tài khoản merchant của shield và thông báo cho platform của bạn (ký HMAC, headerX-Signature: t=…,v1=…; endpoint, secret và hợp đồng payload do đội vận hành shield/AIO cấp). Khớp với đơn hàng của bạn bằngsettle_refbạn đã gửi (PayPalpurchase_units[0].custom_id; Stripe: cùngcustom_idtronggetOrderData, lưu dưới dạng metadata PaymentIntentsettle_ref). Chỉ đơn hàng KHÔNG có bằng chứng đã xác minh mới chờ webhook này (hiển thị đã nhận thanh toán, đang xác nhận); không bao giờ yêu cầu người mua trả tiền lại. - KHÔNG retry thanh toán
- Yêu cầu người mua liên hệ hỗ trợ nếu họ không thấy xác nhận trong vòng 10 phút
#Tab thanh toán dựa trên redirect (chỉ card form — Amazon Pay, Klarna)
Payment Element của card form có thể hiển thị các tab cho phương thức dựa trên redirect (Amazon Pay, Klarna — tùy theo config form_methods của bạn gồm những gì). Xác nhận một trong các tab đó sẽ chuyển người mua RA KHỎI trang của bạn; họ quay lại tại return_url mà SDK dựng từ location.href (được gắn thẻ bằng marker nội bộ ss_r=1 để SDK phân biệt redirect của chính nó với bất kỳ ?payment_intent= khác đã có trên trang đó). Link (nếu được bật trong form_methods) KHÔNG redirect — nó tự điền/xác nhận ngay trên trang như card, nên settle qua đường pay() thông thường, không qua đường này.
Lưu ý khi deploy: một redirect BẮT ĐẦU trên bản build SDK trước khi có
ss_r(chưa có marker) và QUAY LẠI sau một lần deploy thêm/đổi marker sẽ bịhandleRedirectReturnphiên bản MỚI bỏ qua (marker không khớp) — trang sẽ hiển thị checkout thông thường không có callback hoàn tất, giống như bất kỳ?payment_intent=nào SDK không nhận ra. Đây là một khoảng thời gian hẹp (≤ thời gian round-trip của redirect, thường dưới 30 phút) xung quanh bất kỳ lần deploy nào đụng tới marker này. Nó được fallback webhook AIOtransaction.completedxử lý (xem "Khôi phục" ở trên) — không gây lỗi hiển thị cho người mua, chỉ là đối soát bị trì hoãn. v1 chưa được phát hành vào thời điểm viết tài liệu này, nên điều này chỉ quan trọng từ bản phát hành SDK v1.x đầu tiên có markerss_rhoặc thay đổi nó; tránh deploy thay đổi như vậy vào giờ cao điểm nếu có thể.
Khi quay lại: Trang return của store phải gọi StripeShield.init() lại trên cùng một tab trong vòng 30 phút. getOrderData vẫn bắt buộc đối với init() (nếu không sẽ throw lỗi) — SDK xác minh nó có mặt nhưng KHÔNG gọi nó trên đường này; truyền cùng hàm (hoặc bất kỳ hàm nào) bạn dùng trên trang checkout thông thường:
// Trang return (cùng tab, trong vòng 30 phút)
StripeShield.init({
container: { applePay: '#apple', googlePay: '#google' }, // hoặc dùng cardForm.container
shieldKey: window.shieldKey, // Phải khớp với create intent
selectBase: window.selectBase, // Tùy chọn; mặc định là origin script SDK
getOrderData: function () { return fetchYourOrderBody(); }, // bắt buộc với init(), KHÔNG được gọi trên đường này
onCompleted: function (envelope, storeOrderId) {
// Thanh toán thành công; xác minh và xử lý envelope như bình thường
},
onFailed: function (error) {
if (error.code === 'charged_but_unconfirmed') {
showMessage('Đã nhận thanh toán, đang xác nhận...'); // Đối soát qua webhook
} else if (error.code === 'redirect_failed' || error.code === 'redirect_not_succeeded') {
showError('Thanh toán thất bại. Vui lòng thử lại.'); // Chưa bị charge
} else {
showError('Thanh toán thất bại. Vui lòng thử lại.'); // Chưa bị charge (lỗi cấp create-intent/select)
}
}
});Các hành vi quan trọng:
- SDK không gọi
getOrderDatahoặc mount container wallet khi nhận diện một lần return (nó vẫn yêu cầu tùy chọn này phải là một hàm) - Không có callback
onClicked(trang return tiếp tục session đã có) - Emit
onProcessing→onCompleted(envelope, storeOrderId)khi thành công - Nếu marker
ss_r=1bị thiếu (?payment_intent=của một integration khác, một bookmark, một link chia sẻ) → SDK bỏ qua hoàn toàn và hiển thị checkout bình thường, không có callback - Nếu marker CÓ mặt nhưng trạng thái đã lưu bị thiếu/hết hạn/không khớp (>30 phút, tab khác, storage bị xóa) →
onFailed({code:'charged_but_unconfirmed', reason:'redirect_state_missing', paymentIntentId})trừ khiredirect_status=failed(lúc đóonFailed({code:'redirect_failed'})và checkout hiển thị bình thường để người mua có thể thử lại) — việc đối soát khi đó phụ thuộc vào webhook, vì SDK không cópkđể gọi/complete
#Chính sách retry
SDK Stripe v1 tự động retry chỉ với /v1/stripe/complete — không bao giờ với /v1/stripe/select hoặc /v1/stripe/create-intent — và chỉ trong các trường hợp sau:
- Lỗi network (timeout, không có response) trên
/complete - HTTP 502/503/504 trên
/complete, VÀ response không cóissueHOẶCissuelà một trong{SIGNING_UNAVAILABLE, SHIELD_REGISTRY_UNAVAILABLE, SHIELD_REGISTRY_STALE}
Mọi lỗi khác đều là cuối cùng (terminal). Không hiển thị "thử lại" cho người mua.
#Xác minh ở phía server
Mô hình xác nhận — bằng chứng (proof) là chính, AIO là fallback dự phòng.
- Chính (đồng bộ): xác minh bằng chứng Ed25519 ở phía server ngay sau callback (trang này) → hoàn tất đơn hàng ngay và hiển thị trang cảm ơn. Không chờ bất kỳ webhook nào.
- Fallback / đối soát: webhook AIO
transaction.completed(ký HMACX-Signature: t=…,v1=…, khớp theosettle_ref=custom_idcủa bạn) hoàn tất các đơn hàng mà bằng chứng chưa từng đến — PayPalCAPTURED_PROOF_UNAVAILABLE, Stripecharged_but_unconfirmed, hoặc người mua đã đóng tab — và có thể dùng để đối soát sau. Nó phải idempotent (lặp lại an toàn) với bước 1 (một đơn hàng đã hoàn tất từ bằng chứng thì webhook này là no-op). Không bao giờ charge người mua lần nữa khi đơn hàng đang chờ fallback.
Xem docs/api/examples/ để có mẫu hoạt động đầy đủ:
- Node.js (
verify-signed-payload.mjs) — Khuyến nghị dùng; hỗ trợ Ed25519 đầy đủ - PHP (
verify-signed-payload.php) — Dùngsodium_crypto_sign_verify_detached() - Python (
verify-signed-payload.py) — Dùng thư việncryptography
#Checklist xác minh
// 1. Fetch JWKS từ CONFIG SERVER CỦA BẠN (config của đối tác, không lấy từ envelope)
const PARTNER_JWKS_URL = process.env.SHIELD_JWKS_URL; // Config phía server của bạn:
// Live: 'https://keys.vkl-shield.com/v1/keys'
// Sandbox: 'https://keys.vkl-shield.com/sandbox/v1/keys'
let jwks = await fetchWithCache(PARTNER_JWKS_URL);
// 2. Tìm public key theo kid + dựng crypto.PublicKey từ JWK
let jwk = jwks.keys.find(k => k.kid === envelope.kid);
if (!jwk) {
// Key chưa biết: refetch JWKS một lần (tối đa 1 lần/phút), rồi fail nếu vẫn thiếu
jwks = await fetchJwksUncached(PARTNER_JWKS_URL);
jwk = jwks.keys.find(k => k.kid === envelope.kid);
if (!jwk) throw new Error('Unknown key ID: ' + envelope.kid);
}
// Chuyển JWK thành crypto.PublicKey (Node.js crypto.createPublicKey nhận định dạng jwk)
const publicKey = crypto.createPublicKey({key: jwk, format: 'jwk'});
// 3. Xác minh chữ ký Ed25519 dựa trên các byte signed_payload chính xác
const verified = crypto.verify(
null,
Buffer.from(envelope.signed_payload, 'utf8'),
publicKey,
Buffer.from(envelope.sig, 'base64url')
);
if (!verified) throw new Error('Signature mismatch');
// 4. Parse signed_payload
const payload = JSON.parse(envelope.signed_payload);
// 5. Assert các thuộc tính bảo mật — KIỂM TRA QUAN TRỌNG
const now = Math.floor(Date.now() / 1000);
const YOUR_PARTNER_ID = process.env.PARTNER_ID; // ví dụ 'mystore' — từ config
const YOUR_ALLOWED_KEY_IDS = process.env.ALLOWED_KEY_IDS?.split(',') || []; // Các key ID đã được bạn phê duyệt
// Assert type + version
if (payload.typ !== 'stripe_complete') throw new Error('Invalid typ: ' + payload.typ);
if (payload.v !== 1) throw new Error('Invalid version: ' + payload.v);
// Ràng buộc môi trường (live vs sandbox)
if (payload.env !== 'live') throw new Error('Not a live proof');
// Xác thực partner + key
if (payload.partner_id !== YOUR_PARTNER_ID) throw new Error('Wrong partner: ' + payload.partner_id);
if (!YOUR_ALLOWED_KEY_IDS.includes(payload.key_id)) throw new Error('Unapproved key: ' + payload.key_id);
// Ràng buộc đơn hàng
if (payload.invoice_id !== order.id) throw new Error('Invoice mismatch');
// reference_id là TÙY CHỌN và do client cung cấp khi tạo đơn hàng — chỉ
// assert nó NẾU store của BẠN tự đặt nó trên đơn hàng (không phải ranh giới bảo mật):
// if (payload.reference_id !== order.yourReferenceCode) throw new Error('Reference mismatch');
// Trạng thái thanh toán + số tiền
if (payload.status !== 'succeeded') throw new Error('Not succeeded: ' + payload.status);
if (payload.amount_received !== order.totalCents) throw new Error('Amount mismatch: ' + payload.amount_received);
// Stripe v1 chỉ hỗ trợ USD (không có field currency trong bằng chứng)
if (order.currency !== 'USD') throw new Error('Stripe v1 is USD-only; got: ' + order.currency);
// Khoảng thời gian (iat phải gần đây: now −600s đến +300s)
if (payload.iat < now - 600 || payload.iat > now + 300) throw new Error('Timestamp out of range: ' + payload.iat);
// Chống replay (PI id = key chống replay)
if (await alreadySettled(payload.id)) throw new Error('Replayed PaymentIntent: ' + payload.id);
// 6. Hoàn tất dựa trên payload ĐÃ XÁC MINH (không bao giờ dùng envelope.data / envelope.contact)
console.log('Order verified; PI:', payload.id, 'Ship to:', payload.ship_name || 'N/A');Vì sao cần các kiểm tra này:
invoice_id,amount_received: client cung cấp khi tạo đơn hàng → phải khớp với bản ghi của bạn (đây là kiểm tra ràng buộc tiền)reference_id: CŨNG do client cung cấp, nhưng TÙY CHỌN — không phải ranh giới bảo mật. Chỉ assert nó nếu luồng tạo đơn hàng của bạn tự đặt nó; không coi một giá trị thiếu/không khớp là lỗi nghiêm trọng trừ khi bạn tự kiểm soát nópartner_id,key_id,env,typ: do shield đóng dấu → assert để ngăn nhầm lẫn giữa tenant/môi trường/phiên bản
Một cách triển khai hoàn chỉnh, đã test (Node, PHP, Python) có trong docs/api/examples/verify-signed-payload.{mjs,php,py} — truyền amountCents (không phải amount) cho bằng chứng Stripe.
#Cache & xoay vòng JWKS
Endpoint JWKS chuẩn phục vụ các public key dùng để xác minh chữ ký. Tuân theo các quy tắc sau:
Fetch JWKS từ:
- Live:
https://keys.vkl-shield.com/v1/keys - Sandbox:
https://keys.vkl-shield.com/sandbox/v1/keys
Chiến lược cache:
- Cache JWKS theo URL; tuân theo header
Cache-Control: max-age=600(10 phút) - Khi miss (
kidchưa biết), refetch JWKS ngay (rate-limit 1 lần/phút cho mỗi JWKS URL — không phải cho mỗi kid; một endpoint chập chờn không bị refetch ở mọi request, và một kẻ tấn công xoay vòng kid giả không thể dùng cách đó để dội bom endpoint) - Nếu vẫn không tìm thấy sau khi refetch → từ chối envelope
- Loại bỏ các key ID không xác định; không bao giờ tin tưởng một kid nằm ngoài JWKS
Vòng đời & xoay vòng key:
- Key ký được xuất bản lên JWKS ≥7 ngày trước khi kích hoạt
- Key đang hoạt động ký các envelope mới
- Sau 30 ngày ngừng dùng, key cũ bị xóa khỏi JWKS
- Xoay vòng không downtime: Các khoảng thời gian key chồng lấp nhau nghĩa là store có thể deploy code xác minh bất cứ lúc nào; key mới đã sẵn sàng trước khi key cũ hết hạn
- Hành động của store: Deploy code xác minh của bạn trước khi xoay key; test dựa trên endpoint JWKS live
#Test trên sandbox
Dùng key bắt đầu bằng shpk_test_:
shieldKey: 'shpk_test_xxxxxxxxxxxxxxxxxxxxxxxx_xxxxxx'Fetch JWKS từ: https://keys.vkl-shield.com/sandbox/v1/keys
#Checklist go-live
- Đã yêu cầu key live từ đội vận hành shield
- Code verifier đã deploy và test dựa trên endpoint JWKS live (
https://keys.vkl-shield.com/v1/keys) - Đã test với key
shpk_live_*trên staging - Đã xử lý lỗi
charged_but_unconfirmed(đã charge) vsredirect_failed/redirect_not_succeeded(chưa charge) - Đã cấu hình kênh đối soát (hỏi đội vận hành shield)
#Khắc phục sự cố
Các nút không hiển thị
- Kiểm tra URL script SDK (phải là đường dẫn v1)
- Xác minh định dạng và scope của shield_key
- Kiểm tra console trình duyệt để tìm lỗi
Phát hiện wallet thất bại (Apple Pay thiếu trên iOS)
- SDK tự phát hiện dựa trên user agent
- Xác minh thiết bị là iOS/macOS với Safari
- Google Pay trên iPhone trả về null (theo thiết kế)
Xác minh bằng chứng thất bại
- Đảm bảo JWKS được fetch từ URL chuẩn
- Xác minh các byte
signed_payloadkhớp chính xác (encoding quan trọng) - Kiểm tra kid có tồn tại trong JWKS hiện tại