---
title: "Card details"
description: "Display a full card number in an iframe without it touching your servers."
kind: reference
url: /docs/api/card-data
canonical_url: "https://virtual.ktap.io/docs/api/card-data"
---

> Part of the KTAP Cards API documentation.
> Index of every page: https://virtual.ktap.io/llms.txt
> Machine-readable schema: https://virtual.ktap.io/api/partner/v1/openapi.json

# Card details

Display a full card number in an iframe without it touching your servers.

Card numbers are never returned as data. You request a short-lived URL and embed it, which keeps the number off your servers and out of your logs.

## How it works

1. Your server calls `GET /cards/{cardId}/pan` with your key and gets back a `details_url`, valid for 600 seconds.
2. Your page renders that URL in an iframe. The number is displayed by us, inside the frame.
3. When the cardholder uses the copy control, the frame posts a result back to your page.

## A complete embed

```javascript
const res = await fetch(
  `https://virtual.ktap.io/api/partner/v1/cards/${cardId}/pan`,
  { headers: { 'X-API-Key': process.env.KTAP_API_KEY } },
);
const { data } = await res.json();
// data.details_url is valid for 600 seconds. Render it, do not store it.
```

```html
<iframe
  src="{{ details_url }}"
  title="Card details"
  width="360"
  height="230"
  style="border:0;display:block"
  allow="clipboard-write"
  referrerpolicy="no-referrer"></iframe>

<script>
  window.addEventListener('message', function (event) {
    if (event.origin !== 'https://virtual.ktap.io') return;
    if (!event.data || event.data.type !== 'ktap-card-copy-result') return;
    showToast(event.data.ok ? 'Card number copied' : 'Copy failed');
  });
</script>
```

> **`allow="clipboard-write"` is required**
>
> Permissions Policy delegation is per frame, so without it the clipboard permission never reaches the card page and the copy control silently does nothing. If your page sends its own Content-Security-Policy, add `frame-src https://virtual.ktap.io`.

## The contract

- Give the iframe an explicit width and height. The page fills whatever container you give it and has a transparent background.
- No `sandbox` attribute is needed. If your policy forces one, `allow-scripts` and `allow-same-origin` are the minimum, because without scripts you receive no messages at all.
- The message is exactly `{ type: "ktap-card-copy-result", ok: boolean }` and it arrives only when the page is embedded. Check `event.origin` before acting on it.
- Only the immediate parent receives it. If you nest the frame inside another frame of your own, the intermediate frame has to forward it.
- Treat `ok` as a cue to show a confirmation, never as an audit record.
- Render the URL, do not store it. After 600 seconds it stops working and you request a new one.

### GET /cards/{cardId}/pan

Returns a URL on the KTAP host that renders the full card details for the cardholder, plus the card expiry as plain integers. The card number and CVV never appear in this JSON and never pass through your servers: they travel from the card processor to the cardholder's browser inside a nested iframe. Every call mints a fresh URL that is valid for 600 seconds.

**Scope** `cards:pan`

**Path parameters**

| Parameter | Type |  | Description |
| --- | --- | --- | --- |
| `cardId` | string | optional | The card id returned by POST /cards Opaque and case-sensitive. Live ids are 32 characters of letters and digits. Match is exact and scoped to your partner account. |

**Returns** The details URL and the card expiry, as `200`.

**Response 200** The details URL and the card expiry

| Field | Type |  | Description |
| --- | --- | --- | --- |
|   `card_id` | string | optional | The card id you requested, as stored |
|   `details_url` | string | optional | Page to load in an iframe. Valid for 600 seconds from this response. Anyone holding it can view the card, so never log it or store it |
|   `expiration_month` | integer, nullable | optional | 1 to 12. Null when the card metadata lookup at the processor fails |
|   `expiration_year` | integer, nullable | optional | Four digits, for example 2032. Null when the card metadata lookup at the processor fails |

