API reference · v1

Seven endpoints,
one ledger.

Ledge is one hosted, multi-tenant backend — the same shape as Stripe, not something you deploy. Define currencies, grant and spend them atomically, and read a balance that is always right.

Base URL
https://api.ledge.dev/v1
Placeholder domainNot registered yet — today this is served at /api/v1 on the current deployment. A dedicated api. subdomain is a later decision.
Snippets in
— every example below switches together.

Quickstart

Three calls from empty project to a balance you can trust. Use a sk_test_ key — test data is fully isolated from live.

Step 1Define a currency
curl …/v1/currencies \
  -H "Authorization: Bearer sk_test_..." \
  -d '{"code": "gems"}'
Codes are permanent and unique per project. Up to five per mode.
Step 2Grant some of it
curl …/v1/grant \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"external_id": "user_42",
       "currency": "gems",
       "amount": 500}'
No “create customer” step — the account appears on first touch.
Step 3Spend it
curl …/v1/spend \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"external_id": "user_42",
       "currency": "gems",
       "amount": 100}'
# → balances: { "gems": 400 }
Concurrent spends can't cross zero. No lock, no retry loop.

Authentication

Every request carries an API key in the Authorization header. Generate one in the dashboard under Project → API keys → New key. The secret is shown exactly once — we store only a hash.

testsk_test_…Safe to use anywhere you'd use fixtures
livesk_live_…Server-side only — never ship it to a client
Test and live are fully isolatedAn external_id created under a test key and the same one under a live key are unrelated accounts with unrelated balances. There is no shared staging dataset — whichever key authenticates decides which side you read and write. The mode field on responses reflects that automatically.
Request headers
Authorization: Bearer sk_test_51H8x...
Content-Type: application/json
Idempotency-Key: 3f29a1c4-8b12-4e9a-9c3d-1a2b…

Idempotency

/grant, /spend, /transfer and /convert all require an Idempotency-Key header — any string unique to that one operation. A UUID is fine.

Retrying with the same key is always safe: you get the original result back without the mutation being applied twice, even when your first attempt's response was lost to a dropped connection. Keys are scoped to your project, not globally unique.

The webhook case this exists forStripe retries a checkout.session.completed webhook. You call /grantagain with your order id as the key. The player's balance does not move a second time.
Same key, twicegranted once
POST /v1/grant   Idempotency-Key: ord_9182
→ 200  { "transactionId": "9592b19e…",
         "balances": { "gems": 500 } }

POST /v1/grant   Idempotency-Key: ord_9182
→ 200  { "transactionId": "9592b19e…",
         "balances": { "gems": 500 } }

Amounts & decimals

Every amount is a positive integer in the currency's smallest unit — never a float. A currency with decimals: 2 reads 150 as 1.50, the convention ISO 4217 and Stripe both use.

Currencydecimalsamount: 150 means
gems0150 gems
store_credit21.50 credit

Most virtual currencies want the default 0. Because /convert takes two explicit integers rather than a rate, rounding is always your call.

Pagination

List endpoints page with an opaque cursor. Pass the last item's cursor as starting_after to get the next page, and stop when has_more is false.

Requests
?limit=2
→ { "transactions": [ … ], "has_more": true }

?limit=2&starting_after=7c2e2f1b-0d3f-…
→ { "transactions": [ … ], "has_more": false }
Don't parse a cursor, and don't substitute id for it: a convert writes two entries — one per currency leg — sharing a single transaction id, so only cursor is unique across a page.

Errors

Every failure returns the same body shape, so one handler covers the surface.

{ "error": { "code": "insufficient_balance",
             "message": "Insufficient balance: have 400, need 10000" } }
