Essentials
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.
X-API-Key: ktap_live_EXAMPLE0_REPLACE_WITH_YOUR_OWN_KEY_NOT_A_REAL_ONEThe 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.
{
"data": {
"id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
"status": "ACTIVE"
}
}code, never on errorThe 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/jsonon any request with a body, and make the body a JSON object. An array, a string or a bare number is rejected with400. - 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.authorizedroutinely 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
Zsuffix; the one exception in FORMAT is thedatetimeon a card transaction, which is also UTC but carries no offset and noZ, so appendZbefore parsing it. - A path called with a method it does not implement returns
405with anAllowheader, 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. | 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 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.
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
409while 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 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 /ledgerandGET /eventsreturn newest first; fleet pages ofPOST /cards/balanceswalk the fleet in creation order, oldest first. Card transactions follow the card processor's ordering, which is not guaranteed, so reconcile by walking explicitfrom_dateandto_datewindows rather than relying on order.- Read
totalfrom a page that returned rows. OnGET /ledger,GET /eventsand a fleet page ofPOST /cards/balances, a page past the end returnstotal0andtotal_pages1even though earlier pages hold data. limit=0is treated as1rather than returning an empty page, andpage=0becomes page 1. The exception isPOST /cards/balances, which rejects a limit under 1 with400.GET /cardsexcludes closed cards, whilecards.totalon GET /me counts lifetime issuance, so the two disagree as soon as any card has been closed.- POST /cards/balances pages your whole fleet when you omit
card_ids, and is the endpoint to reach for instead of calling GET /cards/{cardId} 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_minutefrom GET /me 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_LIMITEDcarriesRetry-After: 60. That is a fixed upper bound rather than the moment your oldest request ages out, so you can often resume sooner. A429 TOO_MANY_PENDINGfromPOST /fundingis not a rate limit and carries noRetry-After. - A
429is 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` 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`, 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` arrives on authorization and `card.transaction` 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` | 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` and redelivery | 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`, 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` | 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 with the key prefix, the card id, the idempotency key and the time in UTC.