VKL Shield Tài liệu API

#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, key shpk_, 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. 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) — 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:

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:

Khôi phục:

#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

Để 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:

  1. 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 onCompleted
  2. Xá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')
    );
  3. 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ừ:

Chiến lược cache:

Vòng đời 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ị

Thanh toán thất bại với NO_POOL

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