Better Auth ledger
Use @onmax/better-auth-ledger for app-local credits, points, gems, coins, rewards, or internal usage balances tied to Better Auth users.
Do not use it for stored value, banking, cash-out, settlement, custody, or regulated balances. Keep Better Auth origin and CSRF protections enabled, and keep real-money payment and compliance logic in your application.
Register read-only ledger endpoints
Use the server plugin when signed-in users should read their ledger account, balances, and entries.
import { betterAuth } from 'better-auth'
import { ledger } from '@onmax/better-auth-ledger'
export const auth = betterAuth({
plugins: [
ledger({
assets: [
'coin',
{ id: 'gem', maxAmount: 10_000 },
],
createAccountOnSignUp: true,
}),
],
})
This exposes authenticated endpoints under /ledger:
GET /ledger/account
GET /ledger/balance
GET /ledger/balance?asset=coin
GET /ledger/entries?asset=coin&limit=50
Mutation endpoints exist, but they are disabled unless you opt in with enableMutations and authorizeMutation.
Register the client plugin
Use the client plugin when app UI should call the read endpoints through the Better Auth client.
import { createAuthClient } from 'better-auth/client'
import { ledgerClient } from '@onmax/better-auth-ledger/client'
export const authClient = createAuthClient({
plugins: [ledgerClient()],
})
import { authClient } from './auth-client'
const account = await authClient.ledger.account()
const balances = await authClient.ledger.balance()
const coinEntries = await authClient.ledger.entries({
asset: 'coin',
limit: 25,
})
console.log(account.status)
console.table(balances)
console.log(coinEntries[0]?.reason)
Expected behavior for a new user:
active
asset available reserved
coin 0 0
Move mutations behind a server decision
Use mutation endpoints only when your server can authorize the operation from trusted context.
import { betterAuth } from 'better-auth'
import { ledger } from '@onmax/better-auth-ledger'
export const auth = betterAuth({
plugins: [
ledger({
assets: ['coin'],
enableMutations: true,
authorizeMutation: async (ctx, input) => {
const session = (ctx as any).context?.session
const role = session?.user?.role
return input.operation === 'credit' && role === 'admin'
},
}),
],
})
Every mutation body needs an asset, amount, reason, and idempotency key.
const result = await fetch('/api/auth/ledger/credit', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
userId: 'user-123',
asset: 'coin',
amount: 100,
reason: 'daily-reward',
idempotencyKey: 'daily-reward:user-123:2026-05-06',
reference: 'daily-reward:2026-05-06',
}),
})
console.log(result.status)
Expected behavior:
200
If mutations are not enabled, the same request fails closed with Ledger mutation endpoints are disabled.
Use service helpers in trusted jobs
Use root service helpers when a backend job already has a Better Auth adapter and should avoid HTTP.
import type { LedgerAdapter } from '@onmax/better-auth-ledger'
import { creditLedger } from '@onmax/better-auth-ledger'
export async function rewardDailyLogin(adapter: LedgerAdapter, userId: string) {
return await creditLedger(adapter, {
assets: ['coin'],
userId,
asset: 'coin',
amount: 100,
reason: 'daily-login',
idempotencyKey: `daily-login:${userId}:2026-05-06`,
})
}
Expected result shape:
{
"balance": {
"asset": "coin",
"available": "100",
"reserved": "0"
},
"entry": {
"type": "credit",
"reason": "daily-login"
}
}
Use unique idempotency keys per logical mutation. Reusing a key with the same input returns the stored result; reusing it with different input fails with an idempotency conflict.
Reserve before capture
Use reservations when an action needs to hold balance while another step completes.
import type { LedgerAdapter } from '@onmax/better-auth-ledger'
import {
captureLedgerReservation,
releaseLedgerReservation,
reserveLedger,
} from '@onmax/better-auth-ledger'
export async function reservePurchase(adapter: LedgerAdapter, userId: string) {
const reserved = await reserveLedger(adapter, {
assets: ['coin'],
userId,
asset: 'coin',
amount: 25,
reason: 'shop-purchase',
idempotencyKey: `reserve:purchase-123`,
reference: 'purchase-123',
})
return reserved.reservation!.id
}
export async function finishPurchase(adapter: LedgerAdapter, userId: string, reservationId: string) {
return await captureLedgerReservation(adapter, {
assets: ['coin'],
userId,
reservationId,
reason: 'shop-purchase-captured',
idempotencyKey: `capture:${reservationId}`,
})
}
export async function cancelPurchase(adapter: LedgerAdapter, userId: string, reservationId: string) {
return await releaseLedgerReservation(adapter, {
assets: ['coin'],
userId,
reservationId,
reason: 'shop-purchase-cancelled',
idempotencyKey: `release:${reservationId}`,
})
}
Expected balance behavior:
reserve: available decreases, reserved increases
capture: reserved decreases, available stays the same
release: reserved decreases, available increases
Debit and reservation flows require adapter-level atomic mutation support. For development-only adapters, unsafeAllowNonAtomicMutations can opt into non-atomic behavior, but production stores should implement adapter.atomicMutation and adapter.atomicReservationMutation.