KTAP Cards API
API reference

Cards

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.

Card statuses#

StatusMeaning
ACTIVENormal. The card can be used up to its balance
FROZENYou froze it. Purchases are declined; the balance stays on the card and the monthly fee still applies
BLOCKEDWe or the card processor blocked it, typically after repeated velocity breaches or wrong security details. Contact us to unblock
EXPIREDPast its expiry date
DELETEDClosed. 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.

POST/api/partner/v1/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:writeAccepts Idempotency-Key
Headers
Idempotency-Keystring
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-Typestring
The body is parsed as JSON regardless of this header. Send application/json anyway
Body
card_holder_namestringrequired
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_codeintegerrequired
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
phonestringrequired
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
emailstringrequired
The cardholder's email address, passed to the card processor and not stored locally
^[^\s@]+@[^\s@]+\.[^\s@]+$
partner_refstring
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
Response
idstring
The card id. Use it in every other card call
partner_refstring | null
The partner_ref you sent, or null if you sent none
card_holder_namestring
The name the card processor recorded, which is your trimmed value
last4string | null
Last four digits of the card number
statusstring | null
Lifecycle status as returned by the card processor at creation, normally ACTIVE
ACTIVE · FROZEN · DELETED · BLOCKED · INACTIVE · EXPIRED
created_atstring
ISO 8601 UTC timestamp generated when the response is built, so it can sit a few milliseconds after the stored row's created_at
Errors
400BAD_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
400BAD_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
400INVALID_EMAIL
email does not match ^[^\s@]+@[^\s@]+\.[^\s@]+$
Correct the address and resend under the same key
400BAD_REQUEST
partner_ref is present and is either not a string or longer than 128 characters
Shorten it and resend under the same key
400INVALID_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
400INVALID_PHONE
phone_country_code is zero, negative or not a whole number
Send a positive integer and resend under the same key
400INVALID_PHONE
phone does not match ^\d{4,15}$
Send digits only, without the country code, and resend under the same key
400BAD_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
400INVALID_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
400PHONE_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
400BAD_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
401UNAUTHORIZED
No key is presented, or the key is unknown, revoked, or on a suspended account
Do not retry until the key is fixed
403FORBIDDEN
The key lacks the cards:write scope
Do not retry. Ask for the scope to be added
409QUOTA
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
409QUOTA
The shared issuing capacity behind the platform is full, which is separate from your own allowance
Do not retry. Contact us
409ALREADY_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
429RATE_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
502UNAVAILABLE
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
503UPSTREAM_AUTH
The card processor rejected our own credentials
Back off and retry with the same key. Contact us if it persists
500UNAVAILABLE
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.
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"
    }
  }
}
GET/api/partner/v1/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
limitinteger
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
offsetinteger
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
Response
cardsarray of object
Cards you own, newest first, closed cards excluded
idstring
Card id, used in every other card call
partner_refstring | null | null
Your own reference for the cardholder, echoed back untouched, null when you sent none
card_holder_namestring
Name printed on the card, as the issuer stored it: uppercased, accents already converted
last4string | null | null
Last four digits of the card number, null until the processor reports them
statusstring | null | null
Current card state, null before the first status sync. DELETED never appears here because closed cards are filtered out
ACTIVE · FROZEN · BLOCKED · INACTIVE · EXPIRED
created_atstring
ISO 8601 UTC timestamp of when the card was created
countinteger
Rows in this page, never your account total
Errors
401UNAUTHORIZED
Your key is missing, unknown, revoked, or your account is suspended. The four cases are deliberately indistinguishable
Do not retry, fix the credential
403FORBIDDEN
Your key is valid but carries no cards:read scope
Do not retry, ask us for a key with the scope
429RATE_LIMITED
Your key exceeds its requests per rolling minute
Wait for Retry-After, which is 60 seconds, then retry
500UNAVAILABLE
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.
curl -sS -X GET 'https://virtual.ktap.io/api/partner/v1/cards?limit=50' \
  -H 'X-API-Key: ktap_live_...'
