SaaS Developers with Pay-As-You-Go products (Usage-based)
Fungies for SaaS — Usage-Based / Pay-As-You-Go on Top of Subscriptions
Companion tutorial to SaaS Subscription Tutorial. You already have a base monthly subscription wired up. Now you want to bill customers for what they actually consume — API calls, GB transferred, AI tokens, seats added mid-cycle. This walk-through shows how to do that with Fungies'
[POST /v0/subscriptions/{subscriptionIdOrNumber}/charge](https://docs.fungies.io/api-reference/subscriptions/charge-subscription)endpoint, what its real constraints are, and how to wire it into a metering pipeline that won't double-charge or leak revenue.
What you'll build
A SaaS that:
Sells a base monthly plan (e.g. "Pro — $20/mo, includes 10k API calls").
Meters every API call your customers make.
Once a billing period closes (or a hard threshold is crossed), rolls up the overage and charges it to the same subscription as a separate invoice using
/charge.Reconciles via webhook (
payment_successwithpayment.type === "subscription_extra").Optionally sells prepaid credit packs so heavy users can pay up-front and you bill against credits locally.
How /charge actually works
POST /v0/subscriptions/{subscriptionIdOrNumber}/charge
What it does: creates a new invoice on an active subscription and immediately attempts to charge the saved payment method. Does NOT alter the recurring schedule.
What it returns: a
paymentobject with the new payment typesubscription_extra(distinct fromsubscription_initial,subscription_interval,subscription_update). Status flowsPENDING → PAID | FAILED.What it requires: an
activesubscription (nottrialing,paused,canceled,past_due).Auth: same two headers as everywhere else —
x-fngs-public-key+x-fngs-secret-key.
The constraints that shape your design (from the OpenAPI spec)
Constraint
Practical impact
items array: minItems: 1, maxItems: 1
Only ONE line item per call. No multi-metric invoices in one POST. To bill 3 different things, send 3 calls.
unitPrice: integer, exclusiveMinimum: 100
Minimum unit price is 101 cents (~$1.01). Per-event micro-billing (e.g. $0.001/API call) is impossible — you must aggregate first.
quantity: number (double), minimum: 1, default 1
Fractional quantities are allowed (1.42 GB).
currency enum
Must match your workspace currency. Cross-currency charges rejected.
offerId: UUID (optional)
If provided, name / unitPrice / currency are pulled from the offer — you only need to send quantity.
Path param subscriptionIdOrNumber
Accepts the UUID or the human-readable order number with optional # prefix.
Read the second row twice. The single biggest design decision in usage-based billing on Fungies is that you cannot charge less than ~$1 per
/chargecall. This forces an aggregate-then-charge pattern, not a charge-per-event pattern. We lean into that below.
Minimum valid request
Response:
Step 1 — Pricing model first, code second
Before writing a single line, decide the model. Three patterns map cleanly onto the constraints above.
Pattern A — Periodic rollup (most SaaS use this)
Meter locally; at the end of each billing cycle, sum total $ owed, charge once.
Best for: API platforms, AI inference, bandwidth, anything continuously consumed.
Pros: one charge per customer per cycle; minimum-unit-price problem disappears (you're aggregating to dollars).
Cons: customer surprise risk if usage spikes — mitigate with email alerts at 50/80/100% thresholds.
Pattern B — Threshold-triggered
Meter locally; charge as soon as accrued usage crosses a configurable dollar amount (e.g. every $20 of overage).
Best for: customers who want predictable smaller charges, or to limit your AR exposure.
Pros: failed charges caught early; cash flow even within the cycle.
Cons: more API calls, more invoices for the customer.
Pattern C — Prepaid credits
Sell a credit pack via the normal checkout (e.g. "$50 = 5,000 credits"); decrement on usage; offer auto-top-up by calling
/chargewhen balance dips below threshold.
Best for: AI APIs, gaming, anywhere customers want hard caps and the merchant wants money up-front.
Pros: no overage debt, simple mental model.
Cons: credits ledger lives in your DB — you need clear refund/expiry rules.
The rest of this tutorial implements Pattern A with a sketch of B and C at the end.
Step 2 — Meter usage in your DB
You need three tables. Here's a Supabase / Postgres minimal schema:
Write usage on the hot path:
Keep this fire-and-forget cheap. Don't compute totals on the hot path; do that in the rollup.
Step 3 — The nightly / end-of-period rollup job
Run this on a cron (e.g. once a day, plus a final pass an hour after each subscription's current_period_end).
Three things that make this safe
Reserve before charge. The
usage_chargesrow is inserted withstatus: 'pending'before the network call. Theunique (subscription_id, period_start, period_end, metric)constraint guarantees that two cron workers (or one cron and a manual retry) can't both POST/chargefor the same window.One item per call. The OpenAPI spec hard-caps
itemsat 1. If you have multiple metrics (calls + bandwidth + storage), iterate and POST separately, each with its ownusage_chargesrow keyed bymetric.Roll forward sub-dollar overages. Don't lose them; carry into next period as a synthetic
usage_eventso they accrue.
Step 4 — Reconcile via webhook
Add subscription_extra handling to the webhook handler from the base subscriptions tutorial:
If payment_failed arrives for a subscription_extra, mark the row failed and decide your retry policy (e.g. retry tomorrow; if still failing after 3 days, suspend the account or downgrade).
Why webhooks even though
/chargereturns synchronously? The synchronous response tells you the initial charge result. Card auths can later be reversed (PARTIALLY_REFUNDED,REFUNDED, network disputes). Webhooks are the only way to learn about those state transitions. Always treat the synchronous response as a fast path, not the source of truth.
Step 5 — Show the customer what they're being charged
Two surfaces matter.
In your app (real-time)
A "current cycle usage" widget:
In Fungies (after charge)
The customer sees the new invoice in the hosted Customer Portal at /portal under the subscription's invoice list, with the description you sent and a downloadable PDF (invoiceUrl from the response).
Tip: put a human-readable breakdown in name since the portal shows it verbatim. "API calls overage (12,420 calls @ $0.001)" is better than "Overage".
Step 6 — Pattern B (threshold-triggered) in 30 lines
Same plumbing, but the trigger is a metered total instead of a clock:
Call onUsageWritten from a queue (BullMQ, Trigger.dev, Inngest) — never from the hot meter path, and never more than once per subscription concurrently (use a per-subscription lock).
Step 7 — Pattern C (prepaid credits) in sketch
Two pieces:
Sell credit packs as one-time products through the normal checkout. On
payment_successwithtype: "one_time", credit the user's local ledger.Auto-top-up via
/chargewhen balance drops below a threshold and the user has opted in. Use the same idempotency pattern from Step 3, but key on(subscription_id, top_up_request_id)instead of period.
Refunds: when a user cancels, decide whether to refund unused credits via Fungies' refund flow, or let them expire — document this in your ToS.
Production checklist
MIN_CHARGE_CENTS = 101constant in code (matchesexclusiveMinimum: 100).usage_chargesunique index on(subscription_id, period_start, period_end, metric)is enforced.Per-subscription concurrency lock around the rollup (Postgres advisory lock or queue concurrency 1).
currencyon every charge is asserted equal to workspace currency at startup.Multi-metric? One
/chargecall per metric per period (the API capsitemsat 1 — chain calls).Subscription
statuschecked =activebefore POSTing —paused/canceled/past_duewill reject.Webhook handler discriminates
payment.type === "subscription_extra"from regularsubscription_interval.Customer-facing email when an overage charge succeeds AND when one fails.
Sub-dollar overages roll forward into next cycle, not silently dropped.
Synchronous
/chargeresponse NEVER treated as final — webhooks are the source of truth.Manual "charge now" button in admin uses the same code path (don't fork).
Troubleshooting matrix
Symptom
Likely cause
Fix
400 with unitPrice must be greater than 100
Sent unitPrice <= 100
Aggregate until total ≥ 101 cents; carry sub-dollar amounts forward.
400 with items must contain at most 1 item
Sent multiple line items
Loop and POST one call per metric.
400 with currency error
Currency doesn't match workspace
Read workspace currency on boot, assert.
Charge succeeded but no payment_success webhook
Endpoint not subscribed, or filtered type
Check the dashboard's webhook deliveries log; ensure you handle subscription_extra in addition to subscription_initial.
Same period charged twice
Missing unique index on usage_charges, or two workers raced before the row was reserved
Add the unique index; reserve the row BEFORE the network call.
Customer charged on canceled sub
Race between cron and cancellation event
Re-fetch GET /v0/subscriptions/{id} immediately before POST and abort if status !== "active".
Overage charge rejected with payment failure
Card declined, expired, or insufficient funds
Pause overage billing, email customer to update card via [/portal](https://azzeki.com/portal), retry tomorrow.
Need to refund an overage
Use Fungies dashboard refund on the subscription_extra payment
payment_refunded webhook will fire; reverse the local usage_charges row.
Quick reference
Endpoint
POST /v0/subscriptions/{subscriptionIdOrNumber}/charge
Auth
x-fngs-public-key + x-fngs-secret-key (both required)
Body shape
{ description?, items: [{ name, unitPrice, currency, quantity?, offerId? }] }
Limits
items 1..1, unitPrice > 100 (cents), quantity ≥ 1 (double), currency = workspace
Sync result
payment object, type subscription_extra, status PENDING/PAID/FAILED
Async confirm
webhook payment_success / payment_failed with payment.type = "subscription_extra"
Subscription must be
status: "active"
Related
Base flow: SaaS Subscription Tutorial
Last updated