|
| 1 | +--- |
| 2 | +title: "Embedded Card CVC" |
| 3 | +description: "Collect a per-purchase security code on your own branded page" |
| 4 | +--- |
| 5 | + |
| 6 | +Alias-mode purchases with a KERNEL wallet (`provider: "kernel"`) require a fresh CVC for each purchase, on any card network. Authorization returns `pending_authorization` with a `spend_approval` action. Render [`@onkernel/vault-react`](https://www.npmjs.com/package/@onkernel/vault-react)'s `KernelCardCVC` component to keep this step inside your application, or open `action.url` for the hosted fallback. |
| 7 | + |
| 8 | +The cardholder enters the code in a Very Good Security (VGS) iframe. Your page, your servers, and KERNEL never receive the raw CVC. VGS sends KERNEL a temporary alias bound to this purchase. The component doesn't collect a card number, enroll a wallet, or submit merchant checkout. |
| 9 | + |
| 10 | +<Note> |
| 11 | + Use this component only for the CVC action whose URL points to `/card-cvc` on |
| 12 | + your configured KERNEL vault origin. Other spend approvals, including Visa |
| 13 | + network-token authentication, still use the hosted URL. Don't select a CVC |
| 14 | + flow by action name or card network alone. Use [embedded card |
| 15 | + capture](/integrations/wallets/embedded-card-capture) for enrollment. |
| 16 | +</Note> |
| 17 | + |
| 18 | +## Install |
| 19 | + |
| 20 | +```bash |
| 21 | +bun add @onkernel/vault-react@^0.4.0 |
| 22 | +# or: npm install @onkernel/vault-react@^0.4.0 |
| 23 | +``` |
| 24 | + |
| 25 | +The package requires React 18 or later and ships no CSS. |
| 26 | + |
| 27 | +## Deliver the capability from your backend |
| 28 | + |
| 29 | +After authorizing the purchase, retrieve it from your authenticated backend. There isn't a dedicated `action.cvc_token` field yet. The fragment extraction below, including the `token` parameter name and validation regex, is provisional and will be replaced by a dedicated API field. For this interim CVC flow, extract the `token` parameter from the action URL's fragment and deliver it only to the intended cardholder's authenticated application. Validate the origin and `/card-cvc` path before selecting this component: |
| 30 | + |
| 31 | +<CodeGroup> |
| 32 | + |
| 33 | +```typescript TypeScript |
| 34 | +const purchase = await kernel.vaults.items.retrieve("purchase-12345", { |
| 35 | + id_or_name: "user-12345", |
| 36 | +}); |
| 37 | + |
| 38 | +if ( |
| 39 | + purchase.type === "card" && |
| 40 | + purchase.state.status === "pending_authorization" && |
| 41 | + purchase.action?.name === "spend_approval" |
| 42 | +) { |
| 43 | + const actionURL = new URL(purchase.action.url); |
| 44 | + if ( |
| 45 | + actionURL.origin === "https://vault.kernel.sh" && |
| 46 | + actionURL.pathname === "/card-cvc" |
| 47 | + ) { |
| 48 | + const cvcToken = new URLSearchParams(actionURL.hash.slice(1)).get("token"); |
| 49 | + if (cvcToken && /^[a-z0-9]{1,64}\.[A-Za-z0-9_-]{43}$/.test(cvcToken)) { |
| 50 | + // Return cvcToken only to this purchase's cardholder. |
| 51 | + } |
| 52 | + } |
| 53 | +} |
| 54 | +``` |
| 55 | + |
| 56 | +```python Python |
| 57 | +import re |
| 58 | +from urllib.parse import parse_qs, urlparse |
| 59 | + |
| 60 | +purchase = kernel.vaults.items.retrieve( |
| 61 | + "purchase-12345", |
| 62 | + id_or_name="user-12345", |
| 63 | +) |
| 64 | + |
| 65 | +if ( |
| 66 | + purchase.type == "card" |
| 67 | + and purchase.state.status == "pending_authorization" |
| 68 | + and purchase.action is not None |
| 69 | + and purchase.action.name == "spend_approval" |
| 70 | +): |
| 71 | + action_url = urlparse(purchase.action.url) |
| 72 | + if ( |
| 73 | + action_url.scheme == "https" |
| 74 | + and action_url.netloc == "vault.kernel.sh" |
| 75 | + and action_url.path == "/card-cvc" |
| 76 | + ): |
| 77 | + cvc_token = parse_qs(action_url.fragment).get("token", [None])[0] |
| 78 | + if cvc_token and re.fullmatch(r"[a-z0-9]{1,64}\.[A-Za-z0-9_-]{43}", cvc_token): |
| 79 | + # Return cvc_token only to this purchase's cardholder. |
| 80 | + pass |
| 81 | +``` |
| 82 | + |
| 83 | +</CodeGroup> |
| 84 | + |
| 85 | +Use your configured vault origin instead of `https://vault.kernel.sh` when targeting another environment. This fragment extraction is specific to the current CVC integration; it doesn't change enrollment's `action.capture_token` contract. `cvcToken` is a one-purchase capability, not an API key or enrollment token. Keep the action URL available as a hosted fallback. Don't send either capability to your automation agent. |
| 86 | + |
| 87 | +## Render the component |
| 88 | + |
| 89 | +```tsx ConfirmPurchase.tsx |
| 90 | +"use client"; |
| 91 | + |
| 92 | +import { KernelCardCVC } from "@onkernel/vault-react"; |
| 93 | + |
| 94 | +export function ConfirmPurchase({ cvcToken }: { cvcToken: string }) { |
| 95 | + return ( |
| 96 | + <KernelCardCVC |
| 97 | + token={cvcToken} |
| 98 | + className="purchase-cvc" |
| 99 | + fieldCss={{ |
| 100 | + "box-sizing": "border-box", |
| 101 | + height: "100%", |
| 102 | + padding: "0", |
| 103 | + "font-family": "system-ui, sans-serif", |
| 104 | + "font-size": "16px", |
| 105 | + color: "#1f1147", |
| 106 | + "&::placeholder": { color: "#9a8fbf" }, |
| 107 | + }} |
| 108 | + labels={{ |
| 109 | + title: "Verify your purchase", |
| 110 | + description: "Enter your card's security code for this purchase.", |
| 111 | + completeTitle: "Code collected", |
| 112 | + completeDescription: "Continue checkout before this purchase expires.", |
| 113 | + submit: "Continue", |
| 114 | + }} |
| 115 | + onComplete={() => { |
| 116 | + // Ask your backend to retrieve the purchase before resuming checkout. |
| 117 | + }} |
| 118 | + onError={(code) => { |
| 119 | + // Handle the fixed code, not raw VGS or API responses. |
| 120 | + }} |
| 121 | + /> |
| 122 | + ); |
| 123 | +} |
| 124 | +``` |
| 125 | + |
| 126 | +The server-derived merchant, amount, and card's last four digits remain visible. Callbacks never include the code, alias, token, or provider response body. |
| 127 | + |
| 128 | +| Prop | Description | |
| 129 | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |
| 130 | +| `token` | Required purchase capability from the CVC action URL fragment | |
| 131 | +| `baseUrl` | API origin; defaults to `https://api.onkernel.com`. HTTPS required; HTTP loopback is allowed for local development. Paths, query strings, fragments, and URL credentials aren't allowed. | |
| 132 | +| `className` | Root class in both form and completion views | |
| 133 | +| `fieldCss` | CSS inside the single VGS CVC iframe; read at mount | |
| 134 | +| `labels` | Partial text overrides; see below | |
| 135 | +| `onComplete` | Runs once after a 2xx response with `status: "approved"` confirms collection, not payment | |
| 136 | +| `onError` | Receives only a fixed error code | |
| 137 | + |
| 138 | +`KernelCardCVC` requires `baseUrl` to be a bare origin, unlike `KernelCardCapture`, which accepts a path prefix. An invalid `baseUrl` throws a developer error during render, before sending the capability; it doesn't call `onError`. |
| 139 | + |
| 140 | +The component reads `GET /vault/card-cvc/current` directly with the bearer and `credentials: "omit"`. Don't route reads through your backend: the API rate-limits per client address, so a proxy makes cardholders share its limit. The iframe submits through VGS, not through your page or backend. |
| 141 | + |
| 142 | +Changing `token` or `baseUrl` unmounts the old field and resets the component. Don't change them during submission. An unmounted component ignores late callbacks; retrieve purchase state from your backend if you don't know whether collection completed. |
| 143 | + |
| 144 | +## Style the component |
| 145 | + |
| 146 | +Style the surrounding form through your `className`. Give the iframe container an explicit height: |
| 147 | + |
| 148 | +```css |
| 149 | +.purchase-cvc .credential-field { |
| 150 | + margin-bottom: 16px; |
| 151 | +} |
| 152 | +.purchase-cvc .credential-label { |
| 153 | + display: block; |
| 154 | + margin-bottom: 8px; |
| 155 | +} |
| 156 | +.purchase-cvc .card-field { |
| 157 | + height: 48px; |
| 158 | + border: 1px solid #d9d0f5; |
| 159 | + padding: 0 12px; |
| 160 | +} |
| 161 | +.purchase-cvc .card-field iframe { |
| 162 | + display: block; |
| 163 | + width: 100%; |
| 164 | + height: 100%; |
| 165 | + border: 0; |
| 166 | +} |
| 167 | +``` |
| 168 | + |
| 169 | +| Class | Element | |
| 170 | +| -------------------------------------------- | ---------------------------------------- | |
| 171 | +| `card-cvc` / `credential-complete` | Root in form / completion view | |
| 172 | +| `credential-header` | Title, purchase summary, and description | |
| 173 | +| `credential-title`, `credential-description` | Headings and description text | |
| 174 | +| `approval-summary` | Server-derived purchase summary | |
| 175 | +| `credential-form` | Form | |
| 176 | +| `credential-field`, `credential-label` | Field wrapper and label | |
| 177 | +| `card-field` | VGS iframe container | |
| 178 | +| `credential-primary`, `credential-retry` | Submit and initialization-retry buttons | |
| 179 | +| `credential-error` | Fixed error message | |
| 180 | + |
| 181 | +Page CSS can't reach into the iframe. Use `fieldCss` for input styling, including nested selectors such as `&:focus`, `&.invalid.touched`, `&::placeholder`, media queries, and `@font-face`. Supply it before mounting; remount to change it. |
| 182 | + |
| 183 | +Override these `labels` keys: `title`, `description`, `completeTitle`, `completeDescription`, `cvc`, `cvcPlaceholder`, `submit`, `submitting`, `loading`, `retry`, and `missingToken`. Set the title, descriptions, and completion copy to your application's wording; defaults use KERNEL's lowercase copy and mention KERNEL. Error messages are fixed. |
| 184 | + |
| 185 | +## Allow the component in your CSP |
| 186 | + |
| 187 | +Merge these directives into your existing policy: |
| 188 | + |
| 189 | +```text |
| 190 | +script-src 'self' 'nonce-<per-request-nonce>' https://js.verygoodvault.com; |
| 191 | +frame-src https://js.verygoodvault.com; |
| 192 | +connect-src 'self' https://api.onkernel.com; |
| 193 | +``` |
| 194 | + |
| 195 | +Collect loads a pinned, integrity-checked script and submits from its iframe origin. You don't need the inbound proxy hostname or Collect analytics endpoints in your page's `connect-src`. If you use external fonts, allow their source in the appropriate context and test the iframe rendering. The hosted fallback cannot itself be framed. |
| 196 | + |
| 197 | +## Handle completion and expiry |
| 198 | + |
| 199 | +`onComplete` confirms CVC collection, not a merchant payment. Retrieve the purchase from your backend and inspect `state.status === "ready"` and `expires_at` before resuming checkout. Fill and **submit checkout before `expires_at`**. |
| 200 | + |
| 201 | +The unused collection capability lasts 30 minutes. After capture, the purchase expires at the temporary CVC alias deadline, **five minutes by default**. Repeated fill or collection doesn't extend that deadline. An expired alias-mode purchase requires a new purchase and fresh authorization; refreshing an enrollment token won't recover it. |
| 202 | + |
| 203 | +Submission times out after 60 seconds: the component hides the form, calls `onError('unconfirmed')`, and ignores late approval callbacks. |
| 204 | + |
| 205 | +If a dispatched submission has an unknown outcome, retrieve the existing purchase before taking any further action. Don't automatically recollect, create a second purchase, or repeat payment. |
| 206 | + |
| 207 | +## Handle errors |
| 208 | + |
| 209 | +| Code | Meaning | What to do | |
| 210 | +| ------------------ | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | |
| 211 | +| `invalid` | Field validation failed | Correct the code and submit again | |
| 212 | +| `rate_limited` | Too many attempts | Wait, then retry | |
| 213 | +| `network` | View read or Collect initialization failed | Use the initialization retry button | |
| 214 | +| `expired` | Purchase expired | Create and authorize a new purchase | |
| 215 | +| `unavailable` | Capability is invalid or purchase is no longer available | Retrieve purchase state; obtain a new purchase if needed | |
| 216 | +| `invalid_response` | Purchase view couldn't be decoded safely | Stop and check your integration; keep the link for investigation | |
| 217 | +| `unconfirmed` | A dispatched submission timed out, failed ambiguously, or returned an unrecognized response | Retrieve the existing purchase; don't automatically recollect or create another payment | |
| 218 | + |
| 219 | +The form is removed from use after `expired`, `unavailable`, `invalid_response`, or `unconfirmed`. The component doesn't resend CVC submissions on unmount or unload. A known field validation failure or definitive rate limit permits retry. |
| 220 | + |
| 221 | +## Security |
| 222 | + |
| 223 | +- Serve your page over HTTPS with no-store and no-referrer policies. |
| 224 | +- Treat the CVC capability as a secret. Never put it in a path, query string, cookie, browser storage, logs, traces, or analytics. If you deliver a link, use a fragment and clear it after completion or terminal invalidation. |
| 225 | +- Don't add native CVC inputs, raw-value callbacks, analytics, or session replay around the component. Control scripts and dependencies that can modify the surrounding page. |
| 226 | +- Raw CVC stays in the VGS-hosted field and VGS processing. Your page and KERNEL never receive it. This preserves the isolated-field boundary used by embedded card capture; it doesn't certify your PCI scope or SAQ eligibility. Confirm your integration's eligibility and script-security requirements with your acquirer or QSA. |
| 227 | +- Don't configure VGS to accept every integrator origin. The submission origin is `https://js.verygoodvault.com`; no VGS credentials or callback secrets belong in your frontend. |
0 commit comments