> For the complete documentation index, see [llms.txt](https://help.fungies.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://help.fungies.io/tutorials/saas-developers-with-pay-as-you-go-products-usage-based.md).

# 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](/tutorials/saas-developers-with-subscription-products.md). 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

```bash
curl -X POST "https://api.fungies.io/v0/subscriptions/$SUB_ID/charge" \
  -H "x-fngs-public-key: $FUNGIES_PUBLIC_KEY" \
  -H "x-fngs-secret-key: $FUNGIES_SECRET_KEY" \
  -H "content-type: application/json" \
  -d '{
    "description": "API overage — November 2026",
    "items": [{
      "name": "API calls overage (12,420 calls @ $0.001)",
      "unitPrice": 1242,
      "currency": "USD",
      "quantity": 1
    }]
  }'
```

Response:

```json
{
  "status": "success",
  "data": {
    "payment": {
      "object": "payment",
      "id": "<uuid>",
      "type": "subscription_extra",
      "number": "...",
      "status": "PAID",
      "value": 1242,
      "currency": "USD",
      "subscriptionId": "<sub-id>",
      "subscription": { "id": "...", "status": "active" },
      "invoiceNumber": "2026-11-...",
      "invoiceUrl": "https://yourstore.com/api/invoice/<base64>",
      "charges": [
        {
          "id": "...",
          "status": "succeeded",
          "paymentMethod": { "type": "card", "brand": "visa", "last4": "4242" }
        }
      ]
    }
  }
}
```

***

### 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:

```sql
create table subscriptions (
  id text primary key,                          -- Fungies subscription id (e.g. order-number-based)
  user_id uuid not null references users(id),
  base_offer_id uuid not null,
  included_units bigint not null default 0,     -- e.g. 10000 API calls baked into base plan
  unit_price_cents int not null,                -- e.g. 1 = $0.01 per call beyond included
  currency text not null,                       -- must match workspace currency
  current_period_start timestamptz not null,
  current_period_end timestamptz not null,
  status text not null
);

create table usage_events (
  id bigserial primary key,
  subscription_id text not null references subscriptions(id),
  occurred_at timestamptz not null default now(),
  units bigint not null,                        -- 1 per API call, or N for batched events
  metric text not null default 'api_call',
  request_id text,                              -- for idempotency at the meter level
  unique (subscription_id, request_id)          -- swallow duplicate meter writes
);
create index idx_usage_sub_period on usage_events (subscription_id, occurred_at);

create table usage_charges (
  id uuid primary key default gen_random_uuid(),
  subscription_id text not null references subscriptions(id),
  period_start timestamptz not null,
  period_end timestamptz not null,
  metric text not null,
  units_billed bigint not null,
  unit_price_cents int not null,
  total_cents int not null,                     -- equals fungies payment.value
  fungies_payment_id text unique,               -- nullable until POST returns
  status text not null default 'pending',       -- pending | charged | failed | skipped
  created_at timestamptz not null default now(),
  unique (subscription_id, period_start, period_end, metric)  -- idempotency key
);
```

Write usage on the hot path:

```ts
// every customer-facing API call
async function meter(subscriptionId: string, units = 1, requestId: string) {
  await db.from("usage_events").insert({
    subscription_id: subscriptionId,
    units,
    metric: "api_call",
    request_id: requestId, // your own request id; unique constraint dedupes retries
  });
}
```

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`).

```ts
// scripts/run-overage-charges.ts — Node, runs on a schedule
import "dotenv/config";

const FUNGIES_API = process.env.FUNGIES_API_BASE!;
const HEADERS = {
  "content-type": "application/json",
  "x-fngs-public-key": process.env.FUNGIES_PUBLIC_KEY!,
  "x-fngs-secret-key": process.env.FUNGIES_SECRET_KEY!,
};

const MIN_CHARGE_CENTS = 101; // hard API floor (exclusiveMinimum: 100)

async function main() {
  const dueSubs = await db
    .from("subscriptions")
    .select("*")
    .eq("status", "active")
    .lte("current_period_end", new Date().toISOString());

  for (const sub of dueSubs.data ?? []) {
    await rollupAndCharge(sub);
  }
}

async function rollupAndCharge(sub: any) {
  const { data: usage } = await db.rpc("sum_usage", {
    sub_id: sub.id,
    from: sub.current_period_start,
    to: sub.current_period_end,
  });
  const totalUnits = Number(usage?.[0]?.total ?? 0);
  const overUnits = Math.max(0, totalUnits - sub.included_units);
  const totalCents = overUnits * sub.unit_price_cents;

  if (totalCents < MIN_CHARGE_CENTS) {
    await rollForward(sub, overUnits, totalCents);
    return;
  }

  // Reserve an idempotency row BEFORE calling Fungies
  const { data: chargeRow, error } = await db
    .from("usage_charges")
    .insert({
      subscription_id: sub.id,
      period_start: sub.current_period_start,
      period_end: sub.current_period_end,
      metric: "api_call",
      units_billed: overUnits,
      unit_price_cents: sub.unit_price_cents,
      total_cents: totalCents,
      status: "pending",
    })
    .select()
    .single();

  if (error?.code === "23505") {
    // Unique violation: another worker already charged this period
    return;
  }

  // ONE item only per call (API limit: maxItems: 1)
  const r = await fetch(`${FUNGIES_API}/v0/subscriptions/${sub.id}/charge`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify({
      description: `Overage — ${sub.current_period_start.toISOString().slice(0, 7)}`,
      items: [
        {
          name: `API calls overage (${overUnits.toLocaleString()} calls @ $${(sub.unit_price_cents / 100).toFixed(3)})`,
          unitPrice: totalCents, // bundle to satisfy >100 minimum
          currency: sub.currency, // must match workspace currency
          quantity: 1,
        },
      ],
    }),
  });

  const json = await r.json();
  if (json.status !== "success") {
    await db
      .from("usage_charges")
      .update({ status: "failed" })
      .eq("id", chargeRow.id);
    console.error("Fungies /charge failed", sub.id, json.error?.message);
    return;
  }

  await db
    .from("usage_charges")
    .update({ status: "charged", fungies_payment_id: json.data.payment.id })
    .eq("id", chargeRow.id);
}

