For the complete documentation index, see llms.txt. This page is also available as Markdown.

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:

  1. Sells a base monthly plan (e.g. "Pro — $20/mo, includes 10k API calls").

  2. Meters every API call your customers make.

  3. 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.

  4. Reconciles via webhook (payment_success with payment.type === "subscription_extra").

  5. 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 payment object with the new payment type subscription_extra (distinct from subscription_initial, subscription_interval, subscription_update). Status flows PENDING → PAID | FAILED.

  • What it requires: an active subscription (not trialing, 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 /charge call. 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 /charge when 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

  1. Reserve before charge. The usage_charges row is inserted with status: 'pending' before the network call. The unique (subscription_id, period_start, period_end, metric) constraint guarantees that two cron workers (or one cron and a manual retry) can't both POST /charge for the same window.

  2. One item per call. The OpenAPI spec hard-caps items at 1. If you have multiple metrics (calls + bandwidth + storage), iterate and POST separately, each with its own usage_charges row keyed by metric.

  3. Roll forward sub-dollar overages. Don't lose them; carry into next period as a synthetic usage_event so 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 /charge returns 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:

  1. Sell credit packs as one-time products through the normal checkout. On payment_success with type: "one_time", credit the user's local ledger.

  2. Auto-top-up via /charge when 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 = 101 constant in code (matches exclusiveMinimum: 100).

  • usage_charges unique 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).

  • currency on every charge is asserted equal to workspace currency at startup.

  • Multi-metric? One /charge call per metric per period (the API caps items at 1 — chain calls).

  • Subscription status checked = active before POSTing — paused / canceled / past_due will reject.

  • Webhook handler discriminates payment.type === "subscription_extra" from regular subscription_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 /charge response 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"

Last updated