---
title: "Essentials"
description: "Authentication, the response envelope, errors, idempotency, paging and rate limits. What every endpoint has in common."
kind: reference
url: /docs/essentials
canonical_url: "https://virtual.ktap.io/docs/essentials"
---

> 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

# Essentials

Authentication, the response envelope, errors, idempotency, paging and rate limits. What every endpoint has in common.

What holds for every endpoint. Read it once before you build; the reference pages assume it.

## Authentication

Send your key as a header on every request. Two forms are accepted and resolve to the same key; if you send both, `X-API-Key` wins. The bearer form is matched case insensitively.

```http
X-API-Key: ktap_live_EXAMPLE0_REPLACE_WITH_YOUR_OWN_KEY_NOT_A_REAL_ONE
```

```http
Authorization: Bearer ktap_live_EXAMPLE0_REPLACE_WITH_YOUR_OWN_KEY_NOT_A_REAL_ONE
```

> **Server side only**
>
> The key-authenticated API at `/api/partner/v1` sends no CORS headers, so a browser cannot call it cross-origin. (One deliberate exception sits outside that path: the card-data exchange URL minted by `POST /cards/{cardId}/pan-token` allows cross-origin fetches, so the end user's device can call it directly.) A key in front-end code is a published key: it can issue cards, move your balance, and with `cards:pan` mint links to full card numbers. If a key is exposed, email us the prefix and we revoke on receipt.

Each endpoint requires exactly one scope. A key without it gets `403` naming the scope, never a partial result. Scopes are set when we issue the key and cannot be changed through the API.

| Scope | Grants |
| --- | --- |
| `users:read` | Read your account, rates, ledger, funding addresses, funding submissions and webhook registration |
| `users:write` | Change your markup, submit a funding transaction hash, register your webhook endpoint and redeliver events |
| `cards:read` | List cards, read one card, its limits and transactions, read balances in bulk and page the event backfill |
| `cards:write` | Order, top up, unload, freeze, close a card, set its limits, update its phone and set its 3-D Secure delivery |
| `cards:pan` | Mint a short-lived link that displays a full card number, or a single-use token a device exchanges for the card fields |

A `401` means the key is missing, unknown, revoked or the account is suspended, and all four return an identical body so probing cannot enumerate keys. Neither `401` nor `403` carries rate limit headers, because both are decided before the limiter runs. An unmatched path returns `404` before authentication, so a URL typo looks the same whatever your key is.

## Requests and responses

Every path in the reference is relative to `https://virtual.ktap.io/api/partner/v1`. HTTPS only. A successful response wraps its payload in `data`; a failure returns `error` and `code` at the top level and no `data`.

```json
{
  "data": {
    "id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
    "status": "ACTIVE"
  }
}
```

```json
{
  "error": "This key is missing the required scope: cards:write",
  "code": "FORBIDDEN"
}
```

> **Branch on `code`, never on `error`**
>
> The `code` field is part of the contract: an existing endpoint never starts returning a code it did not document. The `error` string is advisory prose: it is reworded between releases and interpolates live values.

- Send `Content-Type: application/json` on any request with a body, and make the body a JSON object. An array, a string or a bare number is rejected with `400`.
- Unknown fields are ignored rather than rejected, so a forward-compatible client can send extra keys safely.
- Money is sent and returned as a JSON number in US dollars, never in cents. Amounts you send take at most two decimal places and are rejected, never rounded, above that.
- Parse every amount as an exact decimal: a float rounded to cents will not reconcile. Webhook amounts are passed through unrounded, and the fee on `card.authorized` routinely carries more than two decimals. Rows from GET /cards/{id}/transactions are the exception: the card processor reports them to the cent.
- Timestamps are ISO 8601 UTC. Webhook and API timestamps carry a `Z` suffix; the one exception in FORMAT is the `datetime` on a card transaction, which is also UTC but carries no offset and no `Z`, so append `Z` before parsing it.
- A path called with a method it does not implement returns `405` with an `Allow` header, decided before authentication.

## Errors

| Status | Code | Meaning | Retry |
| --- | --- | --- | --- |
| `400` | `BAD_REQUEST` | Invalid JSON, not an object, or a field failed validation. Also when the card processor rejects the request as invalid. | Not unchanged |
| `400` | `INVALID_NAME` | Only from `POST /cards`. `card_holder_name` breaks the name rule. The message says how. | Not unchanged |
| `400` | `INVALID_EMAIL` | Only from `POST /cards`. `email` is not a valid address. | Not unchanged |
| `400` | `INVALID_PHONE` | Only from `POST /cards` and `PUT /cards/{cardId}/phone`. The number or country code is malformed, or the card processor refused the number as invalid. | Not unchanged |
| `400` | `PHONE_COUNTRY_NOT_ALLOWED` | Only from `POST /cards` and `PUT /cards/{cardId}/phone`. Cards are not issued to numbers with this country code. The message names it. | With a number from another country |
| `401` | `UNAUTHORIZED` | Key missing, unknown, revoked, or the account is suspended. | No |
| `402` | `INSUFFICIENT_FUNDS` | A top-up exceeds `float.available_usd`, or an unload exceeds what the card holds. The message names which constraint bound. | After funding, or with a smaller amount |
| `403` | `FORBIDDEN` | The key lacks the required scope. | No |
| `404` | `NOT_FOUND` | No such card, the card belongs to another account, or the path matches no route. Ownership and non-existence are deliberately indistinguishable. | No |
| `405` | `BAD_REQUEST` | A real path called with a method it does not implement. | No |
| `409` | `ALREADY_PROCESSED` | An idempotency key is claimed but has no result to replay. See [the conflict cases](/docs/essentials#conflicts). | Depends |
| `409` | `QUOTA` | Your card allowance is exhausted, or issuance capacity is temporarily full. | No, contact us |
| `409` | `ALREADY_SUBMITTED` | Only from `POST /funding`. Another account holds this transaction hash and keeps it: its claim has been paid, or it submitted the hash first on the same network and the money did not come from a wallet registered to you alone, or its claim names another network and your transaction cannot be credited on yours. | No, retrying does not help: if the transaction is yours, check the network and that your sending wallet is registered, then contact us |
| `409` | `ALREADY_CREDITED` | Only from `POST /funding`. This transaction was already credited to a balance by other means. | No |
| `429` | `RATE_LIMITED` | More requests on this key in the trailing 60 seconds than it allows. | After backing off |
| `429` | `TOO_MANY_PENDING` | Only from `POST /funding`. 5 of your submissions are still not found on their network. It carries no `Retry-After`. A correction of your own is still accepted. | After some are found, or rejected as `not_found` 24 hours after their first submission |
| `500` | `UNAVAILABLE` | A fault on our side. The effect of the call is unknown. | Yes, same key |
| `502` | `UNAVAILABLE` | The card processor errored or was unreachable. | Read the warning below |
| `503` | `UNAVAILABLE` | We declined to act because we could not verify state safely. Nothing moved. From `POST /funding` it also means another account's claim on the hash could still give way to yours (it is still being looked up, or is verified on your network but not paid) and your submission could not be verified yet: retry shortly. | Yes, safe |
| `503` | `UPSTREAM_AUTH` | The card processor rejected our own credentials. Ours to fix. | Back off |

That is the whole enum. A code in this field is always ours: no card processor error code is ever passed through, and a `429` is always our own rather than an upstream one. `UNAVAILABLE` is the one code that carries three meanings, separated by status: `500` is unknown, `502` means the processor failed, and `503` means nothing happened.

> **A 502 on a top-up does not mean nothing happened**
>
> A top-up debits your balance before the card is credited. A definite rejection is refunded automatically; a timeout or a `502` does not tell you which side of that line the call landed on, so treat the outcome as open. Retry with the same idempotency key, never a fresh one: the replay returns the original result if the money landed, says so if it was refunded, and keeps answering `409` while we reconcile by hand.

`X-RateLimit-Limit` and `X-RateLimit-Remaining` are present on any response a handler returns, including most failures, and absent on `401`, `403`, `405`, an unmatched-path `404`, and most `500`, `502` and `503`. Read them defensively rather than assuming they are there.

## Idempotency

Three endpoints move money and all honour an `Idempotency-Key` header. Always send one on `POST /cards`, on a top-up and on an unload. `POST /cards/{cardId}/close` needs no key: it is idempotent on the card id. `POST /funding` needs no key either: it is idempotent on the transaction hash. Everywhere else the header is recorded in your request log and has no effect.

> **A top-up without a key is not safe to retry**
>
> With no key, every top-up call is a distinct operation, so a retry after a timeout debits your balance a second time. This is the single most expensive mistake available in this API.

- One key per logical operation, not per attempt. Reuse the same key for every retry of the same intent.
- Format is `[A-Za-z0-9_-]`, 1 to 128 characters. A UUID is a good default. Colons are rejected, because the key becomes a ledger reference and we derive completion markers by appending suffixes.
- Keys are scoped to your account, so they cannot collide with another partner's.
- A top-up or unload key is bound to the card it was first used on. Replaying it against a different card is refused rather than answered with a success about money that went elsewhere.
- Once the first call settles, a replay returns the original result including the original fee. Concurrent calls under one key are safe: exactly one executes and the others get `409` while it is in flight.

### The four conflict cases

All four return `409 ALREADY_PROCESSED` and the correct action differs. Three carry distinct messages; the fourth reads like the first and reveals itself by refusing to clear.

| Case | What it means | What to do |
| --- | --- | --- |
| Still in flight | The first call under this key has not finished. | Wait briefly and retry the same key |
| Reversed | A top-up under this key was rejected and refunded to your balance. | The key is spent. Issue a new one |
| Bound to another card | This key was already used against a different card. | Use one key per logical operation |
| Awaiting reconciliation | An earlier attempt left an outcome we could not confirm. | Do not retry. Contact us with the key |

If the same key keeps returning `409` for longer than a few minutes, stop and email [dev@ktap.io](mailto:dev@ktap.io) with the key rather than working around it.

## Pagination

Five endpoints page, and they do not share an idiom. `page` is 1-based; `offset` is 0-based and counts rows.

| Endpoint | Parameters | Default | Maximum | Over the maximum | Stop when |
| --- | --- | --- | --- | --- | --- |
| `GET /cards` | `limit`, `offset` | 50, 0 | 100 | Clamped | `count` is less than your `limit` |
| `GET /ledger` | `limit`, `page` | 50, 1 | 100 | Rejected with `400` | `page` equals `total_pages` |
| `GET /cards/{id}/transactions` | `limit`, `page` | 25, 1 | 25 | Rejected with `400` | `page` equals `total_pages` |
| `GET /events` | `limit`, `page` | 50, 1 | 100 | Rejected with `400` | `page` equals `total_pages` |
| `POST /cards/balances` | `limit`, `page` in the body | 500, 1 | 1000 | Rejected with `400` | `page` equals `total_pages` |

- `GET /cards`, `GET /ledger` and `GET /events` return newest first; fleet pages of `POST /cards/balances` walk the fleet in creation order, oldest first. Card transactions follow the card processor's ordering, which is not guaranteed, so reconcile by walking explicit `from_date` and `to_date` windows rather than relying on order.
- Read `total` from a page that returned rows. On `GET /ledger`, `GET /events` and a fleet page of `POST /cards/balances`, a page past the end returns `total` `0` and `total_pages` `1` even though earlier pages hold data.
- `limit=0` is treated as `1` rather than returning an empty page, and `page=0` becomes page 1. The exception is `POST /cards/balances`, which rejects a limit under 1 with `400`.
- `GET /cards` excludes closed cards, while `cards.total` on [GET /me](https://virtual.ktap.io/docs/api/account.md#getMe) counts lifetime issuance, so the two disagree as soon as any card has been closed.
- [POST /cards/balances](https://virtual.ktap.io/docs/api/cards.md#getBulkCardBalances) pages your whole fleet when you omit `card_ids`, and is the endpoint to reach for instead of calling [GET /cards/{cardId}](https://virtual.ktap.io/docs/api/cards.md#getCard) per card. It reads from our balance model, so it answers instantly whatever the fleet size.

## Rate limits

- The default is 120 requests per minute, but read `key.rate_limit_per_minute` from [GET /me](https://virtual.ktap.io/docs/api/account.md#getMe) rather than hardcoding it.
- The limit is per key, not per account, over a rolling 60-second window. Put a bulk job on its own key so it cannot starve your interactive traffic.
- A `429 RATE_LIMITED` carries `Retry-After: 60`. That is a fixed upper bound rather than the moment your oldest request ages out, so you can often resume sooner. A `429 TOO_MANY_PENDING` from `POST /funding` is not a rate limit and carries no `Retry-After`.
- A `429` is itself a request and counts against your window. Sleep before retrying: a tight loop keeps the window full and extends the block.
- There is no `X-RateLimit-Reset`. Treat a missing rate limit header as unknown rather than as zero.

## Changes and compatibility

Under `v1` we will not remove a field or an endpoint, repurpose the meaning of an existing field, make an optional request field required, change the envelope, or return a new error `code` from an existing endpoint. A new endpoint can introduce codes of its own, and those are returned only by that endpoint: `ALREADY_SUBMITTED`, `ALREADY_CREDITED` and `TOO_MANY_PENDING` come only from `POST /funding`. Apart from one exception, an exhaustive switch on the codes an endpoint documents is therefore safe. The exception, made on 2026-09-28: `POST /cards` and `PUT /cards/{cardId}/phone` answer `INVALID_NAME`, `INVALID_EMAIL`, `INVALID_PHONE` and `PHONE_COUNTRY_NOT_ALLOWED` where they answered `BAD_REQUEST`, so the reason shows wherever the code does. The status is still `400`.

What can change without notice: the `error` message string, new fields on existing responses, and new values in any enum other than `code`, including card statuses, transaction statuses, decline reasons and funding reasons. Two rules keep a client working across releases: ignore unknown fields, and give every enum a default branch. Your rates change only by agreement with you; read them from `GET /fees` rather than hardcoding them.

The OpenAPI 3.1 document is served unauthenticated at [`/api/partner/v1/openapi.json`](https://virtual.ktap.io/api/partner/v1/openapi.json) and cached for five minutes. Every page here is also available as Markdown by appending `.md` to its URL, and the whole site as one file at [`/llms-full.txt`](https://virtual.ktap.io/llms-full.txt), which is the thing to hand an agent.

## Before you go live

There is no sandbox, so going live is a checklist rather than a switch. Build against real cards you control: issue a few, fund them with small amounts, exercise the paths you care about, then close them. Tag them with a recognisable `partner_ref` so you can find them again. You cannot simulate a purchase, a decline or a 3-D Secure challenge.

| Have in place | Because |
| --- | --- |
| Idempotency keys on every card order and top-up | A retry after a timeout otherwise debits your balance twice |
| A retry policy branching on `code` and status, not the message | Message text changes between releases |
| A webhook endpoint that verifies the signature and returns `2xx` fast | We time out at 8 seconds. Acknowledge first, process afterwards |
| Deduplication on the event id | Delivery is at-least-once, so you will see events twice |
| Handling for the paired purchase events | [`card.authorized`](/docs/webhooks/events) arrives on authorization and [`card.transaction`](/docs/webhooks/events) on every later leg, sharing one `transaction_id`. Record the authorization's amount and fee and keep them when the settlement's `original_amount` matches. Never book a settlement's figures as the charge |
| Handling for [`card.status_changed`](/docs/webhooks/events) | It is how a card state change reaches you, whether you made it or we did, so a webhook-driven card store stays correct without polling |
| A recovery path using [`GET /events`](/docs/webhooks#listEvents) and [redelivery](/docs/webhooks#redeliverEvent) | Automatic retries stop after 8 attempts. Backfill or requeue rather than losing the event |
| A default branch on every enum you read | Statuses and decline reasons can gain values without a version bump |
| Money parsed as an exact decimal | Fee figures carry more than two decimal places |
| Your key out of source control and out of any browser bundle | A leaked key can issue cards and move your balance |
| A low-balance alarm of your own | We send [`float.low`](/docs/webhooks/events#event-float-low), but do not make it your only warning |
| The wallets you fund from registered with us before your first transfer | Only a plain, direct transfer from a wallet registered to your account is credited automatically (or, if your account is set to accept any wallet, from any wallet such as an exchange). Never send through a router, batcher, bridge or solver |
| Every transfer to your funding address submitted to [`POST /funding`](/docs/api/account#submitFunding) | Submitting the transaction hash is what confirms the funding and credits your balance |
| A reconciliation job against `GET /ledger` | Catches a discrepancy while it is still small |
| A technical contact you keep current | It is the only channel we have for a change that needs work on your side |

Watch `cards.total` against `cards.limit` on `GET /me`: the allowance is for life and closing a card does not return capacity. When it runs out, `POST /cards` returns `409 QUOTA` and no amount of retrying helps. For anything in production, email [dev@ktap.io](mailto:dev@ktap.io) with the key prefix, the card id, the idempotency key and the time in UTC.
