Vibes DIY
Vibes DIY / Docs / Reference
creator docs · reference · generated

Stripe payments — take real money with the owner's own Stripe account

This is the same document the Vibes code generator reads when it builds apps — rendered from prompts/pkg/llms/stripe.md on every deploy, so it can't drift from what the platform actually does. Read it from your terminal any time with npx vibes-diy skills.

When someone asks to take money — charge for a booking, collect dues, sell tickets, take a deposit, sell a download — build it on Stripe Checkout from backend.js, with the secret key the owner supplies. This is a backend.js feature (see the backend docs for the general handler contract); this page is the recipe that keeps the key on the server, keeps "paid" a fact only Stripe can establish, and keeps every order honest about where it is.

This skill comes in only when the person asks for payment. A storefront that records orders, a plans page that records requests, a booking page that holds a slot — each of those takes no money and says so in its own words, and they stay that way until the person asks to charge for them. What you may do unasked is offer it: on an app that sells, books, collects dues or sells tickets, the closing row's capability option is ▸ [pay] Take card payments with Stripe, gold when nothing else fits better, and a tap on it is the ask. Words that are the ask: "charge", "pay", "paid", "payment", "deposit", "dues", "tickets", "sell", "checkout", "Stripe". Words that are not: "orders", "price", "plans", "cart" — those describe records, not money moving.

One-time charges only. This recipe takes a payment and records that it happened. It does not mirror a Stripe subscription — cancellation, a failed renewal, an expiry — so a "subscription" or "paid tier" built on a permanently paid order would keep unlocking after billing stopped. Build recurring money as a one-time charge per period the app can see (annual dues, a season pass, a monthly bundle with its own order each month), say so plainly, and leave mode=subscription out of this recipe. A real subscription — a paid newsletter, a monthly club — is the stripe-subscriptions skill, which mirrors Stripe's subscription state into the app and takes the person's cancellations and failed renewals into account.

The money is the owner's, and so is the account. Every buyer pays the same Stripe account, the one whose key is on the vibe; the app is a merchant's page, and a buyer is a signed-in person whose order is a record between them and the merchant. An app where each visitor collects money into their own Stripe account is a platform (Stripe Connect) and is a different thing this recipe does not build — say so plainly and build the one-merchant half.

1. Bring your own Stripe key, and explain how

The owner gets a key from Stripe, once:

  1. Open the Stripe dashboard and sign in (or create a Stripe account).
  2. Turn on Test mode (the toggle at the top) until the app is working.
  3. Go to Developers, then API keys, and reveal the Secret key (sk_test_… in test mode, sk_live_… later).
  4. Set it on the vibe as STRIPE_SECRET_KEY — the vibe's settings page, or vibes-diy secrets set STRIPE_SECRET_KEY <the key>.
  5. To go live, repeat step 3 with Test mode off and set the live key the same way.

The app carries those steps in its own UI, because the person reading them is the person who has to do them.

When the key is missing, the app shows the owner the setup steps for a Stripe secret key instead of an error.

The backend answers { ok: false, reason: "missing-key" } when ctx.secrets.STRIPE_SECRET_KEY is absent, and the client renders the numbered steps, naming the Stripe dashboard and STRIPE_SECRET_KEY in so many words so the owner knows where to go and what to call it. A visitor who is not the owner sees a calmer sentence — payments are not set up yet — never the steps.

Only the owner can set or rotate a secret, and handlers see the current value on every invocation — so the pay button starts working the moment the key lands, with no redeploy. There is no publishable key, no webhook signing secret and no second secret of any kind in this recipe: one key, held by the server.

2. The key never reaches the browser, and neither does Stripe.js

Every Stripe request is made in backend.js with ctx.fetch and ctx.secrets.STRIPE_SECRET_KEY; App.jsx never calls api.stripe.com and never loads Stripe.js.

The client talks to the app's own /_api/... routes, and the backend is the only place that knows the key exists. A secret key in a React component ships to every visitor who views source, and anyone who reads it can refund, pay out and read every customer on the owner's account until it is rotated.

Stripe.js is not needed either: Stripe Checkout is a page Stripe hosts. The backend creates a Checkout Session and gets back a URL; the browser opens that URL; Stripe renders the card form, handles 3-D Secure, Apple Pay, Google Pay and every regional method the owner has turned on; and the buyer lands back on the app. Nothing about the card ever touches the app's code or its documents, which is also why the app has no PCI question to answer.

Checkout opens in a new tab with window.open, because the app runs inside a frame that cannot navigate the page around it.