async function rollForward(sub: any, units: number, cents: number) {
  // Below the $1 API floor — push into next period so it eventually accrues
  await db.from("usage_events").insert({
    subscription_id: sub.id,
    units,
    metric: "carry_forward",
    request_id: `cf_${sub.id}_${sub.current_period_end.toISOString()}`,
  });
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});
```

#### 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:

```ts
case "payment_success":
  if (event.data.payment?.type === "subscription_extra") {
    return onOverageCharged(event);
  }
  return onPaymentSuccess(event);

async function onOverageCharged(event: any) {
  const paymentId = event.data.payment.id;
  await db.from("usage_charges")
    .update({ status: "charged" })
    .eq("fungies_payment_id", paymentId);
  console.log("overage settled", paymentId, event.data.payment.value, event.data.payment.currency);
}
```

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

```ts
case "payment_failed":
  if (event.data.payment?.type === "subscription_extra") {
    await db.from("usage_charges")
      .update({ status: "failed" })
      .eq("fungies_payment_id", event.data.payment.id);
    await notifyCustomerOfFailedOverage(event.data.subscription.id);
  }
  break;
```

> **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:

```tsx
export async function UsageMeter({
  subscriptionId,
}: {
  subscriptionId: string;
}) {
  const { units, included, unitPriceCents, currency } =
    await getCurrentCycleUsage(subscriptionId);
  const over = Math.max(0, units - included);
  const projectedCents = over * unitPriceCents;

  return (
    <div className="rounded-lg border p-4">
      <div className="text-sm text-zinc-500">Current cycle</div>
      <div className="text-2xl font-medium">
        {units.toLocaleString()} / {included.toLocaleString()} calls
      </div>
      {over > 0 && (
        <div className="mt-2 text-sm text-amber-700">
          Projected overage:{" "}
          <strong>
            {(projectedCents / 100).toFixed(2)} {currency}
          </strong>{" "}
          at end of cycle
        </div>
      )}
    </div>
  );
}
```

#### In Fungies (after charge)

The customer sees the new invoice in the [hosted Customer Portal at `/portal`](https://azzeki.com/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:

```ts
const THRESHOLD_CENTS = 2000; // charge every $20 of overage

async function onUsageWritten(subscriptionId: string) {
  const sub = await getSub(subscriptionId);
  const totalUnits = await sumUsageInCurrentCycle(sub);
  const overUnits = Math.max(0, totalUnits - sub.included_units);
  const accrued = overUnits * sub.unit_price_cents;

  const alreadyCharged = await sumChargedInCurrentCycle(sub);
  const due = accrued - alreadyCharged;

  if (due >= THRESHOLD_CENTS) {
    await chargeOverage(sub, due); // same logic as Step 3
  }
}
```

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`                                                                                   |
| **Docs**                 | [docs.fungies.io/api-reference/subscriptions/charge-subscription](https://docs.fungies.io/api-reference/subscriptions/charge-subscription) |
| **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](/tutorials/saas-developers-with-subscription-products.md)
