Subscriptions & usage
How subscriptions, metered usage, and spend caps bill on a plan.
How subscriptions, metered usage, and spend caps bill on a plan.
A @Plan is the unit you sell. It carries a recurring subscription fee, the
rate limits the gateway enforces, and optional metered usage that bills
per unit on top of the fee. Add a spend cap to bound what a subscriber can
run up in a month. You author all of it in TypeScript on the decorated @Product
class — Stripe products, prices, and meters are derived from the compiled
Manifest IR; you never touch Stripe objects directly.
import { Product, Requests, Meter, Feature, Plan, capabilityGrant }
from "@farthershore/product";
@Product({
name: "croncloud",
origin: "https://api.example.com",
displayName: "CronCloud",
})
export default class CronCloud {
@Requests()
requests!: unknown;
@Meter("compute", { display: "Compute", unit: "ms" })
compute!: unknown;
@Feature("cron-jobs", {
plans: ["starter", "pro"],
routes: {
"GET /v1/cron-jobs": {},
"POST /v1/cron-jobs": {},
"DELETE /v1/cron-jobs/{id}": {},
},
})
cronJobsFeature!: unknown;
@Plan("starter", {
name: "Starter",
price: { amount: 2900, currency: "usd", interval: "month" },
limits: {
requests: { rate: 600, interval: "minute", enforcement: "enforce" },
},
})
starter!: unknown;
@Plan("pro", {
name: "Pro",
price: { amount: 19900, currency: "usd", interval: "month" },
limits: {
requests: { rate: 6000, interval: "minute", enforcement: "enforce" },
},
})
pro!: unknown;
}
price is the recurring fee, in integer cents, taken verbatim — no scaling.
amount: 2900 is $29.00/month. The interval is "month" or "year". Omit
price (or pass { free: true }) for a free plan.
@Plan("starter", {
name: "Starter",
price: { amount: 2900, currency: "usd", interval: "month" }, // $29/mo
})
starter!: unknown;
@Plan("free", { name: "Free", price: { free: true } })
free!: unknown;
currency must be "usd" and money is integer-only — 2900 cents, never
29.00. (See the money convention in @farthershore/contracts.)
Every plan must declare at least one rate limit — the platform refuses to publish
a plan with no limits. A limit is a per-window capacity on a metered dimension,
authored as { rate, interval, enforcement }:
rate — the capacity (a positive integer).interval — second | minute | hour | day | week | month.enforcement — "enforce" (the gateway rejects over-limit calls with 429,
wire code rate_limited) or "track" (count only, never block).limits: {
requests: { rate: 600, interval: "minute", enforcement: "enforce" },
}
requests is the platform-managed request meter — declare it once with
@Requests() and it counts 1 per successful request on every metered route, no
backend code required. You can rate-limit any declared @Meter dimension the
same way.
A plan with no rate-limit rule fails the publish gate with
PLAN_RATE_LIMIT_REQUIRED. The hint shows the minimal shape:
limits: { requests: { rate: 600, interval: "minute" } }.
A @Meter declares a billable dimension on top of the flat fee. There are two
ways usage reaches the meter:
cost, or product-wide with a meter's routeDefault.@farthershore/backend. Declare it on the route with reports, and give
the meter an estimate so the gateway can admit the request before the actual
number is known.@Meter("tokens_used", { unit: "token", estimate: 500 })
tokensUsed!: unknown;
@Feature("runs", {
routes: {
// Gateway charges 10 api_credits up front:
"POST /v1/export": { cost: { api_credits: 10 } },
// Upstream reports tokens_used after the call; 500 is the admission estimate:
"POST /v1/chat": { reports: "tokens_used" },
},
})
runs!: unknown;
To price that usage, attach per-unit overage to the plan with meter.
micros is the per-unit rate in micro-dollars (1,000,000 µ = $1.00);
includedUnits is an optional free allotment before billing starts:
@Plan("pro", {
name: "Pro",
price: { amount: 19900, currency: "usd", interval: "month" },
limits: { requests: { rate: 6000, interval: "minute", enforcement: "enforce" } },
// $0.000002 per token after the first 1,000,000 tokens:
meter: { tokens_used: { micros: 2, includedUnits: 1_000_000 } },
})
pro!: unknown;
The same meter cannot be both a fixed cost and a dynamic report on one route
— authoring it both ways throws at compile time. See
usage strategies for the full patterns.
maxMonthlySpendCents bounds total spend on a plan per billing month. Once the
subscriber's metered usage would exceed the cap, further billable calls are
denied with 402 instead of running up the bill. Pair it with
overageBehavior to choose what happens at a limit: "block" (refuse) or
"allow_and_bill" (let it through and charge).
@Plan("pro", {
name: "Pro",
price: { amount: 19900, currency: "usd", interval: "month" },
limits: { requests: { rate: 6000, interval: "minute", enforcement: "enforce" } },
meter: { tokens_used: { micros: 2 } },
maxMonthlySpendCents: 50_000, // never bill more than $500/mo of usage
overageBehavior: "allow_and_bill",
})
pro!: unknown;
minMonthlySpendCents sets a floor — a minimum monthly charge invoiced through
Stripe even if usage is lower (useful for committed-spend plans).
The subscriber can set their own cap inside the plan's ceiling. The Frontend
SDK exposes it through fs.billing (and the zero-prop <SpendCapControl/>
component):
await fs.billing.setSpendCap({ maxMonthlySpendCents: 20_000 }); // their $200/mo cap
const cap = await fs.billing.getSpendCap(); // → number | null
The subscriber-set cap is currently stored, not enforced at the edge (it
rides in subscription metadata pending the rate-limit migration). Surface it as
a preference, not a hard ceiling — the enforced ceiling is the plan's
maxMonthlySpendCents.
The platform tracks usage per subscriber and exposes it through the Frontend SDK — no reporting code on your side:
const usage = await fs.usage.snapshot();
usage.summary.tokens_used; // total of a product-defined meter this period
usage.events[0]?.dimensions; // per-event meter values
const sub = await fs.billing.subscription(); // plan, status, period, trial
The managed components <UsageCard/>, <BillingSummary/>, and <BillEstimator/>
render this with zero props under <FartherShoreRoot>.