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


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://help.fungies.io/tutorials/saas-developers-with-pay-as-you-go-products-usage-based.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