window.location = url would navigate only the app's own frame, leaving Stripe's page boxed inside the app's chrome, and the frame is not allowed to redirect the page around it. window.open(url, "_blank") is allowed, so the pay button calls the backend, then opens the returned URL in a new tab from inside the click handler (a tab opened later, after an await, is a popup the browser blocks). Say in the button's own copy that Stripe opens in a new tab. window.open can still answer null when a browser blocks it; check that before asking the backend for anything, so a blocked tab costs no order. And a Pay button is disabled from the click until the tab is on its way, so a double tap does not start two purchases.

3. One order, then one Checkout Session

The order exists before the money moves. The /checkout route runs as the signed-in buyer (the page calls it with apiFetch, so ctx.userInfo is set), writes an order document in pending with the buyer's handle, the title and the amount, and only then asks Stripe for a session for exactly that order:

  • mode=payment, always: one charge, one order (see One-time charges only).
  • The price is inline price_data — currency, unit_amount, a product name — so the owner never has to create products in the dashboard first.
  • client_reference_id and metadata[orderId] both carry the order's _id, so the session names the order from Stripe's side too.
  • success_url and cancel_url are the app's public address with the order in the fragment: https://vibes.diy/vibe/<owner>/<slug>#paid=<orderId> and #cancelled=<orderId>. The fragment never reaches the server, so the return page is the fast cached one.
  • Idempotency-Key is the order's _id, so a retried request mints one session, not two. And a second press for the same thing reuses the open attempt: the route looks for this buyer's pending order of the same kind with a session still inside its day, and hands back that session's URL rather than minting another order — one purchase attempt, however many times the button or the network retried it.

The routes read the orders database, which access.js binds, so backend.js declares config.fetch.unfilteredReads for it — the obligation that comes with it is yours: every route checks order.buyerHandle against the caller before it returns or settles anything, and no route lists orders for anyone. The owner's view of every order is the page's own live query, which the access function's orders channel already scopes to the owner role.

Amounts are integers in the currency's smallest unit: $12.50 is unit_amount 1250, never 12.5.

Keep the amount an integer in the document as well (amount: 1250), compute it once on the server from the app's own price list, and never trust an amount the browser sends — the route takes what is being bought and looks the price up. The API is form-encoded, not JSON: build the body with URLSearchParams, and write nested fields in bracket form exactly as shown. Stripe answers JSON; the fields this recipe reads are id and url when it creates a session, and payment_status, status, client_reference_id, amount_total and currency when it reads one back.

4. "Paid" is a fact the backend reads back from Stripe

An order is marked paid only after the backend has read the Checkout Session back from Stripe, seen payment_status equal to paid, and matched the session's client_reference_id, amount_total and currency to the order; a browser write can never set it.

The match is what binds a payment to this order: a session is proof that somebody paid something, and the order's own _id, amount and currency in the session are what say it was this. A sessionId is a field in a document, and a document field is only as trustworthy as the rules that guard it — so the read-back checks Stripe's copy, never the order's.

Two things settle an order, and both are the backend reading GET /v1/checkout/sessions/<id> with the key:

  • The return. When the app opens with #paid=<orderId> in its address, the page calls /confirm for that order; the route reads the session back, and if payment_status is "paid" it moves the order to paid and stamps paidAt. A session whose status is "expired" moves the order to expired. Anything else leaves it pending.

A scheduled sweep settles every order still pending by reading its session back from Stripe, so a buyer who closes the tab is still marked paid.

  • The sweep. A buyer who pays and closes the tab never triggers /confirm. Every few minutes scheduled queries orders by status = "pending", reads each one's session back, and settles it the same way. A Checkout Session expires on its own after 24 hours, so the sweep's work is bounded by orders made in the last day, and an order older than that with no session is marked expired rather than read forever.

Why not a Stripe webhook: a webhook arrives with no identity, and a write with no identity has to be opened to every anonymous visitor to be accepted — the wrong door for the one record that means money changed hands. Reading the session back with the key the app already holds is the same proof, from the same source, on a route that can only move an order to the state Stripe reports. access.js makes that structural: an existing order changes only on a write from the app's own backend.js (ctx.requireBackend() on every update, not only on the status), so a buyer cannot swap a cheaper session's id onto a dearer order, and the two handlers that write orders do so only after reading Stripe. A buyer never edits an order; they make a new one.

