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: '$0.50',                 // or { amount_cents: 50, 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). Card economics are yours to weigh: Stripe's fixed per-transaction fee makes sub-$0.50 charges uneconomic on card rails — fine to declare, but know your margin; tabs/aggregation are future work.
  • 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.

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.