---
title: "Account and balance"
description: "Read your account, fund your balance, check your spendable balance and your rates, and set your markup."
kind: reference
url: /docs/api/account
canonical_url: "https://virtual.ktap.io/docs/api/account"
---

> Part of the KTAP Cards API documentation.
> Index of every page: https://virtual.ktap.io/llms.txt
> Machine-readable schema: https://virtual.ktap.io/api/partner/v1/openapi.json

# 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-<NETWORK>-<tx_hash>` 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-<NETWORK>-<tx_hash>` 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-<NETWORK>-<tx_hash>` 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.
