# Tích hợp PayPal Shield SDK

:::v1

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

```html
<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:

```html
<!-- 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

<!-- x-sdk:paypal.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. |

<!-- /x-sdk:paypal.init -->

### Callback & event

<!-- x-sdk:paypal.callbacks -->

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

<!-- /x-sdk:paypal.callbacks -->

### Sơ đồ chuỗi (sequence diagram)

```mermaid
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

<!-- x-postmessage:paypal -->

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

<!-- /x-postmessage:paypal -->

### Envelope bằng chứng (chỉ v1)

Khi `onCompleted` được fire, bạn nhận một **envelope bằng chứng đã ký**:

```json
{
  "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:**
- **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.

```javascript
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, header `X-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ằng **`settle_ref`** bạn đã gửi (PayPal `purchase_units[0].custom_id`; Stripe: cùng `custom_id` trong `getOrderData`, lưu dưới dạng metadata PaymentIntent `settle_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.**
> 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 đủ:

- **Node.js** (`verify-signed-payload.mjs`) — Khuyến nghị dùng; hỗ trợ Ed25519 đầy đủ
- **PHP** (`verify-signed-payload.php`) — Dùng `sodium_crypto_sign_verify_detached()`
- **Python** (`verify-signed-payload.py`) — Dùng thư viện `cryptography`

### 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:
   ```javascript
   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`:
   ```javascript
   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:**
   ```javascript
   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 `kid` có 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_`:

```javascript
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ửi `settle_ref` trong `custom_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_payload` khớp chính xác (encoding quan trọng)
- Kiểm tra kid có tồn tại trong JWKS hiện tại

