Skip to main content
3Nsofts logo
3Nsofts

Insights / SaaS & Web

Credit-Based SaaS on Paddle: How to Build Atomic Usage Billing for AI APIs

Subscription billing does not model AI API usage accurately. Here is how we built credit-based billing on Paddle webhooks with PostgreSQL — including the race condition that took down SnipToCode at launch.

By Ehsan Azish · 3NSOFTS · March 2026

Why subscription billing breaks for AI usage

The classic SaaS billing model charges a flat monthly fee regardless of usage. That works for SaaS tools where marginal cost per user is near zero. For AI API products, the cost structure is inverted: the real cost is per-inference, and heavy users can easily consume 50× the API cost of light users at the same price tier.

A subscription model means either pricing so high it excludes casual users, or absorbing losses on power users. Credit-based billing aligns your cost basis with your revenue. Users pay for what they consume. Power users pay more. You are not subsidizing unlimited inference on a flat fee.

The tradeoff: credit-based billing is significantly more complex to implement correctly. The failure modes are subtle and the consequences are real — either users get charged for credits they never received, or they consume credits you never billed for.

Paddle webhook architecture

Paddle handles the payment surface (checkout, subscription management, tax compliance, fraud screening). Your backend handles credit accounting. The integration point is Paddle's webhook system.

The basic flow:

  1. User purchases a credit pack via Paddle checkout (one-time or subscription)
  2. Paddle fires a transaction.completed webhook to your server
  3. Your webhook handler credits the user's balance in PostgreSQL
  4. User makes an API request — your server checks balance before processing
  5. On success, deduct credits atomically in the same database transaction as recording the usage event

The architecture looks simple. Two non-obvious problems make it hard to implement correctly: idempotency and concurrent deduction.

The race condition that took down our launch

At SnipToCode's initial launch, the credit deduction logic was:

-- Deduction as originally written (broken)
UPDATE users
SET credits = credits - 1
WHERE id = $1 AND credits > 0;

This worked correctly for sequential requests. It failed for concurrent requests from the same user — the kind of concurrent requests that happen when a user triggers multiple AI operations from a frontend that does not disable the button during loading, or when retry logic fires before the first response returns.

Two concurrent requests both read credits = 1, both pass the credits > 0 check, and both proceed. The atomic correction:

-- Correct: use SELECT FOR UPDATE to acquire a row-level lock
BEGIN;

SELECT credits FROM users
WHERE id = $1
FOR UPDATE;  -- locks the row until transaction commits

-- check credits in application code after reading
-- if credits < cost, ROLLBACK and return error

UPDATE users
SET credits = credits - $cost
WHERE id = $1;

INSERT INTO usage_events (user_id, action, credits_used, created_at)
VALUES ($1, $2, $cost, NOW());

COMMIT;

The SELECT FOR UPDATE locks the row until the transaction commits. The second concurrent request blocks until the first completes, then reads the updated (lower) balance. No double-spend.

Idempotent webhook handling

Paddle, like all webhook systems, may deliver the same event more than once. Your webhook handler must be idempotent — processing the same transaction.completed event twice must not credit the user twice.

The standard pattern is a processed_webhooks table:

-- Table: processed_webhooks
CREATE TABLE processed_webhooks (
  event_id TEXT PRIMARY KEY,   -- Paddle's event_id (unique per event)
  processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- In your webhook handler (within a transaction):
INSERT INTO processed_webhooks (event_id)
VALUES ($paddle_event_id)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;

-- If RETURNING returns no rows, this event was already processed
-- Skip credit addition and return 200 to Paddle (do not return an error;
-- Paddle will retry on non-200 responses, making the problem worse)

The PRIMARY KEY on event_id enforces uniqueness at the database level. The INSERT ... ON CONFLICT DO NOTHING is safe to run inside the same transaction as the credit addition — either both operations commit or neither does.

PostgreSQL schema for credit tracking

The minimal schema that supports balance tracking, usage history, and reconciliation:

-- Users: store current balance denormalized for fast reads
ALTER TABLE users ADD COLUMN credits INTEGER NOT NULL DEFAULT 0;

-- Usage events: append-only log
CREATE TABLE usage_events (
  id           BIGSERIAL PRIMARY KEY,
  user_id      UUID NOT NULL REFERENCES users(id),
  action       TEXT NOT NULL,           -- e.g. 'snippet_generate', 'test_generate'
  credits_used INTEGER NOT NULL,
  metadata     JSONB,                   -- request params, model used, token count
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX usage_events_user_id_idx ON usage_events (user_id, created_at DESC);

-- Credit purchases: from Paddle events
CREATE TABLE credit_purchases (
  id             BIGSERIAL PRIMARY KEY,
  user_id        UUID NOT NULL REFERENCES users(id),
  paddle_event_id TEXT NOT NULL UNIQUE,
  credits_added  INTEGER NOT NULL,
  amount_usd     NUMERIC(10,2),
  created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

The denormalized credits column on the users table gives fast balance reads without a SUM() over the event log. The event log provides the audit trail for reconciliation — you can always recompute balance from events and verify it matches the denormalized column.

Production lessons

  • Always return 200 to Paddle for duplicate events. Returning a 4xx or 5xx on a duplicate event causes Paddle to retry, compounding the problem you are trying to prevent.
  • Verify Paddle webhook signatures. Paddle's signature verification prevents forged webhook events from crediting users without payment. Do not skip this.
  • Log the AI response token count in usage_events.metadata. When you need to change your credit pricing model, you have the data to backtest what the new model would have cost your existing user base.
  • Build a reconciliation script from day one. A weekly job that sums credit_purchases - usage_events and compares against the users.credits column catches drift before it becomes a support issue.
  • Do not deduct credits before the AI API call returns. If the API call fails after deduction, you need a refund path. Deduct after success, inside the same transaction as the usage event insert.

Building an AI-powered SaaS?

See how we built SnipToCode end-to-end — credit billing, AI API integration, and iOS client in a 4-week sprint.