# KTAP Cards API > Issue virtual Visa cards to your own customers and fund them from a prepaid balance. Source: https://virtual.ktap.io/docs --- # KTAP Cards API Issue virtual Visa cards to your own customers and fund them from a prepaid USD balance. Issue virtual Visa cards to your own customers, fund them from a prepaid USD balance, and reconcile everything against one ledger. Your customers never contact us and never see our name. ## How it works 1. You hold a prepaid USD balance with us. You fund it by sending USDC or USDT on Polygon, BNB Chain or Base, directly from a wallet you have registered with us, to the funding address [GET /funding/addresses](https://virtual.ktap.io/docs/api/account.md#listFundingAddresses) returns, then submitting the transaction hash to [POST /funding](https://virtual.ktap.io/docs/api/account.md#submitFunding) to confirm the deposit. One load fee is charged when it is credited. 2. You order a card for a customer with a name, a mobile number and an email address, and get back a card id. The name goes in as the user wrote it: accents convert automatically and punctuation like O’Brien is fine. 3. You move money from your balance onto the card at 1:1, with no further load fee, and can pull it back off a card the same way without closing it. 4. You show the card number to your customer through a short-lived URL we mint for your page or their device. The number itself never reaches your servers. 5. Your customer spends. We tell you what happened over webhooks, and each purchase's fee is on its `card.authorized` event. The card is a virtual Visa: no plastic, usable anywhere Visa is accepted online, and addable to Google Pay but not to Apple Wallet. It is denominated in USD. Every purchase carries a fee, charged with the purchase amount at authorization, and a purchase in another currency is also converted. The cardholder name is printed in uppercase, at most 26 characters: José becomes Jose automatically, Müller keeps its umlaut, and the only characters refused are the few with no plain ASCII stand-in, such as ß and non-Latin scripts. 3-D Secure codes go by SMS to the number you supplied when you ordered the card, and you can [change that number later](https://virtual.ktap.io/docs/api/cards.md#updateCardPhone) if the cardholder switches. Programs that would rather deliver the code in their own app can [switch a card to webhook delivery](https://virtual.ktap.io/docs/api/cards.md#setCard3dsMethod) once we enable it for them. ## Base URL and authentication One base URL, one key, sent as a header on every request from your own server. Keys are issued by your account manager; there is no self-serve signup. ```bash curl https://virtual.ktap.io/api/partner/v1/me \ -H "X-API-Key: $KTAP_API_KEY" ``` > **Every key is live and there is no sandbox** > > There is no test mode and no simulated card. Build against a real card you issue for the purpose, fund it with a small amount, and close it when you are done. A test card still carries one month of card fee. ## Where to go next - [Quickstart](https://virtual.ktap.io/docs/quickstart.md): Issue a card, fund it, read it back. Six calls. - [Essentials](https://virtual.ktap.io/docs/essentials.md): Auth, errors, idempotency, paging and limits. Read once. - [API reference](https://virtual.ktap.io/docs/api/account.md): Every endpoint, with parameters, responses and samples. - [Webhooks](https://virtual.ktap.io/docs/webhooks.md): Register a callback, verify the signature, and backfill anything you missed. Questions, or a problem in production: [dev@ktap.io](mailto:dev@ktap.io). Include your key prefix, the card id, the idempotency key if there was one, and the time in UTC. [Source](https://virtual.ktap.io/docs.md) --- # Quickstart Issue a card, fund it, show it, pull money back off it, and close it. Seven calls. Seven calls from your terminal. At the end you will have issued, funded, partly unloaded and closed a real card, and seen every envelope and id the reference talks about. > **This spends real money** > > The card you create here is a real Visa card and the balance you move is your real balance. Use a small amount. Steps 6 and 7 bring it all back. You need a key with `users:read`, `cards:read`, `cards:write` and `cards:pan`, a funded balance ([funding is confirmed with POST /funding](https://virtual.ktap.io/docs/api/account.md#fund)), a mobile number you can receive SMS on, and an email address for the cardholder. **Set up your shell** ```bash export KTAP_API_KEY="ktap_live_..." export KTAP_BASE="https://virtual.ktap.io/api/partner/v1" ``` ## 1. Check your key and balance ```bash curl -sS "$KTAP_BASE/me" -H "X-API-Key: $KTAP_API_KEY" ``` The figure that matters is `data.float.available_usd`. That, not `balance_usd`, is what a top-up is checked against. A `401` here means the key is wrong, revoked or suspended, and the three are indistinguishable. ## 2. Order a card ```bash curl -sS -X POST "$KTAP_BASE/cards" \ -H "X-API-Key: $KTAP_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H 'Content-Type: application/json' \ -d '{ "card_holder_name": "Ada Lovelace", "phone_country_code": 1, "phone": "5551234567", "email": "ada@yoursite.com", "partner_ref": "quickstart-001" }' ``` `card_holder_name` takes letters, digits, `äöüÄÖÜ`, space, and `/ - ^ . ’ )`, at most 26 characters. Send the name as the user wrote it: O’Brien works as is, José converts to `Jose` automatically, and Müller keeps its umlaut. Only `ß`, `Ø`, `Æ` and non-Latin scripts are rejected; send an ASCII form for those. `phone_country_code` is the country calling code on its own, as an integer. `phone` is the subscriber number, 4 to 15 digits with no punctuation, so a leading `+` returns `400`. `email` is required and goes to the card processor; we do not store it. `partner_ref` is your own identifier, handed back on the card and on every webhook, which is what makes reconciliation possible. > **Always send an Idempotency-Key here, on a top-up and on an unload** > > Without one, a retry after a timeout can issue a second card or move your money twice. If a call times out, retry the identical request with the same key. A new key is a second, unrelated operation. See [Idempotency](https://virtual.ktap.io/docs/essentials.md#idempotency). ```bash export CARD_ID="7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u" # data.card.id from the response ``` ## 3. Put money on it ```bash curl -sS -X POST "$KTAP_BASE/cards/$CARD_ID/topup" \ -H "X-API-Key: $KTAP_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H 'Content-Type: application/json' \ -d '{"amount_usd": 25.00}' ``` A top-up moves value at 1:1 with no fee. Amounts take at most two decimal places and are rejected, never rounded, above that. ## 4. Read the card back ```bash curl -sS "$KTAP_BASE/cards/$CARD_ID" -H "X-API-Key: $KTAP_API_KEY" ``` The balance here is read live from the card processor. An empty card reads `0`; `null` means the read itself failed, so treat `null` as unknown and retry rather than as empty. For many cards at once use [POST /cards/balances](https://virtual.ktap.io/docs/api/cards.md#getBulkCardBalances), which answers instantly at any fleet size. ## 5. Show the card number ```bash curl -sS "$KTAP_BASE/cards/$CARD_ID/pan" -H "X-API-Key: $KTAP_API_KEY" ``` Card numbers are never returned as data. You get a URL valid for 600 seconds and put it in an iframe, which keeps the number off your servers and out of your logs. If your app cannot render an iframe, [POST /cards/{cardId}/pan-token](https://virtual.ktap.io/docs/api/card-data.md) mints a single-use URL for the cardholder’s own device to fetch instead. [Card details](https://virtual.ktap.io/docs/api/card-data.md) has both. ## 6. Pull money back off ```bash curl -sS -X POST "$KTAP_BASE/cards/$CARD_ID/unload" \ -H "X-API-Key: $KTAP_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H 'Content-Type: application/json' \ -d '{"amount_usd": 10.00}' ``` The exact inverse of a top-up: money comes off the card and back to your balance at 1:1 with no fee, and the card stays open. Asking for more than the card holds is refused with `402` and nothing moves. The Idempotency-Key rule is the same as on a top-up, because the retry risk is the same money twice. ## 7. Clean up ```bash curl -sS -X POST "$KTAP_BASE/cards/$CARD_ID/close" -H "X-API-Key: $KTAP_API_KEY" ``` Closing sweeps the remaining balance back to you and stops the card permanently. It is idempotent on the card id, so no key is needed. The monthly fee for the month the card existed is still charged, with no proration. ## Next Read [Essentials](https://virtual.ktap.io/docs/essentials.md) once before you build properly: it covers the response envelope, the error codes worth branching on, idempotency, paging and rate limits. Then [register a webhook](https://virtual.ktap.io/docs/webhooks.md) so you stop polling. At fleet scale the calls that matter next are [POST /cards/balances](https://virtual.ktap.io/docs/api/cards.md#getBulkCardBalances) for balances, [POST /cards/{cardId}/unload](https://virtual.ktap.io/docs/api/cards.md#unloadCard) to pull money back without closing a card, and [GET /events](https://virtual.ktap.io/docs/webhooks.md#listEvents) to backfill anything your consumer missed. [Source](https://virtual.ktap.io/docs/quickstart.md) --- # 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 ``` > **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" } } ``` > **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. [Source](https://virtual.ktap.io/docs/essentials.md) --- # Account and balance Read your account, fund your balance, check your spendable balance and your rates, and set your markup. Your account, how you fund it, what you can spend, and what you are charged. ## How your balance works You send us funds and we credit your balance, charging one load fee at that point. Everything after that draws on the balance: a top-up moves value onto a card at 1:1 with no further fee, and closing a card sweeps what is left back. `GET /me` reports it under `float`. Two figures constrain a top-up and `available_usd` is the smaller of them. `balance_usd` is what you have funded, less everything drawn down. `limit_usd` is a ceiling we may set on how much card value you can have outstanding at once, and it is `null` when no ceiling applies. Fees reduce your balance but never consume limit headroom, because the limit is measured against card value alone. > **Read `available_usd`, do not derive it** > > The figure the limit arm subtracts is not returned by this endpoint, so you cannot recompute `available_usd` from the payload. A top-up above it returns `402` rather than clamping. Your rates are the ones on your agreement. Read them from `GET /fees` rather than hardcoding them, so a change reaches you without a deploy. The transaction rates there are a published rate card: reconcile against the fee each purchase actually carried: exactly on its `card.authorized` event, or to the cent on its row in [GET /cards/{id}/transactions](https://virtual.ktap.io/docs/api/ledger.md#listCardTransactions). Its settlement event does not repeat the fee. ## Fund your balance 1. Before your first transfer, tell us through your contact or the support channel which wallet or wallets you will send from. We register them to your account, and the same address counts on every network. A wallet can belong to one account only, so never name an exchange's hot wallet or a wallet that relays or solves bridge transfers; our own wallets and card deposit addresses cannot be registered either. Registration is handled by us and is not visible through the API. 2. Read your funding address from [GET /funding/addresses](https://virtual.ktap.io/docs/api/account.md#listFundingAddresses) just before you send. It lists each network you can fund on (Polygon, BNB Chain and Base) with the tokens accepted there (USDC and USDT), and is refreshed at least every 10 minutes. Never copy an address from these pages or send to a card address. 3. Send a listed token from a registered wallet, directly to the address listed for the network you send on, at least 10 USD per transfer. Credit is automatic only when exactly one wallet paid into your funding address in that transaction, that wallet is registered to your account (or, if your account is set to accept any wallet, from any wallet, such as an exchange), and it paid directly: the wallet itself sent the transaction straight to the token contract, or it is a smart wallet, such as a multisig, executing its own transfer. A gasless, bundled (ERC-4337) or relayed transfer, or a payment routed through another contract such as a router, batcher, bridge or solver, is not credited automatically. A transfer under 10 USD is never picked up, so it cannot be credited: it is rejected with reason `below_minimum`. 4. Submit the transaction hash and its network to [POST /funding](https://virtual.ktap.io/docs/api/account.md#submitFunding) as soon as the transaction is broadcast. We verify it on-chain immediately. 5. We credit your balance once the funds arrive: the amount sent on-chain, minus the load fee on your agreement. On Polygon this is usually within minutes. On BNB Chain and Base the funds are routed first and we credit only once your transaction is final on its network, so it can take several hours. Follow it with [GET /funding/{fundingId}](https://virtual.ktap.io/docs/api/account.md#getFunding) or the [`float.credited`](https://virtual.ktap.io/docs/webhooks/events.md#event-float-credited) webhook. | Status | Meaning | | --- | --- | | `pending` | Received. Either not yet found on its network, and re-checked every couple of minutes for 24 hours, or verified and waiting for the funds to arrive | | `credited` | Added to your balance and announced with `float.credited`. `GET /me` reflects it, and `GET /ledger` has a `funding` row referenced `funding--` plus its load fee row with `:fee` appended. With reason `credited_separately`, it was credited to your balance by other means after a review: `fee_usd` and `credited_usd` are null, `amount_usd` is still the amount sent, and there is no webhook for it | | `rejected` | Cannot be credited as submitted, or a credit we reversed. `reason` says why. Only `not_found` can be corrected, by submitting the hash again with the right network | | `review` | Verified, but not credited automatically: a person decides. `reason` says why. We resolve it: it then becomes `credited`, with the same webhook and ledger rows (for the amount sent, or no more than arrived when the two differ), `credited` with reason `credited_separately`, or `rejected` with reason `rejected_by_operator`. There is nothing to resubmit | | Reason | Status | Meaning | | --- | --- | --- | | `transaction_failed` | `rejected` | The transaction failed on-chain | | `not_a_funding_transfer` | `rejected` | Nothing was transferred to your funding address in that transaction on that network | | `unsupported_token` | `rejected` | A transfer reached your funding address, but in a token not accepted on that network | | `mixed_tokens` | `rejected` | The transaction sent both USDC and USDT to your funding address | | `below_minimum` | `rejected` | Less than 10 USD. It is never picked up, so it cannot be credited | | `unregistered_sender` | `rejected` | The only wallet that paid is registered to another account, so it is that account's money. Wallets registered to other accounts are rejected whether or not your account is set to accept any wallet | | `bridge_fill` | `rejected` | On any network: the transaction delivered bridged funds into the funding address, the arrival of a deposit made elsewhere. The depositor claims their own transaction on the network they sent from. If you bridged the funds yourself, contact us with both hashes | | `before_automatic_funding` | `rejected` | Sent before automatic funding started. Those transfers were settled separately | | `before_account_opened` | `rejected` | The transaction predates your account | | `not_found` | `rejected` | Not found on that network within 24 hours of your first submission, or still unreadable a day later. Submit the hash again, with the right network if it was wrong: it is looked up again | | `rejected_by_operator` | `rejected` | We reviewed it and it cannot be credited | | `reversed_by_operator` | `rejected` | It was credited, and we reversed the credit because it was not your money. See below | | `unregistered_sender` | `review` | The wallet that sent it is registered to no account. Tell us if it is yours. This does not arise if your account is set to accept any wallet, unless the wallet is registered to another account (which is rejected instead) | | `operator_sender` | `review` | One of our own wallets paid into the funding address in that transaction | | `multiple_senders` | `review` | More than one wallet paid into the funding address in that transaction, even when one of them is registered to another account | | `routed_transfer` | `review` | The wallet did not pay directly: the transfer was gasless, bundled (ERC-4337) or relayed, or routed through another contract such as a router, batcher, bridge or solver | | `precision` | `review` | The amount has more than 6 decimal places | | `unlisted_token` | `review` | That token is not listed on that network at the moment | | `not_arrived` | `review` | Verified, but no matching arrival within 72 hours of the transaction. Reviewed shortly after that window | | `ambiguous_match` | `review` | On Polygon: more than one arrival was reported for the same transaction | | `amount_mismatch` | `review` | The arrival reported for it differs from what the chain shows in token, amount or sending wallet | | `receipt_changed` | `review` | On BNB Chain or Base: the transaction is no longer on its chain as it was verified, or has been missing from it for over 2 hours | | `not_final` | `review` | On BNB Chain or Base: an arrival matched, but the transaction did not become final on its network within 6 hours | | `check_failed` | `review` | The chain could not be read for this submission long after its window | | `already_credited` | `review` | The same funds were already credited to a balance by other means | | `held_by_operator` | `review` | We held it, your account is suspended, or your account was credited for the same transaction before and that credit was reversed | | `credited_separately` | `credited` | After a review: the funds were credited to your balance by other means | Whose money it is is decided first, then the amount. A transaction that one of our own wallets paid into goes to `review` with reason `operator_sender`, and one that more than one wallet paid into goes to `review` with reason `multiple_senders`. Otherwise, when the only wallet that paid is registered to another account, it is `rejected` with reason `unregistered_sender`: it is that account's money. One from a wallet registered to nobody, or not paid directly, goes to `review`, and only then are the decimals and the token checked. Chain facts are read only from sources we trust, or confirmed by independent sources that agree, before anything is credited. Each transaction can be credited once. `POST /funding` needs no `Idempotency-Key`: submitting the same hash with the same network returns the same record, so a retry is always safe. If you named the wrong network, submit the hash again with the right one while it is still unverified (`pending` with a null `amount_usd`, or `rejected` with reason `not_found`): the same submission, under the same `id`, is looked up on that network. Resubmitting a hash rejected as `not_found` with the same network looks it up again too. The 24 hours run from your first submission. Once the transaction is verified, its network is fixed and a resubmission returns the same record. At most 5 of your submissions can be unverified at once: past that, a new submission answers `429 TOO_MANY_PENDING`, which carries no `Retry-After`, so wait until some are found or rejected as `not_found`. A correction of your own is still accepted. A network `GET /funding/addresses` does not list at the moment answers `400`, and nothing is stored. When the BNB Chain and Base addresses cannot be read, `GET /funding/addresses` answers `503`, and so does a submission on BNB Chain or Base; Polygon submissions keep working. A hash belongs to the account whose registered wallet sent the money, when it is the only wallet that paid and it was registered before any other account claimed the hash. That account's submission replaces any other account's claim on the same hash that has not been paid, whether `pending`, in `review` or `rejected`, including one we rejected. Failing that, the first account to submit the hash on the right network keeps it, and another account's claim that named another network and never verified there is replaced by one that verifies. A replaced claim disappears: it no longer appears in `GET /funding`, and `GET /funding/{fundingId}` answers `404` for it. A paid claim is never displaced. When 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 yours cannot be read on its network yet, you get `503 UNAVAILABLE`: retry shortly. Otherwise you get `409 ALREADY_SUBMITTED`, and retrying does not help: if the transaction is yours, check the network and that your sending wallet is registered to your account, then contact us. A transaction already credited to a balance by other means answers `409 ALREADY_CREDITED`. Funding on any network other than Polygon, BNB Chain and Base is settled by arrangement with us. If a credited submission turns out not to be your money, we can reverse it. Its `status` becomes `rejected` with reason `reversed_by_operator`, and `fee_usd` and `credited_usd` keep their original figures. `GET /ledger` gains a negative `funding` row referenced `funding-reversal--` and a positive `fee` row with `:fee` appended, which returns the load fee, so your balance drops by `credited_usd` and can go below zero. No webhook is sent for a reversal. ### GET /me Returns the account behind the key, the scopes the key carries, your lifetime card counts, your float broken down, a summary of your load rate, and the commission you have earned. This is the call to make first when wiring up an integration, and again whenever you need a current spendable balance. Everything it returns is scoped to your own account. **Scope** `users:read` **Returns** Account, key, cards, float, load-fee summary and earnings, as `200`. **Response 200** Account, key, cards, float, load-fee summary and earnings | Field | Type | | Description | | --- | --- | --- | --- | |   `id` | string | optional | Your account id, a UUID | |   `name` | string, nullable | optional | Your registered business name | |   `contact_email` | string, nullable | optional | The address we hold for your account | |   `created_at` | string, nullable | optional | ISO 8601 UTC timestamp of when your account was created | |   `name` | string | optional | The label we gave this key when we issued it | |   `prefix` | string | optional | The public half of the key, in the form ktap_live_ plus 8 hex characters | |   `scopes` | array of string | optional | Exactly what this key may do. A call outside these returns 403 One of: `users:read`, `users:write`, `cards:read`, `cards:write`, `cards:pan`, `deposits:read`, `deposits:write`. | |   `mode` | string | optional | Always live. Every key we issue reaches the real card processor One of: `live`, `test`. | |   `rate_limit_per_minute` | integer | optional | Requests this key may make per rolling minute, 120 unless we agreed otherwise | |   `total` | integer | optional | Cards you have ever ordered, closed ones included. This is the number the allowance is measured against | |   `active` | integer | optional | Cards whose status is not DELETED. Frozen and blocked cards count here | |   `limit` | integer, nullable | optional | Your lifetime issuance allowance. Null means no cap | |   `funded_usd` | number | optional | Everything you have sent us, before any fee | |   `spent_usd` | number | optional | Everything drawn down: load fees, monthly card fees, and net card top-ups | |   `balance_usd` | number | optional | funded_usd minus spent_usd | |   `limit_usd` | number, nullable | optional | A ceiling we set on card value. Null means no ceiling | |   `available_usd` | number | optional | What a top-up can draw on right now, never below 0 | |   `ktap_pct` | number | optional | Your load rate on float funding, as a fraction | |   `your_markup_pct` | number | optional | Your own markup, set with PUT /fees. It applies to crypto deposits only | |   `total_pct` | number | optional | fees.ktap_pct plus fees.your_markup_pct. No single charge equals this sum | |     `ktap_fee_usd` | number | optional | The KTAP load fee on $100 funded from float | |     `your_fee_usd` | number | optional | Your markup applied to $100 | |     `lands_on_card_usd` | number | optional | $100 less both figures above | |   `accrued_usd` | number | optional | Every positive commission row, ever | |   `paid_out_usd` | number | optional | Every payout we have recorded against you | |   `owed_usd` | number | optional | accrued_usd minus paid_out_usd, the amount still payable | |   `last_payout_at` | string, nullable | optional | ISO 8601 UTC timestamp of the most recent payout row, null if you have never been paid | ```json { "data": { "partner": { "id": "7c1f4b2e-9a63-4d18-b0f5-2ea84c9d1367", "name": "Acme Ltd", "contact_email": "ops@acme.example", "created_at": "2026-03-14T09:22:41.518Z" }, "key": { "name": "Production server", "prefix": "ktap_live_ef27ebea", "scopes": [ "users:read", "users:write", "cards:read", "cards:write" ], "mode": "live", "rate_limit_per_minute": 120 }, "cards": { "total": 128, "active": 121, "limit": 250 }, "float": { "funded_usd": 50000, "spent_usd": 18495.55, "balance_usd": 31504.45, "limit_usd": 20000, "available_usd": 1854.45 }, "fees": { "ktap_pct": 0.01, "your_markup_pct": 0.02, "total_pct": 0.03, "example_on_100_usd": { "ktap_fee_usd": 1, "your_fee_usd": 2, "lands_on_card_usd": 97 } }, "earnings": { "accrued_usd": 412.5, "paid_out_usd": 300, "owed_usd": 112.5, "last_payout_at": "2026-08-01T10:00:00.000Z" } } } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 401 | `UNAUTHORIZED` | The key is missing, unknown, revoked, or your account is suspended. All four are reported identically | No. Fix the credential | | 403 | `FORBIDDEN` | The key is valid but does not carry users:read | No. Ask us for a key with the scope | | 405 | `BAD_REQUEST` | You send POST, PUT, PATCH or DELETE to /me | No. Use GET | | 429 | `RATE_LIMITED` | Your key made rate_limit_per_minute requests in the trailing minute | Yes, after the 60 seconds Retry-After names | | 500 | `UNAVAILABLE` | An internal fault, such as a database error | Yes, shortly | **Notes** - `float.available_usd` is the only figure a top-up is checked against. It is the smaller of your balance and any spending limit we have set, floored at zero. - You cannot recompute `available_usd` from this payload, because the figure it subtracts is not returned here. Read it, do not derive it. When `float.limit_usd` is `null` it equals `balance_usd`. - `cards.total` counts every card you have ever ordered. Closing a card does not return capacity, so your remaining allowance is `cards.limit` minus `cards.total`. ### GET /funding/addresses Returns the funding address for each network you can fund your balance on, with the tokens it accepts there, refreshed at least every 10 minutes. Only networks whose transaction hashes POST /funding can verify and credit are listed, and only addresses that are currently enabled. Send only a token listed here, from a wallet you have registered with us, to the address listed for the network you send on, and at least 10 USD per transfer. **Scope** `users:read` **Returns** The funding addresses, at most one per network, in the order POLYGON, BNB, BASE, as `200`. **Response 200** The funding addresses, at most one per network, in the order POLYGON, BNB, BASE | Field | Type | | Description | | --- | --- | --- | --- | | `addresses` | array | optional | One entry per network you can fund on. A network that is not listed cannot be credited through POST /funding | |   `network` | string | optional | Name this network when you submit the hash to POST /funding One of: `POLYGON`, `BNB`, `BASE`. | |   `address` | string | optional | Where to send on this network. Compare addresses case-insensitively | |   `tokens` | array | optional | The stablecoins this address accepts on this network, each USDC or USDT. Send nothing else | ```json { "data": { "addresses": [ { "network": "POLYGON", "address": "0x5c1E8A3D7f24B690e1a4C8d3F7B25e906A1D4c8E", "tokens": ["USDC", "USDT"] }, { "network": "BNB", "address": "0x5c1E8A3D7f24B690e1a4C8d3F7B25e906A1D4c8E", "tokens": ["USDC", "USDT"] }, { "network": "BASE", "address": "0x5c1E8A3D7f24B690e1a4C8d3F7B25e906A1D4c8E", "tokens": ["USDC", "USDT"] } ] } } ``` **Response 503** The funding addresses could not be read just now, including when only the BNB Chain and Base addresses could not be read. No address is returned ```json { "error": "Funding addresses are temporarily unavailable", "code": "UNAVAILABLE" } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 401 | `UNAUTHORIZED` | The key is missing, unknown, revoked, or your account is suspended | No. Fix the credential | | 403 | `FORBIDDEN` | The key is valid but does not carry users:read | No. Ask us for a key with the scope | | 405 | `BAD_REQUEST` | Any method other than GET is used on this path | No. Use GET | | 429 | `RATE_LIMITED` | Your key exceeded its per-minute allowance | Yes, after the 60 seconds Retry-After names | | 503 | `UNAVAILABLE` | The funding addresses, or only the BNB Chain and Base ones, could not be read. We never answer with a guessed address instead | Yes, shortly. Do not send funds until it answers | **Notes** - Read the addresses before each transfer rather than storing them. They are refreshed at least every 10 minutes, and a network or token that is not listed at that moment cannot be credited through `POST /funding`. - Send at least 10 USD per transfer, from a wallet you have registered with us. A smaller transfer is never picked up, so it cannot be credited: `POST /funding` rejects it with reason `below_minimum`. - A `503` returns no address at all: wait and retry, and do not send until it answers. It never falls back to a guessed address, and it also answers `503` when only the BNB Chain and Base addresses cannot be read. The address in the example is illustrative: never send funds to an address copied from this documentation. ### POST /funding Send USDC or USDT to the funding address GET /funding/addresses returns for the network you send on, then submit the transaction hash here. We verify it on-chain immediately, then credit your balance once the funds arrive: the amount sent on-chain, minus the load fee on your agreement. On Polygon this is usually within minutes. On BNB Chain and Base the funds are routed first and we credit only once your transaction is final on its network, so it can take several hours. Before your first transfer, tell us through your contact or the support channel which wallet or wallets you send from. A transfer is credited automatically only when exactly one wallet paid into your funding address in that transaction, that wallet is registered to your account (or, if your account is set to accept any wallet, from any wallet, such as an exchange), and it paid directly: the wallet itself sent the transaction straight to the token contract, or it is a smart wallet, such as a multisig, executing its own transfer. A gasless, bundled (ERC-4337) or relayed transfer goes to review. Anything else goes to review or is rejected. Accepted: USDC and USDT on Polygon, BNB Chain and Base, as GET /funding/addresses lists them, at least 10 USD per transfer, always sent to your funding address and never to a card address. No Idempotency-Key is needed: the call is idempotent on the transaction hash. Each transaction can be credited once. **Scope** `users:write` **Headers** | Header | | Description | | --- | --- | --- | | `Content-Type` | optional | application/json. The body is parsed as JSON whatever you send | **Request body** | Field | Type | | Description | | --- | --- | --- | --- | | `tx_hash` | string | required | The hash of the transaction that sent the funds to your funding address 0x followed by 64 hex characters, in any case. Stored lowercase. | | `network` | string | required | The network the transaction was made on. BSC is accepted as another name for BNB, and case does not matter One of: `POLYGON`, `BNB`, `BASE`. | ```json { "tx_hash": "0x4a7e2c9d1b8f3e6a5c0d7b2e9f4a1c8d3e6b0f5a2d9c7e4b1a8f6d3c0e5b2a91", "network": "POLYGON" } ``` **Returns** The hash is recorded. Submitting the same hash again returns this same record; one that corrects the network of an unverified submission returns it looked up on that network, with the same id. Read status: a 200 can already be credited, rejected when the transaction fails verification, or in review when it verified but cannot be credited automatically, as `200`. **Response 200** The hash is recorded. Submitting the same hash again returns this same record; one that corrects the network of an unverified submission returns it looked up on that network, with the same id. Read status: a 200 can already be credited, rejected when the transaction fails verification, or in review when it verified but cannot be credited automatically | Field | Type | | Description | | --- | --- | --- | --- | | `id` | string | optional | The submission's id, a UUID. Read it back with GET /funding/{fundingId}. It is funding_id on the float.credited webhook | | `network` | string | optional | The network the hash is checked on. BSC is stored as BNB. It can be corrected while the submission is unverified, and is fixed once the transaction is verified One of: `POLYGON`, `BNB`, `BASE`. | | `tx_hash` | string | optional | The transaction hash, lowercased | | `status` | string | optional | pending: received, and either not yet found on its network (re-checked every couple of minutes for 24 hours) or verified and waiting for the funds to arrive. credited: added to your balance, and float.credited is sent. rejected: cannot be credited as submitted, or a credit we reversed, see reason; only not_found can be corrected, by submitting the hash again with the right network. review: verified but not credited automatically, see reason; a person decides. We resolve it: it then becomes credited (with reason credited_separately if the funds were credited to your balance by other means), or rejected with reason rejected_by_operator. There is nothing to resubmit One of: `pending`, `credited`, `rejected`, `review`. | | `reason` | string, nullable | optional | Why a submission is rejected, in review or credited separately, null otherwise. Whose money it is is decided first, then the amount. Rejected: transaction_failed (it failed on-chain), not_a_funding_transfer (nothing was transferred to your funding address in that transaction on that network), unsupported_token (a transfer reached your funding address, but in a token that is not accepted on that network), mixed_tokens (it sent both USDC and USDT to your funding address), below_minimum (less than 10 USD, which is never picked up), unregistered_sender (the only wallet that paid is registered to another account, so it is that account's money), bridge_fill (on any network, the transaction delivered bridged funds into the funding address, the arrival of a deposit made elsewhere; the depositor claims their own transaction on the network they sent from, and if you bridged the funds yourself, contact us with both hashes), before_automatic_funding (sent before automatic funding started; those transfers were settled separately), before_account_opened (it predates your account), not_found (not found on that network within 24 hours of your first submission, or still unreadable a day later; submitting it again looks it up again), rejected_by_operator (we reviewed it and it cannot be credited), reversed_by_operator (it was credited and we reversed the credit because it was not your money; fee_usd and credited_usd keep their figures). Review: unregistered_sender (the wallet that sent it is registered to no account), operator_sender (one of our own wallets paid into the funding address in that transaction), multiple_senders (more than one wallet paid into the funding address in that transaction, even when one of them is registered to another account), routed_transfer (the wallet did not pay directly: the transfer was gasless, bundled (ERC-4337) or relayed, or routed through another contract such as a router, batcher, bridge or solver), precision (the amount has more than 6 decimal places), unlisted_token (that token is not listed on that network at the moment), not_arrived (verified, but no matching arrival within 72 hours of the transaction), ambiguous_match (on Polygon, more than one arrival was reported for the same transaction), amount_mismatch (the arrival reported for it differs from what the chain shows in token, amount or sending wallet), receipt_changed (on BNB Chain or Base, the transaction is no longer on its chain as it was verified, or has been missing from it for over 2 hours), not_final (on BNB Chain or Base, an arrival matched but the transaction did not become final on its network within 6 hours), check_failed (the chain could not be read for this submission long after its window), already_credited (the same funds were already credited to a balance by other means), held_by_operator (we held it, your account is suspended, or your account was credited for the same transaction before and that credit was reversed). Credited: credited_separately (after a review, the funds were credited to your balance by other means; fee_usd and credited_usd are null, amount_usd is still the amount sent, and no webhook is sent) One of: `transaction_failed`, `not_a_funding_transfer`, `unsupported_token`, `mixed_tokens`, `below_minimum`, `bridge_fill`, `before_automatic_funding`, `before_account_opened`, `not_found`, `rejected_by_operator`, `reversed_by_operator`, `precision`, `multiple_senders`, `operator_sender`, `unregistered_sender`, `routed_transfer`, `unlisted_token`, `not_arrived`, `ambiguous_match`, `amount_mismatch`, `receipt_changed`, `not_final`, `check_failed`, `already_credited`, `held_by_operator`, `credited_separately`. | | `token` | string, nullable | optional | The stablecoin sent. Null until the transaction has been read on-chain One of: `USDC`, `USDT`. | | `amount_usd` | number, nullable | optional | What the transaction paid into your funding address, as read on-chain: always the amount sent, even when a review credits less. Several transfers of one token in the same transaction are summed. The ledger keeps up to 10 decimal places, so an amount with more than 6 goes to review with reason precision rather than being rounded. Null until verified | | `fee_usd` | number, nullable | optional | The load fee on your agreement, charged on the amount credited: amount_usd, or after a review no more than arrived if less arrived. The same figure as fee_usd on float.credited. Null until credited, and on a credited_separately submission. A reversal keeps it | | `credited_usd` | number, nullable | optional | The amount credited minus fee_usd: what reached your balance, the same figure as credited_usd on float.credited. Null until credited, and on a credited_separately submission. A reversal keeps it, and takes this amount back off your balance | | `created_at` | string | optional | When this submission was first recorded, ISO 8601 UTC. A network correction keeps it | | `credited_at` | string, nullable | optional | When it was credited, ISO 8601 UTC. Null until then | ```json { "data": { "id": "3b9e6f1a-7c42-4d85-a1e0-5f2c8d9b4e63", "network": "POLYGON", "tx_hash": "0x4a7e2c9d1b8f3e6a5c0d7b2e9f4a1c8d3e6b0f5a2d9c7e4b1a8f6d3c0e5b2a91", "status": "pending", "reason": null, "token": "USDC", "amount_usd": 2500.25, "fee_usd": null, "credited_usd": null, "created_at": "2026-09-23T10:15:02.114Z", "credited_at": null } } ``` **Response 400** The body, the hash or the network failed validation, or GET /funding/addresses does not list the network at the moment. Nothing was recorded ```json { "error": "tx_hash must be a 0x-prefixed 64-character transaction hash", "code": "BAD_REQUEST" } ``` **Response 409** Another account holds this hash and keeps it: its claim has been paid, or it submitted the hash first on the same network and your registered wallet was not the only wallet that paid, or its claim names another network and your transaction cannot be credited on yours. Retrying does not help (a 503 UNAVAILABLE, by contrast, means retry shortly) ```json { "error": "This transaction has already been submitted", "code": "ALREADY_SUBMITTED" } ``` **Response 409** This transaction was already credited to a balance by other means ```json { "error": "This transaction has already been credited", "code": "ALREADY_CREDITED" } ``` **Response 429** 5 of your submissions are still not found on their network. Nothing was recorded. It carries no Retry-After header ```json { "error": "You have 5 submissions not yet found on their network. Wait until some are found or refused before submitting more.", "code": "TOO_MANY_PENDING" } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 400 | `BAD_REQUEST` | The body is not valid JSON or not a JSON object, tx_hash is missing or not 0x plus 64 hex characters, network is missing or not one of POLYGON, BNB, BSC and BASE, or GET /funding/addresses does not list that network at the moment | Nothing was recorded. Fix the request and send it again | | 401 | `UNAUTHORIZED` | The key is missing, unknown, revoked, or your account is suspended | No. Fix the credential | | 403 | `FORBIDDEN` | The key is valid but does not carry users:write | No. Ask us for a key with the scope | | 405 | `BAD_REQUEST` | You sent PUT, PATCH or DELETE to /funding | No. POST submits a hash and GET lists your submissions | | 409 | `ALREADY_SUBMITTED` | Another account holds this transaction hash and keeps it: its claim has been paid, 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 to your account, then contact us with the hash | | 409 | `ALREADY_CREDITED` | The transaction was already credited to a balance by other means | No. It has been credited once; find it on GET /ledger | | 429 | `RATE_LIMITED` | Your key exceeded its per-minute allowance | Yes, after the 60 seconds Retry-After names | | 429 | `TOO_MANY_PENDING` | 5 of your submissions are still not found on their network. A correction of your own submission is still accepted | After some are found, or rejected as not_found 24 hours after their first submission. There is no Retry-After header | | 500 | `UNAVAILABLE` | An internal fault. A network that is slow or cannot be read leaves the submission pending instead | Yes, with the same hash. A repeat returns the stored record if the first call got that far | | 503 | `UNAVAILABLE` | The funding addresses could not be read just now, so the hash could not be checked. When only the BNB Chain and Base addresses cannot be read, only a submission on those networks gets this and Polygon keeps working. Also when 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 cannot be read on its network yet, so it could not be verified to replace that claim. Nothing was recorded | Yes, with the same hash, shortly | **Notes** - A `200` means the hash is recorded, not that it is credited. Read `status`, then poll [GET /funding/{fundingId}](https://virtual.ktap.io/docs/api/account.md#getFunding) or listen for [`float.credited`](https://virtual.ktap.io/docs/webhooks/events.md#event-float-credited). Tell us which wallets you send from before your first transfer: credit is automatic only when exactly one wallet paid into your funding address, it is registered to your account (or, if your account is set to accept any wallet, from any wallet, such as an exchange), and it paid directly: it sent the transaction straight to the token contract itself or, as a smart wallet such as a multisig, executed its own transfer. When the only wallet that paid is another account's registered wallet, it is `rejected` with reason `unregistered_sender`; one from an unregistered wallet, or gasless, bundled (ERC-4337), relayed or routed through another contract (`routed_transfer`), goes to `review`. - No `Idempotency-Key` is needed: submitting is idempotent on the hash, so retry a timeout or a `5xx` with the same hash and network. Named the wrong network? While your submission is unverified (`pending` with a null `amount_usd`, or `rejected` with reason `not_found`), submit the same hash with the right network: the same submission, same `id`, is looked up there. The 24 hours run from your first submission, so correct it early. Once the transaction is verified its network is fixed. At most 5 of your submissions can be unverified at once: past that, a new submission answers `429 TOO_MANY_PENDING`, with no `Retry-After`; a correction is still accepted. - Send only a token [GET /funding/addresses](https://virtual.ktap.io/docs/api/account.md#listFundingAddresses) lists for the network, only to the address it lists, at least 10 USD per transfer, and never to a card address, then submit as soon as the transaction is broadcast. A hash belongs to the account whose registered wallet alone sent the money (registered before any other account claimed the hash), and its submission replaces another account's unpaid claim; failing that, the first account to submit it on the right network keeps it. A paid claim is never displaced. `503 UNAVAILABLE` 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 yours could not be verified yet: retry shortly. Retrying `409 ALREADY_SUBMITTED` does not help: check the network and that your sending wallet is registered, then contact us. A transaction that delivered bridged funds into the funding address, on any network, is `rejected` with reason `bridge_fill`: if you bridged them yourself, contact us with both hashes. ### GET /funding/{fundingId} Returns one submission to POST /funding and where it stands. Poll it until status is credited or rejected, or listen for the float.credited webhook instead. A submission in review is resolved by us and then becomes one of those two. A credited submission that was not your money can later be reversed by us, and then reads rejected with reason reversed_by_operator. **Scope** `users:read` **Path parameters** | Parameter | Type | | Description | | --- | --- | --- | --- | | `fundingId` | string | optional | The id POST /funding returned. A submission belonging to another account returns 404, the same as an id that does not exist A UUID. Anything else returns 404. | **Returns** The submission, as `200`. **Response 200** The submission | Field | Type | | Description | | --- | --- | --- | --- | | `id` | string | optional | The submission's id, a UUID. Read it back with GET /funding/{fundingId}. It is funding_id on the float.credited webhook | | `network` | string | optional | The network the hash is checked on. BSC is stored as BNB. It can be corrected while the submission is unverified, and is fixed once the transaction is verified One of: `POLYGON`, `BNB`, `BASE`. | | `tx_hash` | string | optional | The transaction hash, lowercased | | `status` | string | optional | pending: received, and either not yet found on its network (re-checked every couple of minutes for 24 hours) or verified and waiting for the funds to arrive. credited: added to your balance, and float.credited is sent. rejected: cannot be credited as submitted, or a credit we reversed, see reason; only not_found can be corrected, by submitting the hash again with the right network. review: verified but not credited automatically, see reason; a person decides. We resolve it: it then becomes credited (with reason credited_separately if the funds were credited to your balance by other means), or rejected with reason rejected_by_operator. There is nothing to resubmit One of: `pending`, `credited`, `rejected`, `review`. | | `reason` | string, nullable | optional | Why a submission is rejected, in review or credited separately, null otherwise. Whose money it is is decided first, then the amount. Rejected: transaction_failed (it failed on-chain), not_a_funding_transfer (nothing was transferred to your funding address in that transaction on that network), unsupported_token (a transfer reached your funding address, but in a token that is not accepted on that network), mixed_tokens (it sent both USDC and USDT to your funding address), below_minimum (less than 10 USD, which is never picked up), unregistered_sender (the only wallet that paid is registered to another account, so it is that account's money), bridge_fill (on any network, the transaction delivered bridged funds into the funding address, the arrival of a deposit made elsewhere; the depositor claims their own transaction on the network they sent from, and if you bridged the funds yourself, contact us with both hashes), before_automatic_funding (sent before automatic funding started; those transfers were settled separately), before_account_opened (it predates your account), not_found (not found on that network within 24 hours of your first submission, or still unreadable a day later; submitting it again looks it up again), rejected_by_operator (we reviewed it and it cannot be credited), reversed_by_operator (it was credited and we reversed the credit because it was not your money; fee_usd and credited_usd keep their figures). Review: unregistered_sender (the wallet that sent it is registered to no account), operator_sender (one of our own wallets paid into the funding address in that transaction), multiple_senders (more than one wallet paid into the funding address in that transaction, even when one of them is registered to another account), routed_transfer (the wallet did not pay directly: the transfer was gasless, bundled (ERC-4337) or relayed, or routed through another contract such as a router, batcher, bridge or solver), precision (the amount has more than 6 decimal places), unlisted_token (that token is not listed on that network at the moment), not_arrived (verified, but no matching arrival within 72 hours of the transaction), ambiguous_match (on Polygon, more than one arrival was reported for the same transaction), amount_mismatch (the arrival reported for it differs from what the chain shows in token, amount or sending wallet), receipt_changed (on BNB Chain or Base, the transaction is no longer on its chain as it was verified, or has been missing from it for over 2 hours), not_final (on BNB Chain or Base, an arrival matched but the transaction did not become final on its network within 6 hours), check_failed (the chain could not be read for this submission long after its window), already_credited (the same funds were already credited to a balance by other means), held_by_operator (we held it, your account is suspended, or your account was credited for the same transaction before and that credit was reversed). Credited: credited_separately (after a review, the funds were credited to your balance by other means; fee_usd and credited_usd are null, amount_usd is still the amount sent, and no webhook is sent) One of: `transaction_failed`, `not_a_funding_transfer`, `unsupported_token`, `mixed_tokens`, `below_minimum`, `bridge_fill`, `before_automatic_funding`, `before_account_opened`, `not_found`, `rejected_by_operator`, `reversed_by_operator`, `precision`, `multiple_senders`, `operator_sender`, `unregistered_sender`, `routed_transfer`, `unlisted_token`, `not_arrived`, `ambiguous_match`, `amount_mismatch`, `receipt_changed`, `not_final`, `check_failed`, `already_credited`, `held_by_operator`, `credited_separately`. | | `token` | string, nullable | optional | The stablecoin sent. Null until the transaction has been read on-chain One of: `USDC`, `USDT`. | | `amount_usd` | number, nullable | optional | What the transaction paid into your funding address, as read on-chain: always the amount sent, even when a review credits less. Several transfers of one token in the same transaction are summed. The ledger keeps up to 10 decimal places, so an amount with more than 6 goes to review with reason precision rather than being rounded. Null until verified | | `fee_usd` | number, nullable | optional | The load fee on your agreement, charged on the amount credited: amount_usd, or after a review no more than arrived if less arrived. The same figure as fee_usd on float.credited. Null until credited, and on a credited_separately submission. A reversal keeps it | | `credited_usd` | number, nullable | optional | The amount credited minus fee_usd: what reached your balance, the same figure as credited_usd on float.credited. Null until credited, and on a credited_separately submission. A reversal keeps it, and takes this amount back off your balance | | `created_at` | string | optional | When this submission was first recorded, ISO 8601 UTC. A network correction keeps it | | `credited_at` | string, nullable | optional | When it was credited, ISO 8601 UTC. Null until then | ```json { "data": { "id": "3b9e6f1a-7c42-4d85-a1e0-5f2c8d9b4e63", "network": "POLYGON", "tx_hash": "0x4a7e2c9d1b8f3e6a5c0d7b2e9f4a1c8d3e6b0f5a2d9c7e4b1a8f6d3c0e5b2a91", "status": "credited", "reason": null, "token": "USDC", "amount_usd": 2500.25, "fee_usd": 25.0025, "credited_usd": 2475.2475, "created_at": "2026-09-23T10:15:02.114Z", "credited_at": "2026-09-23T10:17:40.502Z" } } ``` **Response 404** No submission with that id belongs to you, or it was replaced by another account's claim: that account's registered wallet sent the money, or its claim verified on the right network ```json { "error": "Funding not found", "code": "NOT_FOUND" } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 401 | `UNAUTHORIZED` | The key is missing, unknown, revoked, or your account is suspended | No. Fix the credential | | 403 | `FORBIDDEN` | The key is valid but does not carry users:read | No. Ask us for a key with the scope | | 404 | `NOT_FOUND` | The id is unknown, is not a UUID, belongs to another account, or the submission was replaced by another account's claim | No. Check the id | | 405 | `BAD_REQUEST` | Any method other than GET is used on this path | No. Use GET | | 429 | `RATE_LIMITED` | Your key exceeded its per-minute allowance | Yes, after the 60 seconds Retry-After names | **Notes** - Once `status` is `credited`, `credited_usd` is what reached your balance and `GET /me` already reflects it. `amount_usd` here is always the amount sent on-chain, while `float.credited` carries the amount credited, which after a review can be lower; `fee_usd` and `credited_usd` match the webhook. - A `review` status is ours to resolve: the submission verified but could not be credited automatically, and `reason` says why. It then becomes `credited`, with a `float.credited` webhook; `credited` with reason `credited_separately` if the funds were credited to your balance by other means (no webhook, null `fee_usd` and `credited_usd`, `amount_usd` still the amount sent); or `rejected` with reason `rejected_by_operator`. Do not resubmit it. - A `credited` submission that was not your money can later become `rejected` with reason `reversed_by_operator`. `fee_usd` and `credited_usd` keep their figures, `GET /ledger` gains a negative `funding` row referenced `funding-reversal--` and a positive `:fee` row, and your balance drops by `credited_usd`. No webhook is sent. ### GET /funding Returns your 50 most recent submissions to POST /funding, newest first, each with its status. There is no paging. **Scope** `users:read` **Returns** Your submissions, newest first, as `200`. **Response 200** Your submissions, newest first | Field | Type | | Description | | --- | --- | --- | --- | | `fundings` | array | optional | At most 50 submissions, ordered by created_at descending | |   `id` | string | optional | The submission's id, a UUID. Read it back with GET /funding/{fundingId}. It is funding_id on the float.credited webhook | |   `network` | string | optional | The network the hash is checked on. BSC is stored as BNB. It can be corrected while the submission is unverified, and is fixed once the transaction is verified One of: `POLYGON`, `BNB`, `BASE`. | |   `tx_hash` | string | optional | The transaction hash, lowercased | |   `status` | string | optional | pending: received, and either not yet found on its network (re-checked every couple of minutes for 24 hours) or verified and waiting for the funds to arrive. credited: added to your balance, and float.credited is sent. rejected: cannot be credited as submitted, or a credit we reversed, see reason; only not_found can be corrected, by submitting the hash again with the right network. review: verified but not credited automatically, see reason; a person decides. We resolve it: it then becomes credited (with reason credited_separately if the funds were credited to your balance by other means), or rejected with reason rejected_by_operator. There is nothing to resubmit One of: `pending`, `credited`, `rejected`, `review`. | |   `reason` | string, nullable | optional | Why a submission is rejected, in review or credited separately, null otherwise. Whose money it is is decided first, then the amount. Rejected: transaction_failed (it failed on-chain), not_a_funding_transfer (nothing was transferred to your funding address in that transaction on that network), unsupported_token (a transfer reached your funding address, but in a token that is not accepted on that network), mixed_tokens (it sent both USDC and USDT to your funding address), below_minimum (less than 10 USD, which is never picked up), unregistered_sender (the only wallet that paid is registered to another account, so it is that account's money), bridge_fill (on any network, the transaction delivered bridged funds into the funding address, the arrival of a deposit made elsewhere; the depositor claims their own transaction on the network they sent from, and if you bridged the funds yourself, contact us with both hashes), before_automatic_funding (sent before automatic funding started; those transfers were settled separately), before_account_opened (it predates your account), not_found (not found on that network within 24 hours of your first submission, or still unreadable a day later; submitting it again looks it up again), rejected_by_operator (we reviewed it and it cannot be credited), reversed_by_operator (it was credited and we reversed the credit because it was not your money; fee_usd and credited_usd keep their figures). Review: unregistered_sender (the wallet that sent it is registered to no account), operator_sender (one of our own wallets paid into the funding address in that transaction), multiple_senders (more than one wallet paid into the funding address in that transaction, even when one of them is registered to another account), routed_transfer (the wallet did not pay directly: the transfer was gasless, bundled (ERC-4337) or relayed, or routed through another contract such as a router, batcher, bridge or solver), precision (the amount has more than 6 decimal places), unlisted_token (that token is not listed on that network at the moment), not_arrived (verified, but no matching arrival within 72 hours of the transaction), ambiguous_match (on Polygon, more than one arrival was reported for the same transaction), amount_mismatch (the arrival reported for it differs from what the chain shows in token, amount or sending wallet), receipt_changed (on BNB Chain or Base, the transaction is no longer on its chain as it was verified, or has been missing from it for over 2 hours), not_final (on BNB Chain or Base, an arrival matched but the transaction did not become final on its network within 6 hours), check_failed (the chain could not be read for this submission long after its window), already_credited (the same funds were already credited to a balance by other means), held_by_operator (we held it, your account is suspended, or your account was credited for the same transaction before and that credit was reversed). Credited: credited_separately (after a review, the funds were credited to your balance by other means; fee_usd and credited_usd are null, amount_usd is still the amount sent, and no webhook is sent) One of: `transaction_failed`, `not_a_funding_transfer`, `unsupported_token`, `mixed_tokens`, `below_minimum`, `bridge_fill`, `before_automatic_funding`, `before_account_opened`, `not_found`, `rejected_by_operator`, `reversed_by_operator`, `precision`, `multiple_senders`, `operator_sender`, `unregistered_sender`, `routed_transfer`, `unlisted_token`, `not_arrived`, `ambiguous_match`, `amount_mismatch`, `receipt_changed`, `not_final`, `check_failed`, `already_credited`, `held_by_operator`, `credited_separately`. | |   `token` | string, nullable | optional | The stablecoin sent. Null until the transaction has been read on-chain One of: `USDC`, `USDT`. | |   `amount_usd` | number, nullable | optional | What the transaction paid into your funding address, as read on-chain: always the amount sent, even when a review credits less. Several transfers of one token in the same transaction are summed. The ledger keeps up to 10 decimal places, so an amount with more than 6 goes to review with reason precision rather than being rounded. Null until verified | |   `fee_usd` | number, nullable | optional | The load fee on your agreement, charged on the amount credited: amount_usd, or after a review no more than arrived if less arrived. The same figure as fee_usd on float.credited. Null until credited, and on a credited_separately submission. A reversal keeps it | |   `credited_usd` | number, nullable | optional | The amount credited minus fee_usd: what reached your balance, the same figure as credited_usd on float.credited. Null until credited, and on a credited_separately submission. A reversal keeps it, and takes this amount back off your balance | |   `created_at` | string | optional | When this submission was first recorded, ISO 8601 UTC. A network correction keeps it | |   `credited_at` | string, nullable | optional | When it was credited, ISO 8601 UTC. Null until then | | `count` | integer | optional | Rows returned, at most 50 | ```json { "data": { "fundings": [ { "id": "3b9e6f1a-7c42-4d85-a1e0-5f2c8d9b4e63", "network": "POLYGON", "tx_hash": "0x4a7e2c9d1b8f3e6a5c0d7b2e9f4a1c8d3e6b0f5a2d9c7e4b1a8f6d3c0e5b2a91", "status": "credited", "reason": null, "token": "USDC", "amount_usd": 2500.25, "fee_usd": 25.0025, "credited_usd": 2475.2475, "created_at": "2026-09-23T10:15:02.114Z", "credited_at": "2026-09-23T10:17:40.502Z" }, { "id": "8d4a2c7e-1f93-4b6d-9e05-c3a7b1f8d240", "network": "BNB", "tx_hash": "0x1f8c3e5a7d2b9046c1e8a3f5d7b2c9e4a6f1d8b3c5e7a2f9d4b6c1e8a3f50246", "status": "rejected", "reason": "not_a_funding_transfer", "token": null, "amount_usd": null, "fee_usd": null, "credited_usd": null, "created_at": "2026-09-22T16:40:11.308Z", "credited_at": null } ], "count": 2 } } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 401 | `UNAUTHORIZED` | The key is missing, unknown, revoked, or your account is suspended | No. Fix the credential | | 403 | `FORBIDDEN` | The key is valid but does not carry users:read | No. Ask us for a key with the scope | | 429 | `RATE_LIMITED` | Your key exceeded its per-minute allowance | Yes, after the 60 seconds Retry-After names | | 500 | `UNAVAILABLE` | An internal fault, such as a database error | Yes, shortly. The endpoint is read-only | **Notes** - Returns your 50 most recent submissions and does not page. Keep the `id` each `POST /funding` returns and read older submissions back by id. - A submission replaced by another account's claim, because that account's registered wallet sent the money or its claim verified on the right network, disappears from this list, and reading it by id answers `404`. ### GET /fees Returns your load rates, your own markup, and the card usage rates. Read your rates from here rather than hardcoding them, so a change on our side reaches you without a deploy. GET and PUT return the identical payload shape. **Scope** `users:read` **Returns** Load rates, your markup, and the card usage schedule, as `200`. **Response 200** Load rates, your markup, and the card usage schedule | Field | Type | | Description | | --- | --- | --- | --- | | `ktap_pct` | number | optional | Your load rate when you fund your float, as a fraction. Identical to load_from_float_pct | | `load_from_float_pct` | number | optional | The rate we charge once, when you send us funds. Card top-ups are then 1:1 | | `load_from_user_deposit_pct` | number | optional | The rate on a load your end user pays us in crypto directly. Applies only if deposits are enabled on your account | | `your_markup_pct` | number | optional | Your own markup on loads, set with PUT /fees. Charged on the deposit route only | | `total_pct` | number | optional | load_from_float_pct plus your_markup_pct. This sum is not charged on any single route | |   `ktap_fee_usd` | number | optional | $100 times load_from_float_pct | |   `your_fee_usd` | number | optional | $100 times your_markup_pct | |   `lands_on_card_usd` | number | optional | $100 less both figures above | |   `usd_purchase_pct` | number | optional | Rate on a USD purchase, as a fraction | |   `fx_purchase_pct` | number | optional | Rate on a purchase in any currency other than USD, as a fraction | |   `high_risk_surcharge_pct` | number | optional | Added to the applicable purchase rate at a high-risk merchant category, not charged instead of it | |   `atm_pct` | number | optional | Rate on an ATM withdrawal, as a fraction | |   `visa_direct_pct` | number | optional | Rate on a push-to-card payout, as a fraction | |   `monthly_per_card_usd` | number | optional | Charged per month for every card that exists, frozen ones included. No proration and no separate issuance fee | |   `your_markup_pct` | number | optional | Your own markup on card transactions. We set this for you; PUT /fees does not change it | ```json { "data": { "ktap_pct": 0.01, "load_from_float_pct": 0.01, "load_from_user_deposit_pct": 0.02, "your_markup_pct": 0.02, "total_pct": 0.03, "example_on_100_usd": { "ktap_fee_usd": 1, "your_fee_usd": 2, "lands_on_card_usd": 97 }, "transaction": { "usd_purchase_pct": 0.01, "fx_purchase_pct": 0.02, "high_risk_surcharge_pct": 0.01, "atm_pct": 0.04, "visa_direct_pct": 0.02, "monthly_per_card_usd": 1, "your_markup_pct": 0.02 } } } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 401 | `UNAUTHORIZED` | The key is missing, unknown, revoked, or your account is suspended | No. Fix the credential | | 403 | `FORBIDDEN` | The key is valid but does not carry users:read | No. Ask us for a key with the scope | | 405 | `BAD_REQUEST` | You send POST, PATCH or DELETE to /fees | No. Use GET or PUT | | 429 | `RATE_LIMITED` | Your key made rate_limit_per_minute requests in the trailing minute | Yes, after the 60 seconds Retry-After names | | 500 | `UNAVAILABLE` | An internal fault, such as a database error | Yes, shortly | **Notes** - Rates are fractions, not percentages. A value of `0.02` means 2%. - The rates under `transaction` are a published rate card, not the figures any one movement is charged at. What a purchase was actually charged is on its `card.authorized` event, not on its settlement event. Its row in GET /cards/{id}/transactions shows the card processor's figures rounded to the cent. Reconcile against those, not against this response. ### PUT /fees Sets the rate you add on top of the KTAP load fee, as a fraction. The change takes effect on the next deposit quoted, and the response is the full GET /fees payload with the new rate applied. Any key holding users:write can change this rate for the whole account, with no approval step. **Scope** `users:write` **Headers** | Header | | Description | | --- | --- | --- | | `Content-Type` | optional | application/json. The body is parsed as JSON regardless of what you send | **Request body** | Field | Type | | Description | | --- | --- | --- | --- | | `markup_pct` | number | required | Your markup as a fraction, so 0.025 means 2.5%. Required: omitting it returns 400 A JSON number, finite, 0 to 0.5 inclusive. Stored at 5 decimal places. | ```json { "markup_pct": 0.025 } ``` **Returns** The full fee payload with the new markup applied, identical in shape to GET /fees, as `200`. **Response 200** The full fee payload with the new markup applied, identical in shape to GET /fees | Field | Type | | Description | | --- | --- | --- | --- | | `ktap_pct` | number | optional | Your load rate when you fund your float, unchanged by this call | | `load_from_float_pct` | number | optional | The same figure, named explicitly | | `load_from_user_deposit_pct` | number | optional | The rate on a load your end user pays us in crypto directly, unchanged by this call | | `your_markup_pct` | number | optional | The markup now in force, read back from storage after rounding to 5 decimal places | | `total_pct` | number | optional | load_from_float_pct plus your_markup_pct. This sum is not charged on any single route | |   `ktap_fee_usd` | number | optional | $100 times load_from_float_pct | |   `your_fee_usd` | number | optional | $100 times the new your_markup_pct | |   `lands_on_card_usd` | number | optional | $100 less both figures above | |   `usd_purchase_pct` | number | optional | Rate on a USD purchase, unchanged by this call | |   `fx_purchase_pct` | number | optional | Rate on a non-USD purchase, unchanged by this call | |   `high_risk_surcharge_pct` | number | optional | Added to the applicable purchase rate at a high-risk merchant category, unchanged by this call | |   `atm_pct` | number | optional | Rate on an ATM withdrawal, unchanged by this call | |   `visa_direct_pct` | number | optional | Rate on a push-to-card payout, unchanged by this call | |   `monthly_per_card_usd` | number | optional | Per card per month, unchanged by this call | |   `your_markup_pct` | number | optional | Your markup on card transactions. A different rate in a different column, and this call does not touch it | ```json { "data": { "ktap_pct": 0.01, "load_from_float_pct": 0.01, "load_from_user_deposit_pct": 0.02, "your_markup_pct": 0.02, "total_pct": 0.03, "example_on_100_usd": { "ktap_fee_usd": 1, "your_fee_usd": 2, "lands_on_card_usd": 97 }, "transaction": { "usd_purchase_pct": 0.01, "fx_purchase_pct": 0.02, "high_risk_surcharge_pct": 0.01, "atm_pct": 0.04, "visa_direct_pct": 0.02, "monthly_per_card_usd": 1, "your_markup_pct": 0.02 } } } ``` **Response 400** The body is not JSON, is not a JSON object, markup_pct is not a number, or it falls outside 0 to 0.5 ```json { "error": "Markup must be between 0 and 0.5 (50%)", "code": "BAD_REQUEST" } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 400 | `BAD_REQUEST` | The request body is not valid JSON. Message: Request body must be valid JSON | No. Fix the body | | 400 | `BAD_REQUEST` | The body parses but is null or an array. Message: Request body must be a JSON object | No. Send a JSON object | | 400 | `BAD_REQUEST` | markup_pct is absent or is not a JSON number, including a numeric string such as "0.025". Message: markup_pct must be a number, e.g. 0.025 for 2.5% | No. Send a bare number | | 400 | `BAD_REQUEST` | markup_pct is negative, above 0.5, or not finite. Message: Markup must be between 0 and 0.5 (50%) | No. Send a value from 0 to 0.5 | | 401 | `UNAUTHORIZED` | The key is missing, unknown, revoked, or your account is suspended | No. Fix the credential | | 403 | `FORBIDDEN` | The key is valid but does not carry users:write | No. Ask us for a key with the scope | | 405 | `BAD_REQUEST` | You send POST, PATCH or DELETE to /fees | No. Use GET or PUT | | 429 | `RATE_LIMITED` | Your key made rate_limit_per_minute requests in the trailing minute | Yes, after the 60 seconds Retry-After names | | 500 | `UNAVAILABLE` | An internal fault, such as a database error | Yes, shortly | **Notes** - Send `markup_pct` as a fraction. `0.025` is 2.5%. Sending `2.5` returns `400`. - The accepted range is `0` to `0.5` inclusive, stored to five decimal places. A value with more precision is rounded on write and the response echoes the rounded figure. - This rate applies to direct card funding only. On a prepaid balance it changes nothing you are billed. [Source](https://virtual.ktap.io/docs/api/account.md) --- # Cards Order a card, fund it, freeze it, close it, and set how 3-D Secure reaches the cardholder. 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](https://virtual.ktap.io/docs/essentials.md#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](https://virtual.ktap.io/docs/api/account.md#getMe). ### POST /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:write` **Idempotency** Accepts `Idempotency-Key` **Headers** | Header | | Description | | --- | --- | --- | | `Idempotency-Key` | optional | 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-Type` | optional | The body is parsed as JSON regardless of this header. Send application/json anyway | **Request body** | Field | Type | | Description | | --- | --- | --- | --- | | `card_holder_name` | string | required | 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_code` | integer | required | 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. | | `phone` | string | required | 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. | | `email` | string | required | The cardholder's email address, passed to the card processor and not stored locally ^[^\s@]+@[^\s@]+\.[^\s@]+$. | | `partner_ref` | string | optional | 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. | ```json { "card_holder_name": "JANE SMITH", "phone_country_code": 1, "phone": "5551234567", "email": "jane.smith@yoursite.com", "partner_ref": "your-user-8842" } ``` **Returns** Card issued, as `201`. **Response 201** Card issued | Field | Type | | Description | | --- | --- | --- | --- | |     `id` | string | optional | The card id. Use it in every other card call | |     `partner_ref` | string, nullable | optional | The partner_ref you sent, or null if you sent none | |     `card_holder_name` | string | optional | The name the card processor recorded, which is your trimmed value | |     `last4` | string, nullable | optional | Last four digits of the card number | |     `status` | string, nullable | optional | Lifecycle status as returned by the card processor at creation, normally ACTIVE One of: `ACTIVE`, `FROZEN`, `DELETED`, `BLOCKED`, `INACTIVE`, `EXPIRED`. | |     `created_at` | string | optional | ISO 8601 UTC timestamp generated when the response is built, so it can sit a few milliseconds after the stored row's created_at | ```json { "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" } } } ``` **Response 400** The cardholder details or the request were refused, and the code says why. INVALID_NAME, INVALID_EMAIL and INVALID_PHONE name the field at fault; INVALID_PHONE also covers a number the card processor refuses as invalid. PHONE_COUNTRY_NOT_ALLOWED means cards are not issued to numbers with that country code. BAD_REQUEST is a malformed body or any other refusal. Nothing was created ```json { "error": "card_holder_name must be at most 26 characters after normalization (got 31)", "code": "INVALID_NAME" } ``` **Response 409** Two distinct causes, told apart by code. QUOTA means either your lifetime card allowance is used up or the shared issuing capacity is full; in both cases nothing was created and no allowance was consumed. ALREADY_PROCESSED means an order under this Idempotency-Key is still in flight, so retry in a moment **Response 502** The card processor failed or could not be reached. No card was created and the Idempotency-Key is released, so retry with the same key. An upstream rate limit that survives internal retries also surfaces here as UNAVAILABLE, not as 429 **Response 503** An authentication problem between us and the card processor, code UPSTREAM_AUTH. Nothing was created. Back off and retry **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 400 | `BAD_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 | | 400 | `BAD_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 | | 400 | `INVALID_EMAIL` | email does not match ^[^\s@]+@[^\s@]+\.[^\s@]+$ | Correct the address and resend under the same key | | 400 | `BAD_REQUEST` | partner_ref is present and is either not a string or longer than 128 characters | Shorten it and resend under the same key | | 400 | `INVALID_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 | | 400 | `INVALID_PHONE` | phone_country_code is zero, negative or not a whole number | Send a positive integer and resend under the same key | | 400 | `INVALID_PHONE` | phone does not match ^\d{4,15}$ | Send digits only, without the country code, and resend under the same key | | 400 | `BAD_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 | | 400 | `INVALID_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 | | 400 | `PHONE_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 | | 400 | `BAD_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 | | 401 | `UNAUTHORIZED` | No key is presented, or the key is unknown, revoked, or on a suspended account | Do not retry until the key is fixed | | 403 | `FORBIDDEN` | The key lacks the cards:write scope | Do not retry. Ask for the scope to be added | | 409 | `QUOTA` | 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 | | 409 | `QUOTA` | The shared issuing capacity behind the platform is full, which is separate from your own allowance | Do not retry. Contact us | | 409 | `ALREADY_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 | | 429 | `RATE_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 | | 502 | `UNAVAILABLE` | 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 | | 503 | `UPSTREAM_AUTH` | The card processor rejected our own credentials | Back off and retry with the same key. Contact us if it persists | | 500 | `UNAVAILABLE` | 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. ### GET /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** | Parameter | Type | | Description | | --- | --- | --- | --- | | `limit` | integer | optional | 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`. | | `offset` | integer | optional | 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`. | **Returns** One page of your open cards, as `200`. **Response 200** One page of your open cards | Field | Type | | Description | | --- | --- | --- | --- | |   `cards` | array of object | optional | Cards you own, newest first, closed cards excluded | |     `id` | string | optional | Card id, used in every other card call | |     `partner_ref` | string \| null, nullable | optional | Your own reference for the cardholder, echoed back untouched, null when you sent none | |     `card_holder_name` | string | optional | Name printed on the card, as the issuer stored it: uppercased, accents already converted | |     `last4` | string \| null, nullable | optional | Last four digits of the card number, null until the processor reports them | |     `status` | string \| null, nullable | optional | Current card state, null before the first status sync. DELETED never appears here because closed cards are filtered out One of: `ACTIVE`, `FROZEN`, `BLOCKED`, `INACTIVE`, `EXPIRED`. | |     `created_at` | string | optional | ISO 8601 UTC timestamp of when the card was created | |   `count` | integer | optional | Rows in this page, never your account total | ```json { "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 } } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 401 | `UNAUTHORIZED` | Your key is missing, unknown, revoked, or your account is suspended. The four cases are deliberately indistinguishable | Do not retry, fix the credential | | 403 | `FORBIDDEN` | Your key is valid but carries no cards:read scope | Do not retry, ask us for a key with the scope | | 429 | `RATE_LIMITED` | Your key exceeds its requests per rolling minute | Wait for Retry-After, which is 60 seconds, then retry | | 500 | `UNAVAILABLE` | 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`. ### GET /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** | Parameter | Type | | Description | | --- | --- | --- | --- | | `cardId` | string | required | 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`. | **Returns** The card and its live balance, as `200`. **Response 200** The card and its live balance | Field | Type | | Description | | --- | --- | --- | --- | |     `id` | string | optional | Card id, used in every other card call | |     `partner_ref` | string \| null, nullable | optional | Your own reference for the cardholder, echoed back untouched, null when you sent none | |     `card_holder_name` | string | optional | Name printed on the card, as the issuer stored it: uppercased, accents already converted | |     `last4` | string \| null, nullable | optional | Last four digits of the card number, null until the processor reports them | |     `status` | string \| null, nullable | optional | Current card state, null before the first status sync. DELETED means you closed the card, and this operation is the only place it appears One of: `ACTIVE`, `FROZEN`, `BLOCKED`, `INACTIVE`, `EXPIRED`, `DELETED`. | |     `created_at` | string | optional | ISO 8601 UTC timestamp of when the card was created | |   `balance_usd` | number \| null, nullable | optional | 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 | ```json { "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 } } ``` **Response 200** The card, with the balance unknown because the processor read failed | Field | Type | | Description | | --- | --- | --- | --- | |   `balance_usd` | number \| null, nullable | optional | null whenever the balance read fails for any reason: timeout, network fault, or an error from the processor | ```json { "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": null } } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 401 | `UNAUTHORIZED` | Your key is missing, unknown, revoked, or your account is suspended | Do not retry, fix the credential | | 403 | `FORBIDDEN` | Your key is valid but carries no cards:read scope | Do not retry, ask us for a key with the scope | | 404 | `NOT_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 | | 429 | `RATE_LIMITED` | Your key exceeds its requests per rolling minute | Wait for Retry-After, which is 60 seconds, then retry | | 500 | `UNAVAILABLE` | 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. ### POST /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` **Request body** | Field | Type | | Description | | --- | --- | --- | --- | | `card_ids` | array | optional | Up to 1000 distinct ids. Omit for the whole fleet, paginated. At most 1000; at most 25 when refresh is true. | | `refresh` | boolean | optional | Live processor reads instead of the model, only with card_ids, at most 25. Defaults to `false`. | | `limit` | number | optional | Fleet mode page size. 1 to 1000. Defaults to `500`. | | `page` | number | optional | Fleet mode page, 1-based. Defaults to `1`. | ```json { "limit": 500, "page": 1 } ``` **Returns** One row per card. Fleet mode adds page, total and total_pages, as `200`. **Response 200** One row per card. Fleet mode adds page, total and total_pages | Field | Type | | Description | | --- | --- | --- | --- | | `balances` | array | optional | In request order, or fleet order when card_ids is omitted | |   `card_id` | string | optional | The card | |   `balance_usd` | number, nullable | optional | 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_of` | string, nullable | optional | When this balance was last read from the processor. null means the background refresh has not reached this card yet | |   `ok` | boolean | optional | 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 | |   `error` | string, nullable | optional | NOT_FOUND for a card that is not yours; UNAVAILABLE on a failed live refresh | | `count` | number | optional | Rows in this response | | `page` | number | optional | Fleet mode only (card_ids omitted): the page returned | | `total` | number | optional | Fleet mode only: cards across all pages | | `total_pages` | number | optional | Fleet mode only: total pages for the fleet | ```json { "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 } } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 400 | `BAD_REQUEST` | More than 1000 ids, refresh with more than 25 ids or without card_ids, duplicates, or non-string entries | Do not retry | **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. ### POST /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:write` **Idempotency** Accepts `Idempotency-Key` **Path parameters** | Parameter | Type | | Description | | --- | --- | --- | --- | | `cardId` | string | optional | 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** | Header | | Description | | --- | --- | --- | | `Idempotency-Key` | optional | 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-Type` | optional | application/json. The body is parsed as JSON whatever you send, so a wrong value does not by itself fail the call | **Request body** | Field | Type | | Description | | --- | --- | --- | --- | | `amount_usd` | number | required | 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. | ```json { "amount_usd": 250.75 } ``` **Returns** The float is debited and the card is credited. A replay of a completed key returns this same shape with the original amounts, as `200`. **Response 200** The float is debited and the card is credited. A replay of a completed key returns this same shape with the original amounts | Field | Type | | Description | | --- | --- | --- | --- | |   `card_id` | string | optional | The card that was credited, echoing the path parameter | |   `amount_requested_usd` | number | optional | 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_usd` | number | optional | Always 0 on the float model, because the load fee is charged when you fund your float | |   `credited_usd` | number | optional | What reached the card. Equal to amount_requested_usd while fee_usd is 0 | |   `partner_balance_usd` | number | optional | 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 | ```json { "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "amount_requested_usd": 250.75, "fee_usd": 0, "credited_usd": 250.75, "partner_balance_usd": 31379.45 } } ``` **Response 400** Your request failed validation, or the card processor rejected the credit as invalid. Both carry code BAD_REQUEST, so read the message to tell them apart and never parse it programmatically | Field | Type | | Description | | --- | --- | --- | --- | | `error` | string | optional | Advisory text. It usually names the field to change | | `code` | string | optional | Always BAD_REQUEST on this status One of: `BAD_REQUEST`. | ```json { "error": "amount_usd must have at most 2 decimal places (USD cents)", "code": "BAD_REQUEST" } ``` **Response 402** Your available_usd will not cover the amount. Nothing is written to the ledger and the Idempotency-Key stays unused ```json { "error": "Cannot top up $250.75, insufficient balance: $120.10 available", "code": "INSUFFICIENT_FUNDS" } ``` **Response 404** No card with that id belongs to you. Checked before the idempotency replay, so a foreign card id returns 404 even with a key you have used before ```json { "error": "Card not found", "code": "NOT_FOUND" } ``` **Response 409** The Idempotency-Key is in use and has no replayable success. Three distinct situations produce this and they need different responses from you: the key is bound to a different card, the top-up was rejected and refunded, or the outcome is not recorded ```json { "error": "A top-up with this Idempotency-Key is still in progress, retry shortly", "code": "ALREADY_PROCESSED" } ``` **Response 502** The card processor returned an error other than 400, 401 or 412. A 5xx from it leaves the top-up unresolved with your float debited; a 403, 404, 409 or 429 from it counts as a definite rejection and your float is refunded before this response ```json { "error": "The card processor is unavailable. Retry shortly", "code": "UNAVAILABLE" } ``` **Response 503** An authentication problem between us and the card processor. It counts as a definite rejection, so your float is already refunded and that Idempotency-Key is spent ```json { "error": "Service temporarily unavailable", "code": "UPSTREAM_AUTH" } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 400 | `BAD_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 | | 400 | `BAD_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 | | 400 | `BAD_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 | | 401 | `UNAUTHORIZED` | The key is missing, unknown, revoked, or the partner is suspended | Fix the credential. Retrying the same call changes nothing | | 402 | `INSUFFICIENT_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 | | 403 | `FORBIDDEN` | The key does not carry cards:write | Use a key with the scope. Retrying changes nothing | | 404 | `NOT_FOUND` | The card id is unknown or belongs to another partner | Do not retry. Check the id | | 405 | `BAD_REQUEST` | You sent GET, PUT, PATCH or DELETE to this path | Resend as POST | | 409 | `ALREADY_PROCESSED` | You reused an Idempotency-Key that is already bound to a different card id | Do not retry. Use one key per operation | | 409 | `ALREADY_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 | | 409 | `ALREADY_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 | | 409 | `QUOTA` | 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 | | 429 | `RATE_LIMITED` | You exceeded the per-minute allowance on this key | Wait for Retry-After (60 seconds), then retry with the same key | | 500 | `UNAVAILABLE` | 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 | | 502 | `UNAVAILABLE` | 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 | | 503 | `UPSTREAM_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. ### POST /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:write` **Idempotency** Accepts `Idempotency-Key` **Path parameters** | Parameter | Type | | Description | | --- | --- | --- | --- | | `cardId` | string | optional | The card to unload. Cards you do not own return 404, the same as ids that do not exist | **Headers** | Header | | Description | | --- | --- | --- | | `Idempotency-Key` | optional | Optional at the transport level and required in practice. Without one, a retry after a timeout can subtract twice. | **Request body** | Field | Type | | Description | | --- | --- | --- | --- | | `amount_usd` | number | required | 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. | ```json { "amount_usd": 50 } ``` **Returns** The money is back in your balance, as `200`. **Response 200** The money is back in your balance | Field | Type | | Description | | --- | --- | --- | --- | | `card_id` | string | optional | The card that was unloaded | | `unloaded_usd` | number | optional | What came off the card | | `partner_balance_usd` | number | optional | Your balance after the credit | ```json { "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "unloaded_usd": 50, "partner_balance_usd": 9995 } } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 402 | `INSUFFICIENT_FUNDS` | The card does not hold that much | Read the card balance and retry with a smaller amount | | 404 | `NOT_FOUND` | Unknown card, or a card that is not yours | Do not retry | | 409 | `ALREADY_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. ### GET /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** | Parameter | Type | | Description | | --- | --- | --- | --- | | `cardId` | string | optional | Cards you do not own return 404 | **Returns** Current limits, as `200`. **Response 200** Current limits | Field | Type | | Description | | --- | --- | --- | --- | | `card_id` | string | optional | The card | | `daily_limit` | number, nullable | optional | USD per day, or null for no daily limit | | `weekly_limit` | number, nullable | optional | USD per week, or null | | `monthly_limit` | number, nullable | optional | USD per month, or null | | `per_transaction_limit_usd` | number | optional | The program-level single-purchase ceiling. Informational: it is not settable per card | ```json { "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "daily_limit": 500, "weekly_limit": null, "monthly_limit": 4000, "per_transaction_limit_usd": 5000 } } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 404 | `NOT_FOUND` | Unknown card, or a card that is not yours | Do not retry | **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. ### PUT /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** | Parameter | Type | | Description | | --- | --- | --- | --- | | `cardId` | string | optional | Cards you do not own return 404 | **Request body** | Field | Type | | Description | | --- | --- | --- | --- | | `daily_limit` | number | optional | 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_limit` | number | optional | Positive USD amount. 0 or null is refused | | `monthly_limit` | number | optional | Positive USD amount. 0 or null is refused | ```json { "daily_limit": 500, "monthly_limit": 4000 } ``` **Returns** The limits as they now stand, as `200`. **Response 200** The limits as they now stand ```json { "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "daily_limit": 500, "weekly_limit": null, "monthly_limit": 4000, "per_transaction_limit_usd": 5000 } } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 400 | `BAD_REQUEST` | An unknown field, a non-number, zero or a negative value | Do not retry | | 404 | `NOT_FOUND` | Unknown card, or a card that is not yours | Do not retry | **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. ### POST /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** | Parameter | Type | | Description | | --- | --- | --- | --- | | `cardId` | string | optional | The card to freeze or unfreeze Opaque processor-issued string, 32 alphanumeric characters in current issuance. | **Headers** | Header | | Description | | --- | --- | --- | | `Idempotency-Key` | optional | Accepted and recorded in your request log, but this route does not use it. Repeating the call is safe on its own | | `Content-Type` | optional | application/json | **Request body** | Field | Type | | Description | | --- | --- | --- | --- | | `frozen` | boolean | required | true blocks spending, false restores it Must be a JSON boolean. Strings such as "true" and numbers such as 1 are rejected with 400. | ```json { "frozen": true } ``` **Returns** The processor accepted the change and the card's stored status is updated to match, as `200`. **Response 200** The processor accepted the change and the card's stored status is updated to match | Field | Type | | Description | | --- | --- | --- | --- | |   `card_id` | string | optional | The card the change applied to | |   `frozen` | boolean | optional | The value you sent, echoed back after the processor accepted it. The response carries no status field | ```json { "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "frozen": true } } ``` **Response 400** The body is not valid JSON, is not a JSON object, frozen is not a boolean, or the card is closed ```json { "error": "frozen must be true or false", "code": "BAD_REQUEST" } ``` **Response 404** No card with that id belongs to you. Checked before the body is read, so a bad id wins over a bad body ```json { "error": "Card not found", "code": "NOT_FOUND" } ``` **Response 502** The card processor errored or could not be reached. Nothing changed on our side ```json { "error": "The card processor is unavailable. Retry shortly", "code": "UNAVAILABLE" } ``` **Response 503** An authentication problem between us and the card processor. Nothing changed ```json { "error": "Service temporarily unavailable", "code": "UPSTREAM_AUTH" } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 400 | `BAD_REQUEST` | The body is not valid JSON or not a JSON object | Fix the body and resend | | 400 | `BAD_REQUEST` | frozen is absent or is not a JSON boolean | Send frozen as true or false and resend | | 400 | `BAD_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 | | 400 | `BAD_REQUEST` | The card processor rejected the change as invalid | Do not retry blindly. Check the card's current state with GET /cards/{cardId} | | 401 | `UNAUTHORIZED` | The key is missing, unknown, revoked, or the partner is suspended | Fix the credential | | 403 | `FORBIDDEN` | The key does not carry cards:write | Use a key with the scope | | 404 | `NOT_FOUND` | The card id is unknown or belongs to another partner | Do not retry. Check the id | | 405 | `BAD_REQUEST` | You sent GET, PUT, PATCH or DELETE to this path | Resend as POST | | 429 | `RATE_LIMITED` | You exceeded the per-minute allowance on this key | Wait for Retry-After (60 seconds) and resend | | 500 | `UNAVAILABLE` | 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} | | 502 | `UNAVAILABLE` | The processor errored or timed out before applying the change | Safe to resend the identical call | | 503 | `UPSTREAM_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}](https://virtual.ktap.io/docs/api/cards.md#getCard). - 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. ### PUT /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** | Parameter | Type | | Description | | --- | --- | --- | --- | | `cardId` | string | optional | Cards you do not own return 404 | **Request body** | Field | Type | | Description | | --- | --- | --- | --- | | `phone_country_code` | number | required | Dialling code without the plus sign, for example 1 for North America Positive integer. | | `phone` | string | required | Digits only, no spaces or separators ^[0-9]{4,15}$. | ```json { "phone_country_code": 1, "phone": "5551234567" } ``` **Returns** The number was updated, as `200`. **Response 200** The number was updated ```json { "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "updated": true } } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 400 | `BAD_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 | Do not retry | | 400 | `INVALID_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 | Do not retry | | 400 | `PHONE_COUNTRY_NOT_ALLOWED` | Phone numbers with this country code are not accepted. The message names the code | Do not retry | | 404 | `NOT_FOUND` | Unknown card, or a card that is not yours | Do not retry | **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. ### POST /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** | Parameter | Type | | Description | | --- | --- | --- | --- | | `cardId` | string | required | 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** | Header | | Description | | --- | --- | --- | | `Idempotency-Key` | optional | Accepted and written to your request log, and ignored by this operation. The card id already makes the close replayable | **Returns** The card is closed and any balance is back on your float, as `200`. **Response 200** The card is closed and any balance is back on your float | Field | Type | | Description | | --- | --- | --- | --- | |   `card_id` | string | optional | The card you closed, echoed back | |   `returned_usd` | number | optional | 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_usd` | number | optional | 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 | ```json { "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "returned_usd": 136.43, "partner_balance_usd": 9988.2 } } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 400 | `BAD_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 | | 401 | `UNAUTHORIZED` | Your key is missing, unknown, revoked, or your account is suspended | Do not retry, fix the credential | | 403 | `FORBIDDEN` | Your key is valid but carries no cards:write scope | Do not retry, ask us for a key with the scope | | 404 | `NOT_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 | | 409 | `ALREADY_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 | | 429 | `RATE_LIMITED` | Your key exceeds its requests per rolling minute | Wait for Retry-After, which is 60 seconds, then retry | | 502 | `UNAVAILABLE` | 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 | | 503 | `UNAVAILABLE` | The balance read fails, so the card is left open rather than closed with money stranded on it | Safe to retry, nothing changed | | 503 | `UPSTREAM_AUTH` | Our own credentials with the card processor fail | Safe to retry shortly. Contact us if it persists | | 500 | `UNAVAILABLE` | 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. ### PUT /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** | Parameter | Type | | Description | | --- | --- | --- | --- | | `cardId` | string | optional | The card whose 3DS delivery you are setting Opaque processor-issued string, 32 alphanumeric characters in current issuance. | **Headers** | Header | | Description | | --- | --- | --- | | `Idempotency-Key` | optional | Accepted and recorded in your request log, but this route does not use it | | `Content-Type` | optional | application/json | **Request body** | Field | Type | | Description | | --- | --- | --- | --- | | `method` | string | required | Where the one time code goes One of: `webhook`. Exactly "webhook", lowercase, and it requires an active registered webhook. "sms", any other value or a missing field returns 400. | ```json { "method": "webhook" } ``` **Returns** The card processor accepted the new delivery method for this card, as `200`. **Response 200** The card processor accepted the new delivery method for this card | Field | Type | | Description | | --- | --- | --- | --- | |   `card_id` | string | optional | The card the setting applies to | |   `three_ds_method` | string | optional | The method now in force, echoing what you sent One of: `webhook`. | ```json { "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "three_ds_method": "webhook" } } ``` **Response 400** The body is not valid JSON, is not a JSON object, method is neither sms nor webhook, or you asked for webhook without an active registered webhook ```json { "error": "Register a webhook with PUT /webhook first. In webhook mode the 3DS code is delivered there, and without one every 3DS purchase on this card would fail.", "code": "BAD_REQUEST" } ``` **Response 404** No card with that id belongs to you. Checked before the body is read ```json { "error": "Card not found", "code": "NOT_FOUND" } ``` **Response 502** The card processor errored or could not be reached. The delivery method is unchanged ```json { "error": "The card processor is unavailable. Retry shortly", "code": "UNAVAILABLE" } ``` **Response 503** An authentication problem between us and the card processor. The delivery method is unchanged ```json { "error": "Service temporarily unavailable", "code": "UPSTREAM_AUTH" } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 400 | `BAD_REQUEST` | The body is not valid JSON or not a JSON object | Fix the body and resend | | 400 | `BAD_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 | | 400 | `BAD_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 | | 400 | `BAD_REQUEST` | The card processor rejected the configuration as invalid | Do not retry blindly. Contact us if it persists | | 401 | `UNAUTHORIZED` | The key is missing, unknown, revoked, or the partner is suspended | Fix the credential | | 403 | `FORBIDDEN` | The key does not carry cards:write | Use a key with the scope | | 404 | `NOT_FOUND` | The card id is unknown or belongs to another partner | Do not retry. Check the id | | 405 | `BAD_REQUEST` | You sent GET, POST, PATCH or DELETE to this path | Resend as PUT | | 429 | `RATE_LIMITED` | You exceeded the per-minute allowance on this key | Wait for Retry-After (60 seconds) and resend | | 500 | `UNAVAILABLE` | An internal fault | Safe to resend the identical call | | 502 | `UNAVAILABLE` | The processor errored or timed out | Safe to resend the identical call | | 503 | `UPSTREAM_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. [Source](https://virtual.ktap.io/docs/api/cards.md) --- # Card details Display a full card number in an iframe without it touching your servers. Card numbers are never returned as data. You request a short-lived URL and embed it, which keeps the number off your servers and out of your logs. ## How it works 1. Your server calls `GET /cards/{cardId}/pan` with your key and gets back a `details_url`, valid for 600 seconds. 2. Your page renders that URL in an iframe. The number is displayed by us, inside the frame. 3. When the cardholder uses the copy control, the frame posts a result back to your page. ## A complete embed ```javascript const res = await fetch( `https://virtual.ktap.io/api/partner/v1/cards/${cardId}/pan`, { headers: { 'X-API-Key': process.env.KTAP_API_KEY } }, ); const { data } = await res.json(); // data.details_url is valid for 600 seconds. Render it, do not store it. ``` > **`allow="clipboard-write"` is required** > > Permissions Policy delegation is per frame, so without it the clipboard permission never reaches the card page and the copy control silently does nothing. If your page sends its own Content-Security-Policy, add `frame-src https://virtual.ktap.io`. ## The contract - Give the iframe an explicit width and height. The page fills whatever container you give it and has a transparent background. - No `sandbox` attribute is needed. If your policy forces one, `allow-scripts` and `allow-same-origin` are the minimum, because without scripts you receive no messages at all. - The message is exactly `{ type: "ktap-card-copy-result", ok: boolean }` and it arrives only when the page is embedded. Check `event.origin` before acting on it. - Only the immediate parent receives it. If you nest the frame inside another frame of your own, the intermediate frame has to forward it. - Treat `ok` as a cue to show a confirmation, never as an audit record. - Render the URL, do not store it. After 600 seconds it stops working and you request a new one. ### GET /cards/{cardId}/pan Returns a URL on the KTAP host that renders the full card details for the cardholder, plus the card expiry as plain integers. The card number and CVV never appear in this JSON and never pass through your servers: they travel from the card processor to the cardholder's browser inside a nested iframe. Every call mints a fresh URL that is valid for 600 seconds. **Scope** `cards:pan` **Path parameters** | Parameter | Type | | Description | | --- | --- | --- | --- | | `cardId` | string | optional | The card id returned by POST /cards Opaque and case-sensitive. Live ids are 32 characters of letters and digits. Match is exact and scoped to your partner account. | **Returns** The details URL and the card expiry, as `200`. **Response 200** The details URL and the card expiry | Field | Type | | Description | | --- | --- | --- | --- | |   `card_id` | string | optional | The card id you requested, as stored | |   `details_url` | string | optional | Page to load in an iframe. Valid for 600 seconds from this response. Anyone holding it can view the card, so never log it or store it | |   `expiration_month` | integer, nullable | optional | 1 to 12. Null when the card metadata lookup at the processor fails | |   `expiration_year` | integer, nullable | optional | Four digits, for example 2032. Null when the card metadata lookup at the processor fails | ```json { "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "details_url": "https://virtual.ktap.io/card-frame/EXAMPLE_TOKEN.EXAMPLE_SIGNATURE", "expiration_month": 10, "expiration_year": 2032 } } ``` **Response 404** No card with that id belongs to you ```json { "error": "Card not found", "code": "NOT_FOUND" } ``` **Response 502** The card processor returned no details URL for this card ```json { "error": "Card details are unavailable for this card", "code": "UNAVAILABLE" } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 401 | `UNAUTHORIZED` | No key, unknown key, revoked key, or your partner account is suspended. The four cases are deliberately indistinguishable | No. Fix the credential | | 403 | `FORBIDDEN` | The key is valid but lacks the cards:pan scope, which is granted separately from cards:read | No. Ask for the scope | | 404 | `NOT_FOUND` | The card id does not exist, or belongs to another partner. Card status is not checked, so a closed card still resolves here | No | | 405 | `BAD_REQUEST` | Any method other than GET. This response is produced before authentication, so it is not rate limited or logged against your key | No | | 429 | `RATE_LIMITED` | Your key exceeded rate_limit_per_minute in a rolling one-minute window counted from the request log. The default allowance is 120 per minute | Yes, after the Retry-After value of 60 seconds | | 500 | `UNAVAILABLE` | An internal fault on the KTAP side | Yes, shortly | | 502 | `UNAVAILABLE` | The card processor did not return a details URL. The route probes the processor before minting the token so a bad card fails here instead of inside a broken iframe | Yes, shortly | **Notes** - The URL is valid for 600 seconds from the moment it is issued. Render it, do not store it. - Your iframe must carry `allow="clipboard-write"`, or the copy control inside the frame silently does nothing. - An expired or tampered URL renders as plain text instead of the card. Treat a frame that did not load as HTML as a signal to request a new URL. ### POST /cards/{cardId}/pan-token Mints a single-use URL, valid for 60 seconds, that returns the card fields as JSON. Call this from your server, hand the url to the end user's DEVICE, and have the device GET it directly: the response is { data: { pan, cvv, expiry_month, expiry_year } }, uncacheable, and the token burns on first use. If your own servers fetch it instead, the card number transits your infrastructure and your PCI DSS obligations change. Route it through the device. Where an iframe is possible, prefer the embeddable frame at GET /cards/{cardId}/pan. **Scope** `cards:pan` **Path parameters** | Parameter | Type | | Description | | --- | --- | --- | --- | | `cardId` | string | optional | Cards you do not own return 404 | **Returns** A single-use exchange URL, as `200`. **Response 200** A single-use exchange URL | Field | Type | | Description | | --- | --- | --- | --- | | `url` | string | optional | Have the device GET this within 60 seconds. It works exactly once | | `expires_in` | number | optional | Seconds until the token dies unused | | `single_use` | boolean | optional | Always true | ```json { "data": { "url": "https://virtual.ktap.io/card-data/example-token", "expires_in": 60, "single_use": true } } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 404 | `NOT_FOUND` | Unknown card, or a card that is not yours | Do not retry | **Notes** - Hand the `url` to the end user's DEVICE and have the device fetch it. If your servers fetch it instead, the card number transits your infrastructure and your PCI DSS obligations change. - One token per display, not per session. The token dies after 60 seconds and burns on first use; expired, used and tampered tokens all answer `404` alike. - From the exchange URL, `404` means the token is dead (expired, used or tampered): mint again, never retry. `502` means card details are temporarily unavailable on our side: back off, then restart the flow. The URL allows cross-origin fetches (`Access-Control-Allow-Origin: *`), so the device may call it straight from a browser or web-view. [Source](https://virtual.ktap.io/docs/api/card-data.md) --- # Ledger Every movement on your balance, and every transaction on a card, with the fees on each. Two histories: your balance, and what happened on a card. Reconcile against these rather than against a rate card. ## Ledger row kinds `GET /ledger` is the movement history of your own balance. Five kinds are possible and the column is constrained to exactly those, so an unknown value never reaches you. Summing `amount_usd` across every page equals `float.balance_usd` on `GET /me`. | Kind | Sign | What it is | | --- | --- | --- | | `funding` | Positive, or negative on a reversal | Money we credited after you sent funds. One submitted with `POST /funding` is referenced `funding--`, and a reversal of one `funding-reversal--` | | `fee` | Negative, or positive on a reversal | A load fee on a funding, referenced as the funding with `:fee` appended (a reversal returns it as a positive row), or a monthly card fee. The card id for a card fee is in the reference, not in `card_id` | | `card_topup` | Negative | One row per top-up. The reference is `topup:` followed by your idempotency key | | `refund` | Positive | A balance returned on closing a card, or a top-up the card processor definitively rejected | | `adjustment` | Zero | A bookkeeping marker. Always zero, and not counted in your balance | ## One row per purchase A purchase appears once. It starts as `pending`: its `amount_usd` has already been charged to the card, fee included, but the merchant can still settle a different amount, and settlement takes a median of around 22 hours. Refunds and reversals do not add a row: the original row changes status, and its `amount_usd` is the card processor's current figure, so a reversal lowers it. Take the exact returned amount from the [`card.transaction`](https://virtual.ktap.io/docs/webhooks/events.md#event-card-transaction) webhook, matched on `transaction_id`. ## Fees on a transaction - `fee_usd` is exactly `provider_fee_usd` plus `client_fee_usd`. - `provider_fee_usd` is the fee inside `amount_usd`, as the card processor itemizes it, to the cent. `client_fee_usd` is your markup, computed on `fee_basis_usd`; it is not part of `amount_usd`. - `fee_breakdown` itemises our charge only, so `fx_usd` plus `atm_usd` plus `deposit_usd` equals `provider_fee_usd` exactly and does **not** sum to `fee_usd`. - Never check a rate against `original_amount`: it is in the cardholder's purchase currency, so multiplying it by a USD rate is wrong by the exchange rate. `fee_basis_usd` is in USD but built from cent-rounded figures, so it is too coarse for a small purchase. Use `amount_usd` and `fee_usd` on the purchase's `card.authorized` event for that. - Every fee field on a declined row is zero. Do not book revenue or cost from a decline. - Row amounts and fees are the card processor's figures rounded to the cent, each fee component rounded separately, so a fee under half a cent reads `0`. The exact fee is on the purchase's `card.authorized` event. Store amounts in a decimal type and sum before you round. `decline_reason` maps the card processor's raw code to a closed set of our own, falling back to `unknown`, so an exhaustive switch is safe and no processor vocabulary reaches you. `retryable` means the same purchase can succeed on a straight retry; `card_blocked` means the card is unusable until we unblock it. ### GET /ledger Returns one row for every event that changed your prepaid balance, newest first. Each row carries a signed USD amount, a kind, and a reference that ties it back to the call or the charge that produced it. Page with limit and page until page equals total_pages. **Scope** `users:read` **Query parameters** | Parameter | Type | | Description | | --- | --- | --- | --- | | `limit` | integer | optional | Entries per page 1 to 100. Out of range returns A value above 100 returns 400. A value that is not a run of digits, including a negative number, a decimal, an empty value or any text, returns 400. The value 0 passes validation and is clamped up to 1. Defaults to `50`. | | `page` | integer | optional | 1-based page number, offset as (page - 1) * limit 1 or greater, no upper bound. Out of range returns A value that is not a run of digits returns 400. The value 0 passes validation and is clamped up to 1. A page past the end returns an empty entries array, not an error. Defaults to `1`. | **Returns** A page of ledger entries, newest first, as `200`. **Response 200** A page of ledger entries, newest first | Field | Type | | Description | | --- | --- | --- | --- | | `entries` | array | optional | The page of rows, ordered by created_at descending and then by internal id descending | |   `amount_usd` | number | optional | Signed USD. Positive adds to your balance, negative draws it down. Carries up to 10 decimal places | |   `kind` | string | optional | What the row is. The column is constrained to these five values in the database One of: `funding`, `fee`, `card_topup`, `refund`, `adjustment`. | |   `reference` | string, nullable | optional | The identifier that ties the row to its cause. See notes for the exact shape per kind | |   `card_id` | string, nullable | optional | The card the row concerns. Null on funding rows, load fee rows, monthly card fee rows, top-up reversal rows and card order claims | |   `description` | string, nullable | optional | Human-readable label for the row. On a card order claim this field holds the id of the card the order produced | |   `created_at` | string | optional | ISO 8601 UTC with milliseconds and a Z suffix | | `count` | integer | optional | Rows on this page | | `page` | integer | optional | The page returned, after clamping | | `total` | integer | optional | Rows across all pages for your account. 0 when the requested page is past the end | | `total_pages` | integer | optional | Pages at the requested limit. Never below 1 | ```json { "data": { "entries": [ { "amount_usd": 43.2, "kind": "refund", "reference": "close:7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "description": "Balance returned on closing ••••7640", "created_at": "2026-08-19T11:02:44.560Z" }, { "amount_usd": 0, "kind": "adjustment", "reference": "topup:order-8842:done", "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "description": "Top-up confirmed by processor", "created_at": "2026-08-04T16:41:12.913Z" }, { "amount_usd": -200, "kind": "card_topup", "reference": "topup:order-8842", "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "description": "Top-up card ••••7640", "created_at": "2026-08-04T16:41:11.402Z" }, { "amount_usd": -2, "kind": "fee", "reference": "cardfee:7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u:2026-08-01", "card_id": null, "description": "Card fee 2026-08", "created_at": "2026-08-04T16:41:09.774Z" }, { "amount_usd": -100, "kind": "fee", "reference": "wire-20260801-0042:fee", "card_id": null, "description": "Load fee on $10000 funding", "created_at": "2026-08-01T09:14:02.118Z" }, { "amount_usd": 10000, "kind": "funding", "reference": "wire-20260801-0042", "card_id": null, "description": "Float funding", "created_at": "2026-08-01T09:14:02.118Z" } ], "count": 6, "page": 1, "total": 6, "total_pages": 1 } } ``` **Response 400** limit or page is not a run of digits, or limit is above 100 ```json { "error": "page must be a positive integer", "code": "BAD_REQUEST" } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 400 | `BAD_REQUEST` | limit or page contains anything other than digits, or limit is greater than 100 | Fix the parameter and resend. Retrying the same request produces the same error | | 401 | `UNAUTHORIZED` | No key was presented, or the key is unknown, revoked, or belongs to a suspended account | Do not retry. Check the key, then contact KTAP | | 403 | `FORBIDDEN` | The key is valid but does not carry users:read | Do not retry. Ask KTAP to add the scope, or use a key that has it | | 405 | `BAD_REQUEST` | Any method other than GET is used on /ledger | Do not retry. Use GET | | 429 | `RATE_LIMITED` | The key exceeded its per-minute allowance | Wait the number of seconds in Retry-After, which is always 60, then resend | | 500 | `UNAVAILABLE` | An unhandled fault on the KTAP side, for example a database error | Retry after a short pause. The endpoint is read-only, so a retry cannot duplicate anything | **Notes** - Summing `amount_usd` across every page equals `float.balance_usd` on [GET /me](https://virtual.ktap.io/docs/api/account.md#getMe). - `amount_usd` carries up to ten decimal places. Parse it as an exact decimal, not as cents. - `adjustment` rows are bookkeeping markers, always zero, and are not counted in your balance. ### GET /cards/{cardId}/transactions Returns purchase activity for one card, one row per purchase, with the fee breakdown, the derived status and a decline reason where the purchase was refused. Only the PURCHASE operation is queried, so approvals, declines, refunds and reversals all appear as lifecycle outcomes on the original purchase row rather than as separate rows. Amounts and the processor's fees are reported to the cent, as the card processor itemizes them. client_fee_usd, and so fee_usd, is computed unrounded from those figures. Fees are zero on every declined row. **Scope** `cards:read` **Path parameters** | Parameter | Type | | Description | | --- | --- | --- | --- | | `cardId` | string | optional | The card id returned by POST /cards Opaque and case-sensitive. Live ids are 32 characters of letters and digits. Match is exact and scoped to your partner account. | **Query parameters** | Parameter | Type | | Description | | --- | --- | --- | --- | | `limit` | integer | optional | Rows per page 1 to 25. Out of range returns Anything that is not a run of digits gives 400 BAD_REQUEST. A value above 25 gives 400 BAD_REQUEST and is never clamped. A value of 0 is accepted and coerced to 1. Defaults to `25`. | | `page` | integer | optional | Page number, 1-based 1 or greater. Out of range returns Anything that is not a run of digits gives 400 BAD_REQUEST. A value of 0 is accepted and coerced to 1. A page beyond total_pages returns 200 with an empty transactions array. Defaults to `1`. | | `from_date` | string | optional | Earliest calendar date to include, YYYY-MM-DD Must match ^\d{4}-\d{2}-\d{2}$ and be a real date. 2026-02-30 and 2026-13-01 are refused. Out of range returns 400 BAD_REQUEST. Also 400 when from_date is later than to_date. When omitted, no lower bound is sent to the processor. | | `to_date` | string | optional | Latest calendar date to include, YYYY-MM-DD Must match ^\d{4}-\d{2}-\d{2}$ and be a real date. Out of range returns 400 BAD_REQUEST. Also 400 when from_date is later than to_date. When omitted, no upper bound is sent to the processor. | **Returns** A page of purchases, as `200`. **Response 200** A page of purchases | Field | Type | | Description | | --- | --- | --- | --- | |   `card_id` | string | optional | The card id you requested, as stored | |   `transactions` | array | optional | Transaction objects for this page | |     `id` | string | optional | This row's own movement id, for example a1c4e7f0. Stable, but no webhook carries it. Join webhooks on transaction_id instead | |     `transaction_id` | string, nullable | optional | The purchase id. Equal to transaction_id on the card.authorized, card.transaction and card.declined webhooks for the same purchase, so this is the field that pairs pulled history with delivered events | |     `partner_ref` | string, nullable | optional | Your own reference for the cardholder. Copied from the CARD record, so it is identical on every row and null when the card was issued without one | |     `datetime` | string | optional | UTC timestamp with no offset and no Z, for example 2026-08-19T14:22:41. Append Z when parsing | |     `status` | string | optional | Derived by us. pending until the merchant settles; settled once a settlement has posted, whatever amount it settled, including a partial reversal followed by a settlement of the rest; declined when the purchase was refused; refunded, partially_refunded, reversed or partially_reversed from the purchase's outcome One of: `pending`, `settled`, `declined`, `refunded`, `partially_refunded`, `reversed`, `partially_reversed`. | |     `merchant` | string, nullable | optional | Merchant name as the processor reports it. Null when the processor sends none | |     `amount_usd` | number | optional | The purchase's current total as the card processor reports it, fee included, to the cent. It is not zeroed on a decline, where nothing left the card. A reversal lowers it | |     `original_amount` | number, nullable | optional | Amount in the currency the cardholder was charged in. Not USD unless original_currency is USD | |     `original_currency` | string, nullable | optional | ISO currency of original_amount, for example CAD | |     `fee_basis_usd` | number | optional | amount_usd minus the fees inside it. Both are rounded to the cent, so this can be a cent away from what the merchant charged. Never negative, and 0 on a declined row. Too coarse to check a small purchase's fee against a rate | |     `exchange_rate` | number, nullable | optional | The card processor's rate between original_amount and the fee-inclusive amount, so on a USD purchase it sits above 1 and is not a market rate. amount_usd is rounded to the cent, so dividing it by original_amount may not reproduce this. Null when the processor sends none | |     `provider_fee_usd` | number | optional | The fees charged on this purchase, as the card processor itemizes them. Each component is rounded to the cent separately, so this can be a cent above or below the exact fee on card.authorized, and a fee under half a cent reads 0. 0 on a declined row | |     `client_fee_usd` | number | optional | Your markup: fee_basis_usd multiplied by your configured transaction markup, unrounded. It inherits the cent rounding of fee_basis_usd. 0 when no markup is set and 0 on a declined row | |     `fee_usd` | number | optional | provider_fee_usd + client_fee_usd exactly. 0 on a declined row | |       `fx_usd` | number | optional | Combined provider-side and client-side FX fee. USD purchase pricing is applied through the exchange rate and lands here, so a non-zero value does not prove a currency conversion happened | |       `atm_usd` | number | optional | Combined provider-side and client-side ATM fee | |       `deposit_usd` | number | optional | Combined provider-side and client-side deposit fee | |     `decline_reason` | object, nullable | optional | Null unless status is declined. Object with code, message, retryable and card_blocked | |       `code` | string | optional | Closed KTAP enum of 14 values. Branch on this One of: `insufficient_card_balance`, `issuer_funds_unavailable`, `daily_limit_exceeded`, `weekly_limit_exceeded`, `monthly_limit_exceeded`, `single_purchase_limit_exceeded`, `velocity_limit_exceeded`, `card_blocked`, `fraud_risk_declined`, `security_check_failed`, `merchant_category_blocked`, `processing_error`, `authorization_timeout`, `unknown`. | |       `message` | string | optional | KTAP-authored explanation, always present, safe to show a cardholder. Wording can change, so never branch on it | |       `retryable` | boolean | optional | True when the same purchase can succeed on a straight retry with no action | |       `card_blocked` | boolean | optional | True when the card is unusable until KTAP unblocks it. Stop retrying and contact support | |   `count` | integer | optional | Rows in this page. Can be lower than limit even mid-history when a row could not be read, so compare it against total | |   `page` | integer | optional | The page returned, 1-based, after coercion of page=0 to 1 | |   `total_pages` | integer | optional | Total pages for this filter, from the processor | |   `total` | integer | optional | Total purchases matching this filter across all pages, from the processor | ```json { "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "transactions": [ { "id": "a1c4e7f0", "transaction_id": "tid_01a07c3e-4f5a-7b6c-9d8e-0f1a2b3c4d5e", "partner_ref": "your-user-8842", "datetime": "2026-08-19T14:22:41", "status": "settled", "merchant": "CORNER CAFE", "amount_usd": 25.5, "original_amount": 25, "original_currency": "USD", "fee_basis_usd": 25, "exchange_rate": 1.02, "provider_fee_usd": 0.5, "client_fee_usd": 0.5, "fee_usd": 1, "fee_breakdown": { "fx_usd": 0.5, "atm_usd": 0, "deposit_usd": 0 }, "decline_reason": null }, { "id": "b2d5f8a1", "transaction_id": "tid_01a0b2d9-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "partner_ref": "your-user-8842", "datetime": "2026-08-19T12:51:08", "status": "pending", "merchant": "UBER EATS", "amount_usd": 10.2, "original_amount": 10, "original_currency": "USD", "fee_basis_usd": 10, "exchange_rate": 1.02, "provider_fee_usd": 0.2, "client_fee_usd": 0.2, "fee_usd": 0.4, "fee_breakdown": { "fx_usd": 0.2, "atm_usd": 0, "deposit_usd": 0 }, "decline_reason": null }, { "id": "c3e6a9b2", "transaction_id": "tid_01a0b2e7-3c4d-7e5f-9a6b-7c8d9e0f1a2b", "partner_ref": "your-user-8842", "datetime": "2026-08-19T11:07:03", "status": "declined", "merchant": "STEAMGAMES.COM", "amount_usd": 12.24, "original_amount": 12, "original_currency": "USD", "fee_basis_usd": 0, "exchange_rate": 1.02, "provider_fee_usd": 0, "client_fee_usd": 0, "fee_usd": 0, "fee_breakdown": { "fx_usd": 0, "atm_usd": 0, "deposit_usd": 0 }, "decline_reason": { "code": "insufficient_card_balance", "message": "The card balance did not cover the purchase.", "retryable": false, "card_blocked": false } } ], "count": 3, "page": 1, "total_pages": 29, "total": 87 } } ``` **Response 400** A query parameter failed validation, or the processor rejected the search as invalid ```json { "error": "from_date must be a real date in YYYY-MM-DD form", "code": "BAD_REQUEST" } ``` **Response 404** No card with that id belongs to you ```json { "error": "Card not found", "code": "NOT_FOUND" } ``` **Response 502** The card processor returned an error status. History is unavailable, not empty ```json { "error": "Rate limit exceeded and retries exhausted", "code": "UNAVAILABLE" } ``` **Response 503** The card processor rejected KTAP's own credentials ```json { "error": "Service temporarily unavailable", "code": "UPSTREAM_AUTH" } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 400 | `BAD_REQUEST` | limit or page is not a run of digits, limit is above 25, from_date or to_date is not a real YYYY-MM-DD date, or from_date is later than to_date | No. Fix the parameter | | 400 | `BAD_REQUEST` | The card processor rejected the search with its own 400 or 412. A deterministic rejection, mapped to 400 so you do not retry it forever | No | | 401 | `UNAUTHORIZED` | No key, unknown key, revoked key, or your partner account is suspended | No. Fix the credential | | 403 | `FORBIDDEN` | The key is valid but lacks the cards:read scope | No. Ask for the scope | | 404 | `NOT_FOUND` | The card id does not exist, or belongs to another partner. Card status is not checked, so a closed card still returns its history | No | | 405 | `BAD_REQUEST` | Any method other than GET. Produced before authentication, so it is not rate limited or logged against your key | No | | 429 | `RATE_LIMITED` | Your key exceeded rate_limit_per_minute in a rolling one-minute window. The default allowance is 120 per minute | Yes, after the Retry-After value of 60 seconds | | 500 | `UNAVAILABLE` | The processor call failed at the transport level, including the 20 second client timeout, or an internal fault occurred. This is the shape a genuinely unreachable processor takes, not 502 | Yes, shortly | | 502 | `UNAVAILABLE` | The processor returned an error status that is neither 401 nor 400 nor 412, including its 5xx responses and a 429 whose retries were exhausted after 5 attempts | Yes, shortly | | 503 | `UPSTREAM_AUTH` | The processor returned 401 to KTAP. A configuration problem on the KTAP side, never yours | Yes, shortly, then contact support | **Notes** - `limit` is capped at 25 here rather than 100. Page with `page` rather than asking for more rows. - A row whose detail lookup fails is dropped from the response, while `total` still counts it. On any page before the last, a `count` below your `limit` means rows are missing and the page is worth retrying. - `pending` is the normal state for a recent purchase. Its `amount_usd` has already been charged to the card, fee included and rounded to the cent here, but the merchant can still settle a different amount. [Source](https://virtual.ktap.io/docs/api/ledger.md) --- # Receiving events Register an endpoint, verify the signature on every delivery, and respond in time. Register one HTTPS endpoint and we post every event to it. This is the only way to learn about a purchase, a decline or a 3-D Secure challenge as it happens. ## Register an endpoint One endpoint per account, set with `PUT /webhook`. The response carries a secret when the registration is new, when the URL changed, or when you asked to rotate. > **The secret is shown exactly once** > > It is returned in the `PUT` response and never again. There is no endpoint that reads it back. Store it before you do anything else; if you lose it, `PUT` the same URL with `rotate` set to `true` and store the new one. ## What a delivery looks like Every delivery is an HTTPS `POST` with a JSON body and four headers. `X-KTAP-Delivery` equals the body's `id`. ```http POST /your/webhook HTTP/1.1 Content-Type: application/json X-KTAP-Event: card.authorized X-KTAP-Delivery: 4f1c9d2a-1f2e-4a55-9a5d-6b6b0a1d9c33 X-KTAP-Timestamp: 1787391672 X-KTAP-Signature: 9f8a1c... ``` ## Verify the signature The signed string is the timestamp header, one ASCII full stop, then the raw request body. No newline, no length prefix, no version token. The HMAC key is your secret as a UTF-8 string, verbatim including the `whsec_` prefix, and the output is lowercase hex. ```text + "." + ``` > **Sign the raw body, not the re-serialised body** > > Read the raw bytes before any JSON middleware consumes them. Parsing and re-serialising can reorder keys or change number formatting, and the comparison then fails for reasons that look like a bug in the signature rather than in the plumbing. **Verification** ```typescript import { createHmac, timingSafeEqual } from 'node:crypto'; /** Five minutes. Long enough for a legitimate retry, short enough to bound replay. */ const TOLERANCE_SECONDS = 300; export function verify(rawBody: string, headers: Headers, secret: string): boolean { const timestamp = headers.get('x-ktap-timestamp'); const signature = headers.get('x-ktap-signature'); if (!timestamp || !signature) return false; // We do not enforce a freshness window, so you should. Without this a // captured body stays replayable forever. const age = Math.abs(Date.now() / 1000 - Number(timestamp)); if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false; const expected = createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex'); // timingSafeEqual throws on a length mismatch, so guard first. const a = Buffer.from(expected); const b = Buffer.from(signature.toLowerCase()); return a.length === b.length && timingSafeEqual(a, b); } ``` We do not enforce a freshness window, so enforce your own: without one a captured body stays replayable forever. The timestamp is generated per attempt, so a retry carries the same `id` and body but a different signature. ## One purchase, end to end A purchase arrives as more than one event. `card.authorized` is the authorization, `card.transaction` is every later leg, and the three legs are told apart by `event` and `status`, not by the event type. They all carry the SAME `transaction_id`, which is how you group them: key your ledger on the envelope `id` to deduplicate, and on `transaction_id` to group. Pulled history joins the same way: each row of [GET /cards/{id}/transactions](https://virtual.ktap.io/docs/api/ledger.md#listCardTransactions) carries the same `transaction_id`. That row's `id` is a different identifier and never matches a webhook. **1. The authorization.** The card is charged now. `amount_usd` is the total charged to the card, fee included, and `fee_usd` is the part of it that is fee. Record both: the settlement does not repeat the fee. The purchase is not settled yet, and the merchant can still settle a different amount. ```json { "id": "3f8a2c61-7b4e-4d09-a5c3-9e1b6f20d847", "type": "card.authorized", "created_at": "2026-09-16T14:16:23.518Z", "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "partner_ref": "user_8812", "transaction_id": "tid_01a0b2c4-5d6e-7f80-9a1b-2c3d4e5f6a7b", "event": "authorized", "status": "pending", "is_pending": true, "delivery_status": "success", "merchant": "CORNER CAFE", "mcc_code": "5814", "mcc_category": "Fast Food Restaurants", "merchant_country": "USA", "amount_usd": 25.5, "original_amount": 25, "original_currency": "USD", "bill_currency": "USD", "exchange_rate": 1.02, "fee_usd": 0.5, "fee_breakdown": { "fx_usd": 0.5, "atm_usd": 0 }, "decline_reason": null, "occurred_at": "2026-09-16T14:16:21.204Z" } } ``` **2. The settlement.** Same `transaction_id`, `status` flips to `settled` and `is_pending` to `false`. The settlement does not repeat the fee taken at authorization, so its `fee_usd` is normally `0` and its `amount_usd` normally sits below the authorization's. Never overwrite the authorization's figures with these. Compare `original_amount` with the authorization's instead: when they match, the merchant settled what it authorized, and the authorization's `amount_usd` and `fee_usd` stand as the charge. Delivery is unordered, so if a settlement arrives before its authorization, do not book its figures as the charge: wait for `card.authorized`. ```json { "id": "b6d1e4f7-2a58-4c3b-8e90-5f7a1d3c6b24", "type": "card.transaction", "created_at": "2026-09-17T09:02:11.006Z", "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "partner_ref": "user_8812", "transaction_id": "tid_01a0b2c4-5d6e-7f80-9a1b-2c3d4e5f6a7b", "event": "cleared", "status": "settled", "is_pending": false, "delivery_status": "success", "merchant": "CORNER CAFE", "mcc_code": "5814", "mcc_category": "Fast Food Restaurants", "merchant_country": "USA", "amount_usd": 25, "original_amount": 25, "original_currency": "USD", "bill_currency": "USD", "exchange_rate": 1, "fee_usd": 0, "fee_breakdown": { "fx_usd": 0, "atm_usd": 0 }, "decline_reason": null, "occurred_at": "2026-09-17T09:02:09.713Z" } } ``` > **When the merchant settles a different amount** > > A tip added afterwards, a fuel pump or a hotel can settle more than was authorized, and a merchant can settle less. Our card processor then recalculates the fee on the settled amount and adjusts the card once, when the purchase closes. You can spot this because the settlement's `original_amount` differs from the authorization's. Do not assume either event carries the adjusted total: treat that purchase's charge as open and reconcile it with us by `transaction_id`. **3a. A refund.** Money goes back to the card. It is its own `card.transaction` with `event` set to `refund`, sharing the original `transaction_id`, so one transaction can carry three or more events over its life. ```json { "id": "d9c27a15-6e3f-4b81-9a4d-2c8e5f1b7a63", "type": "card.transaction", "created_at": "2026-09-19T16:44:02.118Z", "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "partner_ref": "user_8812", "transaction_id": "tid_01a0b2c4-5d6e-7f80-9a1b-2c3d4e5f6a7b", "event": "refund", "status": "settled", "is_pending": false, "delivery_status": "success", "merchant": "CORNER CAFE", "mcc_code": "5814", "mcc_category": "Fast Food Restaurants", "merchant_country": "USA", "amount_usd": 10, "original_amount": 10, "original_currency": "USD", "bill_currency": "USD", "exchange_rate": 1, "fee_usd": 0, "fee_breakdown": { "fx_usd": 0, "atm_usd": 0 }, "decline_reason": null, "occurred_at": "2026-09-19T16:44:00.371Z" } } ``` > **Branch on `event`, not on the sign** > > Amounts are passed through exactly as the card processor reports them, sign included, so do not assume a refund is negative. Branch on `event` to decide the direction, never on the sign of `amount_usd`. Treating a refund as a debit is the single most common way a partner ledger drifts from ours. **3b. A reversal.** The merchant releases all or part of what it authorized. Same shape, `event` set to `reversal`, and it can carry a `fee_usd` of its own. A reversal can be partial and be followed by a settlement of the rest, so one purchase can carry an authorization, a reversal and a settlement. This sample is a different purchase. ```json { "id": "e4a83b29-1d6c-4f57-b2e8-7c9a0d5f3e16", "type": "card.transaction", "created_at": "2026-09-16T18:30:43.402Z", "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "partner_ref": "user_8812", "transaction_id": "tid_01a0b3f2-8a9b-7c0d-9e1f-2a3b4c5d6e7f", "event": "reversal", "status": "settled", "is_pending": false, "delivery_status": "success", "merchant": "CITY PARKING", "mcc_code": "7523", "mcc_category": "Automobile Parking Lots and Garages", "merchant_country": "USA", "amount_usd": 5, "original_amount": 5, "original_currency": "USD", "bill_currency": "USD", "exchange_rate": 1, "fee_usd": 0.1, "fee_breakdown": { "fx_usd": 0.1, "atm_usd": 0 }, "decline_reason": null, "occurred_at": "2026-09-16T18:30:41.086Z" } } ``` A decline is the other ending: it arrives once as `card.declined`, never pairs with an authorization or a settlement, and carries every fee at zero because nothing moved. `decline_reason` is populated there and null everywhere else. Its `status` reads `settled` because the attempt is final, so recognise a decline by its type, never by `status`. | Leg | Type | `event` | `status` | Money moved | | --- | --- | --- | --- | --- | | Authorization | `card.authorized` | `authorized` | `pending` | Charged to the card, fee included | | Settlement | `card.transaction` | `cleared` | `settled` | Nothing more, unless the merchant settles a different amount | | Refund | `card.transaction` | `refund` | `settled` | Returned to the card | | Reversal | `card.transaction` | `reversal` | `settled` | Released back, in full or in part | | Decline | `card.declined` | `declined` | `settled` | Nothing, ever | ## Respond in time - Deliveries originate from a single static IP, `204.168.213.162`. Allowlist it if your endpoint filters inbound traffic. The signature, not the source address, is the authentication: verify it on every delivery regardless, and we will notify you ahead of any change to the address. - Return any `2xx` within 8 seconds. Anything else counts as a failed attempt and the request is aborted at 8 seconds. - Acknowledge first, process afterwards. Doing the work before you reply is the usual cause of a timeout. - Retries run to 8 attempts with doubling backoff from 1 minute to a 64 minute cap, about 127 minutes end to end. After the eighth failure automatic retries stop, but the event is not lost: requeue it with `POST /events/{eventId}/redeliver`, or backfill from `GET /events`. - Deduplicate on the body's `id`. Delivery is at-least-once and unordered, so a settled event can arrive before the authorisation it belongs to. - Ignore any event type you do not recognise and still return `2xx`. New types can appear without a version bump. - Redirects are followed and the status after the last hop is what counts. ## Endpoints ### GET /webhook Returns the callback URL currently registered for your account. The signing secret is never returned here; it is shown once, in the response to the PUT that minted it. Before your first successful PUT, webhook is null. **Scope** `users:read` **Returns** Your current webhook setting, or null if you have never registered one, as `200`. **Response 200** Your current webhook setting, or null if you have never registered one | Field | Type | | Description | | --- | --- | --- | --- | | `webhook` | object, nullable | optional | Null until your first successful PUT /webhook | |   `url` | string | optional | The normalised absolute https URL events are posted to | |   `active` | boolean | optional | Whether the delivery loop will send to this URL. True on every row the API can create | |   `updated_at` | string | optional | ISO 8601 UTC with milliseconds. Moves on every PUT, including one that keeps your existing secret | ```json { "data": { "webhook": { "url": "https://api.yoursite.com/hooks/ktap", "active": true, "updated_at": "2026-08-12T10:37:58.160Z" } } } ``` **Response 200** No webhook has ever been registered for your account ```json { "data": { "webhook": null } } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 401 | `UNAUTHORIZED` | No key was presented, or the key is unknown, revoked, or belongs to a suspended account | Do not retry. Check the key, then contact KTAP | | 403 | `FORBIDDEN` | The key is valid but does not carry users:read | Do not retry. Use a key that carries the scope | | 405 | `BAD_REQUEST` | POST, PATCH or DELETE is used on /webhook | Do not retry. Use GET to read or PUT to set | | 429 | `RATE_LIMITED` | The key exceeded its per-minute allowance | Wait the number of seconds in Retry-After, which is always 60, then resend | | 500 | `UNAVAILABLE` | An unhandled fault on the KTAP side | Retry after a short pause. The endpoint is read-only | **Notes** - `webhook` is `null` until your first successful `PUT`. There is no error for the unconfigured case. - The secret is never returned here and there is no endpoint that reads it back. If you lose it, `PUT` the same URL with `rotate` set to `true`. ### PUT /webhook Sets the https endpoint where KTAP posts your events, and returns a signing secret when one is minted. Re-sending the same URL is idempotent and keeps your existing secret, so a deploy script can assert the configuration on every release without breaking signature verification. Send rotate true to force a new secret. **Scope** `users:write` **Headers** | Header | | Description | | --- | --- | --- | | `Content-Type` | optional | application/json. The body is parsed as JSON regardless of this header | **Request body** | Field | Type | | Description | | --- | --- | --- | --- | | `url` | string | required | Absolute https URL that KTAP posts events to Must parse as an absolute URL and use the https scheme. Maximum 2000 characters. The hostname must be a public name: IPv4 literals and bracketed IPv6 literals are rejected, as are localhost and any host ending in .localhost, .local or .internal. A missing or non-string url is stringified and fails URL parsing, which returns 400. | | `rotate` | boolean | optional | Mint a new signing secret even when the URL is unchanged Only the JSON literal true is honoured. The string "true", 1, and any other truthy value are treated as absent. Defaults to `false`. | ```json { "url": "https://api.yoursite.com/hooks/ktap" } ``` **Returns** A secret was minted: this is your first registration, the URL changed, or you sent rotate true. Store the secret now, because no endpoint returns it again, as `200`. **Response 200** A secret was minted: this is your first registration, the URL changed, or you sent rotate true. Store the secret now, because no endpoint returns it again | Field | Type | | Description | | --- | --- | --- | --- | |   `url` | string | optional | The normalised URL that was stored | | `secret` | string, nullable | optional | The signing secret, formatted as whsec_ followed by 32 base64url characters. Non-null only when a secret was minted on this call | | `note` | string | optional | Human-readable guidance. The wording is not part of the contract, so branch on whether secret is null | ```json { "data": { "webhook": { "url": "https://api.yoursite.com/hooks/ktap" }, "secret": "whsec_Kq7Zt2mV9xR4bN1sYh6vP0LcE3fJa8uD", "note": "Store this secret now. It is not shown again. Setting a new URL rotates it." } } ``` **Response 200** The normalised URL matched what was already stored and rotate was not true. The stored secret is untouched, active is set true and updated_at is refreshed ```json { "data": { "webhook": { "url": "https://api.yoursite.com/hooks/ktap" }, "secret": null, "note": "URL unchanged. Your existing secret still applies. Send {\"rotate\": true} to force a new one." } } ``` **Response 400** The body is not a JSON object, or the URL failed validation. The same status and code are also returned when the save itself fails, because the handler only echoes messages matching its own validation allowlist ```json { "error": "Webhook URL must use https", "code": "BAD_REQUEST" } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 400 | `BAD_REQUEST` | The body is not valid JSON, the body is not a JSON object, url is absent or does not parse as an absolute URL, the scheme is not https, the host is an IP literal, the host is localhost or ends in.localhost,.local or.internal, or the URL exceeds 2000 characters | Fix the URL and resend. The stored configuration is unchanged | | 400 | `BAD_REQUEST` | The save fails for a reason other than validation, for example a database error. The handler collapses any unrecognised failure into a generic invalid-URL message | Resend the identical body. If a valid URL keeps failing, the fault is on the KTAP side, so retry after a pause and then contact KTAP | | 401 | `UNAUTHORIZED` | No key was presented, or the key is unknown, revoked, or belongs to a suspended account | Do not retry. Check the key, then contact KTAP | | 403 | `FORBIDDEN` | The key is valid but does not carry users:write | Do not retry. Use a key that carries the scope | | 405 | `BAD_REQUEST` | POST, PATCH or DELETE is used on /webhook | Do not retry. Use PUT | | 429 | `RATE_LIMITED` | The key exceeded its per-minute allowance | Wait the number of seconds in Retry-After, which is always 60, then resend | **Notes** - A secret is returned in exactly three cases: you had no endpoint registered, the URL differs from the stored one, or `rotate` is `true`. Otherwise `secret` is `null` and the stored secret is unchanged. - Events are signed with the secret in force at the moment of the delivery attempt. A rotation takes effect immediately on the undelivered backlog as well as on new events, so the previous secret stops verifying anything. - Registering an endpoint flushes your undelivered backlog. Events queued before you had a URL are delivered on the next drain. ### GET /events Newest first, delivered or not. This is the backfill: a consumer that was down pages through what it missed instead of losing it. Payloads are exactly what the webhook delivery carried, so one code path can process both the push and this pull. **Scope** `cards:read` **Query parameters** | Parameter | Type | | Description | | --- | --- | --- | --- | | `type` | string | optional | Only events of this type, for example card.transaction | | `since` | string | optional | Only events created at or after this ISO 8601 timestamp | | `limit` | number | optional | Page size 1 to 100. Defaults to `50`. | | `page` | number | optional | 1-based page number Defaults to `1`. | **Returns** Event history, as `200`. **Response 200** Event history | Field | Type | | Description | | --- | --- | --- | --- | | `events` | array | optional | One entry per event, newest first | |   `id` | string | optional | The event id. The same id appears on the webhook delivery, so dedupe works across both | |   `type` | string | optional | The event type | |   `payload` | object | optional | Exactly the object the webhook delivery carries under data | |   `delivered` | boolean | optional | Whether a delivery attempt has succeeded | |   `delivered_at` | string, nullable | optional | When it succeeded, or null | |   `attempts` | number | optional | Delivery attempts so far | |   `created_at` | string | optional | When the event was generated, UTC | | `count` | number | optional | Events in this page | | `page` | number | optional | This page | | `total` | number | optional | Events matching the filter | | `total_pages` | number | optional | Page until page equals total_pages | ```json { "data": { "events": [ { "id": "5f2c9a1e", "type": "card.transaction", "payload": {}, "delivered": true, "delivered_at": "2026-08-22T10:00:05Z", "attempts": 1, "created_at": "2026-08-22T10:00:00Z" } ], "count": 1, "page": 1, "total": 84, "total_pages": 2 } } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 400 | `BAD_REQUEST` | limit above 100, or an unparseable since | Do not retry | **Notes** - Payloads are exactly what the webhook delivery carries under `data`, so one code path can process both the push and this pull. - The event `id` here matches the `id` on the webhook delivery. Deduplicate on it across both sources. ### POST /events/{eventId}/redeliver Queues the event for delivery with a fresh retry schedule. It arrives with the same event id as the original, so a consumer that deduplicates on id, as the webhook guide instructs, can request redelivery at any time without double-processing. **Scope** `users:write` **Path parameters** | Parameter | Type | | Description | | --- | --- | --- | --- | | `eventId` | string | optional | From GET /events. Events belonging to another account return 404, the same as ids that do not exist | **Returns** Queued for delivery, as `200`. **Response 200** Queued for delivery ```json { "data": { "event_id": "5f2c9a1e", "type": "card.transaction", "queued": true } } ``` **Errors** | Status | Code | When | Retry | | --- | --- | --- | --- | | 404 | `NOT_FOUND` | Unknown event, or an event that is not yours | Do not retry | **Notes** - The redelivery carries the SAME event id as the original, so deduplicating on id makes it safe to request at any time. - Attempts reset to zero, so redelivery is not throttled by an earlier backoff that may already be hours long. [Source](https://virtual.ktap.io/docs/webhooks.md) --- # Event reference Every event type we send, with its payload and an example. Every type we send today. Treat the list as open: ignore a type you do not recognise and return `2xx`. A purchase produces at least two events: `card.authorized` when it is authorised, then a `card.transaction` for each later leg, whether it clears, reverses or refunds. They all carry the same `transaction_id`. A decline stands alone. See [Receiving events](https://virtual.ktap.io/docs/webhooks.md) for signature verification and retries. ### `card.issued` A virtual card was created for you and recorded against your account **Sent when** POST /api/partner/v1/cards succeeds. Emitted after the card exists at the issuer, after the cards row is inserted, and after the first month is billed. It is emitted outside any transaction, so it is queued only once the card genuinely exists. | Field | Type | | Description | | --- | --- | --- | --- | | `card_id` | string | optional | The card id. Use it in every /cards/{id} path | | `partner_ref` | string, nullable | optional | The partner_ref you sent on the issue request, null when you sent none | | `last4` | string, nullable | optional | Last four digits of the PAN, null when the issuer did not return them on create | | `card_holder_name` | string | optional | The name on the card as the issuer stored it: uppercased, at most 26 characters, accents already converted to their ASCII form | ```json { "id": "0f4c2c1e-9a3d-4a67-8b21-5b8e3d1c7a90", "type": "card.issued", "created_at": "2026-08-22T00:32:35.412Z", "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "partner_ref": "user_8812", "last4": "4417", "card_holder_name": "MARIA GONZALEZ" } } ``` - Arrives once per card. A retry that replays an existing card under the same idempotency key emits nothing. ### `card.topped_up` Money moved from your float onto one of your cards **Sent when** POST /api/partner/v1/cards/{id}/topup succeeds. Emitted, after the float ledger has committed and after the issuer confirmed the credit. | Field | Type | | Description | | --- | --- | --- | --- | | `card_id` | string | optional | The card that was credited | | `partner_ref` | string, nullable | optional | Your reference stored on the card | | `amount_requested_usd` | number | optional | The amount_usd you asked for, at most 2 decimal places | | `fee_usd` | number | optional | Always 0 on the float model: the load fee is charged when you fund the float, not when you move money to a card | | `credited_usd` | number | optional | What landed on the card. Equal to amount_requested_usd while fee_usd is 0 | | `partner_balance_usd` | number | optional | Your float BALANCE after the debit, not your remaining spend-limit headroom | ```json { "id": "6b1a08d3-2f4c-4d19-9f77-c0a1e2b4d556", "type": "card.topped_up", "created_at": "2026-08-22T09:14:02.087Z", "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "partner_ref": "user_8812", "amount_requested_usd": 250, "fee_usd": 0, "credited_usd": 250, "partner_balance_usd": 18750.25 } } ``` - A top-up whose processor call fails ambiguously emits nothing, so the absence of this event does not prove the card was not funded. ### `card.authorized` A purchase was authorized and its amount, fee included, was charged to the card. The merchant can still settle a different amount **Sent when** The issuer sends a card event with event_type 'authorized' for a card that belongs to you. | Field | Type | | Description | | --- | --- | --- | --- | | `card_id` | string | optional | The card the purchase happened on | | `partner_ref` | string, nullable | optional | Your reference stored on the card | | `transaction_id` | string, nullable | optional | The purchase id. Join on it to pair this authorization with its later settlement, and with the transaction_id field (not id) on GET /cards/{id}/transactions rows | | `event` | string | optional | Always "authorized" on this event type | | `status` | string | optional | Always "pending" on this event type | | `is_pending` | boolean | optional | Always true on this event type | | `delivery_status` | string | optional | The processor's own status on the source event, lowercased. One of pending, success, failed, canceled, time_canceled, manual_check. Kept for support, do not branch on it | | `merchant` | string, nullable | optional | Merchant name, taken from merchant_name and falling back to merchant_data.name | | `mcc_code` | string, nullable | optional | Merchant category code, passed through from merchant_data.mcc_code | | `mcc_category` | string, nullable | optional | Merchant category label, passed through from merchant_data.mcc_category | | `merchant_country` | string, nullable | optional | Merchant country, passed through from merchant_data.country | | `amount_usd` | number, nullable | optional | The total charged to the card at authorization, in the billing currency, fee included. The merchant can still settle a different amount | | `original_amount` | number, nullable | optional | The amount in the currency the cardholder actually paid in | | `original_currency` | string, nullable | optional | Currency of original_amount, read from transaction_currency_code and falling back to transaction_currency | | `bill_currency` | string, nullable | optional | The currency the card was debited in, read from bill_currency_code and falling back to bill_currency. This is what amount_usd is denominated in | | `exchange_rate` | number, nullable | optional | Close to amount_usd divided by original_amount, but not an exact identity, so do not assert it. amount_usd includes the fee, so this rate includes it too and is not a market rate. It can carry more than 6 decimal places | | `fee_usd` | number | optional | The fee inside amount_usd, at full precision. This is the purchase's fee and the settlement event does not repeat it, so keep it. Six decimals appear here in practice | | `fee_breakdown` | object | optional | { "fx_usd": number, "atm_usd": number }, the two components of fee_usd | | `decline_reason` | object, nullable | optional | Always null on this event type | | `occurred_at` | string, nullable | optional | When the purchase happened, UTC with a Z suffix, from the source event's created_at and falling back to datetime. Distinct from the envelope created_at, which is when the event entered our outbox | ```json { "id": "c74e1b6a-3d55-4b8f-9a2e-1c6d0f5a8b41", "type": "card.authorized", "created_at": "2026-08-22T14:07:54.118Z", "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "partner_ref": "user_8812", "transaction_id": "tid_01a09a51-2b3c-7d4e-8f5a-6b7c8d9e0f1a", "event": "authorized", "status": "pending", "is_pending": true, "delivery_status": "success", "merchant": "CORNER CAFE", "mcc_code": "5814", "mcc_category": "Fast Food Restaurants", "merchant_country": "USA", "amount_usd": 25.5, "original_amount": 25, "original_currency": "USD", "bill_currency": "USD", "exchange_rate": 1.02, "fee_usd": 0.5, "fee_breakdown": { "fx_usd": 0.5, "atm_usd": 0 }, "decline_reason": null, "occurred_at": "2026-08-22T14:07:51.906Z" } } ``` - This is the first event for one purchase. Every later leg arrives as `card.transaction`, and they all carry the same `transaction_id`. - Settlement is slow: the median gap to the settled event is around 22 hours. Show the purchase as pending with this event's `amount_usd` and `fee_usd` straight away. When a settlement arrives with the same `original_amount`, keep those figures as the charge. If a settlement arrives first, do not book its figures: wait for this event. ### `card.transaction` A purchase settled, or a refund or reversal posted. A settlement does not repeat the fee taken at authorization, so its amount is not the total charged **Sent when** The issuer sends a card event with event_type 'cleared', 'refund' or 'reversal' for one of your cards. | Field | Type | | Description | | --- | --- | --- | --- | | `card_id` | string | optional | The card the movement happened on | | `partner_ref` | string, nullable | optional | Your reference stored on the card | | `transaction_id` | string, nullable | optional | The purchase id: the same value as on its card.authorized, and as the transaction_id field (not id) on GET /cards/{id}/transactions rows. Resolved by matching the event timestamp against purchases from the previous 45 days, so a settlement that clears days after the hold still pairs. Null only when that lookup fails or more than one purchase matched: we send the event without an id rather than guess and merge two purchases | | `event` | string | optional | Which movement this is: "cleared", "refund" or "reversal". Branch on this, not on the event type | | `status` | string | optional | Always "settled" on this event type | | `is_pending` | boolean | optional | Always false on this event type | | `delivery_status` | string | optional | The processor's own status on the source event, lowercased. One of pending, success, failed, canceled, time_canceled, manual_check | | `merchant` | string, nullable | optional | Merchant name, from merchant_name falling back to merchant_data.name | | `mcc_code` | string, nullable | optional | Merchant category code from merchant_data.mcc_code | | `mcc_category` | string, nullable | optional | Merchant category label from merchant_data.mcc_category | | `merchant_country` | string, nullable | optional | Merchant country from merchant_data.country | | `amount_usd` | number, nullable | optional | On a settlement (event cleared): the amount the merchant settled, in the billing currency, without the fee taken at authorization, so on a purchase that settled what it authorized it sits below the authorization's amount_usd. On a refund or reversal: the amount released back. Branch on event for the direction | | `original_amount` | number, nullable | optional | The amount in the currency the cardholder paid in | | `original_currency` | string, nullable | optional | Currency of original_amount | | `bill_currency` | string, nullable | optional | The currency the card was debited in | | `exchange_rate` | number, nullable | optional | Rate between original_amount and amount_usd on this event. It usually differs from the authorization's rate, because the authorization's amount_usd includes the fee and this one does not | | `fee_usd` | number | optional | Not the purchase's fee. The fee is taken at authorization and reported on card.authorized, so on a settlement this is normally 0. Keep the authorization's fee_usd rather than replacing it with this | | `fee_breakdown` | object | optional | { "fx_usd": number, "atm_usd": number } | | `decline_reason` | object, nullable | optional | Always null on this event type | | `occurred_at` | string, nullable | optional | When the movement happened, UTC with a Z suffix | ```json { "id": "e1907c22-4a6b-49d8-b0f3-5d2c8e7a1140", "type": "card.transaction", "created_at": "2026-08-23T12:41:19.006Z", "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "partner_ref": "user_8812", "transaction_id": "tid_01a09a51-2b3c-7d4e-8f5a-6b7c8d9e0f1a", "event": "cleared", "status": "settled", "is_pending": false, "delivery_status": "success", "merchant": "CORNER CAFE", "mcc_code": "5814", "mcc_category": "Fast Food Restaurants", "merchant_country": "USA", "amount_usd": 25, "original_amount": 25, "original_currency": "USD", "bill_currency": "USD", "exchange_rate": 1, "fee_usd": 0, "fee_breakdown": { "fx_usd": 0, "atm_usd": 0 }, "decline_reason": null, "occurred_at": "2026-08-23T12:41:17.882Z" } } ``` - This is the second event for a purchase that already arrived as `card.authorized`. Pair them on `transaction_id` and tell them apart by `status`. - A refund or reversal is its own `card.transaction` sharing the original `transaction_id`, so one transaction can produce three or more events. Key your ledger on the event `id` for deduplication and on `transaction_id` for grouping. - A settlement does not repeat the fee taken at authorization: its `fee_usd` is normally `0`. Keep the authorization's `amount_usd` and `fee_usd` as the charge while `original_amount` is unchanged. If it changed, treat the charge as open and reconcile it with us by `transaction_id`. ### `card.declined` A purchase was declined at the point of sale **Sent when** The issuer sends a card event with event_type 'declined' for one of your cards. | Field | Type | | Description | | --- | --- | --- | --- | | `card_id` | string | optional | The card the attempt was made on | | `partner_ref` | string, nullable | optional | Your reference stored on the card | | `transaction_id` | string, nullable | optional | The declined attempt's purchase id, resolved the same way as a settlement and null only when that lookup fails. A decline never pairs with another event, so use it to find the attempt on GET /cards/{id}/transactions (its transaction_id field) | | `event` | string | optional | Always "declined" on this event type | | `status` | string | optional | Always "settled". A decline is final but moved no money, so do not read "settled" here as "money moved" | | `is_pending` | boolean | optional | Always false on this event type | | `delivery_status` | string | optional | The processor's own status on the source event, lowercased | | `merchant` | string, nullable | optional | Merchant name | | `mcc_code` | string, nullable | optional | Merchant category code. Use it with mcc_category to recognise a blocked or high-risk category | | `mcc_category` | string, nullable | optional | Merchant category label | | `merchant_country` | string, nullable | optional | Merchant country | | `amount_usd` | number, nullable | optional | The amount that was attempted, in the billing currency. Nothing was charged | | `original_amount` | number, nullable | optional | The attempted amount in the cardholder's currency | | `original_currency` | string, nullable | optional | Currency of original_amount | | `bill_currency` | string, nullable | optional | The currency the card would have been debited in | | `exchange_rate` | number, nullable | optional | Rate that would have applied | | `fee_usd` | number | optional | Always 0 on a decline, forced to zero so this event and GET /cards/{id}/transactions cannot disagree | | `fee_breakdown` | object | optional | { "fx_usd": 0, "atm_usd": 0 } on a decline | | `decline_reason` | object, nullable | optional | The reason object: { "code": string, "message": string, "retryable": boolean, "card_blocked": boolean }. code is one of a fixed set we control: insufficient_card_balance, issuer_funds_unavailable, daily_limit_exceeded, weekly_limit_exceeded, monthly_limit_exceeded, single_purchase_limit_exceeded, velocity_limit_exceeded, card_blocked, fraud_risk_declined, security_check_failed, merchant_category_blocked, processing_error, authorization_timeout, unknown. An unrecognised upstream code maps to unknown. Branch on code; message is display text we author | | `occurred_at` | string, nullable | optional | When the attempt happened, UTC with a Z suffix | ```json { "id": "3c8d5f10-b7a2-4e69-8c11-9f0e2d4a6b73", "type": "card.declined", "created_at": "2026-08-22T18:22:41.503Z", "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "partner_ref": "user_8812", "transaction_id": "tid_01a0b3e1-77c2-7d04-8e5f-6a1b2c3d4e5f", "event": "declined", "status": "settled", "is_pending": false, "delivery_status": "success", "merchant": "STEAMGAMES.COM", "mcc_code": "5816", "mcc_category": "Digital Goods: Games", "merchant_country": "LU", "amount_usd": 59.99, "original_amount": 59.99, "original_currency": "USD", "bill_currency": "USD", "exchange_rate": 1, "fee_usd": 0, "fee_breakdown": { "fx_usd": 0, "atm_usd": 0 }, "decline_reason": { "code": "insufficient_card_balance", "message": "The card balance did not cover the purchase.", "retryable": false, "card_blocked": false }, "occurred_at": "2026-08-22T18:22:40.115Z" } } ``` - A decline stands alone. It never pairs with a `card.authorized` or a `card.transaction`. - `decline_reason.card_blocked` means the card is unusable until we unblock it. Stop retrying and contact us. ### `card.3ds_challenge` A 3DS one-time code for one of your cards, for you to deliver to the cardholder **Sent when** The issuer sends a card event with event_type 'notification_3ds_authorization' for one of your cards. Every card delivers its 3DS codes this way: none are sent by text message. | Field | Type | | Description | | --- | --- | --- | --- | | `card_id` | string | optional | The card being challenged | | `partner_ref` | string, nullable | optional | Your reference stored on the card, so you can route the code to the right end user without a lookup | | `code` | string, nullable | optional | The challenge's identifier. It is not a code for the cardholder to type: send it back with POST /cards/{cardId}/3ds/respond and approve true or false. Null when the source event carried no code | | `auto_approved` | boolean | optional | True when we already approved this challenge because auto-approve is on for your account. Nothing more to do. False means the purchase waits for your answer | | `merchant` | string, nullable | optional | Merchant requesting the challenge, from merchant_name | | `merchant_country` | string, nullable | optional | Merchant country, from merchant_country_code | | `amount` | number, nullable | optional | The transaction amount being challenged, from transaction_amount. Not converted to USD | | `currency` | string, nullable | optional | Currency of amount, from transaction_currency | | `occurred_at` | string, nullable | optional | When the challenge was raised, from transaction_timestamp | ```json { "id": "7d0b3a99-1e42-4c07-a5f8-6b3d9c2e0417", "type": "card.3ds_challenge", "created_at": "2026-08-22T15:03:11.271Z", "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "partner_ref": "user_8812", "code": "481902", "merchant": "BOOKING.COM", "merchant_country": "NL", "amount": 412.5, "currency": "EUR", "occurred_at": "2026-08-22T15:03:10.804Z" } } ``` - Time critical. Acknowledge it immediately and deliver the code to your cardholder: a failed first attempt costs at least a minute of backoff, by which point the challenge has usually expired. - Sent for every card: 3DS codes are delivered by webhook only, never by text message. Keep your webhook registered and fast. ### `card.closed` A card was closed and any balance left on it returned to your float **Sent when** POST /api/partner/v1/cards/{id}/close succeeds. Emitted, after the sweep, after the issuer deleted the card, and after the local cards row is marked DELETED. | Field | Type | | Description | | --- | --- | --- | --- | | `card_id` | string | optional | The card that was closed | | `partner_ref` | string, nullable | optional | Your reference stored on the card | | `returned_usd` | number | optional | The balance swept off the card back to your float. 0 when the card was empty. On a retry of a partly completed close this reports the amount recorded by the original sweep, not the card's current zero balance | ```json { "id": "a2d9f7b1-55c8-4e02-8a3b-71f4c9e0d233", "type": "card.closed", "created_at": "2026-08-22T11:40:55.900Z", "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "partner_ref": "user_8812", "returned_usd": 12.34 } } ``` - Closing does not return card allowance. Your lifetime issuance count still includes the closed card. ### `deposit.credited` A crypto payment matched one of your deposit quotes and the card was credited (quote-matched variant) **Sent when** The watcher confirms an on-chain transfer that matches a pending partner_deposits row on asset, exact amount and destination address. Emitted, after the issuer confirmed the card credit. Available only to partners with deposits_enabled true. It is also sent when the watcher sees a confirmed transfer to an address derived for a specific card, on EVM chains, BTC or TRON. Emitted, after the issuer confirmed the credit. Available only to partners with deposits_enabled true; a transfer to a float-only partner's card is held for manual handling and emits nothing. | Field | Type | | Description | | --- | --- | --- | --- | | `deposit_id` | string | optional | The deposit quote this payment settled. Present only on this variant | | `card_id` | string | optional | The card that was credited | | `credited_usd` | number | optional | USD placed on the card, net of fees, rounded to cents | | `tx_hash` | string | optional | The on-chain transaction hash that funded it | | `partner_ref` | string, nullable | optional | Card-address variant only. Your id for the cardholder, as set at issuance | | `asset` | string | optional | Card-address variant only. The asset code the transfer arrived in | | `amount_received` | number | optional | Card-address variant only. The on-chain amount received, in crypto units of asset, not USD | | `fee_usd` | number | optional | Card-address variant only. Everything deducted before the credit. It can exceed ktap_fee_usd plus partner_fee_usd because it absorbs the sub-cent truncation remainder | | `ktap_fee_usd` | number | optional | Card-address variant only. Our share of the fee | | `partner_fee_usd` | number | optional | Card-address variant only. Your markup. Accrues to your commission balance | ```json { "id": "b58c1d7e-90a3-4f21-b6c4-2e7f8a3d0159", "type": "deposit.credited", "created_at": "2026-08-22T20:11:44.630Z", "data": { "deposit_id": "41f6d2c8-7b09-4a35-9e18-c3d5b0a7f264", "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "credited_usd": 98.4, "tx_hash": "0x9c1f3a7d4e2b6058ab77c0d9e3f1425a6b8c0d2e4f6a8b1c3d5e7f9012345678" } } ``` - Two payload shapes ship under this type. Tell them apart by presence: `deposit_id` marks one, `partner_ref` with `asset` marks the other. Read every field defensively. ### `float.low` Your spendable float fell below the low-balance threshold **Sent when** The watcher's partner float check runs on every tick. It emits this when available_usd falls below the threshold, once per drop. | Field | Type | | Description | | --- | --- | --- | --- | | `available_usd` | number | optional | What you can actually spend: the lower of your balance and your remaining spend limit. The same figure GET /me reports as float.available_usd. This is the number that gates a top-up | | `balance_usd` | number | optional | Your raw float balance, ignoring any spend limit. It can be far higher than available_usd when a limit binds | | `threshold_usd` | number | optional | The threshold that fired, $500 by default | | `message` | string | optional | Fixed display text: "Your balance is running low. Send funds to avoid card top-ups being declined." | ```json { "id": "5a1e8c73-2b90-4f16-8d47-e0c9b3f5a218", "type": "float.low", "created_at": "2026-08-22T07:00:19.442Z", "data": { "available_usd": 412.8, "balance_usd": 50412.8, "threshold_usd": 500, "message": "Your balance is running low. Send funds to avoid card top-ups being declined." } } ``` - About your account, not a card. It carries no `card_id` and no `partner_ref`. - Sent once when your available balance (`available_usd`) falls below the threshold, not repeated while it stays there. It comes again, with a new event id, only after your available balance has recovered to a fifth above the threshold and fallen below it again. ### `float.credited` A transaction hash you submitted with POST /funding was credited to your balance **Sent when** A submission to POST /api/partner/v1/funding becomes credited: the transaction was verified on-chain, the funds arrived (on BNB Chain and Base, once the transaction is final on its network), and the funding and its load fee were written to your ledger. The ledger rows, the credited status and this event are recorded together, so none exists without the others. Also when we credit a submission after review. Sent once per submission. Not sent for a submission credited separately, nor for a reversal. | Field | Type | | Description | | --- | --- | --- | --- | | `funding_id` | string | optional | The id POST /funding returned for this submission | | `network` | string | optional | The network the transaction was made on One of: `POLYGON`, `BNB`, `BASE`. | | `tx_hash` | string | optional | The transaction hash, lowercased | | `token` | string | optional | The stablecoin sent One of: `USDC`, `USDT`. | | `amount_usd` | number | optional | The amount credited before the load fee: normally the amount sent, what the transaction paid into your funding address; after a review, if less reached us than was sent, no more than reached us. It can therefore be lower than amount_usd on GET /funding/{fundingId}, which is always the amount sent | | `fee_usd` | number | optional | The load fee on your agreement, charged on amount_usd | | `credited_usd` | number | optional | amount_usd minus fee_usd: what reached your balance | | `balance_usd` | number | optional | Your balance straight after this credit | ```json { "id": "6f3b8d2a-9c14-4e70-b5a8-1d7e3c9f2b46", "type": "float.credited", "created_at": "2026-09-23T10:17:40.618Z", "data": { "funding_id": "3b9e6f1a-7c42-4d85-a1e0-5f2c8d9b4e63", "network": "POLYGON", "tx_hash": "0x4a7e2c9d1b8f3e6a5c0d7b2e9f4a1c8d3e6b0f5a2d9c7e4b1a8f6d3c0e5b2a91", "token": "USDC", "amount_usd": 2500.25, "fee_usd": 25.0025, "credited_usd": 2475.2475, "balance_usd": 12475.2475 } } ``` - Sent once per submission, when it becomes `credited`. `funding_id` is the `id` that [POST /funding](/docs/api/account#submitFunding) returned. A submission we close as `credited` with reason `credited_separately` after a review sends none: those funds were already credited to your balance by other means. A reversal sends none either. Its `amount_usd` is the amount credited before the load fee: normally the amount sent, but after a review no more than reached us, so it can be below `amount_usd` on [GET /funding/{fundingId}](/docs/api/account#getFunding). - About your account, not a card. It carries no `card_id` and no `partner_ref`. - `balance_usd` is your balance straight after the credit. A top-up is checked against `float.available_usd` on [GET /me](/docs/api/account#getMe), which a limit can hold below it. ### `card.unloaded` Money moved off one of your cards back into your balance **Sent when** POST /api/partner/v1/cards/{id}/unload succeeds. Emitted (unloadPartnerCard), after the processor subtracted from the card and the balance credit committed. | Field | Type | | Description | | --- | --- | --- | --- | | `card_id` | string | optional | The card that was unloaded | | `partner_ref` | string, nullable | optional | Your reference stored on the card | | `amount_usd` | number | optional | What came off the card and returned to your balance | | `partner_balance_usd` | number | optional | Your balance after the credit | ```json { "id": "8c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f", "type": "card.unloaded", "created_at": "2026-08-24T10:15:00.000Z", "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "partner_ref": "your-user-8842", "amount_usd": 50, "partner_balance_usd": 9995 } } ``` - Money left the card and returned to your balance. Pairs with a `POST /cards/{id}/unload` you made; there is no processor-initiated unload. ### `card.status_changed` A card's status changed, whether you changed it or we did **Sent when** The card processor blocks a card on velocity rules, or you freeze or unfreeze one via POST /cards/{id}/freeze. All status changes arrive on this one event so a consumer maintains card state from a single stream. | Field | Type | | Description | | --- | --- | --- | --- | | `card_id` | string | optional | The card whose status changed | | `partner_ref` | string, nullable | optional | Your reference stored on the card | | `status` | string | optional | The status the card is now in One of: `ACTIVE`, `FROZEN`, `BLOCKED`. | | `reason` | string | optional | Why it changed. velocity means the processor blocked it after repeated limit breaches; partner_freeze and partner_unfreeze are your own freeze calls, mirrored here so your webhook consumer sees them without coordinating with the caller One of: `velocity`, `partner_freeze`, `partner_unfreeze`. | | `action_required` | string, nullable | optional | contact_support when only we can restore the card, as with a velocity block. null when no action is needed | | `occurred_at` | string, nullable | optional | When the change happened, UTC | ```json { "id": "3e2a1b0c-9d8e-7f6a-5b4c-3d2e1f0a9b8c", "type": "card.status_changed", "created_at": "2026-08-24T10:20:00.000Z", "data": { "card_id": "7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u", "partner_ref": "your-user-8842", "status": "BLOCKED", "reason": "velocity", "action_required": "contact_support", "occurred_at": "2026-08-24T10:19:58.412Z" } } ``` - One stream for every status change, whether you made it (freeze, unfreeze) or we did (velocity block). `reason` tells you which. - A `BLOCKED` card cannot be restored through the API. `action_required: "contact_support"` means exactly that. Something arriving that is not described here, or an event you expected and did not get: email [dev@ktap.io](mailto:dev@ktap.io) with the delivery id and the time in UTC. [Source](https://virtual.ktap.io/docs/webhooks/events.md)