Credits & Billing
Credit system implementation, Stripe integration, and tier-based usage metering.
Credits & Billing
The billing system combines prepaid credits for LLM usage with tier-based metering for platform features.
Credit Service
The CreditService class manages credit operations:
Balance Management
class CreditService:
def get_balance(self, user_id: str) -> BalanceResponse:
"""Get current credit balance and metadata."""
def purchase(self, user_id: str, amount: int) -> PurchaseResponse:
"""Create Stripe Checkout session for credit purchase."""
def deduct(self, user_id: str, amount: float) -> DeductResponse:
"""Deduct credits after LLM usage (called by gateway)."""
def deduct_batch(self, deductions: list) -> BatchResponse:
"""Batch deductions for efficiency (gateway flush)."""Stripe Checkout Flow
User clicks "Buy Credits"
→ Frontend calls POST /api/credits/purchase { amount: 25 }
→ Services creates Stripe Checkout session
→ User redirected to Stripe
→ Payment succeeds
→ Stripe fires webhook: payment_intent.succeeded
→ Services credits the user's balance
→ User sees updated balanceWebhook Processing
@router.post("/api/credits/webhook")
async def stripe_webhook(request: Request):
payload = await request.body()
sig = request.headers.get("stripe-signature")
event = stripe.Webhook.construct_event(
payload, sig, settings.stripe_webhook_secret
)
if event["type"] == "payment_intent.succeeded":
payment = event["data"]["object"]
user_id = payment["metadata"]["user_id"]
amount = payment["amount"] / 100 # cents to dollars
credit_service.add_credits(user_id, amount)Insufficient Credits
When credits run out, the deduction endpoint returns an error:
class InsufficientCreditsError(Exception):
def __init__(self, balance: float, required: float):
self.balance = balance
self.required = requiredThe gateway handles this by notifying the user to purchase more credits or switch to BYOK.
Tier Metering
Quota Registry
23 features with per-tier limits defined in core/tier_quotas.py:
QUOTA_REGISTRY = {
"ad_generations": {"free": 10, "pro": 100, "enterprise": None},
"brand_analyses": {"free": 5, "pro": 50, "enterprise": None},
"connector_syncs": {"free": 20, "pro": 500, "enterprise": None},
"dashboard_refreshes": {"free": 50, "pro": 500, "enterprise": None},
"goal_creations": {"free": 5, "pro": 50, "enterprise": None},
"agent_creations": {"free": 3, "pro": 20, "enterprise": None},
# ... more features
}None means unlimited.
Tier Gate Middleware
FastAPI dependency that checks quotas before processing requests:
from api.core.tier_gate import require_tier
@router.post("/api/ads/generate")
async def generate_ad(
user: AuthUser = Depends(get_current_user),
_: bool = Depends(require_tier("ad_generations")),
):
# Only reached if user has quota remaining
...Usage Tracking
The tier_usage table tracks per-feature usage with rolling 30-day windows:
CREATE TABLE tier_usage (
id SERIAL PRIMARY KEY,
user_id VARCHAR NOT NULL,
feature VARCHAR NOT NULL,
count INTEGER DEFAULT 1,
window_start TIMESTAMP NOT NULL,
window_end TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);Usage Check
def check_quota(user_id: str, feature: str, db: Session) -> bool:
tier = get_user_tier(user_id, db)
limit = QUOTA_REGISTRY.get(feature, {}).get(tier)
if limit is None: # Unlimited
return True
usage = get_rolling_usage(user_id, feature, days=30, db=db)
return usage < limitAPI Endpoints
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/api/credits/balance | GET | User | Get credit balance |
/api/credits/purchase | POST | User | Start Stripe checkout |
/api/credits/deduct | POST | Service | Deduct credits |
/api/credits/deduct/batch | POST | Service | Batch deductions |
/api/credits/history | GET | User | Transaction history |
/api/credits/webhook | POST | Stripe | Payment webhooks |
/api/tier/usage | GET | User | Current usage vs quotas |
/api/tier/check | GET | User | Check specific feature quota |
Credits and tier quotas are independent systems. Credits cover LLM costs. Tier quotas limit platform feature usage. A Pro user with BYOK still has tier quotas — they just don't use credits for LLM calls.