Manual Payments

Payments

elements man payments Read as markdown

Take payments with Stripe Checkout, the simplest way to accept a card. Stripe hosts the checkout page and holds the card data, so the app never touches a card number. The app does three things: it creates a Checkout Session and sends the buyer to Stripe, it shows a page when Stripe sends them back, and it records the payment when Stripe calls its webhook. The webhook is the only signal that money moved; the redirect back is not.

The official stripe npm package works as is: elements install stripe.

What the User Does, What the Agent Writes

Some steps happen in the Stripe dashboard and need the account owner. Ask the user for them, in this order, and write the code while they do it.

The user, at dashboard.stripe.com:

  1. Create a Stripe account, or sign in. Work in a sandbox (test mode) until the app is ready for real money; test keys start with sk_test_.
  2. Developers, API keys: copy the secret key (sk_test_...). The app creates Checkout Sessions on the server, so the publishable key is not needed.
  3. Product catalog, Add product: give it a name and a one-time price, then copy the price id (price_...).
  4. For local webhooks, install the Stripe CLI and run stripe login, then stripe listen --forward-to localhost:4000/stripe/webhook. It prints a webhook signing secret (whsec_...) and must stay running while you test. Use the port the app runs on.

Then offer the user a choice for the three values: they paste them into the placeholders in config/env/development.env themselves, or they give them to the agent to write there. Either way, the stripe block in config.jsoc reads them from the env file.

The agent writes the config block, the env placeholders, the payments migration, the checkout rpc, the webhook route, and the success page below.

Never ask the user to paste a live key (sk_live_...) into a file that is committed, and never put a secret key in browser-reachable code.

Keys and Config

Secrets are env values, read by config.jsoc (elements man config). Add a stripe block:

// config.jsoc
{
  // ...

  stripe: {
    secretKey: env!("STRIPE_SECRET_KEY"),
    webhookSecret: env!("STRIPE_WEBHOOK_SECRET"),
    priceId: env!("STRIPE_PRICE_ID"),
  },
}

env!() makes each value required, so the build fails until all three are set. Ask for the keys before adding the block. Untagged keys stay on the server; nothing here carries /** @browser */.

Test values go in config/env/development.env:

STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_ID=price_...

development.env is committed by default. Test keys move only test money, but if the repository is shared beyond the team, set these in the shell environment instead: env values come from the process environment first, then the file. Live values go in config/env/production.env, which is gitignored.

Install the package and create one client for the server:

elements install stripe
// app/shared/stripe.ts
import Stripe from "stripe";
import config from "#config";

export const stripe = new Stripe(config.stripe.secretKey);

The Payments Table

elements create migration "add payments" -tables=payments

Add the columns to the generated table:

create table payments (
  id uuid primary key default uuidGenerateV7(),
  createdAt timestamptz not null default now(),
  updatedAt timestamptz not null default now(),
  stripeSessionId text not null unique,
  userId uuid not null,
  amountTotal integer not null,
  currency text not null
);

stripeSessionId is unique because Stripe retries a webhook until it gets a 2xx, and can deliver the same event more than once. The insert below ignores a repeat.

Start Checkout

Scaffold the two pages with elements create page pricing and elements create page checkout-success. An rpc creates the Checkout Session and returns its url. The stripe package returns promises and does not take part in the automatic await transform, so the rpc is async and awaits it (elements man rpc).

// app/pages/pricing/services.ts
import { getAppUrl, session } from "@elements/app";
import config from "#config";
import { stripe } from "#app/shared/stripe";

/** @rpc */
export async function startCheckout(): Promise<string> {
  let userId = session.getOrThrow("userId");

  let checkout = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [{ price: config.stripe.priceId, quantity: 1 }],
    client_reference_id: userId,
    success_url: `${getAppUrl()}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${getAppUrl()}/pricing`,
  });

  return checkout.url!;
}

client_reference_id carries the buyer to the webhook. getAppUrl() is http://localhost:<port> in development and the configured domain in production. Stripe fills in {CHECKOUT_SESSION_ID} itself; leave it literal.

