Most metering guides, including our own introduction, tell you to pick one billable unit and meter it cleanly. That advice is right, and it is where almost everyone starts. Then the product grows. The AI document tool that charged per processed document now also renders PDFs, calls a model that bills per token, and lets users export to a handful of formats under a monthly cap. Three different things cost you money, deliver different value, and run out at different rates — and a single "credits" bucket can no longer describe any of them honestly.

This post is about the next step: metering several resources on one license, each as its own credit pool, without the pools interfering with each other.

Key takeaway: When a product meters more than one resource, give each its own pool rather than converting everything into generic credits. Separate pools keep pricing honest, let you set a different reset cadence and overage policy per resource, and — most importantly — fail independently, so an empty export pool never blocks a render job. The cost is a little more machinery: pool-scoped idempotent draws, an explicit draw-down order when pools overlap, and atomic multi-pool draws for an operation that spends two meters at once.

Why one bucket of credits isn't enough

The tempting shortcut is a single balance of abstract "credits," with a price list that says a document is 1 credit, a render minute is 4, and an export is 10. It falls apart on contact with real customers:

  • The exchange rate is a fiction you maintain forever. The moment your render costs change, every customer's effective price for documents moves too, because they share one bucket. You cannot reprice one resource without repricing all of them.
  • Exhaustion is all-or-nothing. A customer who burns the bucket on renders can no longer export a document they already paid to process. One runaway feature starves every other feature.
  • Nobody can read their own usage. "You have 4,000 credits left" answers no question a customer actually has, which is "how many more reports can I export this month?"

Separate pools fix all three. Each resource has its own balance, its own reset cadence, and its own overage policy, and running one dry has no effect on the others.

What a pool is

A pool is a named, consumable balance that the license carries alongside its flags and limits. Three properties define it:

  • Kind. A quota pool refills on a cadence (say, 2,000 render minutes that reset on the first of the month). A prepaid pool is a block the customer bought that persists until spent and never auto-refills.
  • Overage policy. What happens at zero — hard stop, a small grace buffer, or metered overage billed after the fact. Decide this per pool; a hard stop on exports and overage on AI tokens is a perfectly reasonable mix.
  • Reconciliation. How the local draw-down is trued up against the authoritative server balance, which matters most for offline and on-prem deployments.

Metering against a named pool

With one pool the SDK call named a unit; with several, it names the pool. Everything else from single-pool metering still holds — count at the choke point, pass a stable idempotency key, and fail closed — but now each of those rules applies per pool.

var client = KeyrightClient.Initialize(options); // At the single point where render minutes are consumed. var draw = await client.MeterAsync( pool: "render-minutes", amount: job.Minutes, idempotencyKey: job.Id); // stable: a retry never double-charges if (!draw.Allowed) { // This pool is empty. Fail closed — do not render — but say which pool. ShowTopUpPrompt(pool: "render-minutes", message: draw.Message); return; } RunRenderJob(job); // only runs when a render minute was actually metered 

MeterAsync returns a MeterResult carrying Allowed, a human-readable Message, and Remaining so you can drive a low-balance banner without a second call. As with every Keyright check, a draw against an unknown or exhausted pool fails closed — Allowed is false — so the deny path is the default, not something you have to remember to code.

Independent exhaustion is the whole point

Because the draw is scoped to one pool, an empty pool stops only its own operations. In the figure above the export pool is at zero, but a render still succeeds, because the render draw never touches the export balance. Read balances directly when you need to show state or warn early:

PoolBalance exports = client.GetBalance("exports"); // ResetsAt is non-null for a quota pool, null for a prepaid block. if (exports.Remaining == 0) DisableExportButton(reason: $"Export quota resets {exports.ResetsAt:d}."); else if (exports.Remaining <= exports.Limit * 0.2) ShowLowBalanceBanner(exports); // warn at 20% — a surprise is a support ticket 

Hiding the export button is UX, not enforcement: the authoritative check is the MeterAsync draw at the point the export is produced. The banner just means the customer learns about an empty pool before they hit it mid-task.

Draw-down order: included quota before prepaid top-up

Pools get interesting when two of them can pay for the same operation. A common shape: every plan includes a monthly quota of render minutes, and customers can buy prepaid blocks for anything beyond it. You want to spend the included minutes first — they expire at month end, the prepaid ones don't — so the customer never burns money they could have used for free.

Express that as an ordered walk over the candidate pools:

