Cards
The card lifecycle. Ordering and funding both move money, so both take an Idempotency-Key.
POST /cards and on a top-upWithout one, a retry after a timeout can issue a second card or debit your balance twice. See 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.
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.
Idempotency-KeystringContent-Typestringcard_holder_namestringrequiredphone_country_codeintegerrequiredphonestringrequiredemailstringrequiredpartner_refstringidstringpartner_refstring | nullcard_holder_namestringlast4string | nullstatusstring | nullcreated_atstringBAD_REQUESTBAD_REQUESTINVALID_EMAILBAD_REQUESTINVALID_NAMEINVALID_PHONEINVALID_PHONEBAD_REQUESTINVALID_PHONEPHONE_COUNTRY_NOT_ALLOWEDBAD_REQUESTUNAUTHORIZEDFORBIDDENQUOTAQUOTAALREADY_PROCESSEDRATE_LIMITEDUNAVAILABLEUPSTREAM_AUTHUNAVAILABLE- The card is created with a zero balance. Fund it with a separate top-up.
card_holder_nametakes letters, digits,äöüÄÖÜ, space, and/ - ^ . ' ), 26 characters at most. Send the name as the user wrote it: O'Brien works as is, José converts toJoseautomatically, 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}/phoneif the cardholder switches SIM.
curl -sS -X POST 'https://virtual.ktap.io/api/partner/v1/cards' \
-H 'X-API-Key: ktap_live_...' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d' \
-d '{"card_holder_name":"JANE SMITH","phone_country_code":1,"phone":"5551234567","email":"jane.smith@yoursite.com","partner_ref":"your-user-8842"}'{
"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"
}
}
}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.
limitintegeroffsetintegercardsarray of objectidstringpartner_refstring | null | nullcard_holder_namestringlast4string | null | nullstatusstring | null | nullcreated_atstringcountintegerUNAUTHORIZEDFORBIDDENRATE_LIMITEDUNAVAILABLE- Closed cards are excluded from this list. Read one back by id to see its final state.
countis the number of rows on this page, not a total. Keep paging whilecountequals yourlimit, and stop when it comes back smaller.limitandoffsetare clamped, never rejected. Asking forlimit=500returns 100 rows and a200.
curl -sS -X GET 'https://virtual.ktap.io/api/partner/v1/cards?limit=50' \
-H 'X-API-Key: ktap_live_...'{
"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
}
}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.
cardIdstringrequiredidstringpartner_refstring | null | nullcard_holder_namestringlast4string | null | nullstatusstring | null | nullcreated_atstringbalance_usdnumber | null | nullUNAUTHORIZEDFORBIDDENNOT_FOUNDRATE_LIMITEDUNAVAILABLE- 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, never0, and the rest of the card still comes back. Treatnullas unknown and retry. Zero is a real answer. balance_usdsits besidecardin the envelope, not inside the card object.
curl -sS -X GET 'https://virtual.ktap.io/api/partner/v1/cards/7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u' \
-H 'X-API-Key: ktap_live_...'{
"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
}
}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.
card_idsarrayrefreshbooleanlimitnumberpagenumberbalancesarraycard_idstringbalance_usdnumber | nullas_ofstring | nullokbooleanerrorstring | nullcountnumberpagenumbertotalnumbertotal_pagesnumberBAD_REQUEST- Reads come from our balance model, so fleet-wide calls are instant at any size.
as_ofon each row is when that balance was last read from the processor; recently active cards are freshest because every money movement writes through. refresh: trueis 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.
curl -sS -X POST 'https://virtual.ktap.io/api/partner/v1/cards/balances' \
-H 'X-API-Key: ktap_live_...' \
-H 'Content-Type: application/json' \
-d '{"limit":500,"page":1}'{ "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 } }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.
cardIdstringIdempotency-KeystringContent-Typestringamount_usdnumberrequiredcard_idstringamount_requested_usdnumberfee_usdnumbercredited_usdnumberpartner_balance_usdnumberBAD_REQUESTBAD_REQUESTBAD_REQUESTUNAUTHORIZEDINSUFFICIENT_FUNDSFORBIDDENNOT_FOUNDBAD_REQUESTALREADY_PROCESSEDALREADY_PROCESSEDALREADY_PROCESSEDQUOTARATE_LIMITEDUNAVAILABLEUNAVAILABLEUPSTREAM_AUTHamount_usdmust be a JSON number with at most two decimal places. More precision is rejected with400rather 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 returns402rather 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.
curl -sS -X POST 'https://virtual.ktap.io/api/partner/v1/cards/7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u/topup' \
-H 'X-API-Key: ktap_live_...' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d' \
-d '{"amount_usd":250.75}'{
"data": {
"card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
"amount_requested_usd": 250.75,
"fee_usd": 0,
"credited_usd": 250.75,
"partner_balance_usd": 31379.45
}
}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.
cardIdstringIdempotency-Keystringamount_usdnumberrequiredcard_idstringunloaded_usdnumberpartner_balance_usdnumberINSUFFICIENT_FUNDSNOT_FOUNDALREADY_PROCESSED- 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.unloadedwebhook once the credit has committed. A replay under the same Idempotency-Key emits nothing. - Asking for more than the card holds is refused with
402and nothing moves in either direction.
curl -sS -X POST 'https://virtual.ktap.io/api/partner/v1/cards/7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u/unload' \
-H 'X-API-Key: ktap_live_...' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d' \
-d '{"amount_usd":50}'{ "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "unloaded_usd": 50, "partner_balance_usd": 9995 } }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.
cardIdstringcard_idstringdaily_limitnumber | nullweekly_limitnumber | nullmonthly_limitnumber | nullper_transaction_limit_usdnumberNOT_FOUND- The per-transaction ceiling in the response is program-level and informational. It cannot be changed per card.
nullfor a limit means only the per-transaction ceiling applies.
curl -sS -X GET 'https://virtual.ktap.io/api/partner/v1/cards/7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u/limits' \
-H 'X-API-Key: ktap_live_...'{ "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "daily_limit": 500, "weekly_limit": null, "monthly_limit": 4000, "per_transaction_limit_usd": 5000 } }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.
cardIdstringdaily_limitnumberweekly_limitnumbermonthly_limitnumberBAD_REQUESTNOT_FOUND- 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.declinedevent withdecline_reason.codenaming which limit.
curl -sS -X PUT 'https://virtual.ktap.io/api/partner/v1/cards/7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u/limits' \
-H 'X-API-Key: ktap_live_...' \
-H 'Content-Type: application/json' \
-d '{"daily_limit":500,"monthly_limit":4000}'{ "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "daily_limit": 500, "weekly_limit": null, "monthly_limit": 4000, "per_transaction_limit_usd": 5000 } }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.
cardIdstringIdempotency-KeystringContent-Typestringfrozenbooleanrequiredcard_idstringfrozenbooleanBAD_REQUESTBAD_REQUESTBAD_REQUESTBAD_REQUESTUNAUTHORIZEDFORBIDDENNOT_FOUNDBAD_REQUESTRATE_LIMITEDUNAVAILABLEUNAVAILABLEUPSTREAM_AUTH- The response returns only
card_idand thefrozenvalue you sent. Read the resulting status from GET /cards/{cardId}. - 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_changedevent withreasonset topartner_freezeorpartner_unfreeze, so a webhook-driven card store stays in sync without polling.
curl -sS -X POST 'https://virtual.ktap.io/api/partner/v1/cards/7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u/freeze' \
-H 'X-API-Key: ktap_live_...' \
-H 'Content-Type: application/json' \
-d '{"frozen":true}'{
"data": {
"card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
"frozen": true
}
}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.
cardIdstringphone_country_codenumberrequiredphonestringrequiredBAD_REQUESTINVALID_PHONEPHONE_COUNTRY_NOT_ALLOWEDNOT_FOUND- Applies to cards in
sms3DS mode. Onwebhookmode 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.
curl -sS -X PUT 'https://virtual.ktap.io/api/partner/v1/cards/7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u/phone' \
-H 'X-API-Key: ktap_live_...' \
-H 'Content-Type: application/json' \
-d '{"phone_country_code":1,"phone":"5551234567"}'{ "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "updated": true } }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.
cardIdstringrequiredIdempotency-Keystringcard_idstringreturned_usdnumberpartner_balance_usdnumberBAD_REQUESTUNAUTHORIZEDFORBIDDENNOT_FOUNDALREADY_PROCESSEDRATE_LIMITEDUNAVAILABLEUNAVAILABLEUPSTREAM_AUTHUNAVAILABLE- Closing sweeps the remaining balance back to your account and stops the card permanently. Replaying a close returns
200with 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.
curl -sS -X POST 'https://virtual.ktap.io/api/partner/v1/cards/7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u/close' \
-H 'X-API-Key: ktap_live_...'{
"data": {
"card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
"returned_usd": 136.43,
"partner_balance_usd": 9988.2
}
}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.
cardIdstringIdempotency-KeystringContent-Typestringmethodstringrequiredcard_idstringthree_ds_methodstringBAD_REQUESTBAD_REQUESTBAD_REQUESTBAD_REQUESTUNAUTHORIZEDFORBIDDENNOT_FOUNDBAD_REQUESTRATE_LIMITEDUNAVAILABLEUNAVAILABLEUPSTREAM_AUTH- 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
smsmode is live, and putting a card inwebhookmode early would leave its 3-D Secure purchases unable to complete. webhookmode 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.
smssends the code to the number the card was ordered with; update it withPUT /cards/{id}/phoneif it changes.
curl -sS -X PUT 'https://virtual.ktap.io/api/partner/v1/cards/7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u/3ds' \
-H 'X-API-Key: ktap_live_...' \
-H 'Content-Type: application/json' \
-d '{"method":"webhook"}'{
"data": {
"card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
"three_ds_method": "webhook"
}
}