KTAP Cards API

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_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.

ScopeGrants
users:readRead your account, rates, ledger, funding addresses, funding submissions and webhook registration
users:writeChange your markup, submit a funding transaction hash, register your webhook endpoint and redeliver events
cards:readList cards, read one card, its limits and transactions, read balances in bulk and page the event backfill
cards:writeOrder, top up, unload, freeze, close a card, set its limits, update its phone and set its 3-D Secure delivery
cards:panMint 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"
  }
}
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#

StatusCodeMeaningRetry
400BAD_REQUESTInvalid JSON, not an object, or a field failed validation. Also when the card processor rejects the request as invalid.Not unchanged
400INVALID_NAMEOnly from POST /cards. card_holder_name breaks the name rule. The message says how.Not unchanged
400INVALID_EMAILOnly from POST /cards. email is not a valid address.Not unchanged
400INVALID_PHONEOnly 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
400PHONE_COUNTRY_NOT_ALLOWEDOnly 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
401UNAUTHORIZEDKey missing, unknown, revoked, or the account is suspended.No
402INSUFFICIENT_FUNDSA 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
403FORBIDDENThe key lacks the required scope.No
404NOT_FOUNDNo such card, the card belongs to another account, or the path matches no route. Ownership and non-existence are deliberately indistinguishable.No
405BAD_REQUESTA real path called with a method it does not implement.No
409ALREADY_PROCESSEDAn idempotency key is claimed but has no result to replay. See the conflict cases.Depends
409QUOTAYour card allowance is exhausted, or issuance capacity is temporarily full.No, contact us
409ALREADY_SUBMITTEDOnly 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
409ALREADY_CREDITEDOnly from POST /funding. This transaction was already credited to a balance by other means.No
429RATE_LIMITEDMore requests on this key in the trailing 60 seconds than it allows.After backing off
429TOO_MANY_PENDINGOnly 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
500UNAVAILABLEA fault on our side. The effect of the call is unknown.Yes, same key
502UNAVAILABLEThe card processor errored or was unreachable.Read the warning below
503UNAVAILABLEWe 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
503UPSTREAM_AUTHThe 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.

CaseWhat it meansWhat to do
Still in flightThe first call under this key has not finished.Wait briefly and retry the same key
ReversedA top-up under this key was rejected and refunded to your balance.The key is spent. Issue a new one
Bound to another cardThis key was already used against a different card.Use one key per logical operation
Awaiting reconciliationAn 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.

EndpointParametersDefaultMaximumOver the maximumStop when
GET /cardslimit, offset50, 0100Clampedcount is less than your limit
GET /ledgerlimit, page50, 1100Rejected with 400page equals total_pages
GET /cards/{id}/transactionslimit, page25, 125Rejected with 400page equals total_pages
GET /eventslimit, page50, 1100Rejected with 400page equals total_pages
POST /cards/balanceslimit, page in the body500, 11000Rejected with 400page 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 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_minute from 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_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` 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 placeBecause
Idempotency keys on every card order and top-upA retry after a timeout otherwise debits your balance twice
A retry policy branching on code and status, not the messageMessage text changes between releases
A webhook endpoint that verifies the signature and returns 2xx fastWe time out at 8 seconds. Acknowledge first, process afterwards
Deduplication on the event idDelivery 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 redeliveryAutomatic retries stop after 8 attempts. Backfill or requeue rather than losing the event
A default branch on every enum you readStatuses and decline reasons can gain values without a version bump
Money parsed as an exact decimalFee figures carry more than two decimal places
Your key out of source control and out of any browser bundleA leaked key can issue cards and move your balance
A low-balance alarm of your ownWe send `float.low`, but do not make it your only warning
The wallets you fund from registered with us before your first transferOnly 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 /ledgerCatches a discrepancy while it is still small
A technical contact you keep currentIt 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.

Essentials · KTAP Cards API