Utilities

Better Auth ledger

Add app-local virtual balances to Better Auth.

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.

server/auth.ts
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:

endpoints
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.

app/auth-client.ts
import { createAuthClient } from 'better-auth/client'
import { ledgerClient } from '@onmax/better-auth-ledger/client'

export const authClient = createAuthClient({
  plugins: [ledgerClient()],
})
app/load-wallet-panel.ts
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:

output
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.

server/auth.ts
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.

server/reward-user.ts
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:

output
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.

server/jobs/reward.ts
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:

result.json
{
  "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.

server/jobs/shop.ts
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:

output
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.

Copyright © 2026