How to Build a Credit System for SaaS Products
Credit-based billing is one of the most flexible monetization models for SaaS products. Instead of charging a flat monthly fee regardless of usage, users purchase credits and spend them as they use features. This aligns cost with value: light users pay less, heavy users pay more, and nobody pays for a subscription they are not using.
But building a reliable credit system is harder than it looks. You need atomic transactions (credits cannot be double-spent or lost), multi-product support (one credit pool across many features), webhook integration (for payment processor callbacks), and robust error handling (what happens when a credit deduction fails mid-operation?).
This article walks through the complete architecture of a production credit system, from database schema to API endpoints to webhook handling, based on a real system serving five products across thousands of transactions.
Architecture Overview
A credit system has four core components:
- Database — Stores licenses, credit balances, and transaction history
- API Server — Handles credit operations (check balance, deduct, add) and proxies AI requests
- Webhook Handler — Receives payment notifications and provisions credits
- Client SDK — Thin library in your desktop/web app that communicates with the API
┌─────────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Desktop App │────▶│ Credit Server │────▶│ AI Provider │
│ (Client SDK) │◀────│ (Flask + PG) │◀────│ (API) │
└─────────────────┘ └────────┬─────────┘ └──────────────┘
│
┌────────▼─────────┐
│ Payment Webhook │
│ (Payhip/Stripe) │
└──────────────────┘
Database Schema
The schema needs three primary tables: licenses, credit transactions, and usage logs.
Licenses Table
CREATE TABLE licenses (
id SERIAL PRIMARY KEY,
license_key VARCHAR(64) UNIQUE NOT NULL,
machine_id VARCHAR(128),
product VARCHAR(32) NOT NULL,
tier VARCHAR(32) DEFAULT 'basic',
credits_remaining INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
last_used_at TIMESTAMP,
email VARCHAR(255),
payhip_sale_id VARCHAR(64)
);
CREATE INDEX idx_licenses_key ON licenses(license_key);
CREATE INDEX idx_licenses_machine ON licenses(machine_id);
CREATE INDEX idx_licenses_product ON licenses(product);
Each license is tied to a specific product and optionally bound to a machine ID. The credits_remaining field is the source of truth for the user's balance. Using an integer (not a float) is critical—floating-point arithmetic can accumulate rounding errors that cause credits to appear or disappear.
Transactions Table
CREATE TABLE credit_transactions (
id SERIAL PRIMARY KEY,
license_id INTEGER REFERENCES licenses(id),
transaction_type VARCHAR(20) NOT NULL, -- 'purchase', 'deduct', 'refund', 'bonus'
amount INTEGER NOT NULL, -- Positive for additions, negative for deductions
balance_after INTEGER NOT NULL, -- Balance after this transaction
description VARCHAR(255),
reference_id VARCHAR(64), -- External reference (sale ID, request ID)
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_transactions_license ON credit_transactions(license_id);
CREATE INDEX idx_transactions_created ON credit_transactions(created_at);
Every credit change creates a transaction record. This provides a complete audit trail—you can always reconstruct how a user's balance changed over time. The balance_after field is redundant with the sum of all transactions but provides a fast way to verify consistency.
Usage Logs Table
CREATE TABLE usage_logs (
id SERIAL PRIMARY KEY,
license_id INTEGER REFERENCES licenses(id),
endpoint VARCHAR(128) NOT NULL,
credits_used INTEGER NOT NULL,
request_metadata JSONB,
response_status INTEGER,
processing_time_ms INTEGER,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_usage_license ON usage_logs(license_id);
CREATE INDEX idx_usage_created ON usage_logs(created_at);
Credit Deduction: The Critical Path
The most important operation in a credit system is deduction. It must be atomic (either the full deduction succeeds or nothing changes), must prevent negative balances, and must handle concurrent requests correctly.
Atomic Deduction with Row-Level Locking
import psycopg2
def deduct_credits(license_key, amount, description, reference_id=None):
"""
Atomically deduct credits from a license.
Returns (success, remaining_balance, error_message)
"""
conn = get_db_connection()
try:
with conn:
with conn.cursor() as cur:
# Lock the license row to prevent concurrent modifications
cur.execute("""
SELECT id, credits_remaining
FROM licenses
WHERE license_key = %s AND is_active = TRUE
FOR UPDATE
""", (license_key,))
row = cur.fetchone()
if not row:
return False, 0, "License not found or inactive"
license_id, current_balance = row
if current_balance < amount:
return False, current_balance, "Insufficient credits"
new_balance = current_balance - amount
# Update balance
cur.execute("""
UPDATE licenses
SET credits_remaining = %s, last_used_at = NOW()
WHERE id = %s
""", (new_balance, license_id))
# Record transaction
cur.execute("""
INSERT INTO credit_transactions
(license_id, transaction_type, amount, balance_after,
description, reference_id)
VALUES (%s, 'deduct', %s, %s, %s, %s)
""", (license_id, -amount, new_balance,
description, reference_id))
return True, new_balance, None
except Exception as e:
conn.rollback()
return False, 0, str(e)
finally:
conn.close()
The key mechanism here is FOR UPDATE, which acquires a row-level lock on the license record. If two requests try to deduct credits simultaneously, the second request blocks until the first completes. This prevents double-spending without using application-level locks.
API Endpoints
Check Balance
@app.route('/api/v1/credits/balance', methods=['POST'])
def check_balance():
data = request.json
license_key = data.get('license_key')
machine_id = data.get('machine_id')
license = get_license(license_key)
if not license:
return jsonify({'error': 'Invalid license'}), 401
# Verify machine binding
if license['machine_id'] and license['machine_id'] != machine_id:
return jsonify({'error': 'License bound to different machine'}), 403
return jsonify({
'credits_remaining': license['credits_remaining'],
'product': license['product'],
'tier': license['tier']
})
Deduct Credits
@app.route('/api/v1/credits/deduct', methods=['POST'])
def deduct():
data = request.json
license_key = data.get('license_key')
amount = data.get('amount', 1)
description = data.get('description', 'API usage')
if amount <= 0:
return jsonify({'error': 'Amount must be positive'}), 400
success, balance, error = deduct_credits(
license_key, amount, description
)
if not success:
return jsonify({'error': error, 'balance': balance}), 402
return jsonify({
'success': True,
'credits_remaining': balance,
'credits_deducted': amount
})
AI Proxy with Credit Deduction
For products that proxy AI API calls through the credit server, the deduction happens as part of the request:
@app.route('/api/v1/ai/complete', methods=['POST'])
def ai_complete():
data = request.json
license_key = data.get('license_key')
prompt = data.get('prompt')
product = identify_product(license_key)
# Determine credit cost based on product and request type
cost = calculate_credit_cost(product, data)
# Deduct credits first (fail fast if insufficient)
success, balance, error = deduct_credits(
license_key, cost, f"AI completion - {product}"
)
if not success:
return jsonify({'error': error}), 402
# Forward to AI provider
try:
response = call_ai_provider(prompt, product)
return jsonify({
'result': response,
'credits_used': cost,
'credits_remaining': balance
})
except Exception as e:
# Refund credits on AI provider failure
refund_credits(license_key, cost, "Refund: AI provider error")
return jsonify({'error': 'AI provider unavailable'}), 503
Webhook Integration
When a customer purchases credits through your payment processor, you need to automatically add credits to their license. This is done via webhooks—HTTP callbacks that the payment processor sends to your server when a sale completes.
Payhip Webhook Handler
@app.route('/webhooks/payhip', methods=['POST'])
def payhip_webhook():
data = request.json
# Verify webhook authenticity
api_key = request.headers.get('X-Payhip-API-Key')
if api_key != os.environ.get('PAYHIP_API_KEY'):
return jsonify({'error': 'Unauthorized'}), 401
sale_id = data.get('sale_id')
product_id = data.get('product_id')
buyer_email = data.get('buyer_email')
# Map Payhip product to credit amount
credit_map = {
'PROD_500_CREDITS': 500,
'PROD_2000_CREDITS': 2000,
'PROD_10000_CREDITS': 10000,
}
credits = credit_map.get(product_id)
if not credits:
# Not a credit pack (might be a license purchase)
return handle_license_purchase(data)
# Find or create license for this buyer
license = find_license_by_email(buyer_email)
if not license:
license = create_license(buyer_email, product_id, sale_id)
# Add credits
add_credits(license['license_key'], credits,
f"Purchase: {credits} credits",
reference_id=sale_id)
# Send confirmation email
send_credit_confirmation(buyer_email, credits)
return jsonify({'status': 'ok'}), 200
Idempotency
Webhooks can be sent multiple times (network retries, payment processor bugs). Your handler must be idempotent—processing the same webhook twice should not add credits twice:
def add_credits(license_key, amount, description, reference_id):
"""Add credits with idempotency check."""
conn = get_db_connection()
with conn:
with conn.cursor() as cur:
# Check if this reference_id was already processed
cur.execute("""
SELECT id FROM credit_transactions
WHERE reference_id = %s AND transaction_type = 'purchase'
""", (reference_id,))
if cur.fetchone():
return # Already processed, skip
# Proceed with credit addition
cur.execute("""
UPDATE licenses
SET credits_remaining = credits_remaining + %s
WHERE license_key = %s
RETURNING credits_remaining
""", (amount, license_key))
new_balance = cur.fetchone()[0]
cur.execute("""
INSERT INTO credit_transactions
(license_id, transaction_type, amount, balance_after,
description, reference_id)
SELECT id, 'purchase', %s, %s, %s, %s
FROM licenses WHERE license_key = %s
""", (amount, new_balance, description,
reference_id, license_key))
Multi-Product Support
When a single credit server supports multiple products, you need to identify which product is making the request and apply appropriate credit costs:
def identify_product(license_key):
"""Determine which product a license belongs to."""
license = get_license(license_key)
if not license:
return None
return license['product']
def calculate_credit_cost(product, request_data):
"""Calculate credit cost based on product and operation."""
base_costs = {
'beatsync': {'ai_complete': 2, 'vision': 5, 'generate': 10},
'clareon': {'ai_complete': 2, 'enhance': 3, 'upscale': 5},
'nexus': {'ai_complete': 2, 'scan': 1, 'analyze': 3},
'prometheus': {'ai_complete': 2, 'build': 20},
}
operation = request_data.get('operation', 'ai_complete')
product_costs = base_costs.get(product, {})
return product_costs.get(operation, 1)
Deployment on Railway
Railway provides one-click deployment for Flask applications with managed PostgreSQL. The deployment process:
# Procfile
web: gunicorn credit_server:app --bind 0.0.0.0:$PORT --workers 4
# requirements.txt
flask==3.0.0
gunicorn==21.2.0
psycopg2-binary==2.9.9
requests==2.31.0
Environment variables on Railway:
DATABASE_URL=postgresql://... (auto-set by Railway PostgreSQL addon)
SECRET_KEY=your-secret-key
ADMIN_API_KEY=your-admin-key
PAYHIP_API_KEY=your-payhip-webhook-key
Railway handles SSL termination, automatic deployments from Git, and PostgreSQL backups. For a credit server that needs to be reliable but does not need massive scale, it is an excellent hosting choice at approximately $5–20/month depending on usage.
Security Considerations
- Never store AI API keys on client machines. The credit server proxies all AI requests, keeping API keys server-side only.
- Rate limit all endpoints. A compromised license key should not be able to drain credits instantly.
- Validate webhook signatures. Always verify that webhooks come from your payment processor, not from an attacker.
- Use HTTPS exclusively. License keys and credit operations must be encrypted in transit.
- Log everything. The transaction log is your forensic record if disputes arise.
- Machine binding is optional but recommended. Binding a license to a machine ID prevents casual sharing, though a determined user can spoof machine IDs.
Monitoring and Alerts
Set up monitoring for these critical scenarios:
- Failed deductions — High failure rates may indicate a bug or attack
- Webhook failures — If webhooks are not being received, customers are paying but not receiving credits
- Unusual consumption — A license consuming credits at 10x its normal rate may be compromised
- Database connection pool exhaustion — Under load, connection pools can be depleted, causing cascading failures
Lessons from Production
After running this system across five products with thousands of transactions, here are the key lessons:
- Deduct first, refund on failure. This is safer than deducting after success because server crashes between the operation and the deduction mean lost revenue.
- Integer credits, not float. Floating-point arithmetic causes rounding errors. Use integers and define your smallest credit unit.
- Idempotency is not optional. Webhooks will be sent multiple times. Your handler must handle duplicates gracefully.
- The transaction log is more important than the balance field. If the balance field ever gets corrupted, you can recalculate it from the transaction log. The reverse is not true.
- Multi-provider fallback is critical. If your AI provider is down, you need a fallback. The credit server is the natural place to implement this because it is already the request proxy.
See a Credit System in Production
All five RendereelStudio products use a centralized credit system. Try any product to see how seamless usage-based billing can be.
Explore Products