Base 3:16
Guides

Sell credit packs

Sell prepaid units of a metered feature — 1,000 AI credits for $10 — through Base316's hosted pay page, show a customer's balance, and hear when it runs low.

A credit pack is a one-off price that grants units of a metered feature: "1,000 AI credits for $10". A customer buys one on Base316's hosted pay page; when the payment lands, the units become a credit grant (reason PURCHASE) that check() and the billing run spend like any other credit.

The order units are spent in

For a metered feature, in each billing period:

  1. The plan's allowance, scaled by quantity for a per-unit allowance.
  2. Credit grants, soonest-to-expire first. Voided and expired grants are skipped. A grant that was live at any point in the period still covers the usage it covered while it was live.
  3. Overage, only when the plan version prices the feature's usage (a soft limit), up to any hard cap. Without an overage price the limit is hard and check() refuses with USAGE_LIMIT_EXCEEDED once allowance and credits are gone.

Credits are drawn down when the period is invoiced. GET /v1/credits/balance applies this period's draw already, so its figures are what will be left.

Create a pack

In the dashboard: Catalog → Credit packs → New credit pack. Or with the SDK:

await base316.creditPacks.create({
	key: "credits-1000",
	name: "1,000 AI credits",
	featureKey: "ai_credits",
	units: 1000,
	currency: "USD",
	amount: "10.00",
	// Optional volume discount over the number of packs bought.
	tiers: [
		{ upTo: 4, unitAmount: "10.00" },
		{ upTo: 9, unitAmount: "9.00" },
		{ upTo: null, unitAmount: "8.00" },
	],
	// Purchased credits last 365 days by default; null never expires.
	expiresInDays: 365,
	// Most packs one checkout may buy. 1,000 unless set; up to 1,000,000.
	maxQuantity: 1000,
});
  • The feature must be METERED.
  • Volume tiers price the whole quantity at the band it lands in: five packs at the 5–9 price, not four at one price and one at another. Exactly one band is open-ended (upTo: null).
  • The key, feature, units and currency are fixed once created — they are what a purchase bought. Price, expiry and maxQuantity can change, and apply to purchases made afterwards. Archive a pack to stop selling it.

Top-ups of any amount

maxQuantity caps how many packs one checkout buys, 1,000 unless you set it. To let a customer top up any whole-dollar amount, sell a pack worth one dollar and raise the cap — 100 units of a feature counted in cents, for $1.00, with maxQuantity: 25000, takes any top-up from $1 to $25,000:

await base316.creditPacks.create({
	key: "ad_funds",
	name: "Ad funds",
	featureKey: "ad_spend", // metered in cents
	units: 100,
	currency: "USD",
	amount: "1.00",
	expiresInDays: null,
	maxQuantity: 25000,
});

// A $250 top-up.
await base316.creditPacks.checkout({
	externalId: account.id,
	creditPackKey: "ad_funds",
	quantity: 250,
});

A checkout past the cap is refused with INVALID_REQUEST naming it. The public pack list includes maxQuantity, so a picker can bound its input.

List packs in your app

The packs on sale are public, like plans, so a "buy more credits" picker can read them in the browser with a publishable key:

const { data: packs } = await browser.billing.creditPacks();

GET /v1/public/credit-packs leaves out archived packs and anything about a customer.

Sell one

Buying names the account being sold to, so your server starts it:

const checkout = await base316.creditPacks.checkout(
	{
		externalId: account.id,
		creditPackKey: "credits-1000",
		quantity: 2,
		returnUrl: "https://app.example.com/billing",
	},
	{ idempotencyKey: `pack-${requestId}` },
);

