Quickstart

Tally is a credit layer API that sits between your payment processor and your app. Get up and running in under an hour.

1

Create an account

Sign up at tally-dashboard-beta.vercel.app and create your first app. You'll get an API key automatically.

Then install the SDK:

Terminal
npm install tally-credits-sdk
2

Add your first credits

Call the API with your key to top up a user's balance.

JavaScript
const res = await fetch('https://api.usetally.dev/credits/add', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_KEY',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    user_id: 'user_001',
    amount: 100,
    description: 'Welcome credits',
  })
})

const data = await res.json()
// { success: true, balance_after: 100 }
3

Deduct credits when a user acts

Call deduct every time a user consumes something in your app.

SDK
Raw fetch
JavaScript
const result = await tally.credits.deduct({
  user_id: 'user_001',
  amount: 10,
  description: 'AI generation',
})

if (!result.success) {
  // User is out of credits
  return res.status(402).json({ error: 'Insufficient credits' })
}
// { success: true, balance_after: 90 }
JavaScript
const res = await fetch('https://YOUR_API_URL/credits/deduct', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_KEY',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ user_id: 'user_001', amount: 10 })
})
const data = await res.json()
if (!data.success) {
  return res.status(402).json({ error: 'Insufficient credits' })
}
4

Connect your payment provider

Go to the Settings tab in your app dashboard. Copy your webhook URL and paste it into Stripe or Polar. Credits are added automatically on every successful payment.


Authentication

All API requests require a Bearer token in the Authorization header.

HTTP
Authorization: Bearer tally_your_api_key_here

You can find and manage your API keys in the API Keys tab of any app in the dashboard. Keep your key secret — never expose it in client-side code.

⚠ Never use your API key in frontend JavaScript. Always call the Tally API from your backend server.

How to integrate Tally

Here's how a typical credits-based SaaS uses Tally end to end.

The flow

User pays → Stripe/Polar/LS webhook fires
Tally receives webhook → credits user automatically
User performs action in your app
Your backend calls /credits/deduct
If insufficient → return 402, prompt upgrade

Show balance in your UI

JavaScript
// Fetch balance from your backend (not directly from frontend)
const res = await fetch(
  `https://api.usetally.dev/credits/balance?user_id=${userId}`,
  { headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }
)
const { balance } = await res.json()

// Show in your UI
creditsDisplay.textContent = `${balance} credits remaining`

Add credits

POST /credits/add
Top up a user's credit balance. Creates the user automatically if they don't exist yet.
ParameterTypeRequiredDescription
user_idstringrequiredYour internal user identifier
amountnumberrequiredNumber of credits to add (must be positive)
descriptionstringoptionalHuman-readable reason for this transaction
reference_idstringoptionalExternal reference e.g. Stripe payment ID
metadataobjectoptionalAny extra data to store with this transaction
Response
{
  "success": true,
  "ledger_id": "uuid",
  "user_id": "user_001",
  "amount": 100,
  "balance_after": 100
}

Deduct credits

POST /credits/deduct
Atomically deduct credits from a user. Returns 402 if the user has insufficient balance. Safe against race conditions.
ParameterTypeRequiredDescription
user_idstringrequiredYour internal user identifier
amountnumberrequiredNumber of credits to deduct
descriptionstringoptionalWhat the credits were used for
reference_idstringoptionalYour internal action or request ID
Response — success
{
  "success": true,
  "ledger_id": "uuid",
  "balance_before": 100,
  "balance_after": 90
}
Response — insufficient credits (402)
{
  "error": "Insufficient credits",
  "balance": 5
}

Refund credits

POST /credits/refund
Reverse a previous transaction by its ledger ID. Adds the credits back to the user's balance.
ParameterTypeRequiredDescription
ledger_idstringrequiredThe ID of the ledger entry to reverse
descriptionstringoptionalReason for the refund

Get balance

GET /credits/balance?user_id=:id
Returns the current credit balance for a user.
ParameterTypeRequiredDescription
user_idstring (query)requiredYour internal user identifier
Response
{
  "user_id": "user_001",
  "balance": 90,
  "updated_at": "2026-03-31T10:00:00Z"
}

Transaction history

GET /credits/history?user_id=:id
Returns a paginated audit log of all credit events for a user.
ParameterTypeRequiredDescription
user_idstring (query)requiredYour internal user identifier
limitnumber (query)optionalNumber of results (default: 50)
offsetnumber (query)optionalPagination offset (default: 0)

Stripe

Tally auto-credits users when a Stripe payment succeeds. No code needed beyond adding metadata to your payment.

