Farther ShoreDocs
Go to Farther Shore
Design and operate commerce
Entitlements vs economics
Cohorts & releases
Plan transitions
Connect Stripe
Subscriptions & usage
Plan changes
Billing strategies
Pricing catalogs
Funding & allowances
Economic agreements
Commercial releases
Bill preview API
Usage & billing policy
Ledger & settlement
Subscription + overage
Freemium that converts
Add a trial
Add a spend cap
Change a price
Prepaid wallet
OutcomeInitial purchase and later refillsPrerequisitesDefine prepaid fundingAdd the subscriber refill controlValidate and launchVerifyCommon failures and recoveryNext stepsAgent prompt
Meter AI tokens
commerce HTTP contracts
Status
Docs/Cookbook/Prepaid wallet

Prepaid wallet

Sell a prepaid bucket that metered usage draws down and that stops at zero.

Outcome

Sell a Quillby prepaid plan. A $5 prepaid funding bucket is issued by the ledger, metered usage draws it down, the gateway blocks at zero, and customers can replenish it (topUp: true) through a Stripe payment. There is no recurring subscription charge.

Use a prepaid wallet when customers prefer a known spend before consumption, or when usage is irregular enough that a recurring subscription is awkward.

Initial purchase and later refills

Initial purchase chooses the prepaid plan and pays for its declared bucket. The managed PlansTable and FsOnboardingPlanRail components already start that checkout through fs.plans.subscribe() or fs.plans.startOnboarding().

A Refill is different: an already-subscribed customer purchases more value after the initial bucket has been issued. There is no managed refill component or typed fs.billing method yet. Add a subscriber-owned control that calls the public Core endpoint through fs.core() as shown below. This is not a builder CLI or MCP action because the signed-in subscriber is the purchasing principal.

Prerequisites

  • A declared usage meter
  • Attested request-bound usage reporting

Define prepaid funding

ts
import * as fs from "@farthershore/business";

const words = fs.measure("words");
const usage = fs.meter("word_usage", { measures: [words] });
const usagePricing = fs.pricing("word_usage", {
  meter: usage,
  catalog: [fs.rate.per(1000, fs.money.usd(0.2))],
});
const api = fs.backend("api", {
  transport: { mode: "direct" },
  default: true,
});
const generate = fs.route("/v1/generate", {
  post: { backend: api },
});
fs.meterRoutes("generated-words", generate, {
  reports: [usage],
  maxOutputUnits: words.atMost(4_000),
});

fs.plan("prepaid", {
  kind: fs.plan.kind.prepaid,
  usagePricing: usagePricing.current(),
  funding: {
    buckets: [fs.prepaid(fs.money.usd(5), { topUp: true })],
  },
  spendPolicy: { onExhaustion: fs.exhaustion.block },
});

export default fs.business();

prepaid plans require every unbounded measure to declare a bound (maxOutputUnits, chunkPolicy, or a post-stream settlementMax) so the gateway can reserve each request's economic maximum — here at most 4,000 words × $0.0002 = $0.80 — against available funding before forwarding it. The compiler rejects an unbounded prepaid route (ADMISSION_OUTPUT_BOUND_REQUIRED).

Report usage with the exact words key:

ts
await ctx.report({
  meter: "word_usage",
  values: { words: generatedWordCount },
});

Add the subscriber refill control

Call the top-up HTTP contract from a signed-in subscriber session. The member needs permission invoice:pay. The amount is an integer from 100 through 100,000 cents ($1 through $1,000).

ts
import type { FartherShoreClient } from "@farthershore/farthershore-js";

type TopUpCheckout = Readonly<{ ok: true; checkoutUrl: string }>;

export async function startPrepaidRefill(
  fs: FartherShoreClient,
  amountCents: number,
): Promise<void> {
  const boot = await fs.bootstrap();
  const returnUrl = window.location.href;
  const requestId = crypto.randomUUID();
  const result = await fs.core<TopUpCheckout>({
    method: "POST",
    path: `/portal/businesses/${encodeURIComponent(boot.business.id)}/me/balance-top-up`,
    body: {
      amountCents,
      requestId,
      successUrl: returnUrl,
      cancelUrl: returnUrl,
    },
  });

  window.location.assign(result.checkoutUrl);
}

fs.core() supplies the session bearer and portal environment/organization scope; do not call an internal route or send a builder credential. A POST is not automatically retried by the SDK. If your UI lets a customer retry an ambiguous attempt, retain the same requestId for that logical purchase instead of creating a second intent.

Validate and launch

bash
farthershore build --format json
farthershore validate --format json
git add business/ && git commit -m "add prepaid wallet" && git push
farthershore backend create quillby \
  --name "Quillby API" \
  --slug api \
  --transport direct \
  --origin-url https://api.example.com \
  --default \
  --idempotency-key <persisted-backend-create-attempt-key> \
  --format json
farthershore business publish quillby --dry-run --format json
# After explicit approval of the first draft activation:
farthershore business publish quillby --format json --idempotency-key <persisted-business-publish-attempt-key>
farthershore business status quillby --format json

Poll until ACTIVE and live: true.

Verify

  • Subscribing issues the declared $5 prepaid funding bucket once.
  • A successful refill follows pending → available exactly once; a duplicate Stripe webhook does not issue twice.
  • Reported usage reserves, then captures, available funding; the bill preview's allowance remainingNanos falls by exactly $0.0002 per word.
  • After available funding reaches zero, the next request is denied credit_exhausted (402) before it reaches the origin.

Common failures and recovery

SymptomFix
Balance never changesMatch the meter and measure keys; report through ctx.report().
Refill appears twiceRetain one requestId per logical purchase; do not create rail objects manually.
Units cost too muchRecheck the exact rate and human-unit money amount in the release.
Build rejects the routeAdd maxOutputUnits (or chunkPolicy / postStream.settlementMax) to the binding.
Customer is locked outcredit_exhausted means available funding is zero — top up or move to a hybrid plan with overage.

Correct the contract and publish forward. For an incorrect usage event, stop the faulty producer and follow billing diagnosis before replaying or adjusting anything.

Next steps

  • Subscription plus overage
  • Backend metering
  • Funding & allowances
  • Monetary admission

Agent prompt

Add a kind-prepaid words plan to Quillby with no recurring fee, a $5 prepaid
funding bucket that permits top-ups, and exhaustion.block. Price words at
$0.0002 with fs.rate.per, bound the route with maxOutputUnits, report usage
through ctx.report(), and validate in preview. Show the production publish preview and ask for confirmation before
publishing. Then verify funding burn-down, exhaustion denial, top-up, and
idempotency.
PreviousChange a priceNextMeter AI tokens

On this page

OutcomeInitial purchase and later refillsPrerequisitesDefine prepaid fundingAdd the subscriber refill controlValidate and launchVerifyCommon failures and recoveryNext stepsAgent prompt