StatuscodeMeaning
401unauthorizedMissing or invalid API key.
400invalid_requestMissing or malformed request field — see message.
400missing_idempotency_key/grant, /spend, /transfer or /convert called without an Idempotency-Key header.
400unknown_currencycurrency doesn't exist for this project — create it first.
400currency_already_existsPOST /v1/currencies called with a code already used in this project + mode.
400insufficient_balanceSpend would take the account below zero. Nothing is deducted.
400currency_cap_exceededProject already has the maximum currencies (5 in v0, applied per mode — 5 test plus 5 live, not 5 combined).
500internal_errorSomething broke on our end.
No rate limiting yetNothing throttles requests at any tier today, and nothing returns 429. Don't build a retry-on-429 path expecting one.
POST/v1/currencies

Define a currency

Name a thing your users can hold — gems, coins, a GenAI app's credits. Capped at five per project per mode, so maxing out test doesn't touch your live cap.

Required headers
Authorization
Body
codestringrequired
Your identifier for this currency, e.g. "gems". Unique per project, and permanent — transactions reference it directly.
decimalsintoptional
Minor-unit precision, default 0. A currency with decimals: 2 treats amount: 150 as 1.50. Most virtual currencies want 0.
modeMatches whichever key authenticated the request — sk_test_ gives "test", sk_live_ gives "live". Not something you set.
Duplicate codeCalling this twice with the same code (same project, same mode) fails with currency_already_exists — not a silent no-op.
Request · curl
curl https://api.ledge.dev/v1/currencies \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{"code": "gems"}'
Response200 ok
{
  "id": "1268308e-91fb-40b8-a5d4-2ee705151554",
  "projectId": "8ed677e7-134e-4890-8ab5-…",
  "code": "gems",
  "decimals": 0,
  "mode": "test",
  "createdAt": "2026-07-24T21:46:40.911Z"
}
POST/v1/grant

Credit an account

Purchases (after you've collected the payment yourself), promos, compensation — any source of currency. Creates the account automatically if the external_id hasn't been seen before.

Required headers
AuthorizationIdempotency-Key
Body
external_idstringrequired
Your own identifier for the account. No Ledge-issued id to track separately.
currencystringrequired
Must already exist — create it with POST /v1/currencies.
amountintrequired
Positive integer, in the currency's smallest unit.
reasonstringoptional
Free-form tag for your own reporting: "purchase", "promo", "compensation". Doesn't affect behavior.
metadataobjectoptional
Arbitrary JSON attached to the transaction — your order id, for instance.
balancesAlways the account's full set across every project currency, not just the one granted — one round trip refreshes a whole wallet UI.
Request · curl
curl https://api.ledge.dev/v1/grant \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "external_id": "user_42",
    "currency": "gems",
    "amount": 500,
    "reason": "purchase",
    "metadata": { "order_id": "ord_9182" }
  }'
Response200 ok
{
  "transactionId": "9592b19e-80a0-498c-8740-…",
  "balances": { "gems": 500 }
}
POST/v1/spend

Debit an account

Atomic and race-safe: two concurrent spends against the same account can never take a balance negative, whatever the timing. You never lock, retry, or reason about it.

Required headers
AuthorizationIdempotency-Key
Body — identical shape to /grant
external_idstringrequired
The account to debit.
currencystringrequired
Must already exist for this project.
amountintrequired
Positive integer, in the currency's smallest unit.
reason, metadataoptional
Same as /grant.
Request · curl
curl https://api.ledge.dev/v1/spend \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"external_id": "user_42", "currency": "gems", "amount": 100}'
Response200 ok
{
  "transactionId": "ed005177-58e4-4cc2-9616-…",
  "balances": { "gems": 400 }
}
Rejected — nothing deducted400
{ "error": {
    "code": "insufficient_balance",
    "message": "Insufficient balance: have 400, need 10000" } }
GET/v1/accounts/{external_id}/balance

Read a balance

Every currency defined for the project, always — including ones this account has never touched, and including accounts with no activity at all. Accounts are implicit; there's nothing to 404 on.

Required headers
Authorization
Late-added currenciesA currency created after this account's first activity still appears here, at 0 — so your UI never has to special-case a missing key.
Request · curl
curl https://api.ledge.dev/v1/accounts/user_42/balance \
  -H "Authorization: Bearer sk_test_..."
Response200 ok
{
  "external_id": "user_42",
  "balances": { "gems": 400, "coins": 0 }
}
GET/v1/accounts/{external_id}/transactions

