Credit Consumption
How credits move from a tenant’s balance to consumed during an activity run — including reservation, confirmation, retry handling, and overage policy.
Lifecycle of a Credit
1. RESERVE
Job enters BullMQ queue
└── Worker checks balance before starting
└── Required credits reserved (subtracted from available, held in reserve)
└── If balance insufficient → job paused, admin notified
2. CONSUME
Activity run completes successfully
└── Reserved credits moved to consumed
└── credit_ledger row written: type = 'consumed'
└── balance_after = prior balance - credits consumed
3. RELEASE (on failure)
Activity run fails after all retries
└── Reserved credits returned to available balance
└── credit_ledger row written: type = 'released'
└── balance_after = prior balance (restored)
└── Admin notified of failed activityReservation vs Consumption
Credits are reserved — not consumed — when a job starts. This prevents race conditions where multiple jobs start simultaneously and collectively overspend the balance.
// packages/billing/src/credits.ts
async function reserveCredits(
tenantId: string,
activityRunId: string,
deliverableType: string,
): Promise<void> {
const cost = CREDIT_RATES[deliverableType];
if (!cost) throw new Error(`Unknown deliverable type: ${deliverableType}`);
await db.transaction(async (tx) => {
const balance = await tx.query.creditBalances.findFirst({
where: eq(creditBalances.tenantId, tenantId),
for: 'update', // row lock — prevents concurrent overspend
});
const available = balance.creditsAvailable - balance.creditsReserved;
if (available < cost) {
throw new InsufficientCreditsError(
`Insufficient credits: need ${cost}, have ${available} available`
);
}
// Hold credits in reserve
await tx.update(creditBalances)
.set({ creditsReserved: sql`credits_reserved + ${cost}` })
.where(eq(creditBalances.tenantId, tenantId));
// Write ledger entry
await tx.insert(creditLedger).values({
tenantId,
activityRunId,
deliverableType,
credits: -cost,
type: 'reserved',
balanceAfter: available - cost,
createdAt: new Date(),
});
});
}
async function consumeCredits(tenantId: string, activityRunId: string): Promise<void> {
// Move from reserved → consumed; ledger entry records the final deduction
await db.transaction(async (tx) => {
const ledgerEntry = await tx.query.creditLedger.findFirst({
where: and(
eq(creditLedger.activityRunId, activityRunId),
eq(creditLedger.type, 'reserved')
),
});
const cost = Math.abs(ledgerEntry.credits);
await tx.update(creditBalances)
.set({
creditsReserved: sql`credits_reserved - ${cost}`,
creditsConsumed: sql`credits_consumed + ${cost}`,
})
.where(eq(creditBalances.tenantId, tenantId));
await tx.insert(creditLedger).values({
tenantId,
activityRunId,
deliverableType: ledgerEntry.deliverableType,
credits: -cost,
type: 'consumed',
balanceAfter: ledgerEntry.balanceAfter,
createdAt: new Date(),
});
});
}
async function releaseCredits(tenantId: string, activityRunId: string): Promise<void> {
// Return reserved credits to available; activity failed — no charge
await db.transaction(async (tx) => {
const ledgerEntry = await tx.query.creditLedger.findFirst({
where: and(
eq(creditLedger.activityRunId, activityRunId),
eq(creditLedger.type, 'reserved')
),
});
const cost = Math.abs(ledgerEntry.credits);
await tx.update(creditBalances)
.set({ creditsReserved: sql`credits_reserved - ${cost}` })
.where(eq(creditBalances.tenantId, tenantId));
await tx.insert(creditLedger).values({
tenantId,
activityRunId,
deliverableType: ledgerEntry.deliverableType,
credits: +cost, // positive = returned
type: 'released',
balanceAfter: ledgerEntry.balanceAfter + cost,
createdAt: new Date(),
});
});
}Retry Handling
When an activity run fails and BullMQ retries it:
Attempt 1 fails
└── Reserved credits stay reserved (not released yet)
└── BullMQ schedules retry after backoff delay
Attempt 2 (retry 1) starts
└── No new reservation needed — existing reservation covers it
└── Run executes
Attempt 2 fails
└── Same — reserved credits remain held
Attempt 3 (retry 2) — final attempt
└── Succeeds → credits consumed
└── Fails → credits released, activity marked 'failed'The reservation is held across all retry attempts. Credits are only released if the activity exhausts all retries and is marked permanently failed.
BullMQ retry config for agent jobs:
{
attempts: 3,
backoff: { type: 'exponential', delay: 30_000 },
}So a failed 3-attempt job holds credits in reserve for up to ~90 seconds (30s + 60s backoff) before the final failure triggers a release.
HITL Rejection and Re-Run
When a DM reviewer rejects an activity output and requests a re-run, the re-run is treated as a new credit charge:
Activity runs → HITL review → Rejected (e.g. "wrong tone")
└── Original run: credits consumed (the run happened; agent did real work)
└── Re-run enqueued: new credit reservation made
└── Re-run completes: additional credits consumedThe re-run cost is the same as the original. There is no half-price retry for human rejection — the agent does a full new run. The admin sees both runs in the activity history.
Why charge for rejections?
- The original run produced real output (tokens were spent, tools were called)
- Rejection is typically a brief quality issue, not a system failure
- If re-runs were free, a poorly-configured brief could trigger unlimited re-runs at no cost
Human-Initiated Activities
Some activities are not agent-run — they are human tasks (e.g. DM Review, manual blog formatting). These do not consume credits.
Human task (assigned_to_type = 'human'):
└── No credit reservation
└── No credit consumption
└── Appears in the activity list but not in the credit ledgerOnly agent-run activities consume credits.
Overage Policy
When a tenant’s available balance (allocation + any top-ups) reaches zero:
New job enters queue
└── reserveCredits() checks balance
└── available = 0 (or less than the cost of this job)
└── InsufficientCreditsError thrown
└── Job is NOT enqueued
└── Activity record status set to 'blocked_insufficient_credits'
└── Admin notification sent: "X activities blocked — insufficient credits"
└── In-app warning banner shown in DashboardAlready-reserved jobs (in progress) are not interrupted. Only new job starts are blocked.
No silent overage. Leadmetrics does not extend credit. If a tenant runs out mid-month, their pipeline pauses until they either purchase top-up credits or the month resets.
Admin options when balance is zero
| Option | How |
|---|---|
| Buy top-up credits | Dashboard → Usage → Buy Credits (Razorpay) |
| Wait for monthly reset | Credits refill on the 1st of next month |
| Upgrade plan | Moving from Pro to Agency increases monthly allocation immediately |
| Manually skip blocked activities | Mark blocked activities as ‘skipped’ to clear the queue notification |
Activity Planner Credit Cost
The Activity Planner run itself consumes 2 credits — even though it generates no content. It is an agent run that costs real LLM inference. The 2-credit charge appears in the credit ledger at the start of each month’s pipeline creation.
This means a Starter tenant’s effective content budget is 100 − 2 = 98 credits per month for actual deliverables (100 total, minus 2 for the Activity Planner run). A Professional tenant has 300 − 2 = 298 credits.
The Deliverable Planner similarly costs 1 credit when it runs. This is a one-time or infrequent charge (only when the plan is first created or refreshed).
Credit Consumption Sequence in a Full Month
Using the Leadmetrics April 2026 plan as an example (Starter — 100 cr/month):
Apr 1 — Activity Planner run 2 cr consumed
Apr 1 — Keyword Research Cluster 1 cr consumed
Apr 1 — Social Calendar (April) 1 cr consumed
Apr 2 — Content Brief #1 (AI Marketing Agencies) 1 cr consumed
Apr 3 — Research Notes #1 1 cr consumed
Apr 4 — Email Newsletter #1 2 cr consumed
Apr 5 — Instagram Post #1 1 cr consumed
Apr 5 — Facebook Post #1 1 cr consumed
Apr 5 — LinkedIn Post #1 1 cr consumed
Apr 7 — Blog Post #1 (AI Marketing Agencies) 2 cr consumed
...
Apr 9 — Content Brief #2 (Ad Spend Waste) 1 cr consumed
Apr 10 — Email Newsletter #2 2 cr consumed
Apr 12 — Instagram Post #2 1 cr consumed
Apr 12 — Google Ads RSA refresh 2 cr consumed
Apr 12 — Meta Ads copy refresh 2 cr consumed
Apr 14 — Blog Post #2 (Ad Spend Waste) 2 cr consumed
...
Apr 18 — Email Newsletter #3 2 cr consumed
Apr 19 — Instagram Post #3 1 cr consumed
Apr 19 — Facebook Post #2 1 cr consumed
Apr 19 — LinkedIn Post #2 1 cr consumed
Apr 21 — YouTube Script #1 1 cr consumed
Apr 24 — Blog Post #3 (recycled brief) 2 cr consumed
...
Apr 28 — Blog Post #4 (recycled brief) 2 cr consumed
Apr 29 — Email Newsletter #4 2 cr consumed
Apr 29 — YouTube Script #2 1 cr consumed
Apr 30 — Monthly Performance Report 2 cr consumed
Total consumed: ~40 cr (of 100 allocated)
Remaining: ~60 cr bufferRelated
- Credits Overview — what credits are, plan allocations, top-ups
- Credit Tracking — database schema, API, monthly reset
- Cost & Usage Tracking — internal USD cost tracking
- Sample Deliverable Plan — April 2026