An engineer looks frustrated by complex flowcharts on a whiteboard, struggling with a tangled stripe credit pool problem.
Production engineering patternUpdated

Stripe Credit Pools: Dual-Pool Ledger Pattern for SaaS Billing

An engineering pattern for running free and paid credit pools side by side on Stripe Billing without consume-order bugs, double-grants on webhook retry, or refund accounting surprises.

The problem

The bug shows up in a support ticket that reads "I had 500 free credits and 1,000 paid credits, I used 600 credits, why is my paid balance 900?" The customer's mental model is that the free credits should have drained first, then the paid pool. Your code drained whichever pool the application layer happened to read first. Or it drained both proportionally. Or it drained the paid pool because the free-credit feature was bolted on later and the original consume function was not updated. Whatever the cause, the user-visible answer is wrong, and the only honest fix is to credit them back and ship a structural change.

Consume-order drift across credit features

The same shape repeats across every credit-granting feature a SaaS adds over time. The free-tier monthly allowance. The promotional credit for a referral. The support credit issued to apologize for an outage. The paid top-up the customer bought yesterday. The annual prepaid bundle from the enterprise contract. Each one shows up as its own column or its own balance field or its own one-off code path, and the consume function — the code that decides which bucket pays for the next usage event — turns into a tangle of conditionals that nobody is confident enough to refactor.

Deferred-revenue vs marketing-expense leak

The accounting consequences are worse than the user-facing ones. Promotional credits are a marketing expense; paid credits are deferred revenue. If your consume function drains the paid pool when it should have drained the promotional pool, you are recognizing revenue against actions that should have been a marketing cost. The auditor catches it at the worst possible time. Finance opens a ticket asking why the deferred-revenue waterfall does not match the invoice line items, and the engineer who wrote the original code has been gone for six months.

Refunds against a commingled balance

The refund path makes everything sharper. A customer disputes a charge and wins. Your billing layer needs to refund the paid credits they bought, but not the promotional credits you granted them — those were not the customer's money to begin with. If both pools are commingled in a single balance column, you cannot tell which dollars to refund without a heuristic, and any heuristic you pick will be wrong for some customer. The right answer is to keep them un-commingled in the first place.

Stripe primitives that solve most of this

Stripe has shipped primitives that solve most of this. The credit grant resource is backed by an immutable, append-only ledger, supports a priority field that controls consume order, supports both monetary and quantity-denominated units, and applies automatically to metered subscription invoices at finalization time. The pattern below uses those primitives the way they are meant to be used and keeps a Postgres mirror locally so your application can answer "how many credits do I have right now" without round-tripping Stripe on every page load.

Engineers collaborate on a dual-pool ledger pattern, pointing at abstract data structures on a monitor to manage stripe credit pools.

What changes for your business

The architecture has four pieces: a credit grants ledger that mirrors Stripe's grants in Postgres, a credit consumptions ledger that records every draw against those grants, an idempotent webhook handler for the grant lifecycle events, and a consume function that picks grants in the same order Stripe will pick them at invoice finalization. Each piece is small. The discipline is in keeping them separate and append-only.

Grants table with pool and priority

Start with the grants table. One row per Stripe credit grant, with the Stripe grant ID as a unique column so duplicate webhooks cannot insert twice. The pool column is the local label that drives consume order — promotional for free, comp, and referral grants; paid for purchases and prepaid contracts. The priority column mirrors Stripe's priority field so the local consume function sees the same order Stripe will see at invoice time.

