VKL Shield Tài liệu API

#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, key shpk_ 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": "[email protected]",
    "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. kid nà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 resolve kid bằng cách tra cứu trong response từ JWKS URL CHUẨN (keys.vkl-shield.com, xem dòng kid bê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": "[email protected]",
  "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 contact chứ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 field ship_* ở 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:

Khôi phục:

#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ị handleRedirectReturn phiê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 AIO transaction.completed xử 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ó marker ss_r hoặ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:

#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:

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.

  1. 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.
  2. Fallback / đối soát: webhook AIO transaction.completed (ký HMAC X-Signature: t=…,v1=…, khớp theo settle_ref = custom_id của bạn) hoàn tất các đơn hàng mà bằng chứng chưa từng đến — PayPal CAPTURED_PROOF_UNAVAILABLE, Stripe charged_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 đủ:

#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:

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ừ:

Chiến lược cache:

Vòng đời & xoay vòng key:

#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

#Khắc phục sự cố

Các nút không hiển thị

Phát hiện wallet thất bại (Apple Pay thiếu trên iOS)

Xác minh bằng chứng thất bại