#Tích hợp PayPal Shield SDK
#Tổng quan v1
SDK JavaScript phía parent cho PayPal Smart Buttons. Store nhúng một script
và gọi PaypalShield.init(config); SDK chọn một shield server, hiển thị
các nút thanh toán trong một iframe do nó quản lý, và đưa ra toàn bộ chu trình
thanh toán dưới dạng callback — store không cần viết code postMessage.
Không bao giờ nạp cả SDK legacy và v1 trên cùng một trang. Cả hai đều dùng biến global
window.PaypalShield; hành vi không xác định và chỉ v1 cảnh báo trong console. Chỉ nạp một: chuyển đổi toàn bộ trang bằng cách cập nhật URL script, keyshpk_, và server verifier cùng lúc.
#Bắt đầu nhanh
<div id="paypal-shield"></div>
<!-- Host mặc định: live.vkl-shield.com (production) / sandbox.vkl-shield.com (testing, minh họa dưới đây).
Tham số ?domain= là tùy chọn (xem Pinned mode). 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/paypal-shield-sdk.js"></script>
<script>
var handle = PaypalShield.init({
container: '#paypal-shield',
shieldKey: 'shpk_test_xxxxxxxxxxxxxxxxxxxxxxxx_xxxxxx',
getOrderData: function (fundingSource) {
return fetchYourOrderBody(fundingSource);
},
onCompleted: function (envelope, orderId) {
// Xác minh envelope ở phía server trước khi hoàn tất đơn hàng
console.log(envelope);
}
});
// Re-init trong SPA: gọi handle.destroy() trước khi init() lại
</script>#Pinned mode (tùy chọn)
Nếu store của bạn luôn phân giải tới một shield domain cố định (không xoay vòng pool), bạn có thể bỏ qua lời gọi /v1/shield/select bằng cách pin (cố định) domain:
<!-- Pinned mode: iframe dùng trực tiếp domain này, bỏ qua /v1/shield/select -->
<script src="https://live.vkl-shield.com/assets/v1/paypal-shield-sdk.js?domain=live.vkl-shield.com"></script>Script bản thân nó vẫn được nạp từ shield trung tâm (mặc định live.vkl-shield.com / sandbox.vkl-shield.com, hoặc host riêng nếu được cấp); chỉ origin của iframe thay đổi. Config init không thay đổi.
Đánh đổi: Pinning cũng bỏ qua việc kiểm tra shield đang hoạt động (active-shield check). Nếu shield bị vô hiệu hóa, lỗi sẽ xuất hiện bên trong iframe tại /v1/create_order (sau khi người mua click) thay vì được phát hiện sớm qua onFailed({code: 'NO_POOL'}).
Dùng pinning khi: Store được phục vụ bởi đúng một shield và không có pool để xoay vòng.
KHÔNG pin khi: Store dùng một pool nhiều shield; việc pin sẽ vô hiệu hóa cơ chế xoay vòng pool và cố định mọi người mua vào một shield duy nhất.
#Tham chiếu các tùy chọn init
| Field | Type | Bắt buộc | Mặc định | Mô tả |
|---|---|---|---|---|
container |
CSS selector | Element | ✅ | — | Nơi gắn iframe chứa các nút thanh toán. |
shieldKey |
string | ✅ | — | Public key cấp cho store của bạn (định dạng v1: shpk_live_* hoặc shpk_test_*); gửi kèm mọi request. |
getOrderData |
async (fundingSource) => {id, body} |
✅ | — | Trả về đơn hàng cần thanh toán. Được gọi mỗi lần người mua click nút. |
slug |
string | ❌ | — | Định tuyến pool. Cho phép nhiều slug phân tách bằng dấu |. |
iframeParams |
object | ❌ | {} | Query params bổ sung cho trang chứa các nút. |
selectBase |
string | ❌ | origin của script (mặc định https://live.vkl-shield.com hoặc https://sandbox.vkl-shield.com) |
Ghi đè base URL cho select — đặt thành shield host riêng nếu bạn được cấp một host như vậy. |
#Callback & event
| Callback | Signature | Khi nào | Bạn cần làm gì |
|---|---|---|---|
onClicked |
(source) => void |
Người mua click một nút thanh toán | Ghi log event hoặc disable nút thử lại |
onCapturing |
() => 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, redirect |
onCancel |
() => void |
Người mua hủy | Hiển thị lựa chọn thử lại |
onFailed |
(error) => void |
Lỗi thanh toán (trước-capture hoặc tạo bằng chứng capture thất bại) | Xem phần xử lý lỗi dưới đây |
#Sơ đồ chuỗi (sequence diagram)
sequenceDiagram
participant Store as Store Page
participant SDK as PaypalShield SDK
participant Central as Central Shield
participant ShieldServer as Shield Server
participant PayPal as PayPal API
Store->>SDK: init(config)
SDK->>Central: GET /v1/shield/select?shield_key=...
Central-->>SDK: {shield_domain}
SDK->>SDK: Tạo iframe<br/>pin vào shield_domain
Store->>SDK: (Người mua click nút)
SDK->>ShieldServer: postMessage CHECKOUT_ORDER_DATA
ShieldServer->>ShieldServer: onClicked → emit
SDK-->>Store: onClicked(fundingSource)
Store->>SDK: getOrderData(fundingSource)
SDK->>ShieldServer: postMessage {id, body}
ShieldServer->>PayPal: POST /v2/checkout/orders<br/>(OrdersCreateRequest)
PayPal-->>ShieldServer: {id: order_id}
ShieldServer->>ShieldServer: onCapturing → emit
SDK-->>Store: onCapturing()
ShieldServer->>PayPal: POST /v2/checkout/orders/{order_id}/capture<br/>(OrdersCaptureRequest)
PayPal-->>ShieldServer: {id, status, purchase_units, ...}
ShieldServer->>ShieldServer: Dựng envelope v1<br/>(ký bằng Ed25519)
ShieldServer->>SDK: postMessage PAYPAL_PAYMENT_COMPLETED<br/>{signed_payload, sig, kid, alg, v, data}
SDK-->>Store: onCompleted(envelope, orderId)
Store->>Store: Xác minh sig qua JWKS<br/>(endpoint chuẩn)
Store->>Store: Assert partner_id,<br/>invoice_id, iat
Store->>Store: Hoàn tất đơn hàng,<br/>redirect
#Hợp đồng postMessage
| Type | Hướng | Payload | Mục đích |
|---|---|---|---|
CHECKOUT_ORDER_DATA |
parent → iframe | {value: {id, body}, hostname} |
Nội bộ SDK; truyền qua getOrderData() |
PAYPAL_PAYMENT_INIT |
iframe → parent | {source} |
SDK fire onClicked(source) |
PAYPAL_CAPTURING |
iframe → parent | (không có) | SDK fire onCapturing() |
PAYPAL_PAYMENT_COMPLETED |
iframe → parent | {value: envelope, order_id} |
SDK fire onCompleted(envelope, orderId) |
PAYPAL_PAYMENT_CANCEL |
iframe → parent | (không có) | SDK fire onCancel() |
PAYPAL_PAYMENT_ERROR |
iframe → parent | {value: error} |
SDK fire onFailed(error) |
PAYPAL_RESIZE_FORM |
iframe → parent | {value: height} |
Nội bộ SDK; thay đổi kích thước iframe |
#Envelope bằng chứng (chỉ v1)
Khi onCompleted được fire, bạn nhận một envelope bằng chứng đã ký:
{
"error": false,
"data": {
"id": "5O190127TN364715T",
"purchase_units": [
{
"reference_id": "partner-code-example",
"payee": {
"merchant_id": "MERCHANTEXAMPLE"
},
"payments": {
"captures": [
{
"id": "3C679366HH908993F",
"status": "COMPLETED",
"amount": {
"value": "19.99",
"currency_code": "USD"
},
"invoice_id": "1001"
}
]
}
}
]
},
"signed_payload": "{\"amount\":\"19.99\",\"capture_id\":\"3C679366HH908993F\",\"currency\":\"USD\",\"env\":\"sandbox\",\"iat\":1793500000,\"invoice_id\":\"1001\",\"key_id\":\"key_EXAMPLE000000000\",\"order_id\":\"5O190127TN364715T\",\"partner_id\":\"acme\",\"payee_merchant_id\":\"MERCHANTEXAMPLE\",\"reference_id\":\"partner-code-example\",\"shield\":\"shield-sandbox.example\",\"status\":\"COMPLETED\",\"store_domain\":\"store.example\",\"typ\":\"paypal_capture\",\"v\":1}",
"sig": "S668XzUJHlMJirZWTW8CEwzjerdaKI6r8Qwtbo6EX7U2YzMMYX4sGWDdQxT3tPeo0ZCtzSV1t_PvBrDK-Wk9CA",
"kid": "shsig_TESTVECTORPAYPAL00000000",
"alg": "Ed25519",
"v": 1
}
kid/sigở trên lấy từtests/fixtures/v1-ed25519-test-vector.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) — không bao giờ pin việc xác minh vào một giá trị kid cụ thể.
Hướng dẫn các field:
| Field | Type | Ý nghĩa | Hành động của bạn |
|---|---|---|---|
error |
boolean | Luôn false khi thành công |
Assert false |
data |
object | Response capture của PayPal (chuẩn) | Dùng để ghi lại đơn hàng |
signed_payload |
string | JSON chính xác đã ký; các byte UTF-8 | Xác minh chữ ký dựa trên các byte này |
sig |
string | Chữ ký Ed25519 base64url | Xác minh dựa trên kid trong JWKS |
kid |
string | ID key ký (shsig_ + 24 ký tự base62) — KHÔNG phải key shpk_ của bạn |
Lấy public key khớp từ JWKS |
alg |
string | Luôn "Ed25519" |
Assert thuật toán |
v |
number | Luôn 1 |
Assert phiên bản |
Trong signed_payload (sau khi parse JSON):
| Field | Ý nghĩa | Kiểm tra của bạn |
|---|---|---|
typ / v |
"paypal_capture" / 1 |
Assert |
order_id |
ID đơn hàng PayPal | Chỉ mang tính thông tin (khớp data.id) |
capture_id |
ID capture PayPal | Key chống replay — từ chối nếu đã settle rồi |
status |
"COMPLETED" |
Assert |
payee_merchant_id |
Merchant PayPal nhận tiền | Ghi lại để đối soát |
shield |
Shield host đã thực hiện capture | Chỉ mang tính thông tin |
invoice_id |
ID hóa đơn của bạn | Assert khớp với đơn hàng của bạn |
reference_id |
Mã tham chiếu đối tác (client cung cấp) | Assert khớp với reference của bạn nếu có |
amount |
Số tiền bị charge, chuỗi decimal (ví dụ "19.99") |
Assert === chuỗi tổng tiền đơn hàng của bạn |
currency |
Mã tiền tệ (client cung cấp) | Assert khớp với tiền tệ đơn hàng |
partner_id |
ID đối tác ([a-z0-9]{2,32}) do shield đóng dấu |
Assert khớp với partner ID của bạn |
key_id |
ID key ký (key_ + 16 ký tự base62) do shield đóng dấu |
Assert là một trong các key ID được bạn cho phép |
env |
"live" hoặc "sandbox" do shield đóng dấu |
Assert === "live" trên live |
iat |
Timestamp phát hành | Assert ≤ now + 5min và ≥ now - 10min |
store_domain |
Hostname store | Chỉ mang tính thông tin; không liên quan bảo mật |
Vì sao các kiểm tra này quan trọng:
- reference_id / invoice_id / amount / currency: Client cung cấp khi tạo đơn hàng; dễ bị giả mạo khi truyền nếu không được xác minh bằng mật mã học
- partner_id / env / typ: Do shield đóng dấu; chứng minh đối tác và môi trường nào đã phát hành bằng chứng này (ranh giới bảo mật)
- key_id ∈ allowedKeyIds: Bảo vệ việc định tuyến tiền giữa các pool của riêng bạn — một key bị lộ hoặc đã xoay vòng không được phép capture tiền
Bạn PHẢI xác minh sig dựa trên signed_payload ở phía server (dùng JWKS URL chuẩn, không bao giờ lấy từ envelope) trước khi tin tưởng bất kỳ field nào.
#Mã lỗi & cách xử lý
#Tham chiếu mã lỗi
Lỗi phía SDK (HTTP 200 với cờ error):
Các lỗi này fire onFailed({code: '...', message?: string}) sau lời gọi select của SDK hoặc trong iframe.
| Mã | Khi nào | Hình dạng payload | Code của bạn |
|---|---|---|---|
SELECT_TIMEOUT |
Lời gọi select của SDK hết thời gian chờ | {code, message} |
Người mua đã click nút; hiển thị "Đang xử lý..." rồi retry |
SELECT_NETWORK |
Lỗi network trong lời gọi select của SDK | {code, message} |
Vấn đề network; có thể retry an toàn |
SELECT_FAILED |
Response select không có shield_domain VÀ không có issue từ server (ví dụ body sai định dạng, response proxy không mong đợi) |
{code, message} |
Có thể retry an toàn (tạm thời) |
SHIELD_REGISTRY_UNAVAILABLE, SHIELD_REGISTRY_STALE, SHIELD_ENV_MISCONFIGURED |
select trả 5xx CÓ issue từ server — chuyển tiếp nguyên văn thành code thay vì fallback SELECT_FAILED ở trên |
{code, message} |
SHIELD_ENV_MISCONFIGURED là lỗi cấu hình (cần leo thang); hai mã còn lại là tạm thời — có thể retry an toàn |
ORDER_DATA_FAILED |
getOrderData() throw lỗi hoặc trả về hình dạng không hợp lệ |
{code, message} |
Kiểm tra logic dựng đơn hàng; có thể không retry được |
NO_POOL |
Pool/slug của key này không phân giải được tới shield nào (chỉ trong chế độ không pin — pinned mode không bao giờ gọi select, nên không bao giờ thấy NO_POOL) | {code} |
Kiểm tra gán pool/slug của key trong dashboard |
NO_ACTIVE |
Không có shield nào hoạt động trong toàn bộ registry | {code} |
Gián đoạn ngắn; có thể retry an toàn sau 10 giây |
Lỗi phía shield, theo bước thực sự sinh ra lỗi (fire từ iframe):
| Mã | HTTP | Bước | Lỗi được fire dưới dạng | Bạn cần làm gì |
|---|---|---|---|---|
SHIELD_KEY_INVALID, SHIELD_KEY_ENV_MISMATCH, SHIELD_KEY_EXPIRED, SHIELD_KEY_REVOKED, SCOPE_DENIED, ORIGIN_NOT_ALLOWED |
401/403 | select | onFailed({code: '...'}) |
Kiểm tra/xoay key, xác minh config scope/origin |
POOL_MISMATCH, RATE_LIMITED, TEMPORARILY_LOCKED, INVALID_ORDER, v.v. (lỗi create_order) |
4xx | create | onError(Error) riêng của PayPal JS SDK — không phải object {issue}/{code}; issue phía shield không bao giờ đến onFailed ở đây, chỉ là lỗi PayPal SDK chung |
Người mua CHƯA bị charge; có thể retry an toàn |
RATE_LIMITED, TEMPORARILY_LOCKED, SIGNING_UNAVAILABLE, SHIELD_REGISTRY_UNAVAILABLE, SHIELD_REGISTRY_STALE |
429/503 | capture | onFailed({issue: '...', message, docs}) (503) hoặc onFailed({issue: '...'}) (429) — không có field code |
Trước-capture; người mua KHÔNG bị charge; có thể retry an toàn |
CAPTURED_PROOF_UNAVAILABLE |
502 | capture | onFailed({issue: '...', message, docs}) |
Người mua đã bị charge; đối soát qua webhook |
PayPal từ chối (ví dụ INSTRUMENT_DECLINED) |
500 | capture | onFailed(paypal_error_object) — lỗi PayPal gốc ({name, message, details:[{issue}], debug_id}), không phải {issue}/{code} |
Người mua KHÔNG bị charge; hiển thị lý do từ chối từ error.details[0].issue |
Đọc error.issue ?? error.code trong handler của bạn — lỗi select dùng code, lỗi capture dùng issue (hoặc là một PayPal SDK Error trần không có cả hai), không bao giờ có cả hai cùng lúc.
#CAPTURED_PROOF_UNAVAILABLE (502) — Xử lý đặc biệt
Lỗi này có nghĩa là người mua ĐÃ bị charge. Thanh toán đã thành công ở phía PayPal, nhưng bằng chứng v1 (chữ ký) không thể tạo được.
var captureStarted = false; // chuyển thành true khi onCapturing, reset mỗi lần thử mới
// onCapturing() fire ngay khi capture_order đang được chuẩn bị gọi (hook
// iframe post nó như điều ĐẦU TIÊN trong onApprove, trước khi fetch) — đây
// là ranh giới: trước nó, PayPal CHƯA được yêu cầu capture; sau nó, một lần
// thử capture đang diễn ra hoặc đã chạy xong. Reset ở đầu mỗi lần thử
// (onClicked) để lần thử thứ 2 sau một capture THẤT BẠI không còn bị đánh
// dấu "đã bắt đầu capture" từ lần thử đầu.
// onClicked: function () { captureStarted = false; },
// onCapturing: function () { captureStarted = true; },
onFailed: function (error) {
var RETRYABLE_CAPTURE_ISSUES = ['RATE_LIMITED', 'TEMPORARILY_LOCKED', 'SIGNING_UNAVAILABLE',
'SHIELD_REGISTRY_UNAVAILABLE', 'SHIELD_REGISTRY_STALE'];
if (error.issue === 'CAPTURED_PROOF_UNAVAILABLE' ||
(captureStarted && !error.details && !error.issue && !error.code)) {
// (a) Lỗi tạo bằng chứng đã biết, HOẶC capture đã bắt đầu và lỗi này
// không có hình dạng từ-chối của PayPal cũng không có BẤT KỲ issue/code
// nào — trạng thái không xác định SAU KHI capture đã bắt đầu (ví dụ lỗi
// network/parse giữa lời gọi capture và response của nó). Người mua có
// thể đã bị charge; không bao giờ giả định điều ngược lại. Loại trừ
// RETRYABLE_CAPTURE_ISSUES dưới đây: các lỗi đó mang `issue` và fire từ
// ROUTE capture trước khi PayPal từng được gọi — chưa bị charge, dù
// captureStarted là true.
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.
// Backend của bạn sẽ nhận thông báo webhook trong vài phút.
// KHÔNG BAO GIỜ retry capture.
} else if (!captureStarted || Array.isArray(error.details) ||
RETRYABLE_CAPTURE_ISSUES.indexOf(error.issue) !== -1 || error.code) {
// (b) Chưa bị charge: hoặc capture chưa từng bắt đầu (create_order — một
// `Error` trần của PayPal JS SDK, hoặc một `{issue}`/`{code}` phía
// shield từ select/create), HOẶC một object từ-chối của PayPal
// (`{details:[{issue}]}`) từ một capture THẤT BẠI, HOẶC một lỗi route
// capture trước-PayPal (429/503 RETRYABLE_CAPTURE_ISSUES) chưa từng đến
// PayPal.
var reason = (Array.isArray(error.details) && error.details[0] && error.details[0].issue) || error.issue || error.code;
showError('Thanh toán thất bại (' + reason + '). Vui lòng thử lại.');
} else {
// (c) captureStarted với một `error.issue` không phải
// CAPTURED_PROOF_UNAVAILABLE cũng không phải một issue route-capture có
// thể retry đã biết (ví dụ một chuỗi issue mới/chưa nhận diện) — trạng
// thái charge KHÔNG XÁC ĐỊNH, không phải "chưa bị charge". KHÔNG bảo
// người mua thử lại; xử lý giống như charged-but-unconfirmed cho đến khi
// webhook đối soát xong.
showMessage('Đang xác nhận đơn hàng của bạn...');
}
}Vì sao điều này xảy ra:
- Key ký đã hết hạn giữa lúc capture và lúc tạo bằng chứng
- Không có key hợp lệ nào đang hoạt động trên registry vào thời điểm capture
- Registry không truy cập được chính xác vào thời điểm cần tạo bằng chứng
Khôi phục:
- Người mua ĐÃ bị charge trên PayPal. Đố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. - 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
#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
Để có cách triển khai tham chiếu đầy đủ (Node.js, PHP, Python), xem docs/api/examples/verify-signed-payload.mjs.
Các điểm quan trọng khi xác minh:
Chỉ fetch JWKS từ URL chuẩn (không bao giờ lấy từ envelope). Dùng URL đã cấu hình ở backend của bạn:
const CANONICAL_JWKS_URL = { live: 'https://keys.vkl-shield.com/v1/keys', sandbox: 'https://keys.vkl-shield.com/sandbox/v1/keys' }; const jwksUrl = CANONICAL_JWKS_URL[expectedEnv]; // set before calling onCompletedXác minh chữ ký TRƯỚC khi parse — xác minh dựa trên các byte UTF-8 chính xác của
signed_payload:const publicKey = crypto.createPublicKey({ key: {kty: 'OKP', crv: 'Ed25519', x: jwk.x}, // định dạng JWK OKP Ed25519 format: 'jwk' }); const verified = crypto.verify( null, Buffer.from(envelope.signed_payload, 'utf8'), publicKey, Buffer.from(envelope.sig, 'base64url') );Sau khi chữ ký hợp lệ, parse và assert các claim:
const payload = JSON.parse(envelope.signed_payload); // Assert các ranh giới bảo mật bất biến assert(payload.typ === 'paypal_capture'); assert(payload.status === 'COMPLETED'); assert(payload.env === 'live'); // hoặc 'sandbox' trên sandbox assert(payload.partner_id === YOUR_PARTNER_ID); assert(YOUR_APPROVED_KEYS.includes(payload.key_id)); // Assert các field client cung cấp (ràng buộc tiền) assert(payload.invoice_id === your_order.invoice_id); assert(payload.amount === your_order.total); // chuỗi decimal assert(payload.currency === your_order.currency); // Assert độ mới của timestamp const now = Math.floor(Date.now() / 1000); assert(payload.iat >= now - 600 && payload.iat <= now + 300); // Assert chống replay (tùy chọn nhưng khuyến nghị) assert(!hasBeenSeen(payload.capture_id)); recordCapture(payload.capture_id);
#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:
- Xác minh chữ ký, sau đó kiểm tra
kidcó trong cache không; chỉ refetch JWKS nếu chưa biết - Tuân theo header
Cache-Control: max-age=600(thường là 10 phút; cache theo cặp(url, kid)) - Nếu không tìm thấy key ID (
kid) trong cache, refetch JWKS ngay (có rate-limit: tối đa 1 lần/phút cho mỗi JWKS URL) - 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 key:
- Key đối tác (shpk_): hết hạn cứng sau 180 ngày; key hết hạn trả về 401 SHIELD_KEY_EXPIRED khi select
- Key ký (xuất bản lên JWKS):
- Được xuất bản ≥7 ngày trước khi kích hoạt (không cần rollover gấp — bạn có một tuần)
- Được giữ trong JWKS 30 ngày sau khi ngừng dùng
- JWKS max-age=600 (10 phút), nên đối tác cache theo
(url, kid)và refetch các kid chưa biết
- Xoay key đối tác: Thêm key id mới vào allowlist trong config shield trước (không cần deploy), chuyển key SDK, chờ TTL cache trang + 10 phút, xóa key id cũ
- Nếu không xoay key: Sau khi key đối tác hết hạn, mọi lời gọi select trả về 401 SHIELD_KEY_EXPIRED
#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 (key đối tác do đội vận hành shield cấp)
- Code verifier đã deploy và test dựa trên endpoint JWKS live
- Đã test với key
shpk_live_*trên staging - Đã xử lý lỗi CAPTURED_PROOF_UNAVAILABLE (502)
- Endpoint webhook AIO
transaction.completedđã cấu hình và xác minh; mọi đơn hàng đều gửisettle_reftrongcustom_id
#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 xác minh origin
Thanh toán thất bại với NO_POOL
- Pinned mode hoặc tất cả shield đã bị vô hiệu hóa
- Kiểm tra trạng thái pool trong dashboard
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