Billing
Your users' plans, wallets, usage, and invoices — read from your app, rendered in your UI, plus the 429 upgrade moment.
Your users' plans, credit wallets, usage, and invoices — readable from your
app, rendered in your UI. Inlay meters and collects; you own the pricing
page, the wallet header, and the paywall. Every read is scoped by the session
JWT: the end user sees only their own data, and no tenant_id/user_id ever
travels from your client.
This is the billing integration for your product's end users, not an Inlay price list. Configure plans and hosted payment flows only after the required account setup; confirm Inlay's commercial terms during onboarding.
All money figures are micro-USD ($1 = 1,000,000) — format with your own currency helper.
The facade
createBilling({ session }) gives you a typed facade; a chat client also
exposes it as chatClient.billing. A pricing page doesn't need a chat
client — standalone construction is the common case:
// Just the session — the API origin defaults to production.
const billing = createBilling({ session: conn.session });
async function PricingPage() {
const { plans, currentPlanId } = await billing.plans();
return plans.map((plan) => ({
...plan,
isCurrent: plan.id === currentPlanId,
// All money is micro-USD: format with your own currency helper.
priceUsd: plan.monthlyPriceMicros / 1_000_000,
}));
}Wallet and usage
balance() returns the credit decomposition (granted − committed −
live overage = available) plus per-grant remaining; usage() returns
the current period's tokens and cost against the plan's allowance — the
same counter figures the gate enforces from, so the number you show is
the number that will deny the next run:
async function WalletHeader() {
const [balance, usage] = await Promise.all([billing.balance(), billing.usage()]);
return {
creditsUsd: balance.availableMicros / 1_000_000,
usedUsd: usage.costMicros / 1_000_000,
allowanceUsd: usage.allowanceMicros !== undefined ? usage.allowanceMicros / 1_000_000 : null,
// Absolute — show "resets Sept 1", never a countdown retry.
resetsAt: usage.resetsAt ? new Date(usage.resetsAt) : null,
};
}resetsAt is an absolute timestamp. Show "resets Sept 1" — never a
retry countdown (see below).
The upgrade moment (429)
When an end user exhausts their monthly allowance plus credit headroom,
send() rejects with a 429. The SDK distinguishes the two 429s for you:
err.isQuotaExceeded— a billing state. The payload carries the quota figures (err.quota.limitMicros/usedMicros, orlimitTokens/usedTokens) anderr.quota.resetsAt. Do not auto-retry — nothing changes until the user upgrades, tops up, or the period resets. Render your own upgrade CTA with the figures.err.isRateLimited— a transient throttle. HonorRetry-Afterand back off; safe to retry.
Version skew: a pre-7A server's quota denial has no code member,
so both accessors return false for it — treat an unrecognized 429
conservatively (don't auto-retry) rather than assuming it's transient.
function useUpgradeCta() {
const { send } = useInlayChat({ agentId: conn.agentId, session: conn.session });
async function sendWithUpgrade(content: string) {
try {
await send(content);
} catch (err) {
// A quota denial is a billing state — render YOUR paywall with the
// figures. Never auto-retry it (nothing changes until the user acts).
if (err instanceof InlayHttpError && err.isQuotaExceeded) {
return {
upgrade: true as const,
limitUsd: err.quota?.limitMicros !== undefined ? err.quota.limitMicros / 1_000_000 : null,
resetsAt: err.quota?.resetsAt ?? null,
};
}
throw err;
}
return { upgrade: false as const };
}
return sendWithUpgrade;
}Hosted subscribe and top-up
Mutations are hosted flows — Stripe Checkout / the hosted invoice page — so card data never touches your or our servers. Prerequisites: your account must have a connected Stripe account with charges enabled (the dashboard's Connect card; the routes return 409 until then, so you can hide the CTA), and only plans you've marked published are subscribable. Subscribe charges month 1 upfront and saves the card for period-close invoices (allowance + overage, rated in arrears against the next period's advance price — the standard renewal shape). Top-up is a one-off hosted invoice; credits land via webhook when the user pays.
async function startSubscribe(planId: string) {
// Month 1 charges upfront; the card is saved for period-close
// invoices. Stripe-hosted — card data never touches your servers.
const { url } = await billing.subscribeCheckout({
planId,
successUrl: "https://yourapp.com/billing/success",
cancelUrl: "https://yourapp.com/billing/cancel",
});
// The facade throws on a missing URL, so it's always present — the type
// is optional, so guard for the compiler.
if (url) window.location.assign(url);
}
async function startTopup(amountUsd: number) {
const { url } = await billing.topup(amountUsd);
if (url) window.location.assign(url);
}Checkout is for new subscriptions (the route 409s when the user
already has one — plan changes are operator-side today). Only plans
you've marked published are subscribable here; the list's
eligibility.action tells your pricing table which CTA to render
(current / upgrade / downgrade / subscribe).
What this surface is not
- Not operator data. The tenant's full catalog (including unpublished
plans), every user's usage, and the platform's own invoices live on the
operator routes (
/v1/plans,/v1/usage,/v1/invoices,/v1/credits), not here. Plans appear on this surface only once you mark them published in the dashboard.