Because the rpc is declared async, the browser call returns a promise. Await it, then leave the page with redirect():

app/pages/pricing/template.html:

import "./style.css";
import { redirect } from "@elements/app";
import { startCheckout } from "./services";

async function buy() {
  let url = await startCheckout();
  redirect(url);
}

<html lang="en" class="page-pricing">
  <head>
    <title>Pricing</title>
  </head>
  <body>
    <main class="page-shell">
      <button class="button is-primary" onclick={() => buy()}>Buy</button>
    </main>
  </body>
</html>

For a subscription, use a recurring price and mode: "subscription".

The Webhook

Stripe POSTs events to a route. The route verifies the signature against the raw body, which Elements keeps on req.bodyBuffer next to the parsed req.body (elements man router). Verify against req.bodyBuffer: the signature covers the exact bytes Stripe sent, and a re-serialized req.body does not match them.

// app/pages/stripe-webhook/index.ts
import { Request, Response, sql } from "@elements/app";
import config from "#config";
import { stripe } from "#app/shared/stripe";

export default function route(req: Request, res: Response) {
  let event;

  try {
    event = stripe.webhooks.constructEvent(
      req.bodyBuffer!,
      req.headers["stripe-signature"] as string,
      config.stripe.webhookSecret,
    );
  } catch {
    res.status(400).send("invalid signature");
    return;
  }

  if (event.type === "checkout.session.completed" || event.type === "checkout.session.async_payment_succeeded") {
    let checkout = event.data.object;

    if (checkout.payment_status === "paid") {
      sql(`
        insert into payments (stripeSessionId, userId, amountTotal, currency)
        values (${checkout.id}, ${checkout.client_reference_id}, ${checkout.amount_total}, ${checkout.currency})
        on conflict (stripeSessionId) do nothing
      `);
    }
  }

  return "ok";
}

Register it for POST only:

// index.ts
app.route({ method: "post", path: "/stripe/webhook", handler: stripeWebhook });

Grant what was bought here, in the same place the row is written, not on the success page. A handler that throws returns a 500 and Stripe retries the event later. checkout.session.async_payment_succeeded covers payment methods that settle after the buyer returns; for a card, completed arrives already paid.

The Success Page

Stripe sends the buyer to success_url. The webhook may not have arrived yet, so the page asks Stripe for the session's state and says what it knows. It does not grant anything.

// app/pages/checkout-success/index.ts
import { Request, Response } from "@elements/app";
import { stripe } from "#app/shared/stripe";
import html from "./template";

export default async function route(req: Request, res: Response) {
  let checkout = await stripe.checkout.sessions.retrieve(String(req.query.session_id));

  return new html({ paid: checkout.payment_status === "paid" });
}

app/pages/checkout-success/template.html:

import "./style.css";

<html lang="en" class="page-checkout-success" (paid: boolean)>
  <head>
    <title>Thanks</title>
  </head>
  <body>
    <main class="page-shell">
      <h1 e:if={paid}>Payment received</h1>
      <h1 e:else>Payment processing</h1>
    </main>
  </body>
</html>
// index.ts
app.route("/pricing", pricing);
app.route("/checkout/success", checkoutSuccess);

Testing

With stripe listen running, open /pricing, click Buy, and pay with the test card 4242 4242 4242 4242, any future expiry, and any CVC. Then check the row:

elements db -sql "select stripe_session_id, amount_total from payments"

stripe trigger checkout.session.completed sends a synthetic event with no client_reference_id, so the insert above rejects it. Test with a real test checkout instead.

Going Live

The user, in the dashboard, with live mode on:

  1. Activate the account for live payments.
  2. Developers, API keys: copy the live secret key (sk_live_...).
  3. Create the product and price again in live mode and copy its price id.
  4. Developers, Webhooks, Add endpoint: https://<domain>/stripe/webhook, listening for checkout.session.completed and checkout.session.async_payment_succeeded. Copy its signing secret.

The agent puts the three live values in config/env/production.env and deploys (elements man deploy).