List account history

This account's side of each transaction, newest first. A grant's offsetting entry against the internal issuance account isn't included — this is the dev-facing view, not the raw double-entry ledger.

Required headers
Authorization
Query params
limitintoptional
Max rows to return. Default 50, max 100.
starting_afterstringoptional
A previous page's last item's cursor value. Fetches the page after it.
cursor, not idA convert produces two entries — one per currency leg — against the same transaction id, so id alone isn't unique across a page. Page on cursor.
Request · curl
curl "https://api.ledge.dev/v1/accounts/user_42/\
transactions?limit=2" \
  -H "Authorization: Bearer sk_test_..."
Response200 ok
{
  "external_id": "user_42",
  "transactions": [
    { "id": "ed005177-58e4-…",
      "cursor": "3b1f1e0a-9e2e-…",
      "type": "spend",
      "reason": null,
      "createdAt": "2026-07-24T21:46:42.207Z",
      "currencyCode": "gems",
      "amount": -100 }
  ],
  "has_more": true
}
POST/v1/transfer

Move currency between accounts

One user sends another gems. Debit and credit happen together or not at all, race-safe the same way /spend is. Closed-loop by design: same currency, same project, no cash-out.

Required headers
AuthorizationIdempotency-Key
Body
from_external_idstringrequired
Sender's account.
to_external_idstringrequired
Recipient's account. Must differ from the sender.
currencystringrequired
Same currency on both sides — there's no cross-currency transfer.
amountintrequired
Positive integer.
reason, metadataoptional
Same as /grant.
Request · curl
curl https://api.ledge.dev/v1/transfer \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"from_external_id": "alice",
       "to_external_id": "bob",
       "currency": "coins",
       "amount": 20}'
Response200 ok
{
  "transactionId": "e1387948-9279-4f13-a12a-…",
  "from": { "externalId": "alice",
            "balances": { "coins": 50 } },
  "to":   { "externalId": "bob",
            "balances": { "coins": 20 } }
}
Sender can't cover it — nothing moves400
{ "error": {
    "code": "insufficient_balance",
    "message": "Insufficient balance: have 15, need 20" } }
POST/v1/convert

Exchange one currency for another

A spend of one currency and a grant of another, bundled into a single atomic transaction. Both amounts are explicit integers you supply rather than a floating-point rate — so rounding is always your call, not ours.

Required headers
AuthorizationIdempotency-Key
Body
external_idstringrequired
The account holding both currencies.
from_currencystringrequired
Must differ from to_currency.
to_currencystringrequired
The currency being credited.
from_amountintrequired
Positive integer, deducted from from_currency.
to_amountintrequired
Positive integer, credited in to_currency.
reason, metadataoptional
Same as /grant.
Two rows, one transactionA convert writes one history entry per currency leg, both carrying this transactionId. Expect the pair when you read the account's history.
Request · curl
curl https://api.ledge.dev/v1/convert \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"external_id": "alice",
       "from_currency": "gems",
       "to_currency": "coins",
       "from_amount": 20,
       "to_amount": 200}'
Response200 ok
{
  "transactionId": "3f5c52ef-4b51-48f8-bdb6-…",
  "balances": { "coins": 250, "gems": 80 }
}

TypeScript SDK

A server-side wrapper over this API, covering every endpoint with typed params and responses, and generating idempotency keys for you. This document stays the source of truth for the wire format either way.

ledge-sdk on GitHubServer-side only — it holds a secret key.
npm
npm i ledge-sdk

import { Ledge } from "ledge-sdk";
const ledge = new Ledge(process.env.LEDGE_SECRET_KEY!, {
  baseUrl: process.env.LEDGE_API_URL!,
});

await ledge.grant({
  externalId: "user_42",
  currency: "gems",
  amount: 500,
});

Not yet available

A hosted purchase flowv0 is ledger-only — Ledge doesn't touch money movement. Bring your own payment integration and call /grantonce you've confirmed the payment yourself.
© 2026 Ledge