Guides

Paid actions

Price is one line beside the handler. The quote pins it, the human (or their standing mandate) approves it, and the charge runs on your own Stripe account — Bubblio takes no cut and never holds funds. Money moves buyer → your Stripe, full stop.

Declare a price

lib/bubblio/door.tsts
export const door = defineDoorActions({
  actions: {
    generate_image: {
      description: 'Generate an image from a text prompt',
      parameters: [{ name: 'prompt', type: 'string', required: true }],
      policySuggestion: 'keyed',
      price: '$1.00',                 // or { amount_cents: 100, currency: 'usd' }
      approvalSuggestion: 'mandate',  // silent spend only under a standing mandate
      handler: async (args, user) => generateImage(user!.id, String(args.prompt)),
    },
  },
})
  • '$0.50' or { amount_cents, currency }. USD only for now — multi-currency, dynamic, and metered pricing are deliberately deferred.
  • Any price from 1¢ to $10,000 is declarable (the ceiling guards cents-vs-dollars unit mistakes) — but Stripe's charge minimum follows YOUR account's settlement currency, not USD. A Mexican account needs ≥ 10 MXN (≈ $0.59, moves with FX); below the floor, checkout links can't be minted at all and agents are told the rail looks misconfigured. Card economics are yours to weigh too: Stripe's fixed per-transaction fee takes a heavy bite out of sub-$1 charges (63% of a $0.50 charge; still a third at $0.90). Practical guidance: $1.00 or more on card rails, and serve smaller work through member coverage below.
  • A price is a shape: synced like any other, shown on every discovery surface (manifest, llms.txt, list_actions, the directory and feed), and it never opens anything — policy still rules.
  • Fail-closed: declaring a price against a Bubblio API that isn't payments-current refuses the whole sync — serving a paid action unpriced is a money bug, not a degraded mode. The previously-synced registry keeps serving.

Members pay like members

An agent carrying one of your users' keys is that user — pricing included. The listed price is the walk-in price and the ceiling: when a keyed quote arrives for a priced action, Bubblio asks your route (signed callback, 4 seconds, once) what this action costs for this user, and your own billing logic answers — the same logic your website runs:

lib/bubblio/door.ts — the priceFor resolver (0.14+)ts
generate_image: {
  price: '$1.00',                       // the walk-in price — and the ceiling
  priceFor: async (args, user) => {
    if (!user) return null              // no identity → walk-in price
    if (await freeSlotUnused(user.id)) return { covered: true, reason: 'free slot' }
    if (await creditBalance(user.id) >= 1) return { covered: true, reason: 'credits' }
    return null                         // needs payment → the listed price
  },
  handler: async (args, user) => { … }, // debits credits exactly like your site
}
  • { covered: true } pins the quote at “$0.00 — covered by your <platform> account” (your door's label fills the placeholder): no payment machinery, no checkout, no ledger spend. That wording is what the human's phone shows and what their passkey signs. Your handler then serves the run and debits their balance/credits itself — the same code path as your site. One obligation is yours: skip your own debit when meta.payment is set (a settled charge already paid for that run).
  • { price: '$…' } pins a member price — only strictly below the listed price. At or above the ceiling (or a bare $0.00 — coverage has its own vocabulary) falls back to listed.
  • Every failure fails toward charging, never toward free: null, a throw, a timeout, an unreachable route, an SDK that predates the feature — all pin the listed price. A $0.00 pin can only come from your authenticated covered: true.
  • Anonymous quotes never consult — walk-ins always pay the listed price, and paid actions still always bind to a key. Receipts carry price_source (listed / platform_covered / platform_resolved), so every receipt says who covered what.
  • reason is log-only — Bubblio never renders it. Keep balances and anything personal out of it.

The charge-wrap

app/api/bubblio/tools/route.tsts
export const POST = createBubblioToolRoute({
  bubblioApiKey: process.env.BUBBLIO_API_KEY!,
  // The charge-wrap — required once ANY action declares a price:
  // charge on YOUR Stripe → run the handler → auto-refund on throw.
  // The secret key stays in your environment; Bubblio never sees it.
  payments: { stripeSecretKey: process.env.STRIPE_SECRET_KEY! },
  tools: { ...door.handlers },
  sync: {
    door,
    callbackUrl: `${process.env.SITE_URL}/api/bubblio/tools`,
    // The door-level payment profile: which rails you accept, and — for the
    // wallet rail — whom to grant to (your PUBLISHABLE key; Bubblio refuses
    // sk_/rk_ values outright, by name, before anything is stored).
    payments: {
      rails: ['payment_link', 'stripe_spt'],
      stripePublishableKey: process.env.NEXT_PUBLIC_STRIPE_PK!,
    },
  },
})

On a paid confirm the route charges first, then runs your handler, and auto-refunds if the handler throws (refunds run under quoteId + ':refund'). The quote id is the Stripe idempotency key, so a redelivered callback replays the same PaymentIntent instead of charging twice. The payment token is transit-only: it reaches the one paymentIntents.create call and is never stored, logged, echoed, or handed to your handler — meta.payment is the settled summary. Raw card numbers are hard-refused at both ends.

If the connection to Stripe dies mid-charge, the route retries once under the same idempotency key and otherwise answers “outcome unknown” — it will never claim nothing was charged without knowing, and never invites a re-quote that could double-charge. The PaymentIntent (if it exists) carries bubblio_quote metadata so you can find and settle it from your Stripe dashboard.

Two rails

A priced quote carries a Stripe Checkout URL minted on your account through your callback route (paymentLinkRequest → answer { url, ref }createBubblioToolRoute does this for you). The agent relays the link, the human pays on their phone (Stripe handles SCA), and your checkout webhook resumes the quote — the agent's confirm poll answers awaiting_payment until settlement, then executes:

your Stripe webhookts
// Your Stripe webhook route — the payment-link rail's resume:
if (event.type === 'checkout.session.completed') {
  await completeBubblioCheckout(event.data.object, {
    bubblioApiKey: process.env.BUBBLIO_API_KEY!,
    payments: { stripeSecretKey: process.env.STRIPE_SECRET_KEY! },
  })
}
// Non-Bubblio sessions are ignored — safe on a shared webhook. A payment
// Bubblio refuses to settle (expired quote, already executed, already settled
// under another payment) is AUTO-REFUNDED on your Stripe under
// quoteId + ':refund:' + sessionId — money that bought nothing never strands.

stripe_spt — the autonomy rail

An amount-capped Shared Payment Token a wallet grants against your publishable key, charged at confirm through the charge-wrap. Bubblio only lets an SPT spend silently (no human tap on that quote) under a live standing mandate — give paid actions approvalSuggestion: 'mandate' so owners set the matching control.

The guarantees, in one breath

  • The quote pins the price for its lifetime — an agent never pays more than quoted.
  • The charged amount always derives from the pin — never from anything a caller sent. A settlement whose amount or args_hash doesn't match the pin is refused, and a refused payment is refunded.
  • Exactly-once execution is the same machinery as everywhere else: duplicate confirms replay the receipt; the ledger's spend row is keyed by the quote id.
  • Bubblio takes no cut of door payments and never holds funds.

Dispute evidence

Every paid receipt carries the pinned price_cents, the settled payment summary (rail, amount_cents, currency, the Stripe ref), and — where a human approved — approval_id / approved_at: the pointer to a WebAuthn assertion whose challenge was the SHA-256 of the exact approved terms, amount included. When a charge is disputed, you hold a receipt that says this person's passkey signed this action at this price. See the security model.