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.
Three calls from empty project to a balance you can trust. Use a sk_test_ key — test data is fully isolated from live.
curl …/v1/currencies \
-H "Authorization: Bearer sk_test_..." \
-d '{"code": "gems"}'curl …/v1/grant \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"external_id": "user_42",
"currency": "gems",
"amount": 500}'curl …/v1/spend \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"external_id": "user_42",
"currency": "gems",
"amount": 100}'
# → balances: { "gems": 400 }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.
Authorization: Bearer sk_test_51H8x... Content-Type: application/json Idempotency-Key: 3f29a1c4-8b12-4e9a-9c3d-1a2b…
/grant, /spend, /transfer and /convert all require an Idempotency-KeyIdempotency-KeyAny string unique to one operation — a UUID, or better, your own order id. Same key, same result, one mutation. 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.
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 } }Every amount is a positive integer in the currency's smallest unitsmallest unitAmounts are integers in the currency's minor unit. decimals: 0 means 500 is 500 gems; decimals: 2 means 150 is 1.50. — never a float. A currency with decimals: 2 reads 150 as 1.50, the convention ISO 4217 and Stripe both use.
Most virtual currencies want the default 0. Because /convert takes two explicit integers rather than a rate, rounding is always your call.
List endpoints page with an opaque cursorcursorAn opaque pagination token. Pass the last row's cursor as starting_after. Never parse it, and never use id instead — a convert shares one id across two rows.. Pass the last item's cursor as starting_after to get the next page, and stop when has_more is false.
?limit=2
→ { "transactions": [ … ], "has_more": true }
?limit=2&starting_after=7c2e2f1b-0d3f-…
→ { "transactions": [ … ], "has_more": false }Every failure returns the same body shape, so one handler covers the surface.
{ "error": { "code": "insufficient_balance",
"message": "Insufficient balance: have 400, need 10000" } }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.
curl https://api.ledge.dev/v1/currencies \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{"code": "gems"}'{
"id": "1268308e-91fb-40b8-a5d4-2ee705151554",
"projectId": "8ed677e7-134e-4890-8ab5-…",
"code": "gems",
"decimals": 0,
"mode": "test",
"createdAt": "2026-07-24T21:46:40.911Z"
}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.
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" }
}'{
"transactionId": "9592b19e-80a0-498c-8740-…",
"balances": { "gems": 500 }
}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.
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}'{
"transactionId": "ed005177-58e4-4cc2-9616-…",
"balances": { "gems": 400 }
}{ "error": {
"code": "insufficient_balance",
"message": "Insufficient balance: have 400, need 10000" } }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.
curl https://api.ledge.dev/v1/accounts/user_42/balance \ -H "Authorization: Bearer sk_test_..."
{
"external_id": "user_42",
"balances": { "gems": 400, "coins": 0 }
}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.
curl "https://api.ledge.dev/v1/accounts/user_42/\ transactions?limit=2" \ -H "Authorization: Bearer sk_test_..."
{
"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
}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.
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}'{
"transactionId": "e1387948-9279-4f13-a12a-…",
"from": { "externalId": "alice",
"balances": { "coins": 50 } },
"to": { "externalId": "bob",
"balances": { "coins": 20 } }
}{ "error": {
"code": "insufficient_balance",
"message": "Insufficient balance: have 15, need 20" } }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.
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}'{
"transactionId": "3f5c52ef-4b51-48f8-bdb6-…",
"balances": { "coins": 250, "gems": 80 }
}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.
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,
});