---
title: "Ledger"
description: "Every movement on your balance, and every transaction on a card, with the fees on each."
kind: reference
url: /docs/api/ledger
canonical_url: "https://virtual.ktap.io/docs/api/ledger"
---

> 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

# 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-<NETWORK>-<tx_hash>`, and a reversal of one `funding-reversal-<NETWORK>-<tx_hash>` |
| `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.
