---
title: "Cards"
description: "Order a card, fund it, freeze it, close it, and set how 3-D Secure reaches the cardholder."
kind: reference
url: /docs/api/cards
canonical_url: "https://virtual.ktap.io/docs/api/cards"
---

> 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

# Cards

Order a card, fund it, freeze it, close it, and set how 3-D Secure reaches the cardholder.

The card lifecycle. Ordering and funding both move money, so both take an `Idempotency-Key`.

> **Always send an Idempotency-Key on `POST /cards` and on a top-up**
>
> Without one, a retry after a timeout can issue a second card or debit your balance twice. See [Idempotency](https://virtual.ktap.io/docs/essentials.md#idempotency).

## Card statuses

| Status | Meaning |
| --- | --- |
| `ACTIVE` | Normal. The card can be used up to its balance |
| `FROZEN` | You froze it. Purchases are declined; the balance stays on the card and the monthly fee still applies |
| `BLOCKED` | We or the card processor blocked it, typically after repeated velocity breaches or wrong security details. Contact us to unblock |
| `EXPIRED` | Past its expiry date |
| `DELETED` | Closed. The balance was swept back to you and the card is permanently unusable |

Treat this list as open: a new status can appear without a version bump, so give your switch a default branch. `GET /cards` excludes `DELETED` rows, so read a closed card back by id to see its final state.

Freezing is reversible and leaves the money where it is. Closing is not: it sweeps the remaining balance back to your account, is billed for the whole month with no proration, and does not return card allowance. Your allowance counts every card you have ever ordered, so watch `cards.total` against `cards.limit` on [GET /me](https://virtual.ktap.io/docs/api/account.md#getMe).

### POST /cards

You order a card by sending the cardholder's printed name and contact details. The card is created at the card processor, stored against your account, billed for its first month straight away, and returned in full. Card issuance draws on a finite lifetime allowance, so send an Idempotency-Key on every call.

**Scope** `cards:write`

**Idempotency** Accepts `Idempotency-Key`

**Headers**

| Header |  | Description |
| --- | --- | --- |
| `Idempotency-Key` | optional | Your own key for this order, 1 to 128 characters of letters, digits, hyphen or underscore. A retry under the same key returns the card you already ordered instead of creating another |
| `Content-Type` | optional | The body is parsed as JSON regardless of this header. Send application/json anyway |

**Request body**

| Field | Type |  | Description |
| --- | --- | --- | --- |
| `card_holder_name` | string | required | Name printed on the card, uppercased by the issuer. Send it as the user wrote it: accented letters with an ASCII equivalent convert automatically (José becomes Jose, Łukasz becomes Lukasz) and äöüÄÖÜ are kept as they are. ß, Ø, Æ and non-Latin scripts have no equivalent and are rejected; for those, send an ASCII form yourself Letters, digits, äöüÄÖÜ, space, and / - ^ . ' ) after normalization; the right parenthesis is accepted, the left is not. At most 26 characters, measured after normalization. Trimmed first; internal spacing preserved. |
| `phone_country_code` | integer | required | Country calling code for the cardholder's mobile, sent on its own Must be a JSON number that is an integer and greater than 0. A string such as "1" is rejected. |
| `phone` | string | required | Subscriber number only, with no country code, plus sign, spaces or dashes ^\d{4,15}$. This is the floor the API enforces; the card processor validates the number again and rejects placeholders and impossible ranges with 400 INVALID_PHONE, and numbers from some countries with 400 PHONE_COUNTRY_NOT_ALLOWED. |
| `email` | string | required | The cardholder's email address, passed to the card processor and not stored locally ^[^\s@]+@[^\s@]+\.[^\s@]+$. |
| `partner_ref` | string | optional | Your own identifier for this cardholder, echoed back on the card and on every webhook about it At most 128 characters. An empty string passes validation and is treated as absent, so the card stores partner_ref null. |

```json
{
  "card_holder_name": "JANE SMITH",
  "phone_country_code": 1,
  "phone": "5551234567",
  "email": "jane.smith@yoursite.com",
  "partner_ref": "your-user-8842"
}
```

**Returns** Card issued, as `201`.

**Response 201** Card issued

| Field | Type |  | Description |
| --- | --- | --- | --- |
|     `id` | string | optional | The card id. Use it in every other card call |
|     `partner_ref` | string, nullable | optional | The partner_ref you sent, or null if you sent none |
|     `card_holder_name` | string | optional | The name the card processor recorded, which is your trimmed value |
|     `last4` | string, nullable | optional | Last four digits of the card number |
|     `status` | string, nullable | optional | Lifecycle status as returned by the card processor at creation, normally ACTIVE One of: `ACTIVE`, `FROZEN`, `DELETED`, `BLOCKED`, `INACTIVE`, `EXPIRED`. |
|     `created_at` | string | optional | ISO 8601 UTC timestamp generated when the response is built, so it can sit a few milliseconds after the stored row's created_at |

```json
{
  "data": {
    "card": {
      "id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
      "partner_ref": "your-user-8842",
      "card_holder_name": "JANE SMITH",
      "last4": "7640",
      "status": "ACTIVE",
      "created_at": "2026-08-22T09:14:02.318Z"
    }
  }
}
```

**Response 400** The cardholder details or the request were refused, and the code says why. INVALID_NAME, INVALID_EMAIL and INVALID_PHONE name the field at fault; INVALID_PHONE also covers a number the card processor refuses as invalid. PHONE_COUNTRY_NOT_ALLOWED means cards are not issued to numbers with that country code. BAD_REQUEST is a malformed body or any other refusal. Nothing was created

```json
{
  "error": "card_holder_name must be at most 26 characters after normalization (got 31)",
  "code": "INVALID_NAME"
}
```

**Response 409** Two distinct causes, told apart by code. QUOTA means either your lifetime card allowance is used up or the shared issuing capacity is full; in both cases nothing was created and no allowance was consumed. ALREADY_PROCESSED means an order under this Idempotency-Key is still in flight, so retry in a moment

**Response 502** The card processor failed or could not be reached. No card was created and the Idempotency-Key is released, so retry with the same key. An upstream rate limit that survives internal retries also surfaces here as UNAVAILABLE, not as 429

**Response 503** An authentication problem between us and the card processor, code UPSTREAM_AUTH. Nothing was created. Back off and retry

**Errors**

| Status | Code | When | Retry |
| --- | --- | --- | --- |
| 400 | `BAD_REQUEST` | The body is not valid JSON, or is JSON but not an object | Fix the body and resend. Reusing the same Idempotency-Key is safe because no claim was made |
| 400 | `BAD_REQUEST` | card_holder_name, phone or email is not a JSON string, or phone_country_code is not a JSON number | Correct the type and resend under the same key |
| 400 | `INVALID_EMAIL` | email does not match ^[^\s@]+@[^\s@]+\.[^\s@]+$ | Correct the address and resend under the same key |
| 400 | `BAD_REQUEST` | partner_ref is present and is either not a string or longer than 128 characters | Shorten it and resend under the same key |
| 400 | `INVALID_NAME` | card_holder_name is empty, longer than 26 characters after normalization, or contains a character with no ASCII equivalent (ß, Ø, Æ, non-Latin scripts, or a left parenthesis) | Correct the name and resend under the same key |
| 400 | `INVALID_PHONE` | phone_country_code is zero, negative or not a whole number | Send a positive integer and resend under the same key |
| 400 | `INVALID_PHONE` | phone does not match ^\d{4,15}$ | Send digits only, without the country code, and resend under the same key |
| 400 | `BAD_REQUEST` | The Idempotency-Key header does not match ^[A-Za-z0-9_-]{1,128}$, for example because it contains a colon | Choose a key from the allowed charset and resend |
| 400 | `INVALID_PHONE` | The card processor refuses the phone number as invalid, for example a placeholder or an impossible range | Collect the cardholder's real mobile number and resend. The Idempotency-Key was released, so the same key can be reused |
| 400 | `PHONE_COUNTRY_NOT_ALLOWED` | Cards are not issued to phone numbers with this country code. The message names the code | Not with this number. A number with a different country code can be sent under the same key |
| 400 | `BAD_REQUEST` | The card processor refuses the order for any other reason | Correct the value. The Idempotency-Key was released, so the same key can be reused |
| 401 | `UNAUTHORIZED` | No key is presented, or the key is unknown, revoked, or on a suspended account | Do not retry until the key is fixed |
| 403 | `FORBIDDEN` | The key lacks the cards:write scope | Do not retry. Ask for the scope to be added |
| 409 | `QUOTA` | Your lifetime card count has reached your contracted allowance. Closed cards still count, so closing does not free capacity | Do not retry. Ask for the allowance to be raised. The Idempotency-Key is released and stays usable |
| 409 | `QUOTA` | The shared issuing capacity behind the platform is full, which is separate from your own allowance | Do not retry. Contact us |
| 409 | `ALREADY_PROCESSED` | Another request holds this Idempotency-Key and has not finished yet | Retry the same key after a short pause. If it keeps returning this for more than a few minutes, the order is stuck and needs support |
| 429 | `RATE_LIMITED` | You made more requests in the last rolling minute than the key allows | Wait for the Retry-After value, which is always 60 seconds, then resend the same key |
| 502 | `UNAVAILABLE` | The card processor errored or was unreachable while creating the card | Retry with the same Idempotency-Key. The claim was released, so no card was created |
| 503 | `UPSTREAM_AUTH` | The card processor rejected our own credentials | Back off and retry with the same key. Contact us if it persists |
| 500 | `UNAVAILABLE` | An unexpected fault such as a database error | Retry with the same Idempotency-Key rather than a new one |

**Notes**

- The card is created with a zero balance. Fund it with a separate top-up.
- `card_holder_name` takes letters, digits, `äöüÄÖÜ`, space, and `/ - ^ . ' )`, 26 characters at most. Send the name as the user wrote it: O'Brien works as is, José converts to `Jose` automatically, Müller keeps its umlaut. Only `ß`, `Ø`, `Æ` and non-Latin scripts are rejected; send an ASCII form for those.
- This is where 3-D Secure codes are sent, so collect a real, reachable number. It can be changed later with `PUT /cards/{id}/phone` if the cardholder switches SIM.

### GET /cards

Returns the cards your account owns, ordered by creation time with the newest first. Closed cards are left out, so a card disappears from this list once you close it. Page through the results with limit and offset.

**Scope** `cards:read`

**Query parameters**

| Parameter | Type |  | Description |
| --- | --- | --- | --- |
| `limit` | integer | optional | Rows to return in this page 1 to 100. Maximum 100. A value above 100 becomes 100, a value below 1 becomes 1, and a value that is not a finite number falls back to 50. Out of range returns clamped. Defaults to `50`. |
| `offset` | integer | optional | Rows to skip before this page Minimum 0. A negative value becomes 0, and a value that is not a finite number falls back to 0. Fractions are truncated toward zero. Out of range returns clamped. Defaults to `0`. |

**Returns** One page of your open cards, as `200`.

**Response 200** One page of your open cards

| Field | Type |  | Description |
| --- | --- | --- | --- |
|   `cards` | array of object | optional | Cards you own, newest first, closed cards excluded |
|     `id` | string | optional | Card id, used in every other card call |
|     `partner_ref` | string \| null, nullable | optional | Your own reference for the cardholder, echoed back untouched, null when you sent none |
|     `card_holder_name` | string | optional | Name printed on the card, as the issuer stored it: uppercased, accents already converted |
|     `last4` | string \| null, nullable | optional | Last four digits of the card number, null until the processor reports them |
|     `status` | string \| null, nullable | optional | Current card state, null before the first status sync. DELETED never appears here because closed cards are filtered out One of: `ACTIVE`, `FROZEN`, `BLOCKED`, `INACTIVE`, `EXPIRED`. |
|     `created_at` | string | optional | ISO 8601 UTC timestamp of when the card was created |
|   `count` | integer | optional | Rows in this page, never your account total |

```json
{
  "data": {
    "cards": [
      {
        "id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
        "partner_ref": "your-user-8842",
        "card_holder_name": "JANE SMITH",
        "last4": "7640",
        "status": "ACTIVE",
        "created_at": "2026-08-19T14:07:51.906Z"
      },
      {
        "id": "7pQ2mHt4Zc0R",
        "partner_ref": "your-user-8617",
        "card_holder_name": "OMAR HADDAD",
        "last4": "2118",
        "status": "FROZEN",
        "created_at": "2026-08-14T09:22:10.006Z"
      },
      {
        "id": "Ba9xLf3KdN1s",
        "partner_ref": null,
        "card_holder_name": "PRIYA NAIR",
        "last4": "5093",
        "status": "ACTIVE",
        "created_at": "2026-07-30T16:45:02.881Z"
      }
    ],
    "count": 3
  }
}
```

**Errors**

| Status | Code | When | Retry |
| --- | --- | --- | --- |
| 401 | `UNAUTHORIZED` | Your key is missing, unknown, revoked, or your account is suspended. The four cases are deliberately indistinguishable | Do not retry, fix the credential |
| 403 | `FORBIDDEN` | Your key is valid but carries no cards:read scope | Do not retry, ask us for a key with the scope |
| 429 | `RATE_LIMITED` | Your key exceeds its requests per rolling minute | Wait for Retry-After, which is 60 seconds, then retry |
| 500 | `UNAVAILABLE` | Something fails on our side, such as the database | Safe to retry shortly |

**Notes**

- Closed cards are excluded from this list. Read one back by id to see its final state.
- `count` is the number of rows on this page, not a total. Keep paging while `count` equals your `limit`, and stop when it comes back smaller.
- `limit` and `offset` are clamped, never rejected. Asking for `limit=500` returns 100 rows and a `200`.

### GET /cards/{cardId}

Returns a single card you own, together with the balance read from the card processor at the moment of the call. The card object is the same shape as a list row, and this operation also serves cards you have closed. When the balance read fails you still get 200, with balance_usd set to null.

**Scope** `cards:read`

**Path parameters**

| Parameter | Type |  | Description |
| --- | --- | --- | --- |
| `cardId` | string | required | Id of a card your account owns An opaque string as returned by POST /cards and GET /cards. No format is validated, so an unknown or malformed id returns 404. Defaults to `7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u`. |

**Returns** The card and its live balance, as `200`.

**Response 200** The card and its live balance

| Field | Type |  | Description |
| --- | --- | --- | --- |
|     `id` | string | optional | Card id, used in every other card call |
|     `partner_ref` | string \| null, nullable | optional | Your own reference for the cardholder, echoed back untouched, null when you sent none |
|     `card_holder_name` | string | optional | Name printed on the card, as the issuer stored it: uppercased, accents already converted |
|     `last4` | string \| null, nullable | optional | Last four digits of the card number, null until the processor reports them |
|     `status` | string \| null, nullable | optional | Current card state, null before the first status sync. DELETED means you closed the card, and this operation is the only place it appears One of: `ACTIVE`, `FROZEN`, `BLOCKED`, `INACTIVE`, `EXPIRED`, `DELETED`. |
|     `created_at` | string | optional | ISO 8601 UTC timestamp of when the card was created |
|   `balance_usd` | number \| null, nullable | optional | Spendable balance in USD, read live from the card processor. Sits beside card, not inside it. null means the read failed and the balance is unknown, while an empty card returns 0 |

```json
{
  "data": {
    "card": {
      "id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
      "partner_ref": "your-user-8842",
      "card_holder_name": "JANE SMITH",
      "last4": "7640",
      "status": "ACTIVE",
      "created_at": "2026-08-19T14:07:51.906Z"
    },
    "balance_usd": 136.43
  }
}
```

**Response 200** The card, with the balance unknown because the processor read failed

| Field | Type |  | Description |
| --- | --- | --- | --- |
|   `balance_usd` | number \| null, nullable | optional | null whenever the balance read fails for any reason: timeout, network fault, or an error from the processor |

```json
{
  "data": {
    "card": {
      "id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
      "partner_ref": "your-user-8842",
      "card_holder_name": "JANE SMITH",
      "last4": "7640",
      "status": "ACTIVE",
      "created_at": "2026-08-19T14:07:51.906Z"
    },
    "balance_usd": null
  }
}
```

**Errors**

| Status | Code | When | Retry |
| --- | --- | --- | --- |
| 401 | `UNAUTHORIZED` | Your key is missing, unknown, revoked, or your account is suspended | Do not retry, fix the credential |
| 403 | `FORBIDDEN` | Your key is valid but carries no cards:read scope | Do not retry, ask us for a key with the scope |
| 404 | `NOT_FOUND` | No card with this id exists under your account, which includes a card that belongs to another account | Do not retry, check the id |
| 429 | `RATE_LIMITED` | Your key exceeds its requests per rolling minute | Wait for Retry-After, which is 60 seconds, then retry |
| 500 | `UNAVAILABLE` | Something fails on our side, such as the database | Safe to retry shortly |

**Notes**

- The balance is read from the card processor on every call, so each request costs a round trip. Poll it deliberately rather than on every page render.
- A failed balance read returns `null`, never `0`, and the rest of the card still comes back. Treat `null` as unknown and retry. Zero is a real answer.
- `balance_usd` sits beside `card` in the envelope, not inside the card object.

### POST /cards/balances

Answers instantly for any number of cards because it reads from our balance model rather than calling the card processor per card. Omit card_ids to page through your whole fleet, or send up to 1000 specific ids. Every row carries as_of, the moment that balance was last read from the processor: the model is written through whenever a balance is fetched and refreshed continuously in the background, stalest first. Send refresh true with at most 25 ids to force live processor reads for cards where the display must be exact right now.

**Scope** `cards:read`

**Request body**

| Field | Type |  | Description |
| --- | --- | --- | --- |
| `card_ids` | array | optional | Up to 1000 distinct ids. Omit for the whole fleet, paginated. At most 1000; at most 25 when refresh is true. |
| `refresh` | boolean | optional | Live processor reads instead of the model, only with card_ids, at most 25. Defaults to `false`. |
| `limit` | number | optional | Fleet mode page size. 1 to 1000. Defaults to `500`. |
| `page` | number | optional | Fleet mode page, 1-based. Defaults to `1`. |

```json
{ "limit": 500, "page": 1 }
```

**Returns** One row per card. Fleet mode adds page, total and total_pages, as `200`.

**Response 200** One row per card. Fleet mode adds page, total and total_pages

| Field | Type |  | Description |
| --- | --- | --- | --- |
| `balances` | array | optional | In request order, or fleet order when card_ids is omitted |
|   `card_id` | string | optional | The card |
|   `balance_usd` | number, nullable | optional | The balance as of as_of. null only when no reading exists yet or a live refresh failed; a drained card reads 0, not null |
|   `as_of` | string, nullable | optional | When this balance was last read from the processor. null means the background refresh has not reached this card yet |
|   `ok` | boolean | optional | false when no dated reading exists yet (a card the sweep has not reached) or the row could not be served. Inspect error to tell them apart: error null with ok false means not-yet-read |
|   `error` | string, nullable | optional | NOT_FOUND for a card that is not yours; UNAVAILABLE on a failed live refresh |
| `count` | number | optional | Rows in this response |
| `page` | number | optional | Fleet mode only (card_ids omitted): the page returned |
| `total` | number | optional | Fleet mode only: cards across all pages |
| `total_pages` | number | optional | Fleet mode only: total pages for the fleet |

```json
{ "data": { "balances": [ { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "ok": true, "balance_usd": 240.50, "as_of": "2026-08-24T10:12:00.000Z", "error": null } ], "count": 1, "page": 1, "total": 4000, "total_pages": 8 } }
```

**Errors**

| Status | Code | When | Retry |
| --- | --- | --- | --- |
| 400 | `BAD_REQUEST` | More than 1000 ids, refresh with more than 25 ids or without card_ids, duplicates, or non-string entries | Do not retry |

**Notes**

- Reads come from our balance model, so fleet-wide calls are instant at any size. `as_of` on each row is when that balance was last read from the processor; recently active cards are freshest because every money movement writes through.
- `refresh: true` is for the handful of cards where a display must be exact right now. It is capped at 25 because each one is a live processor call.
- For live tracking prefer the event stream. Every top-up, unload and transaction carries its amount, so polling this in a loop is the slow way to learn what you were already told.

### POST /cards/{cardId}/topup

You move money from your prepaid float onto one of your cards, one dollar for one dollar. The debit is written to your ledger before the card processor is called, so any failure after that point says something about money that has already left your float. The Idempotency-Key you send is the only way to find out which outcome you got, so send one on every call.

**Scope** `cards:write`

**Idempotency** Accepts `Idempotency-Key`

**Path parameters**

| Parameter | Type |  | Description |
| --- | --- | --- | --- |
| `cardId` | string | optional | The card to credit. Cards you do not own return 404, the same as ids that do not exist Opaque processor-issued string, 32 alphanumeric characters in current issuance. Treat it as opaque and store it as text. |

**Headers**

| Header |  | Description |
| --- | --- | --- |
| `Idempotency-Key` | optional | Optional at the transport level and required in practice on this route. 1 to 128 characters from A-Z, a-z, 0-9, hyphen and underscore. An empty header value counts as absent, which leaves you with no replay protection at all |
| `Content-Type` | optional | application/json. The body is parsed as JSON whatever you send, so a wrong value does not by itself fail the call |

**Request body**

| Field | Type |  | Description |
| --- | --- | --- | --- |
| `amount_usd` | number | required | US dollars to move from your float to the card Must be a JSON number, finite, greater than 0, and carry at most 2 decimal places. No coercion: "250.75" as a string, true, and null are all rejected. No upper bound in code; the ceiling is your available_usd. |

```json
{
  "amount_usd": 250.75
}
```

**Returns** The float is debited and the card is credited. A replay of a completed key returns this same shape with the original amounts, as `200`.

**Response 200** The float is debited and the card is credited. A replay of a completed key returns this same shape with the original amounts

| Field | Type |  | Description |
| --- | --- | --- | --- |
|   `card_id` | string | optional | The card that was credited, echoing the path parameter |
|   `amount_requested_usd` | number | optional | What you asked to move. On a replay this is rebuilt from the stored ledger rows as credited plus fee, not from your new request |
|   `fee_usd` | number | optional | Always 0 on the float model, because the load fee is charged when you fund your float |
|   `credited_usd` | number | optional | What reached the card. Equal to amount_requested_usd while fee_usd is 0 |
|   `partner_balance_usd` | number | optional | Your float balance after the debit. On a replay this is your balance at the moment of the replay, so it can differ from the value the first call returned |

```json
{
  "data": {
    "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
    "amount_requested_usd": 250.75,
    "fee_usd": 0,
    "credited_usd": 250.75,
    "partner_balance_usd": 31379.45
  }
}
```

**Response 400** Your request failed validation, or the card processor rejected the credit as invalid. Both carry code BAD_REQUEST, so read the message to tell them apart and never parse it programmatically

| Field | Type |  | Description |
| --- | --- | --- | --- |
| `error` | string | optional | Advisory text. It usually names the field to change |
| `code` | string | optional | Always BAD_REQUEST on this status One of: `BAD_REQUEST`. |

```json
{
  "error": "amount_usd must have at most 2 decimal places (USD cents)",
  "code": "BAD_REQUEST"
}
```

**Response 402** Your available_usd will not cover the amount. Nothing is written to the ledger and the Idempotency-Key stays unused

```json
{
  "error": "Cannot top up $250.75, insufficient balance: $120.10 available",
  "code": "INSUFFICIENT_FUNDS"
}
```

**Response 404** No card with that id belongs to you. Checked before the idempotency replay, so a foreign card id returns 404 even with a key you have used before

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

**Response 409** The Idempotency-Key is in use and has no replayable success. Three distinct situations produce this and they need different responses from you: the key is bound to a different card, the top-up was rejected and refunded, or the outcome is not recorded

```json
{
  "error": "A top-up with this Idempotency-Key is still in progress, retry shortly",
  "code": "ALREADY_PROCESSED"
}
```

**Response 502** The card processor returned an error other than 400, 401 or 412. A 5xx from it leaves the top-up unresolved with your float debited; a 403, 404, 409 or 429 from it counts as a definite rejection and your float is refunded before this response

```json
{
  "error": "The card processor is unavailable. Retry shortly",
  "code": "UNAVAILABLE"
}
```

**Response 503** An authentication problem between us and the card processor. It counts as a definite rejection, so your float is already refunded and that Idempotency-Key is spent

```json
{
  "error": "Service temporarily unavailable",
  "code": "UPSTREAM_AUTH"
}
```

**Errors**

| Status | Code | When | Retry |
| --- | --- | --- | --- |
| 400 | `BAD_REQUEST` | The body is not valid JSON, is not a JSON object, amount_usd is missing or not a JSON number, amount_usd is zero or negative, amount_usd has more than 2 decimal places, or the Idempotency-Key is outside [A-Za-z0-9_-]{1,128} | Nothing was recorded. Fix the request and send it again with the same Idempotency-Key |
| 400 | `BAD_REQUEST` | The card is closed (status DELETED) and this is a genuinely new top-up. A replay of a key that completed before the close still returns its original 200 | Do not retry. Issue a new card and top that up instead |
| 400 | `BAD_REQUEST` | The card processor rejected the credit with a validation error. Your float was debited and automatically refunded before you saw this | That key is spent. Correct the values and send a new Idempotency-Key |
| 401 | `UNAUTHORIZED` | The key is missing, unknown, revoked, or the partner is suspended | Fix the credential. Retrying the same call changes nothing |
| 402 | `INSUFFICIENT_FUNDS` | amount_usd exceeds available_usd, which is the smaller of your balance and your float limit. The message names which of the two bound | Fund your float or ask us to raise the limit, then retry with the same key. Nothing was consumed |
| 403 | `FORBIDDEN` | The key does not carry cards:write | Use a key with the scope. Retrying changes nothing |
| 404 | `NOT_FOUND` | The card id is unknown or belongs to another partner | Do not retry. Check the id |
| 405 | `BAD_REQUEST` | You sent GET, PUT, PATCH or DELETE to this path | Resend as POST |
| 409 | `ALREADY_PROCESSED` | You reused an Idempotency-Key that is already bound to a different card id | Do not retry. Use one key per operation |
| 409 | `ALREADY_PROCESSED` | The processor rejected an earlier attempt under this key and the amount went back to your float. The message says the amount was returned and asks for a new key | Safe to move the money again. Send a new Idempotency-Key |
| 409 | `ALREADY_PROCESSED` | This key has a committed float debit and no recorded outcome. Either a first attempt is still running, or the processor call failed ambiguously and the top-up is unresolved. The message reads the same in both cases | Poll the same key with backoff. If it clears within seconds it was in flight. If it persists, the top-up is unresolved and only an operator can close it out: contact support with the key and never open a new one |
| 409 | `QUOTA` | Rare on this route. The processor reports a platform capacity limit, which counts as a definite rejection, so your float is refunded first | Do not retry. Contact us. If you later retry, use a new key |
| 429 | `RATE_LIMITED` | You exceeded the per-minute allowance on this key | Wait for Retry-After (60 seconds), then retry with the same key |
| 500 | `UNAVAILABLE` | A processor timeout, a network failure, or an internal fault after the float was debited | Retry the same Idempotency-Key only. It returns the result if the top-up completed and 409 while the outcome is unknown |
| 502 | `UNAVAILABLE` | The processor errored. A 5xx leaves the top-up unresolved, a non-validation 4xx means it was refunded | Retry the same key to learn which. A 200 means it landed, a 409 naming a returned amount means it was refunded and you need a new key |
| 503 | `UPSTREAM_AUTH` | An authentication failure between us and the processor. Treated as a definite rejection, so your float is refunded | Back off, then send a new Idempotency-Key. The old one now returns 409 |

**Notes**

- `amount_usd` must be a JSON number with at most two decimal places. More precision is rejected with `400` rather than rounded, because rounding would move money you did not ask to move.
- There is no per-call maximum. The binding figure is `float.available_usd`, and exceeding it returns `402` rather than clamping.
- Your balance is debited before the card processor is called, so any failure after validation concerns money that has already left it. A definite rejection is refunded automatically. A timeout is not, and is reconciled by hand.

### POST /cards/{cardId}/unload

The inverse of topup: takes amount_usd off the card and returns it to your balance, one dollar for one dollar, without closing the card. The order of operations is reversed from topup because the risk is reversed. A durable intent row is written first, then the processor subtracts from the card, then your balance is credited. A failure between the last two steps leaves an intent row that support is alerted on, so the money is reconciled rather than lost.

**Scope** `cards:write`

**Idempotency** Accepts `Idempotency-Key`

**Path parameters**

| Parameter | Type |  | Description |
| --- | --- | --- | --- |
| `cardId` | string | optional | The card to unload. Cards you do not own return 404, the same as ids that do not exist |

**Headers**

| Header |  | Description |
| --- | --- | --- |
| `Idempotency-Key` | optional | Optional at the transport level and required in practice. Without one, a retry after a timeout can subtract twice. |

**Request body**

| Field | Type |  | Description |
| --- | --- | --- | --- |
| `amount_usd` | number | required | How much to move back. At most 2 decimal places; a finer amount is rejected with 400, never rounded Greater than 0, at most 2 decimal places. |

```json
{ "amount_usd": 50 }
```

**Returns** The money is back in your balance, as `200`.

**Response 200** The money is back in your balance

| Field | Type |  | Description |
| --- | --- | --- | --- |
| `card_id` | string | optional | The card that was unloaded |
| `unloaded_usd` | number | optional | What came off the card |
| `partner_balance_usd` | number | optional | Your balance after the credit |

```json
{ "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "unloaded_usd": 50, "partner_balance_usd": 9995 } }
```

**Errors**

| Status | Code | When | Retry |
| --- | --- | --- | --- |
| 402 | `INSUFFICIENT_FUNDS` | The card does not hold that much | Read the card balance and retry with a smaller amount |
| 404 | `NOT_FOUND` | Unknown card, or a card that is not yours | Do not retry |
| 409 | `ALREADY_PROCESSED` | The key was already used, or the unload is still in flight | See idempotency above |

**Notes**

- No fee in either direction: the load fee was charged once when you funded your balance, and moving money between your balance and your cards is free both ways.
- Emits a `card.unloaded` webhook once the credit has committed. A replay under the same Idempotency-Key emits nothing.
- Asking for more than the card holds is refused with `402` and nothing moves in either direction.

### GET /cards/{cardId}/limits

The card's configured daily, weekly and monthly spending limits, read live from the card processor. null means no limit of that kind is set, in which case only the program-level per-transaction ceiling applies.

**Scope** `cards:read`

**Path parameters**

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

**Returns** Current limits, as `200`.

**Response 200** Current limits

| Field | Type |  | Description |
| --- | --- | --- | --- |
| `card_id` | string | optional | The card |
| `daily_limit` | number, nullable | optional | USD per day, or null for no daily limit |
| `weekly_limit` | number, nullable | optional | USD per week, or null |
| `monthly_limit` | number, nullable | optional | USD per month, or null |
| `per_transaction_limit_usd` | number | optional | The program-level single-purchase ceiling. Informational: it is not settable per card |

```json
{ "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "daily_limit": 500, "weekly_limit": null, "monthly_limit": 4000, "per_transaction_limit_usd": 5000 } }
```

**Errors**

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

**Notes**

- The per-transaction ceiling in the response is program-level and informational. It cannot be changed per card.
- `null` for a limit means only the per-transaction ceiling applies.

### PUT /cards/{cardId}/limits

Send any of daily_limit, weekly_limit and monthly_limit. Each is a positive USD amount. Fields you omit keep their current value; removing a limit is not yet supported. The response reads the limits back from the processor, so it shows what actually took effect rather than echoing your input.

**Scope** `cards:write`

**Path parameters**

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

**Request body**

| Field | Type |  | Description |
| --- | --- | --- | --- |
| `daily_limit` | number | optional | Positive USD amount Greater than 0, at most 2 decimal places. 0 or null is refused: freeze the card instead. Removing a limit is not yet supported. |
| `weekly_limit` | number | optional | Positive USD amount. 0 or null is refused |
| `monthly_limit` | number | optional | Positive USD amount. 0 or null is refused |

```json
{ "daily_limit": 500, "monthly_limit": 4000 }
```

**Returns** The limits as they now stand, as `200`.

**Response 200** The limits as they now stand

```json
{ "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "daily_limit": 500, "weekly_limit": null, "monthly_limit": 4000, "per_transaction_limit_usd": 5000 } }
```

**Errors**

| Status | Code | When | Retry |
| --- | --- | --- | --- |
| 400 | `BAD_REQUEST` | An unknown field, a non-number, zero or a negative value | Do not retry |
| 404 | `NOT_FOUND` | Unknown card, or a card that is not yours | Do not retry |

**Notes**

- Fields you omit keep their current value. Removing a limit is not yet supported, and zero is refused; freeze the card instead.
- A purchase that breaches a limit is declined at the point of sale and arrives as a `card.declined` event with `decline_reason.code` naming which limit.

### POST /cards/{cardId}/freeze

You block or restore spending on a card without touching its balance. Freezing is reversible, the card keeps its number and expiry, and any money already loaded stays on it. A frozen card still attracts the monthly card fee.

**Scope** `cards:write`

**Path parameters**

| Parameter | Type |  | Description |
| --- | --- | --- | --- |
| `cardId` | string | optional | The card to freeze or unfreeze Opaque processor-issued string, 32 alphanumeric characters in current issuance. |

**Headers**

| Header |  | Description |
| --- | --- | --- |
| `Idempotency-Key` | optional | Accepted and recorded in your request log, but this route does not use it. Repeating the call is safe on its own |
| `Content-Type` | optional | application/json |

**Request body**

| Field | Type |  | Description |
| --- | --- | --- | --- |
| `frozen` | boolean | required | true blocks spending, false restores it Must be a JSON boolean. Strings such as "true" and numbers such as 1 are rejected with 400. |

```json
{
  "frozen": true
}
```

**Returns** The processor accepted the change and the card's stored status is updated to match, as `200`.

**Response 200** The processor accepted the change and the card's stored status is updated to match

| Field | Type |  | Description |
| --- | --- | --- | --- |
|   `card_id` | string | optional | The card the change applied to |
|   `frozen` | boolean | optional | The value you sent, echoed back after the processor accepted it. The response carries no status field |

```json
{
  "data": {
    "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
    "frozen": true
  }
}
```

**Response 400** The body is not valid JSON, is not a JSON object, frozen is not a boolean, or the card is closed

```json
{
  "error": "frozen must be true or false",
  "code": "BAD_REQUEST"
}
```

**Response 404** No card with that id belongs to you. Checked before the body is read, so a bad id wins over a bad body

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

**Response 502** The card processor errored or could not be reached. Nothing changed on our side

```json
{
  "error": "The card processor is unavailable. Retry shortly",
  "code": "UNAVAILABLE"
}
```

**Response 503** An authentication problem between us and the card processor. Nothing changed

```json
{
  "error": "Service temporarily unavailable",
  "code": "UPSTREAM_AUTH"
}
```

**Errors**

| Status | Code | When | Retry |
| --- | --- | --- | --- |
| 400 | `BAD_REQUEST` | The body is not valid JSON or not a JSON object | Fix the body and resend |
| 400 | `BAD_REQUEST` | frozen is absent or is not a JSON boolean | Send frozen as true or false and resend |
| 400 | `BAD_REQUEST` | The card's stored status is DELETED, meaning it is closed. Closing is permanent and freezing would resurrect the status locally | Do not retry. A closed card cannot be reopened |
| 400 | `BAD_REQUEST` | The card processor rejected the change as invalid | Do not retry blindly. Check the card's current state with GET /cards/{cardId} |
| 401 | `UNAUTHORIZED` | The key is missing, unknown, revoked, or the partner is suspended | Fix the credential |
| 403 | `FORBIDDEN` | The key does not carry cards:write | Use a key with the scope |
| 404 | `NOT_FOUND` | The card id is unknown or belongs to another partner | Do not retry. Check the id |
| 405 | `BAD_REQUEST` | You sent GET, PUT, PATCH or DELETE to this path | Resend as POST |
| 429 | `RATE_LIMITED` | You exceeded the per-minute allowance on this key | Wait for Retry-After (60 seconds) and resend |
| 500 | `UNAVAILABLE` | An internal fault, including a failure to write the status column after the processor already applied the change | Safe to resend the identical call. Confirm with GET /cards/{cardId} |
| 502 | `UNAVAILABLE` | The processor errored or timed out before applying the change | Safe to resend the identical call |
| 503 | `UPSTREAM_AUTH` | Authentication between us and the processor failed | Back off and resend the identical call |

**Notes**

- The response returns only `card_id` and the `frozen` value you sent. Read the resulting status from [GET /cards/{cardId}](https://virtual.ktap.io/docs/api/cards.md#getCard).
- Freezing leaves the balance on the card and does not stop the monthly fee. Only closing does.
- A successful freeze or unfreeze emits a `card.status_changed` event with `reason` set to `partner_freeze` or `partner_unfreeze`, so a webhook-driven card store stays in sync without polling.

### PUT /cards/{cardId}/phone

Updates the mobile number held on this card. Cardholders change numbers; before this existed the only remedy was closing the card and issuing a new one. 3-D Secure codes for purchases do not go to this number: they are delivered to your webhook as card.3ds_challenge events. Keep it current anyway, since the card keeps it on record.

**Scope** `cards:write`

**Path parameters**

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

**Request body**

| Field | Type |  | Description |
| --- | --- | --- | --- |
| `phone_country_code` | number | required | Dialling code without the plus sign, for example 1 for North America Positive integer. |
| `phone` | string | required | Digits only, no spaces or separators ^[0-9]{4,15}$. |

```json
{ "phone_country_code": 1, "phone": "5551234567" }
```

**Returns** The number was updated, as `200`.

**Response 200** The number was updated

```json
{ "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "updated": true } }
```

**Errors**

| Status | Code | When | Retry |
| --- | --- | --- | --- |
| 400 | `BAD_REQUEST` | The body is not a JSON object, phone_country_code is not a number or phone not a string, or the card processor refuses the change for another reason | Do not retry |
| 400 | `INVALID_PHONE` | phone_country_code is not a positive integer, phone does not match ^[0-9]{4,15}$, or the card processor refuses the number as invalid | Do not retry |
| 400 | `PHONE_COUNTRY_NOT_ALLOWED` | Phone numbers with this country code are not accepted. The message names the code | Do not retry |
| 404 | `NOT_FOUND` | Unknown card, or a card that is not yours | Do not retry |

**Notes**

- Applies to cards in `sms` 3DS mode. On `webhook` mode the code goes to your endpoint instead, but keep the number current in case you switch back.
- The number is not echoed back. It is cardholder data you just sent us.

### POST /cards/{cardId}/close

Ends a card permanently and returns whatever balance is left on it to your prepaid float. The balance is read and returned before the card is deleted, so a card whose balance cannot be read stays open and you get 503. Closing is irreversible, and it stops the monthly card fee from the following month.

**Scope** `cards:write`

**Path parameters**

| Parameter | Type |  | Description |
| --- | --- | --- | --- |
| `cardId` | string | required | Id of a card your account owns An opaque string as returned by POST /cards and GET /cards. No format is validated, so an unknown or malformed id returns 404. Defaults to `7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u`. |

**Headers**

| Header |  | Description |
| --- | --- | --- |
| `Idempotency-Key` | optional | Accepted and written to your request log, and ignored by this operation. The card id already makes the close replayable |

**Returns** The card is closed and any balance is back on your float, as `200`.

**Response 200** The card is closed and any balance is back on your float

| Field | Type |  | Description |
| --- | --- | --- | --- |
|   `card_id` | string | optional | The card you closed, echoed back |
|   `returned_usd` | number | optional | Balance swept off the card and credited to your float. 0 when the card was empty. On a replay this is the amount recorded by the original close, not a fresh reading |
|   `partner_balance_usd` | number | optional | Your float balance after the sweep: everything you funded minus everything drawn down. This ignores any spend ceiling we set, so it can exceed float.available_usd on GET /me |

```json
{
  "data": {
    "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
    "returned_usd": 136.43,
    "partner_balance_usd": 9988.2
  }
}
```

**Errors**

| Status | Code | When | Retry |
| --- | --- | --- | --- |
| 400 | `BAD_REQUEST` | The card processor rejects the balance subtraction or the deletion as invalid | Do not retry unchanged. Read the card, then contact support if the state looks wrong |
| 401 | `UNAUTHORIZED` | Your key is missing, unknown, revoked, or your account is suspended | Do not retry, fix the credential |
| 403 | `FORBIDDEN` | Your key is valid but carries no cards:write scope | Do not retry, ask us for a key with the scope |
| 404 | `NOT_FOUND` | No card with this id exists under your account, which includes a card that belongs to another account | Do not retry, check the id |
| 409 | `ALREADY_PROCESSED` | An earlier close of this card recorded a sweep intent but no credit, so the money is being reconciled by hand. Your account is alerted at the same moment | Do not retry. Contact support, and expect it to clear within the hour |
| 429 | `RATE_LIMITED` | Your key exceeds its requests per rolling minute | Wait for Retry-After, which is 60 seconds, then retry |
| 502 | `UNAVAILABLE` | The card processor errors or cannot be reached while subtracting the balance or deleting the card | Safe to retry. If the sweep already moved the money, the retry reports the recorded amount rather than 0 |
| 503 | `UNAVAILABLE` | The balance read fails, so the card is left open rather than closed with money stranded on it | Safe to retry, nothing changed |
| 503 | `UPSTREAM_AUTH` | Our own credentials with the card processor fail | Safe to retry shortly. Contact us if it persists |
| 500 | `UNAVAILABLE` | Something fails on our side, such as the database | Safe to retry shortly |

**Notes**

- Closing sweeps the remaining balance back to your account and stops the card permanently. Replaying a close returns `200` with the amount already returned.
- Closing does not return card allowance. Your lifetime cap counts every card you have ever issued.
- The month in which you close is billed in full. There is no proration.

### PUT /cards/{cardId}/3ds

One time 3-D Secure codes for your cards are delivered by webhook only: the code arrives on your registered webhook as a card.3ds_challenge event and you deliver it to the cardholder yourself, by email, in-app message or push. Every card is set to webhook delivery when it is created, so you do not need to call this. A method of sms is refused.

**Scope** `cards:write`

**Path parameters**

| Parameter | Type |  | Description |
| --- | --- | --- | --- |
| `cardId` | string | optional | The card whose 3DS delivery you are setting Opaque processor-issued string, 32 alphanumeric characters in current issuance. |

**Headers**

| Header |  | Description |
| --- | --- | --- |
| `Idempotency-Key` | optional | Accepted and recorded in your request log, but this route does not use it |
| `Content-Type` | optional | application/json |

**Request body**

| Field | Type |  | Description |
| --- | --- | --- | --- |
| `method` | string | required | Where the one time code goes One of: `webhook`. Exactly "webhook", lowercase, and it requires an active registered webhook. "sms", any other value or a missing field returns 400. |

```json
{
  "method": "webhook"
}
```

**Returns** The card processor accepted the new delivery method for this card, as `200`.

**Response 200** The card processor accepted the new delivery method for this card

| Field | Type |  | Description |
| --- | --- | --- | --- |
|   `card_id` | string | optional | The card the setting applies to |
|   `three_ds_method` | string | optional | The method now in force, echoing what you sent One of: `webhook`. |

```json
{
  "data": {
    "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
    "three_ds_method": "webhook"
  }
}
```

**Response 400** The body is not valid JSON, is not a JSON object, method is neither sms nor webhook, or you asked for webhook without an active registered webhook

```json
{
  "error": "Register a webhook with PUT /webhook first. In webhook mode the 3DS code is delivered there, and without one every 3DS purchase on this card would fail.",
  "code": "BAD_REQUEST"
}
```

**Response 404** No card with that id belongs to you. Checked before the body is read

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

**Response 502** The card processor errored or could not be reached. The delivery method is unchanged

```json
{
  "error": "The card processor is unavailable. Retry shortly",
  "code": "UNAVAILABLE"
}
```

**Response 503** An authentication problem between us and the card processor. The delivery method is unchanged

```json
{
  "error": "Service temporarily unavailable",
  "code": "UPSTREAM_AUTH"
}
```

**Errors**

| Status | Code | When | Retry |
| --- | --- | --- | --- |
| 400 | `BAD_REQUEST` | The body is not valid JSON or not a JSON object | Fix the body and resend |
| 400 | `BAD_REQUEST` | method is missing, or is anything other than the exact lowercase strings sms and webhook | Send one of the two accepted values and resend |
| 400 | `BAD_REQUEST` | You asked for webhook mode and you have no active registered webhook. The request is refused because a webhook-mode card with nowhere to send the code fails every 3DS purchase | Register an https endpoint with PUT /webhook, then resend |
| 400 | `BAD_REQUEST` | The card processor rejected the configuration as invalid | Do not retry blindly. Contact us if it persists |
| 401 | `UNAUTHORIZED` | The key is missing, unknown, revoked, or the partner is suspended | Fix the credential |
| 403 | `FORBIDDEN` | The key does not carry cards:write | Use a key with the scope |
| 404 | `NOT_FOUND` | The card id is unknown or belongs to another partner | Do not retry. Check the id |
| 405 | `BAD_REQUEST` | You sent GET, POST, PATCH or DELETE to this path | Resend as PUT |
| 429 | `RATE_LIMITED` | You exceeded the per-minute allowance on this key | Wait for Retry-After (60 seconds) and resend |
| 500 | `UNAVAILABLE` | An internal fault | Safe to resend the identical call |
| 502 | `UNAVAILABLE` | The processor errored or timed out | Safe to resend the identical call |
| 503 | `UPSTREAM_AUTH` | Authentication between us and the processor failed | Back off and resend the identical call |

**Notes**

- Webhook delivery of 3DS codes is enabled per program. Contact us to switch it on before you build on it: until it is enabled for your program only `sms` mode is live, and putting a card in `webhook` mode early would leave its 3-D Secure purchases unable to complete.
- `webhook` mode is refused unless you already have an active registered endpoint, and that is checked only at the moment you set it. If your endpoint later goes down, 3-D Secure purchases on this card stop completing.
- The setting lives at the card processor and there is no endpoint that reads it back, so record what you set. `sms` sends the code to the number the card was ordered with; update it with `PUT /cards/{id}/phone` if it changes.