✓ Find your unique webhook URL in the Settings tab of your app dashboard.

1. Add the webhook in Stripe

Go to Stripe Dashboard → Developers → Webhooks → Add endpoint. Paste your Tally webhook URL and select payment_intent.succeeded.

2. Pass metadata on payment creation

Node.js
const intent = await stripe.paymentIntents.create({
  amount: 1000,
  currency: 'gbp',
  metadata: {
    tally_user_id: 'user_001',   // required
    tally_credits: '500',       // optional if credit_rate is set
  },
})

Polar

Tally listens for order.paid events from Polar.

Setup

Go to Polar → Settings → Webhooks → Add Endpoint. Paste your Tally webhook URL, set format to Raw, and select order.paid.

Pass metadata on checkout

Node.js
const checkout = await polar.checkouts.create({
  productId: 'your-product-id',
  metadata: {
    tally_user_id: 'user_001',
    tally_credits: '500',
  },
})

Idempotency

To prevent duplicate credits from network retries, pass an Idempotency-Key header with any unique string. Tally stores the result and returns it if the same key is used again.

JavaScript
fetch('https://api.usetally.dev/credits/add', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_KEY',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'order_abc123_credits',
  },
  body: JSON.stringify({ user_id: 'user_001', amount: 500 })
})
✓ Webhook handlers automatically use idempotency keys based on the payment ID — no setup needed.

Error handling

Tally uses standard HTTP status codes.

Code Meaning
200 Success
400 Bad request — missing or invalid parameters
401 Unauthorized — invalid or missing API key
402 Insufficient credits — user balance too low
402 Plan limit reached — upgrade your Tally plan
500 Internal server error — try again

Displaying credits in your UI

Tally stores and manages the balance. Showing it to your users is your job — here are the three patterns developers use.

Option A — Fetch on page load

The simplest approach. When the user loads your app, fetch their balance from your backend which proxies to Tally.

Your backend (Node.js)
// Your backend route — never expose API key to frontend
app.get('/api/credits', authenticate, async (req, res) => {
  const response = await fetch(
    `https://api.usetally.dev/credits/balance?user_id=${req.user.id}`,
    { headers: { 'Authorization': `Bearer ${process.env.TALLY_API_KEY}` } }
  )
  const { balance } = await response.json()
  res.json({ balance })
})
Your frontend (React)
const [credits, setCredits] = useState(0)

useEffect(() => {
  async function loadCredits() {
    const { balance } = await fetch('/api/credits').then(r => r.json())
    setCredits(balance)
  }
  loadCredits()
}, [])

// In your UI
return <div>{credits.toLocaleString()} credits remaining</div>

Option B — Update instantly after each action

The best user experience. Since /credits/deduct returns balance_after, you can update the UI immediately without an extra fetch.

Your backend (Node.js)
// When user triggers an action in your app
app.post('/api/generate', authenticate, async (req, res) => {
  const response = await fetch('https://api.usetally.dev/credits/deduct', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.TALLY_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ user_id: req.user.id, amount: 10, description: 'AI generation' })
  })

  const data = await response.json()

  if (!data.success) {
    return res.status(402).json({
      error: 'Insufficient credits',
      balance: data.balance
    })
  }

  // Do the actual work here (generate image, call AI, etc.)
  const result = await doTheWork()

  // Return result AND new balance so frontend can update instantly
  res.json({ result, balance: data.balance_after })
})
Your frontend (React)
const handleGenerate = async () => {
  const data = await fetch('/api/generate', { method: 'POST' }).then(r => r.json())

  if (data.error === 'Insufficient credits') {
    showUpgradeModal()
    return
  }

  // Update balance instantly — no extra fetch needed
  setCredits(data.balance)
  showResult(data.result)
}

Option C — Refresh after payment

After a user pays and is redirected back to your app, refetch their balance so it reflects the new credits from the webhook.

Your frontend (React)
// On your payment success page / redirect handler
useEffect(() => {
  if (searchParams.get('payment') === 'success') {
    // Wait briefly for webhook to process, then refresh
    setTimeout(async () => {
      const { balance } = await fetch('/api/credits').then(r => r.json())
      setCredits(balance)
      showSuccessMessage(`Credits added! You now have ${balance} credits.`)
    }, 2000) // 2 second delay for webhook to land
  }
}, [])
✓ Most apps combine all three — fetch on load, update after each action, and refresh after payment. This gives users an always-accurate balance with zero extra API calls.
⚠ Never call the Tally API directly from your frontend. Always proxy through your backend to keep your API key secret.
tally Get API Key →