// Spend the resetting quota first, then fall back to the prepaid block. static async Task<MeterResult> MeterWithFallbackAsync( KeyrightClient client, string[] poolsInOrder, long amount, string idempotencyKey) { foreach (var pool in poolsInOrder) { var draw = await client.MeterAsync(pool, amount, idempotencyKey); if (draw.Allowed) return draw; // first pool with room wins } return MeterResult.Denied("All matching pools exhausted. Top up to continue."); } var draw = await MeterWithFallbackAsync( client, poolsInOrder: ["render-minutes-included", "render-minutes-prepaid"], amount: job.Minutes, idempotencyKey: job.Id); 

The single idempotencyKey represents the whole operation, not a single pool, so a denied attempt debits nothing and a retry replays the settled result regardless of which pool ended up paying. If you would rather not carry the ordering in client code, declare it once on the server as a pool group — an ordered set the SDK resolves for you — and go back to a plain MeterAsync(pool: "render-minutes", …) that walks included-then-prepaid under the hood.

One operation, two pools: atomic multi-pool draws

The hard case is a single logical operation that spends from more than one pool at once. A render job might consume both render minutes and the AI tokens its captioning step burns. Debiting them with two separate MeterAsync calls is a trap: if the first succeeds and the second fails, you have charged a pool for work you are about to abandon, and there is no clean way to hand that credit back.

The fix is an all-or-nothing multi-draw. Describe every pool the operation touches, and let the SDK check them all, debit them together, or debit none:

var plan = new[] { new MeterDraw(pool: "render-minutes", amount: job.Minutes), new MeterDraw(pool: "ai-tokens", amount: job.CaptionTokens), }; // Every pool must have room, or nothing is debited. var result = await client.MeterManyAsync(plan, idempotencyKey: job.Id); if (!result.Allowed) { // ShortfallPool names the first pool that could not cover its draw. ShowTopUpPrompt(pool: result.ShortfallPool, message: result.Message); return; } RunRenderJob(job); // both pools debited together, under one idempotency key 

This is the metering equivalent of a transaction: the invariant is that the customer's balances and the work delivered never disagree. ShortfallPool tells you which meter blocked the job, so the top-up prompt points at the right pool instead of a generic "out of credits."

Resets, reconciliation and offline

Each pool reconciles on its own terms. A quota pool refills on its cadence; the server resets Remaining to Limit at the period boundary and the SDK reflects it on the next reconcile. A prepaid pool only ever goes down until the customer buys another block.

For disconnected deployments the same signed-block mechanism from single-pool metering applies, once per pool. The server issues a cryptographically signed balance for each pool — "this license holds 2,000 render minutes and 1,000,000 AI tokens" — which the client verifies against its embedded public key and draws down locally, with no network call per draw. When the machine reconnects it reconciles each pool independently: reports what it consumed, the server trues up every balance, and reissues fresh signed blocks. An air-gapped or on-prem customer can meter several resources at once without ever being online during a draw.

The honest limit is unchanged from any client-side meter: a determined attacker with a decompiler can try to patch a local balance, so the authoritative count stays on a server you control, signed blocks reconcile against it, and you make the client hard to patch — see protecting your licensing checks. Offline per-pool metering is a controlled trust extension for legitimate disconnected use, not a guarantee against tampering.

Doing this with Keyright

Keyright treats each pool as a first-class part of the license, next to seats, feature flags and numeric limits. You declare a pool per metered resource — its kind, reset cadence and overage policy — sell quotas and prepaid blocks against it, and the SDK (.NET, Node, Python, Java) meters each pool independently, idempotently and fail-closed. Pool groups let you declare draw-down order once; MeterManyAsync keeps a multi-pool operation atomic; and signed per-pool blocks carry the whole thing offline, reconciling when a machine reconnects. Because it is the same license that already handles your trials, seats and entitlements, metering several resources is part of the license, not a billing system bolted onto the side.

The Community Edition is free and self-hosted, so you can model a multi-pool product and wire it end to end before you commit — see the pricing, or read the Keyright docs for the exact metering surface. If per-feature usage pricing is central to your product, the feature overview walks through pools, quotas and overage in depth.

Takeaway: One bucket of credits is the right model for exactly one metered resource. The moment you meter a second, give it its own pool — independent balance, independent reset, independent exhaustion — and reach for ordered draw-down when pools overlap and an atomic multi-draw when one operation spends two meters at once.