CREATE TABLE credit_grants (
  id                   uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id          text NOT NULL,
  stripe_grant_id      text NOT NULL UNIQUE,
  pool                 text NOT NULL CHECK (pool IN ('promotional', 'paid')),
  -- Mirrors Stripe's priority field. Lower = applies first.
  -- Convention: promotional grants 10-19, paid grants 100-199.
  priority             smallint NOT NULL,
  -- Monetary credits stored in the smallest currency unit (cents).
  amount_cents         bigint NOT NULL,
  currency             text NOT NULL,
  effective_at         timestamptz NOT NULL,
  expires_at           timestamptz,
  -- 'pending' | 'granted' | 'depleted' | 'expired' | 'voided'
  state                text NOT NULL DEFAULT 'pending',
  -- Reference back to whatever caused this grant.
  origin_kind          text NOT NULL, -- 'free_tier_monthly' | 'referral' | 'support_comp' | 'purchase' | 'enterprise_prepay'
  origin_reference     text,
  created_at           timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX credit_grants_active
  ON credit_grants (customer_id, priority, expires_at NULLS LAST, effective_at)
  WHERE state = 'granted';

Append-only consumptions ledger

The consumptions table records every draw. Each row points at the grant it came from, carries the meter event ID that triggered it, and stamps an idempotency key so retries are safe. The unique constraint on (grant_id, meter_event_idempotency_key) is the structural guarantee that the same meter event cannot consume from the same grant twice.

CREATE TABLE credit_consumptions (
  id                              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  grant_id                        uuid NOT NULL REFERENCES credit_grants(id),
  customer_id                     text NOT NULL,
  amount_cents                    bigint NOT NULL,
  meter_event_idempotency_key     text NOT NULL,
  consumed_at                     timestamptz NOT NULL DEFAULT now(),
  UNIQUE (grant_id, meter_event_idempotency_key)
);

Consume function with locked grant walk

The consume function takes a customer ID and an amount, locks the customer's active grants in consume order, and walks them until the amount is satisfied. The lock is what prevents two concurrent usage events from racing on the same balance. The walk is what implements the dual-pool drain order — promotional grants come first because their priority is lower, and Stripe applies the same order at invoice time.

async function consumeCredits(
  customerId: string,
  amountCents: bigint,
  meterEventIdempotencyKey: string,
): Promise<{ drawn: bigint; remaining: bigint }> {
  return db.transaction(async (tx) => {
    // Lock active grants in Stripe's documented application order:
    // priority asc, then earliest expires_at, then earliest effective_at.
    const grants = await tx.query<GrantRow>(
      `SELECT * FROM credit_grants
       WHERE customer_id = $1 AND state = 'granted'
         AND effective_at <= now()
         AND (expires_at IS NULL OR expires_at > now())
       ORDER BY priority ASC, expires_at ASC NULLS LAST, effective_at ASC, created_at ASC
       FOR UPDATE`,
      [customerId],
    );

    let remaining = amountCents;
    for (const grant of grants) {
      if (remaining <= 0n) break;

      const available = await grantAvailableBalance(tx, grant.id);
      if (available <= 0n) continue;

      const draw = remaining < available ? remaining : available;

      // The unique index on (grant_id, meter_event_idempotency_key) makes
      // this insert the structural dedupe boundary. A retry on the same
      // meter event finds the existing row and falls through.
      await tx.query(
        `INSERT INTO credit_consumptions
           (grant_id, customer_id, amount_cents, meter_event_idempotency_key)
         VALUES ($1, $2, $3, $4)
         ON CONFLICT (grant_id, meter_event_idempotency_key) DO NOTHING`,
        [grant.id, customerId, draw, meterEventIdempotencyKey],
      );

      remaining -= draw;
    }

    return { drawn: amountCents - remaining, remaining };
  });
}

Idempotent grant-lifecycle webhook handler

The webhook handler for billing.credit_grant.created, billing.credit_grant.updated, and the corresponding void and expire events is where idempotency lives. Dedupe on the Stripe event ID at the boundary, do the grant upsert inside the same transaction, and let the unique constraint on stripe_grant_id catch any path that somehow bypasses the event dedupe. The defense is layered on purpose — the failure modes that have historically caused double-grants in production are not the ones you predict in design review.

async function handleCreditGrantWebhook(event: Stripe.Event): Promise<void> {
  await db.transaction(async (tx) => {
    const inserted = await tx.processedEvents.insertIfAbsent({
      event_id: event.id,
      received_at: new Date(),
    });
    if (!inserted) return; // already processed; safe to ack

    if (event.type === "billing.credit_grant.created") {
      const grant = event.data.object as Stripe.Billing.CreditGrant;
      const pool = grant.category === "promotional" ? "promotional" : "paid";
      const priority = pool === "promotional" ? 10 : 100;

      await tx.query(
        `INSERT INTO credit_grants
           (customer_id, stripe_grant_id, pool, priority, amount_cents,
            currency, effective_at, expires_at, state, origin_kind, origin_reference)
         VALUES ($1, $2, $3, $4, $5, $6, to_timestamp($7), to_timestamp($8),
                 'granted', $9, $10)
         ON CONFLICT (stripe_grant_id) DO NOTHING`,
        [
          grant.customer,
          grant.id,
          pool,
          priority,
          grant.amount.monetary?.value ?? 0,
          grant.amount.monetary?.currency ?? "usd",
          grant.effective_at,
          grant.expires_at,
          grant.metadata?.origin_kind ?? "unknown",
          grant.metadata?.origin_reference ?? null,
        ],
      );
    }
    // ...handle voided, expired, updated similarly
  });
}

Three of the design choices in this pattern carry their weight only in the failure cases. The unique constraint on stripe_grant_id makes the webhook-retry case impossible to corrupt even if the event-ID dedupe is bypassed by a code path you forgot existed. The append-only consumptions table makes the "what did this customer spend on April 14" question a single query instead of a reconstruction. And the explicit priority gap between promotional and paid grants means the consume order survives a future change to Stripe's tie-break rules. 0) ON CONFLICT (stripe_grant_id) DO NOTHING`, [ grant.customer, grant.id, pool, priority, grant.amount.monetary?.value ?? 0, grant.amount.monetary?.currency ?? "usd", grant.effective_at, grant.expires_at, grant.metadata?.origin_kind ?? "unknown", grant.metadata?.origin_reference ?? null, ], ); } // ...handle voided, expired, updated similarly }); } ```

Failure-case guarantees

Three of the design choices in this pattern carry their weight only in the failure cases. The unique constraint on stripe_grant_id makes the webhook-retry case impossible to corrupt even if the event-ID dedupe is bypassed by a code path you forgot existed. The append-only consumptions table makes the "what did this customer spend on April 14" question a single query instead of a reconstruction. And the explicit priority gap between promotional and paid grants means the consume order survives a future change to Stripe's tie-break rules.

A calm CTO reviews a clean dashboard, showing balanced and separated stripe credit pool data, reflecting control over SaaS billing.

More on this

Common failure modes

The first sharp edge is the priority-tie-with-expiry-first case. Stripe's documented application order is priority first, then earliest expires_at, then promotional category, then earliest effective_at. If you set promotional grants to priority 100 and paid grants to priority 100 because you forgot to assign different priorities, an expiring paid credit can drain before a non-expiring promotional credit and the user's balance display lies about which pool funded the usage. The fix is the gap — promotional at 10, paid at 100 — so the first comparator resolves the tie before the expiry comparator runs.

The second is the credit-note refund hole. Stripe's docs are explicit that issuing a credit note does not reinstate credit grants, so a customer who gets a credit note for a disputed charge keeps the usage but loses the credit balance that paid for it. If your refund flow assumes the credit balance comes back, the customer ends up paying for the usage twice. The pattern is to make credit-note issuance a trigger to create a new compensating credit grant, so the balance is restored explicitly instead of by assumption. Pick the policy in design, codify it in the refund handler, and document it in the help center.

The third is the void-with-expired-grant case. Voiding an invoice reinstates the applied credit balance to the original grant, but if that grant is already past its expiration date, the reinstated credits expire immediately. The customer sees a void confirmation and then no balance change. The fix is the same shape as the credit-note case — on void, check the grant's expiration, and if it has passed, issue a fresh grant for the reinstated amount with a new expiration window. The runbook for billing disputes calls this branch out explicitly because nobody discovers it until the first dispute on a year-old credit.

The fourth is the 100-grants-per-customer cap. Stripe enforces a maximum of 100 unused credit grants per customer, measured by ledger balance rather than available balance, so a grant with a positive ledger balance but zero available balance still counts toward the cap. Heavy promotional issuance — a per-referral grant, a per-support-ticket grant, a per-blog-comment grant — hits the cap fast and starts failing. The mitigation is consolidation: a daily or weekly batch job rolls outstanding promotional grants for each customer into one consolidated grant, keeps the per-event audit trail in the metadata, and frees the cap slots for new issuance.

The fifth is the preview-versus-finalize divergence. Stripe applies credits only at invoice finalization, and credits shown on a preview invoice can change if a different finalized invoice consumed them first. A UI that promises a customer "your next invoice will be $X" based on the preview can be wrong by the time the invoice finalizes. The fix is to label preview totals as estimates, and to drive the in-app live balance from the local ledger rather than from preview invoices. The local ledger is the only thing that updates in real time as the customer uses the product.

The sixth is the timestamp-window failure on the meter event side. Stripe accepts meter event timestamps within the past 35 calendar days or up to 5 minutes in the future. A queue that stalls for longer than 35 days will start dropping events when it drains, and the events that drop are the ones whose credit consumption already landed in your local ledger — so the local pool is drained but Stripe was not told. The reconciliation job has to catch this and either resubmit with a current timestamp plus a metadata note about the original time, or escalate to a human.

What this looks like in production

At BFEAI the dual-pool credit ledger runs the billing for every paying customer. Every new account gets a recurring monthly promotional grant for the free-tier allowance, with priority 10 and an expiration tied to the end of the calendar month. Every purchase issues a paid grant at priority 100 with the expiration set to the purchase terms — typically 12 months for annual bundles, no expiration for top-ups. Referrals issue a promotional grant at priority 15 so they drain after the free-tier allowance but before any paid credit, which is the order the marketing team wants because it gives the referred user a visible reason to come back next month.

The webhook handler ingests credit grant lifecycle events from Stripe inside the same transaction as the processed-events insert, which is the structural reason credit reload retries cannot double-grant. The unique constraint on stripe_grant_id is the second layer of defense, and it has caught exactly one race in the production logs over the last year — a case where a manual replay from the engineering team and the natural Stripe retry both landed inside the same minute. The replay lost the insert, the natural retry committed, and the customer's balance was correct without anyone noticing until the audit query ran the next morning.

The consume function runs inside the same transaction as the meter event submission. The order matters: lock the customer's grants, compute the draw across pools, insert the consumption rows, submit the meter event to Stripe with the same idempotency key that the consumption rows carry, and only then commit. If the Stripe submission fails, the whole transaction rolls back and the next worker pass tries again. The shared idempotency key is what makes the retry safe — the consumption rows already exist from the rolled-back attempt are not actually there, because the rollback released them, and the meter event identifier reuse means Stripe deduplicates the second submission against the first if it actually did land.

The end-of-period reconciliation job pulls the credit grant balances from Stripe's API and compares them to the local ledger's view of available balance per grant. Drift means one of two things: a consumption row that exists locally but was not reflected in a Stripe meter event (replay it), or a Stripe-side application that does not have a corresponding local consumption (alert and investigate, because that usually means a code path is calling Stripe directly without going through the consume function). The metric that actually pages a human is the second case — the first one auto-replays and clears, the second one indicates a structural problem in the codebase.

The dashboard finance asks for has three numbers per customer per billing period: total usage in monetary terms, total usage paid by promotional credits, and total usage paid by paid credits. Because the consumptions ledger carries the grant pool on every row, those three numbers are a single GROUP BY query and the deferred-revenue waterfall reconciles automatically. The number that used to require a CSV and an analyst is now a query, and the audit team has stopped opening tickets at quarter-end.

The runbook for "promotional credit balance unexpectedly drained" is one page. Pull the customer's consumption rows in the window, group by grant, and check whether the draw order matches priority. If priority order is broken, the grant priorities are wrong — fix the grant rows and replay. If priority order is correct but the user expected a different pool to be drained, that is a product communication problem, not a billing problem, and the fix lives in the balance UI not in the ledger. Either way, the answer is in the consumptions table, not in a postmortem of when the consume function was last touched.

What to watch in your own implementation

Open your codebase and search for every code path that calls Stripe's credit grant API or that mutates a credit balance field on a customer row. For each one, answer two questions. Does it go through the consume function — meaning, does the meter event submission and the consumption write happen inside the same transaction with a shared idempotency key? And does the grant write live in a webhook handler that dedupes on the Stripe event ID before doing anything else? Any "no" is a future double-grant or a future drift incident.

Then look at your refund handler. Does it call the credit notes API or does it void the invoice? If it issues credit notes, your credit balances are not being reinstated, and any code that assumes they are is wrong today. The fix is either to switch refund paths or to add an explicit compensating-grant step inside the credit-note handler. Either is correct; doing neither is not.

Then run one reconciliation query against last month. Sum your local consumptions per customer per pool. Pull the corresponding grant application history from Stripe for the same window. Anywhere the two disagree by more than rounding is a customer whose balance is wrong today, and the size of the diff is the size of the bug the dual-pool ledger pattern is built to prevent. That number is what you take to the engineering review, and it is also what goes to zero once the pattern above is in place and the legacy code paths are retired.

Outcomes you should expect

What this delivers

  • Free-tier credits, promotional grants, and purchased credits all draw from one customer balance with predictable consume order, instead of three half-built code paths fighting on every invoice.
  • Credit reload events from Stripe webhooks land exactly once in your ledger, even when Stripe retries delivery for three days against an at-least-once endpoint.
  • Refunds and invoice voids reinstate the correct pool — promotional credits do not silently become cash-equivalent when a customer disputes a charge.
  • Finance gets a clean answer to the question 'how much of last month's usage was paid for by promotional credits versus paid credits' without a CSV-and-spreadsheet exercise.
  • Engineering stops shipping one-off code paths for every new credit-granting feature (referrals, support comps, annual top-ups, trial extensions) because the ledger already models all of them as grants with different priorities.

Primary sources

By the numbers

  • A Stripe credit grant tracks a set of prepaid or promotional billing credits allocated to a customer, and is backed by an immutable, append-only ledger.

    Source ↗

  • When multiple credit grants apply to an invoice, grants with higher priority (lower number) apply first, then earlier expires_at, then promotional category, then earlier effective_at, then earlier created timestamps.

    Source ↗

  • Credits apply to invoices only at the time of finalization; credits applied to a preview or draft invoice might change if a finalized invoice uses them first.

    Source ↗

  • Voiding an invoice with credits applied reinstates the applied balance to the credit grant; if the credit grant is past the expiration date, the reinstated credits expire immediately.

    Source ↗

  • Issuing a Credit Note does not refund credit grants applied to an invoice; to restore those billing credits you must create a new credit grant.

    Source ↗

  • Customers can have up to 100 unused credit grants at a time; the limit is based on ledger balance, so a grant with zero available balance but positive ledger balance still counts.

    Source ↗

  • Stripe enforces uniqueness on the meter event identifier within a rolling period of at least 24 hours, primarily addressing accidental retries within brief intervals.

    Source ↗

  • Stripe processes meter events asynchronously, so aggregated usage in meter event summaries and on upcoming invoices might not immediately reflect recently received meter events.

    Source ↗

Live in production today

The same engineering, shipped in production at BFEAI.

I'm co-founder & CTO of Be Found Everywhere (BFEAI), a 7-app AI SaaS platform running today. The work I deliver for clients is the work I do every week on my own platform.

7

Production apps

200K+

Keywords generated

1,500+

AI scans run

7,000+

Sites automated

Common questions

What buyers ask before reaching out

Why run two credit pools instead of one balance?

Because the two kinds of credit have to behave differently in the cases that matter. Promotional credits should drain first so they cannot accumulate on an account and become a stale liability, and they should not be refundable as cash when a customer churns. Paid credits should drain second and should be refundable in the cases where you owe the customer money back. One balance field cannot encode both rules. A ledger with a priority field can.

Does Stripe handle the dual-pool consume order natively?

Yes for the common shape. Stripe Billing Credits supports a priority field on every credit grant, lower numbers apply first, and ties break on earlier expires_at then promotional category then earlier effective_at. If you map promotional grants to priority 10 and paid grants to priority 100, Stripe will drain promotional first on every metered invoice without any application code. The ledger pattern below mirrors Stripe's order locally so your in-app balance display matches the invoice.

Why mirror Stripe's credits in Postgres if Stripe already tracks the balance?

Three reasons. Your app needs to show a live balance to the user before the invoice finalizes, and Stripe only applies credits at invoice finalization. You need a query-able history for support and finance, and the Stripe dashboard is not built for that. And you need a source of truth that survives a Stripe outage or a wrong API call, because reconstructing the pool from invoices alone loses the grant-by-grant detail.

How do I make credit reload webhooks idempotent?

Dedupe on the Stripe event ID at the webhook boundary, inside the same database transaction that writes the grant row. If the insert into the processed-events table fails the unique constraint, short-circuit and return 2xx. The grant row itself carries the Stripe credit grant ID as a unique column too, so even if the webhook fires through a different code path, the second insert fails cleanly instead of double-granting.

What happens to promotional credits when a customer is refunded?

It depends on how you refund. A credit note does not reinstate credit grants — those balances are gone unless you create a new grant. Voiding the invoice does reinstate the applied balance, but if the original grant has already expired, the reinstated credits expire immediately. The pattern is to decide up front which refund path your business takes and codify it: most teams I work with issue a new promotional grant on refund rather than relying on void semantics.

What about the 100-unused-grants-per-customer cap?

It is real and it is based on ledger balance, not available balance. A grant with a positive ledger balance but zero available balance still counts. Heavy promotional issuance — a free credit per referral, per support ticket, per blog comment — will hit the cap fast and start failing. The mitigation is to consolidate: roll multiple promotional grants into one per period instead of one per event, and use the metadata field to keep the event-level audit trail.

How do credit expiry rules interact with the dual-pool order?

Stripe's tie-breaker after priority is earliest expires_at first. That means an expiring paid credit can apply before a non-expiring promotional credit if you priced the priority field naively. If you want promotional-first behavior to hold even when paid credits are about to expire, you need to either gap the priorities so promotional grants win on the first comparison every time, or accept that expiring paid credits jump the queue and tell the user that explicitly in the balance UI.

Can I model credits as money or do they have to be quantity-based?

Both are supported. Stripe credit grants can be monetary (a dollar amount applied to invoice totals) or denominated in custom pricing units when you want to track an abstract resource like API tokens or render minutes. The dual-pool pattern works the same way for both — the ledger row carries the unit type and the consume order logic does not care. Most usage-billed SaaS land on monetary because it composes cleanly with discounts and tax.

How does this pattern coexist with metered billing reconciliation?

It sits on top of it. The metered billing reconciliation pattern keeps your usage ledger and Stripe's recorded usage in lockstep. The dual-pool credit ledger sits one layer up: it tracks the grants that get applied to the resulting invoice. Same idempotency discipline, same append-only ledger philosophy, same end-of-period reconciliation job — just running against credit grants and applications instead of meter events.

Ready to see if this is a fit?

A 15-minute call. No deck, no slides. We talk about what you're shipping and where engineering is the bottleneck. Either way, you walk away with a senior engineer's read on your situation.