Response
{
  "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
  }
}
GET/api/partner/v1/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
cardIdstringrequired
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
Response
idstring
Card id, used in every other card call
partner_refstring | null | null
Your own reference for the cardholder, echoed back untouched, null when you sent none
card_holder_namestring
Name printed on the card, as the issuer stored it: uppercased, accents already converted
last4string | null | null
Last four digits of the card number, null until the processor reports them
statusstring | null | null
Current card state, null before the first status sync. DELETED means you closed the card, and this operation is the only place it appears
ACTIVE · FROZEN · BLOCKED · INACTIVE · EXPIRED · DELETED
created_atstring
ISO 8601 UTC timestamp of when the card was created
balance_usdnumber | null | null
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
Errors
401UNAUTHORIZED
Your key is missing, unknown, revoked, or your account is suspended
Do not retry, fix the credential
403FORBIDDEN
Your key is valid but carries no cards:read scope
Do not retry, ask us for a key with the scope
404NOT_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
429RATE_LIMITED
Your key exceeds its requests per rolling minute
Wait for Retry-After, which is 60 seconds, then retry
500UNAVAILABLE
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.
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
  }
}
POST/api/partner/v1/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
Body
card_idsarray
Up to 1000 distinct ids. Omit for the whole fleet, paginated.
At most 1000; at most 25 when refresh is true
refreshboolean
Live processor reads instead of the model, only with card_ids, at most 25.
Defaults to false
limitnumber
Fleet mode page size.
1 to 1000·Defaults to 500
pagenumber
Fleet mode page, 1-based.
Defaults to 1
Response
balancesarray
In request order, or fleet order when card_ids is omitted
card_idstring
The card
balance_usdnumber | null
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_ofstring | null
When this balance was last read from the processor. null means the background refresh has not reached this card yet
okboolean
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
errorstring | null
NOT_FOUND for a card that is not yours; UNAVAILABLE on a failed live refresh
countnumber
Rows in this response
pagenumber
Fleet mode only (card_ids omitted): the page returned
totalnumber
Fleet mode only: cards across all pages
total_pagesnumber
Fleet mode only: total pages for the fleet
Errors
400BAD_REQUEST
More than 1000 ids, refresh with more than 25 ids or without card_ids, duplicates, or non-string entries
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.
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}'
Response
{ "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 } }
POST/api/partner/v1/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:writeAccepts Idempotency-Key
Path parameters
cardIdstring
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
Idempotency-Keystring
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-Typestring
application/json. The body is parsed as JSON whatever you send, so a wrong value does not by itself fail the call
Body
amount_usdnumberrequired
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
Response
card_idstring
The card that was credited, echoing the path parameter
amount_requested_usdnumber
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_usdnumber
Always 0 on the float model, because the load fee is charged when you fund your float
credited_usdnumber
What reached the card. Equal to amount_requested_usd while fee_usd is 0
partner_balance_usdnumber
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
Errors
400BAD_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
400BAD_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
400BAD_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
401UNAUTHORIZED
The key is missing, unknown, revoked, or the partner is suspended
Fix the credential. Retrying the same call changes nothing
402INSUFFICIENT_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
403FORBIDDEN
The key does not carry cards:write
Use a key with the scope. Retrying changes nothing
404NOT_FOUND
The card id is unknown or belongs to another partner
Do not retry. Check the id
405BAD_REQUEST
You sent GET, PUT, PATCH or DELETE to this path
Resend as POST
409ALREADY_PROCESSED
You reused an Idempotency-Key that is already bound to a different card id
Do not retry. Use one key per operation
409ALREADY_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
409ALREADY_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
409QUOTA
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
429RATE_LIMITED
You exceeded the per-minute allowance on this key
Wait for Retry-After (60 seconds), then retry with the same key
500UNAVAILABLE
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
502UNAVAILABLE
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
503UPSTREAM_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.
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
  }
}
POST/api/partner/v1/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:writeAccepts Idempotency-Key
Path parameters
cardIdstring
The card to unload. Cards you do not own return 404, the same as ids that do not exist
Headers
Idempotency-Keystring
Optional at the transport level and required in practice. Without one, a retry after a timeout can subtract twice.
Body
amount_usdnumberrequired
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
Response
card_idstring
The card that was unloaded
unloaded_usdnumber
What came off the card
partner_balance_usdnumber
Your balance after the credit
Errors
402INSUFFICIENT_FUNDS
The card does not hold that much
Read the card balance and retry with a smaller amount
404NOT_FOUND
Unknown card, or a card that is not yours
409ALREADY_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.
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}'
Response
{ "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "unloaded_usd": 50, "partner_balance_usd": 9995 } }
GET/api/partner/v1/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
cardIdstring
Cards you do not own return 404
Response
card_idstring
The card
daily_limitnumber | null
USD per day, or null for no daily limit
weekly_limitnumber | null
USD per week, or null
monthly_limitnumber | null
USD per month, or null
per_transaction_limit_usdnumber
The program-level single-purchase ceiling. Informational: it is not settable per card
Errors
404NOT_FOUND
Unknown card, or a card that is not yours
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.
curl -sS -X GET 'https://virtual.ktap.io/api/partner/v1/cards/7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u/limits' \
  -H 'X-API-Key: ktap_live_...'
Response
{ "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "daily_limit": 500, "weekly_limit": null, "monthly_limit": 4000, "per_transaction_limit_usd": 5000 } }
PUT/api/partner/v1/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
cardIdstring
Cards you do not own return 404
Body
daily_limitnumber
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_limitnumber
Positive USD amount. 0 or null is refused
monthly_limitnumber
Positive USD amount. 0 or null is refused
Errors
400BAD_REQUEST
An unknown field, a non-number, zero or a negative value
404NOT_FOUND
Unknown card, or a card that is not yours
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.
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}'
Response
{ "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "daily_limit": 500, "weekly_limit": null, "monthly_limit": 4000, "per_transaction_limit_usd": 5000 } }
POST/api/partner/v1/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
cardIdstring
The card to freeze or unfreeze
Opaque processor-issued string, 32 alphanumeric characters in current issuance
Headers
Idempotency-Keystring
Accepted and recorded in your request log, but this route does not use it. Repeating the call is safe on its own
Content-Typestring
application/json
Body
frozenbooleanrequired
true blocks spending, false restores it
Must be a JSON boolean. Strings such as "true" and numbers such as 1 are rejected with 400
Response
card_idstring
The card the change applied to
frozenboolean
The value you sent, echoed back after the processor accepted it. The response carries no status field
Errors
400BAD_REQUEST
The body is not valid JSON or not a JSON object
Fix the body and resend
400BAD_REQUEST
frozen is absent or is not a JSON boolean
Send frozen as true or false and resend
400BAD_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
400BAD_REQUEST
The card processor rejected the change as invalid
Do not retry blindly. Check the card's current state with GET /cards/{cardId}
401UNAUTHORIZED
The key is missing, unknown, revoked, or the partner is suspended
Fix the credential
403FORBIDDEN
The key does not carry cards:write
Use a key with the scope
404NOT_FOUND
The card id is unknown or belongs to another partner
Do not retry. Check the id
405BAD_REQUEST
You sent GET, PUT, PATCH or DELETE to this path
Resend as POST
429RATE_LIMITED
You exceeded the per-minute allowance on this key
Wait for Retry-After (60 seconds) and resend
500UNAVAILABLE
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}
502UNAVAILABLE
The processor errored or timed out before applying the change
Safe to resend the identical call
503UPSTREAM_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}.
  • 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.
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
  }
}
PUT/api/partner/v1/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
cardIdstring
Cards you do not own return 404
Body
phone_country_codenumberrequired
Dialling code without the plus sign, for example 1 for North America
Positive integer
phonestringrequired
Digits only, no spaces or separators
^[0-9]{4,15}$
Errors
400BAD_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
400INVALID_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
400PHONE_COUNTRY_NOT_ALLOWED
Phone numbers with this country code are not accepted. The message names the code
404NOT_FOUND
Unknown card, or a card that is not yours
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.
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"}'
Response
{ "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "updated": true } }
POST/api/partner/v1/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
cardIdstringrequired
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
Idempotency-Keystring
Accepted and written to your request log, and ignored by this operation. The card id already makes the close replayable
Response
card_idstring
The card you closed, echoed back
returned_usdnumber
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_usdnumber
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
Errors
400BAD_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
401UNAUTHORIZED
Your key is missing, unknown, revoked, or your account is suspended
Do not retry, fix the credential
403FORBIDDEN
Your key is valid but carries no cards:write scope
Do not retry, ask us for a key with the scope
404NOT_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
409ALREADY_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
429RATE_LIMITED
Your key exceeds its requests per rolling minute
Wait for Retry-After, which is 60 seconds, then retry
502UNAVAILABLE
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
503UNAVAILABLE
The balance read fails, so the card is left open rather than closed with money stranded on it
Safe to retry, nothing changed
503UPSTREAM_AUTH
Our own credentials with the card processor fail
Safe to retry shortly. Contact us if it persists
500UNAVAILABLE
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.
curl -sS -X POST 'https://virtual.ktap.io/api/partner/v1/cards/7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u/close' \
  -H 'X-API-Key: ktap_live_...'
Response
{
  "data": {
    "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u",
    "returned_usd": 136.43,
    "partner_balance_usd": 9988.2
  }
}
PUT/api/partner/v1/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
cardIdstring
The card whose 3DS delivery you are setting
Opaque processor-issued string, 32 alphanumeric characters in current issuance
Headers
Idempotency-Keystring
Accepted and recorded in your request log, but this route does not use it
Content-Typestring
application/json
Body
methodstringrequired
Where the one time code goes
webhook·Exactly "webhook", lowercase, and it requires an active registered webhook. "sms", any other value or a missing field returns 400
Response
card_idstring
The card the setting applies to
three_ds_methodstring
The method now in force, echoing what you sent
webhook
Errors
400BAD_REQUEST
The body is not valid JSON or not a JSON object
Fix the body and resend
400BAD_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
400BAD_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
400BAD_REQUEST
The card processor rejected the configuration as invalid
Do not retry blindly. Contact us if it persists
401UNAUTHORIZED
The key is missing, unknown, revoked, or the partner is suspended
Fix the credential
403FORBIDDEN
The key does not carry cards:write
Use a key with the scope
404NOT_FOUND
The card id is unknown or belongs to another partner
Do not retry. Check the id
405BAD_REQUEST
You sent GET, POST, PATCH or DELETE to this path
Resend as PUT
429RATE_LIMITED
You exceeded the per-minute allowance on this key
Wait for Retry-After (60 seconds) and resend
500UNAVAILABLE
An internal fault
Safe to resend the identical call
502UNAVAILABLE
The processor errored or timed out
Safe to resend the identical call
503UPSTREAM_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.
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"
  }
}
Cards · KTAP Cards API