5. What the buyer and the owner each see

  • A buyer sees their own orders and each one's state — pending with a "Finish paying" link that reuses the session URL while it is open, paid with the date, expired with a fresh "Pay" button that makes a new order.
  • The owner sees every order: who, what, how much, when, and a total of what has been paid. This is the app's own ledger; it is also in the Stripe dashboard, and the two agree because the app only ever copies Stripe's answer.
  • A paid order unlocks whatever it was for in the app's own terms — the member shows as dues paid on the roster, the booking shows as deposit received, the ticket shows as going — read off the order's status, never off the return URL.

6. Errors keep their reason

Stripe's refusals come back as JSON with error.message; keep that sentence and return it as { ok: false, reason: "stripe", message } so the page can show the owner why ("Invalid API Key provided" is the whole diagnosis of a key pasted with a trailing space). A refused ctx.fetch — the { vibesEgressDenied: true } shape — is a platform refusal, not Stripe's; log it with ctx.log and answer { ok: false, reason: "unavailable" }. api.stripe.com is CORS-open to the app's own origin, so a keyed POST from ctx.fetch is admitted on the CORS lane with no platform arrangement; nothing here needs the curated list.

Complete backend.js

backend.js

js// Stripe Checkout for one merchant: the owner's key, hosted Checkout, orders settled
// by reading each session back. Nothing in this file touches a card.
export const config = {
  scheduled: { interval: "5m" },
  // The routes read a buyer's own order back; access.js binds the database, so the fetch lane
  // needs this declaration, and the routes owe the buyerHandle check it waives.
  fetch: { unfilteredReads: { dbs: ["orders"], why: "the /checkout and /confirm routes read the signed-in buyer's own order, checked by buyerHandle before anything is returned" } },
};

const STRIPE = "https://api.stripe.com/v1";
const DB = "orders";
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // a Checkout Session expires on its own after a day

// The app's own price list, in the currency's smallest unit. The browser sends a
// `kind`, never an amount.
const PRICES = {
  dues: { title: "Annual membership dues", amount: 4000, currency: "usd" },
  deposit: { title: "Session deposit", amount: 5000, currency: "usd" },
};

function json(body, status = 200) {
  return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}

function appUrl(ctx, hash) {
  return `https://vibes.diy/vibe/${ctx.appInfo.ownerHandle}/${ctx.appInfo.appSlug}${hash}`;
}

// One call to Stripe. Form-encoded in, JSON out, the refusal's own sentence kept.
async function stripe(ctx, method, path, form, headers = {}) {
  const key = ctx.secrets.STRIPE_SECRET_KEY;
  if (!key) return { ok: false, reason: "missing-key" };
  const res = await ctx.fetch(`${STRIPE}${path}`, {
    method,
    headers: {
      authorization: `Bearer ${key}`,
      ...(form ? { "content-type": "application/x-www-form-urlencoded" } : {}),
      ...headers,
    },
    body: form ? form.toString() : undefined,
  });
  const body = await res.json().catch(() => null);
  if (!res.ok) {
    if (body?.vibesEgressDenied) {
      ctx.log("warn", "stripe egress denied", { gate: body.gate });
      return { ok: false, reason: "unavailable" };
    }
    return { ok: false, reason: "stripe", message: body?.error?.message ?? `HTTP ${res.status}` };
  }
  return { ok: true, body };
}

// Move one pending order to what Stripe says about its session. Returns the order as settled.
async function settle(ctx, order) {
  if (order.status !== "pending") return order;
  if (!order.sessionId) {
    if (Date.now() - order.createdAt > SESSION_TTL_MS) {
      const expired = { ...order, status: "expired" };
      await ctx.db.put(expired, { db: DB, id: order._id, ifRev: order._rev });
      return expired;
    }
    return order;
  }
  const session = await stripe(ctx, "GET", `/checkout/sessions/${order.sessionId}`);
  if (!session.ok) return order; // a refusal settles nothing; the next read tries again
  const s = session.body;
  // The session must be THIS order's: Stripe's copy of the order id, amount and currency,
  // never the document's own claim about which session it holds.
  const matches = s.client_reference_id === order._id && s.amount_total === order.amount && s.currency === order.currency;
  if (!matches) {
    ctx.log("warn", "stripe session does not match its order", { orderId: order._id });
    return order;
  }
  let next = order;
  if (s.payment_status === "paid") {
    next = { ...order, status: "paid", paidAt: Date.now(), amountPaid: s.amount_total };
  } else if (s.status === "expired") {
    next = { ...order, status: "expired" };
  }
  if (next !== order) await ctx.db.put(next, { db: DB, id: order._id, ifRev: order._rev });
  return next;
}

