Skip to Content
Credits & BillingCredit Consumption

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 activity

Reservation 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 consumed

The 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 ledger

Only 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 Dashboard

Already-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

OptionHow
Buy top-up creditsDashboard → Usage → Buy Credits (Razorpay)
Wait for monthly resetCredits refill on the 1st of next month
Upgrade planMoving from Pro to Agency increases monthly allocation immediately
Manually skip blocked activitiesMark 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 buffer

© 2026 Leadmetrics — Internal use only