Quickstart
Tally is a credit layer API that sits between your payment processor and your app. Get up and running in under an hour.
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:
npm install tally-credits-sdk
Add your first credits
Call the API with your key to top up a user's balance.
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 }
Deduct credits when a user acts
Call deduct every time a user consumes something in your app.
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 }
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' }) }
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.
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.
How to integrate Tally
Here's how a typical credits-based SaaS uses Tally end to end.
The flow
Show balance in your UI
// 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
| Parameter | Type | Required | Description |
|---|---|---|---|
| user_id | string | required | Your internal user identifier |
| amount | number | required | Number of credits to add (must be positive) |
| description | string | optional | Human-readable reason for this transaction |
| reference_id | string | optional | External reference e.g. Stripe payment ID |
| metadata | object | optional | Any extra data to store with this transaction |
{
"success": true,
"ledger_id": "uuid",
"user_id": "user_001",
"amount": 100,
"balance_after": 100
}
Deduct credits
| Parameter | Type | Required | Description |
|---|---|---|---|
| user_id | string | required | Your internal user identifier |
| amount | number | required | Number of credits to deduct |
| description | string | optional | What the credits were used for |
| reference_id | string | optional | Your internal action or request ID |
{
"success": true,
"ledger_id": "uuid",
"balance_before": 100,
"balance_after": 90
}
{
"error": "Insufficient credits",
"balance": 5
}
Refund credits
| Parameter | Type | Required | Description |
|---|---|---|---|
| ledger_id | string | required | The ID of the ledger entry to reverse |
| description | string | optional | Reason for the refund |
Get balance
| Parameter | Type | Required | Description |
|---|---|---|---|
| user_id | string (query) | required | Your internal user identifier |
{
"user_id": "user_001",
"balance": 90,
"updated_at": "2026-03-31T10:00:00Z"
}
Transaction history
| Parameter | Type | Required | Description |
|---|---|---|---|
| user_id | string (query) | required | Your internal user identifier |
| limit | number (query) | optional | Number of results (default: 50) |
| offset | number (query) | optional | Pagination offset (default: 0) |
Stripe
Tally auto-credits users when a Stripe payment succeeds. No code needed beyond adding metadata to your payment.
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
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
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.
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 }) })
Error handling
Tally uses standard HTTP status codes.
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 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 }) })
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.
// 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 }) })
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.
// 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 } }, [])