export async function fetch(request, ctx) {
  const url = new URL(request.url);

  // Is Stripe set up? The owner gets the steps; everyone else gets a sentence.
  if (url.pathname === "/status" && request.method === "GET") {
    const owner = ctx.userInfo?.userHandle === ctx.appInfo.ownerHandle;
    return json({ ok: true, configured: Boolean(ctx.secrets.STRIPE_SECRET_KEY), owner });
  }

  // Start a purchase: the order first, then a Checkout Session for exactly that order.
  if (url.pathname === "/checkout" && request.method === "POST") {
    const me = ctx.userInfo?.userHandle;
    if (!me) return json({ ok: false, reason: "sign-in" }, 401);
    if (!ctx.secrets.STRIPE_SECRET_KEY) return json({ ok: false, reason: "missing-key" }, 503);
    const { kind } = await request.json();
    const price = PRICES[kind];
    if (!price) return json({ ok: false, reason: "unknown-item" }, 400);

    // One attempt per buyer per item: a second press, a retry after a lost answer, a reload,
    // all land on the session already open rather than minting another order.
    const pending = await ctx.db.query({ db: DB, field: "status", key: "pending", limit: 100 });
    const open = pending.find((o) => o.buyerHandle === me && o.kind === kind && o.sessionUrl && Date.now() - o.createdAt < SESSION_TTL_MS);
    if (open) return json({ ok: true, orderId: open._id, url: open.sessionUrl });

    const id = "order-" + crypto.randomUUID();
    const order = {
      _id: id,
      type: "order",
      kind,
      title: price.title,
      amount: price.amount,
      currency: price.currency,
      buyerHandle: me,
      status: "pending",
      createdAt: Date.now(),
    };
    await ctx.db.put(order, { db: DB, id });

    const form = new URLSearchParams({
      mode: "payment",
      "line_items[0][quantity]": "1",
      "line_items[0][price_data][currency]": price.currency,
      "line_items[0][price_data][unit_amount]": String(price.amount),
      "line_items[0][price_data][product_data][name]": price.title,
      client_reference_id: id,
      "metadata[orderId]": id,
      success_url: appUrl(ctx, `#paid=${id}`),
      cancel_url: appUrl(ctx, `#cancelled=${id}`),
    });
    const session = await stripe(ctx, "POST", "/checkout/sessions", form, { "idempotency-key": id });
    if (!session.ok) return json(session, session.reason === "stripe" ? 502 : 503);

    await ctx.db.put({ ...order, sessionId: session.body.id, sessionUrl: session.body.url }, { db: DB, id });
    return json({ ok: true, orderId: id, url: session.body.url });
  }

  // The buyer is back: read the session, move the order to what Stripe says.
  if (url.pathname === "/confirm" && request.method === "POST") {
    const me = ctx.userInfo?.userHandle;
    if (!me) return json({ ok: false, reason: "sign-in" }, 401);
    const { orderId } = await request.json();
    const order = await ctx.db.get(orderId, { db: DB });
    if (!order || order.buyerHandle !== me) return json({ ok: false, reason: "not-found" }, 404);
    const settled = await settle(ctx, order);
    return json({ ok: true, status: settled.status });
  }

  return json({ ok: false, reason: "not-found" }, 404);
}

// The sweep: anyone who paid and closed the tab is still marked paid within a few minutes.
export async function scheduled(event, ctx) {
  if (!ctx.secrets.STRIPE_SECRET_KEY) return;
  let after;
  let settled = 0;
  do {
    const page = await ctx.db.query({ db: DB, field: "status", key: "pending", limit: 100, after });
    for (const order of page) {
      const next = await settle(ctx, order);
      if (next !== order) settled += 1;
    }
    after = page.next;
  } while (after);
  ctx.log("info", "stripe sweep", { settled, at: event.scheduledTime });
}

access.js — the status moves only on the backend's word

access.js