redirect(checkout.url);
  • Base316 raises a one-off invoice for the packs (tax and the customer's wallet are applied as on any invoice) and returns the hosted pay page's url. After paying, the buyer gets a receipt and a link back to returnUrl, which must be one of the environment's allowed redirect URLs.
  • When the customer's wallet covers the whole amount, status is PAID, url is null and the credits are already granted.
  • The credits are granted when the invoice is paid — by card on the pay page, by an offline payment you record, or marked paid in the dashboard — exactly once, however many times the payment is reported. credit_pack.purchased is sent then.
  • An unpaid checkout is never charged automatically, even with a card on file. It lapses at expiresAt (24 hours by default, expiresInMinutes to change it) and its invoice is voided.
  • A managed tenant billed to its parent spends its parent's balance, so the checkout is refused (CONFLICT) — sell the pack to the parent account.

From the dashboard, a customer's page has Sell a pack, which creates the same payment link to send by hand.

Refunds

A refund is a credit note on the purchase invoice. The grant keeps the share of its units that was not credited back, and never less than what has already been spent: crediting half of a $10 pack takes 500 of 1,000 unspent credits away. The money goes where the credit note sends it: to the customer's wallet by default, or back to their card (see Refunds in the billing concepts). A credit note that moved money cannot be voided, so the units do not come back once the money has.

Refund everything unused

When a customer leaves, or stops using a feature, refund all of their unused purchased credit for it in one call:

const refund = await base316.credits.refund({
	externalId: account.id,
	featureKey: "ad_spend",
	reason: "Closed the ads account",
	idempotencyKey: `refund-${account.id}-ad_spend`,
});

refund.refundedCents; // 3766
refund.creditNotes; // one per purchase refunded
  • Only what is really unused. Usage this period counts as spent, even though the billing run only records it at the period's close. A customer with $50 bought and $12.34 used so far this month is refunded $37.66, and the $12.34 still comes out of their credit when the period closes — not billed as overage.
  • One credit note per purchase, on the invoice that paid for it, for that purchase's unused share of the invoice total (tax included), rounded down to the cent. The money goes back to the payment that bought it; a pack paid from the wallet is returned to the wallet.
  • Credit nobody paid for is withdrawn, not refunded — promotional or manual grants — and listed in grantsVoided.
  • Auto-recharge for the feature is turned off. Otherwise the empty balance is a low balance, and the next check would buy a pack straight back. It shows as disabledReason: "CREDITS_REFUNDED" and can be turned on again.
  • A purchase whose refund is refused (for example, a Stripe account that cannot issue refunds) is left exactly as it was and listed in skipped.
  • idempotencyKey is required. A retry with the same key returns the first result, whenever it arrives, and refunds nothing more.
  • credits.refunded is sent once the refund is done.
  • A managed tenant spends its parent's credit, so the parent is refunded: the call is refused (CONFLICT) for the tenant.

Show the balance

const balance = await base316.credits.balance({ externalId: account.id, featureKey: "ai_credits" });
// { allowance: 5000, used: 5600, allowanceRemaining: 0,
//   purchasedRemaining: 400, creditsRemaining: 400, remaining: 400, ... }

The purchase history for a billing page is creditPacks.purchases({ externalId }).

Low-balance events

credits.low_balance is sent when a feature's remaining balance — allowance plus credits, less usage — falls below a threshold, once per crossing: it re-arms only when the balance is next seen at or above the threshold (a top-up, a new period, a bigger plan).

  • The product's threshold is set on the feature: catalog.features.update(featureId, { lowBalanceThreshold: 500 }), or Catalog → Features → Edit.
  • A customer's own threshold overrides it: credits.setAlert({ externalId, featureKey: "ai_credits", threshold: 1000 }). threshold: null falls back to the product's. A threshold set above a balance that is already low fires at once.

The payload carries customerId, featureKey, threshold, thresholdSource (customer or product), remaining, allowance, allowanceRemaining, used, purchasedRemaining, creditsRemaining, overagePriced, periodEnd and crossedAt. Both events can also trigger an email template.

Auto-recharge

Auto-recharge buys a pack with the customer's saved card when a balance runs low, so an agent mid-task does not stop at zero. It is per customer and metered feature, and off by default.

await base316.credits.setAutoRecharge({
	externalId: account.id,
	featureKey: "ai_credits",
	enabled: true,
	threshold: 200, // recharge when fewer than 200 credits are left
	creditPackKey: "credits-1000",
	monthlyMaxCount: 5, // and/or monthlyMaxAmount: "50.00"
});

const { data } = await base316.credits.autoRecharge({ externalId: account.id });

Fields left out keep their stored value, so { featureKey, enabled: false } turns it off. Turning it on needs a monthly maximum — a count of purchases, an amount (before tax, in the pack's currency), or both.

When it charges. The balance is checked after every metered event (the same moment credits.low_balance is decided) and by a sweep every 15 minutes. Below the threshold, the setting is marked due and charged within about 30 seconds: Base316 raises the pack's invoice and charges the default saved card off session. Payment grants the credits as a PURCHASE grant, as any pack purchase does, and sends credits.auto_recharged (and invoice.paid, whose email template carries the invoice PDF — use either for the receipt).

Safety.

  • Never more than the monthly maximum, counted from this calendar month's (UTC) automatic purchases, pending or paid.
  • At least 10 minutes between attempts, however often the balance dips.
  • Never two charges in flight. A charge waiting on the card's bank holds everything else until it settles.

When it can't charge.

SituationWhat happens
No card on file, or invoiced on termsNothing is charged or invoiced. It stays on, flagged needsPaymentMethod, and credits.auto_recharge_failed is sent once (reason: NO_PAYMENT_METHOD or MANUAL_COLLECTION).
Card declinedThe invoice is voided and the purchase expires. One retry an hour later (retryAt).
Declined againAuto-recharge is turned off (disabledReason: PAYMENT_FAILED), the customer is emailed, and the event says disabled: true.
Monthly maximum reachedNothing more is bought this month; the event is sent once (reason: MONTHLY_LIMIT).
The pack is archivedTurned off (PACK_UNAVAILABLE).

A declined pack invoice does not start dunning: there is no subscription to mark past due. Turning auto-recharge back on clears the failure count.

Where customers manage it. The billing portal has a Credits section. Mint a link that lands on it with billingPortal.createSession(customerId, { page: "credits" }) and send the customer there: it shows each balance, lets them buy a pack, turn auto-recharge on or off with their own threshold and maximum, and shows when it was turned off and why.

To send someone a link that tops up credits and can do nothing else, mint a credits page link from your server with creditsPortal.createSession(customerId, { returnUrl }) (POST /v1/credits-portal/sessions, secret key) and redirect the customer to url straight away: the hosted credits page has the same controls as the portal's section. The link opens once, within ten minutes, then lasts a 30-minute visit; returnUrl must be one of the environment's redirect URIs, exactly. It opens the credits page and nothing else — not the billing portal, not the API keys page — so a link sent to top up cannot change the plan. In the dashboard, each customer's page has the same controls under Balances and credit packs.

When the card is the problem. "No card on file" and "declined twice" are both fixed by the customer entering a card. From the credits page, its Card on file section has an Update card button that runs the billing portal's card update (a Stripe-hosted page) and comes back to the credits page; in the billing portal, the Credits section's warnings link to the Payment methods section (page: "payment-methods"). The dashboard's auto-recharge panel offers Copy credits page link in those states. See Saved cards.

A managed tenant billed to its parent spends the parent's pool, so auto-recharge is set on the parent (CONFLICT for the child).

On this page