```json
{
  "data": {
    "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
    "details_url": "https://virtual.ktap.io/card-frame/EXAMPLE_TOKEN.EXAMPLE_SIGNATURE",
    "expiration_month": 10,
    "expiration_year": 2032
  }
}
```

**Response 404** No card with that id belongs to you

```json
{
  "error": "Card not found",
  "code": "NOT_FOUND"
}
```

**Response 502** The card processor returned no details URL for this card

```json
{
  "error": "Card details are unavailable for this card",
  "code": "UNAVAILABLE"
}
```

**Errors**

| Status | Code | When | Retry |
| --- | --- | --- | --- |
| 401 | `UNAUTHORIZED` | No key, unknown key, revoked key, or your partner account is suspended. The four cases are deliberately indistinguishable | No. Fix the credential |
| 403 | `FORBIDDEN` | The key is valid but lacks the cards:pan scope, which is granted separately from cards:read | No. Ask for the scope |
| 404 | `NOT_FOUND` | The card id does not exist, or belongs to another partner. Card status is not checked, so a closed card still resolves here | No |
| 405 | `BAD_REQUEST` | Any method other than GET. This response is produced before authentication, so it is not rate limited or logged against your key | No |
| 429 | `RATE_LIMITED` | Your key exceeded rate_limit_per_minute in a rolling one-minute window counted from the request log. The default allowance is 120 per minute | Yes, after the Retry-After value of 60 seconds |
| 500 | `UNAVAILABLE` | An internal fault on the KTAP side | Yes, shortly |
| 502 | `UNAVAILABLE` | The card processor did not return a details URL. The route probes the processor before minting the token so a bad card fails here instead of inside a broken iframe | Yes, shortly |

**Notes**

- The URL is valid for 600 seconds from the moment it is issued. Render it, do not store it.
- Your iframe must carry `allow="clipboard-write"`, or the copy control inside the frame silently does nothing.
- An expired or tampered URL renders as plain text instead of the card. Treat a frame that did not load as HTML as a signal to request a new URL.

### POST /cards/{cardId}/pan-token

Mints a single-use URL, valid for 60 seconds, that returns the card fields as JSON. Call this from your server, hand the url to the end user's DEVICE, and have the device GET it directly: the response is { data: { pan, cvv, expiry_month, expiry_year } }, uncacheable, and the token burns on first use. If your own servers fetch it instead, the card number transits your infrastructure and your PCI DSS obligations change. Route it through the device. Where an iframe is possible, prefer the embeddable frame at GET /cards/{cardId}/pan.

**Scope** `cards:pan`

**Path parameters**

| Parameter | Type |  | Description |
| --- | --- | --- | --- |
| `cardId` | string | optional | Cards you do not own return 404 |

**Returns** A single-use exchange URL, as `200`.

**Response 200** A single-use exchange URL

| Field | Type |  | Description |
| --- | --- | --- | --- |
| `url` | string | optional | Have the device GET this within 60 seconds. It works exactly once |
| `expires_in` | number | optional | Seconds until the token dies unused |
| `single_use` | boolean | optional | Always true |

```json
{ "data": { "url": "https://virtual.ktap.io/card-data/example-token", "expires_in": 60, "single_use": true } }
```

**Errors**

| Status | Code | When | Retry |
| --- | --- | --- | --- |
| 404 | `NOT_FOUND` | Unknown card, or a card that is not yours | Do not retry |

**Notes**

- Hand the `url` to the end user's DEVICE and have the device fetch it. If your servers fetch it instead, the card number transits your infrastructure and your PCI DSS obligations change.
- One token per display, not per session. The token dies after 60 seconds and burns on first use; expired, used and tampered tokens all answer `404` alike.
- From the exchange URL, `404` means the token is dead (expired, used or tampered): mint again, never retry. `502` means card details are temporarily unavailable on our side: back off, then restart the flow. The URL allows cross-origin fetches (`Access-Control-Allow-Origin: *`), so the device may call it straight from a browser or web-view.