jsexport function orders(doc, oldDoc, user, ctx) {
  if (!user?.userHandle) throw { forbidden: "sign in" };
  if (doc.type !== "order") throw { forbidden: "unknown document type" };
  if (oldDoc) {
    // Only backend.js changes an existing order — the /checkout route adding the session, the
    // /confirm route and the sweep settling it — so no field (sessionId included) is a buyer's to edit.
    ctx.requireBackend();
    if (doc.buyerHandle !== oldDoc.buyerHandle || doc.amount !== oldDoc.amount || doc.currency !== oldDoc.currency || doc.kind !== oldDoc.kind) {
      throw { forbidden: "an order keeps its buyer and its price" };
    }
  } else {
    if (doc.buyerHandle !== user.userHandle) throw { forbidden: "an order belongs to its buyer" };
    if (doc.status !== "pending") throw { forbidden: "an order starts pending" };
    ctx.requireBackend("fetch"); // the /checkout route writes it, so the amount is the server's
  }
  const mine = `user:${doc.buyerHandle}`;
  return { channels: [mine, "orders"], grant: { users: { [doc.buyerHandle]: [mine] }, roles: { owner: ["orders"] } } };
}

App.jsx sketch

App.jsx

jsximport React, { useEffect, useState } from "react";
import { useFireproof } from "use-fireproof";
import { apiFetch, useVibe } from "use-vibes";
import { Badge, Button, Heading, Stack, Text } from "@vibes.diy/pouf";

const SETUP_STEPS = [
  "Open the Stripe dashboard and turn on Test mode.",
  "Go to Developers → API keys and reveal the Secret key (sk_test_…).",
  "Set it on this vibe as STRIPE_SECRET_KEY: the Settings page, or `vibes-diy secrets set STRIPE_SECRET_KEY`.",
];

export default function App() {
  const { me } = useVibe("orders");
  const { useLiveQuery } = useFireproof("orders");
  const { docs: orders } = useLiveQuery("type", { key: "order" });
  const [status, setStatus] = useState(null); // { configured, owner } from the backend
  const [error, setError] = useState(null);

  useEffect(() => {
    apiFetch("/status").then((r) => r.json()).then(setStatus).catch(() => setStatus({ configured: false, owner: false }));
  }, []);

  // Back from Stripe: ask the backend to read the session and settle the order.
  // Back from Stripe. The wrapper hands the app its fragment just after first render and fires
  // hashchange, so read the hash now AND on every change — a mount-only read can miss it.
  useEffect(() => {
    const confirm = () => {
      const m = /#paid=([\w-]+)/.exec(location.hash);
      if (!m) return;
      history.replaceState(null, "", location.pathname);
      apiFetch("/confirm", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ orderId: m[1] }) });
    };
    confirm();
    window.addEventListener("hashchange", confirm);
    return () => window.removeEventListener("hashchange", confirm);
  }, []);

  const [busy, setBusy] = useState(false);
  const pay = async (kind) => {
    setError(null);
    const tab = window.open("", "_blank"); // opened inside the click, before any await, so it is not a popup
    if (!tab) {
      setError("Your browser blocked the Stripe tab. Allow pop-ups for this page and press Pay again.");
      return; // nothing was ordered
    }
    setBusy(true);
    try {
      const res = await apiFetch("/checkout", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ kind }) });
      const data = await res.json();
      if (!data.ok) {
        tab.close();
        setError(data.reason === "stripe" ? data.message : "Payments are not available right now.");
        return;
      }
      tab.location.href = data.url;
    } finally {
      setBusy(false);
    }
  };

  const configured = status?.configured === true;
  if (status && !configured && status.owner) {
    return (
      <Stack>
        <Heading>Set up payments</Heading>
        <Text>Add your Stripe secret key once and the Pay buttons go live for everyone.</Text>
        <ol>{SETUP_STEPS.map((s) => <li key={s}>{s}</li>)}</ol>
      </Stack>
    );
  }

  const mine = orders.filter((o) => o.buyerHandle === me?.userHandle);
  const paid = mine.some((o) => o.kind === "dues" && o.status === "paid");
  return (
    <Stack>
      <Heading>Membership</Heading>
      {paid ? (
        <Badge tone="up">Dues paid</Badge>
      ) : (
        <Button onClick={() => pay("dues")} disabled={!configured || busy} loading={busy}>
          Pay $40 dues — Stripe opens in a new tab
        </Button>
      )}
      {error && <Text>{error}</Text>}
      {status?.owner && (
        <ul>
          {orders.map((o) => (
            <li key={o._id}>@{o.buyerHandle} · {o.title} · {(o.amount / 100).toFixed(2)} {o.currency.toUpperCase()} · {o.status}</li>
          ))}
        </ul>
      )}
    </Stack>
  );
}

The sketch is one item; a page with several keeps the same shape with one entry per item in PRICES and one pay(kind) per button. Something that recurs is a new order each period — next year's dues are next year's pay("dues") — and a true subscription, where Stripe renews and retries on its own, is the stripe-subscriptions skill.