Building apps

Taking payments

Selling the owner's products from a generated app: the pay module, the pay provider actions, and who owns what.

View as markdown

How it works

The owner sets products on Ripping (Settings → Payments: name, price, one-time or monthly/yearly) and picks a processor: Ripping Payments (a Stripe Connect account with Ripping's fee), their own Stripe, or their own PayPal. The app names products by id and never sends an amount; every price comes from the owner's catalogue, so nothing a buyer's browser sends can change what they pay.

Buying needs no account. Knowing what someone owns does.

The client module

type Product = { id: string; name: string; description: string; amountCents: number; currency: string; kind: "one_time" | "subscription"; interval: "month" | "year"; active: boolean };
type Paid = { paid: boolean; status: string; amountCents: number; currency: string; description: string | null };
type Purchase = { id: string; productIds: string[]; description: string | null; amountCents: number; refundedCents: number; currency: string; status: string; processor: "ripping" | "stripe" | "paypal"; createdAt: string };
type Subscription = { active: boolean; status: string; priceId: string | null; subscriptionId: string; currentPeriodEnd: string | null; cancelAtPeriodEnd: boolean };
type Purchases = { purchases: Purchase[]; owned: string[]; subscription: Subscription | null };

const money: (cents: number, currency?: string) => string;   // "$19.99", "$20"
const priceLabel: (p: Product) => string;                    // "$9 / month"

function useProducts(): { products: Product[]; ready: boolean; error: string | null };
function checkout(items: { product: string; quantity?: number }[], opts?: { returnTo?: string; cancelTo?: string; email?: string }): Promise<never>;
function confirmPayment(): Promise<Paid | null>;
function myPurchases(): Promise<Purchases>;
function owns(productId: string): boolean;
function usePurchases(): Purchases & { ready: boolean; error: string | null; refresh: () => Promise<void> };
function manageSubscription(returnTo = "/purchases"): Promise<never>;

Selling

const { products } = useProducts();
await checkout([{ product: p.id, quantity: 1 }]);   // leaves the page

checkout sends success_url as <page>#<returnTo> (default #/thanks) and cancel_url as <page>#<cancelTo> (default #/). It never resolves: the browser is leaving for the processor.

On the return route call confirmPayment(). It reads the processor's reference from the query string (session_id for Stripe, token for PayPal), asks Ripping to confirm, then removes the reference from the address. It returns null when there is no reference in the address.

const r = await confirmPayment();   // { paid: true, status: "paid", amountCents: 4900, currency: "usd", description: "Beginners' course" }

Every function throws an Error with the server's message, or Payment call failed (<status>).

Who owns what

const { owned, purchases, subscription, ready } = usePurchases();
if (owned.includes(courseId)) …                       // unlock the thing they paid for
owns(courseId)                                        // the same check outside a component, from the last answer

usePurchases is empty for a signed-out visitor. myPurchases() fetches fresh each call (one request in flight at a time); owns reads the cached answer, so call myPurchases() or use the hook first.

A one-time product is owned once paid and not fully refunded. A subscription product is owned while the person's subscription is active (active or trialing).

Every app with products has a Purchases page at /purchases, in the account menu, built on the same call. manageSubscription() opens Stripe's billing portal (change card, cancel) on whichever Stripe the owner uses — Ripping Payments or their own — and returns to #/purchases. On PayPal it throws PayPal here takes one-time payments, so there is no subscription to manage.

The pay provider

All calls are POST /api/apps/<projectId>/call with { "provider": "pay", "action": … }. The pay provider is rate limited per IP (60 a minute) and per app (300 a minute): 429 Too many requests. Slow down and try again in a minute. It does not count against the app's monthly connected-service allowance. If the owner paused connected services: 403 This app's access to connected services is paused by its owner.

Any failure below is 400 { "error": "<message>" } unless noted. An unknown action: 400 unknown pay action; use products, checkout, confirm or purchases.

products

No sign-in needed.

{ "provider": "pay", "action": "products" }
{ "products": [ { "id": "…", "name": "Beginners' course", "description": "", "amountCents": 4900, "currency": "usd", "kind": "one_time", "interval": "month", "active": true } ] }

Only active products, in the owner's order.

checkout

No sign-in needed. When a token is sent, the buyer's email and account id are attached to the payment so it is theirs the moment they come back.

{
  "provider": "pay", "action": "checkout",
  "items": [ { "product": "<productId>", "quantity": 1 } ],
  "success_url": "https://myapp.example/#/thanks",
  "cancel_url": "https://myapp.example/#/",
  "customer_email": "ada@example.com"
}
{ "url": "https://checkout.stripe.com/…", "processor": "ripping", "id": "cs_…", "amountCents": 4900, "currency": "usd" }

Rules:

  • Up to 20 items; quantity is clamped to 1–99.
  • success_url and cancel_url must be absolute URLs: success_url and cancel_url must be absolute URLs.
  • An empty list: Nothing to pay for: send items [{ product, quantity }].
  • An id not in the catalogue: Unknown product "<id>". Products are set on the app's Settings → Payments.
  • Mixed currencies: Every product in one checkout must use the same currency.
  • A subscription is bought alone, quantity 1: A subscription is bought on its own, one at a time.
  • PayPal takes one-time payments only: PayPal here takes one-time payments; sell subscriptions with Stripe.
  • Nothing connected: Stripe isn't connected for this app's owner (Connections)., PayPal isn't connected for this app's owner (Connections)., or <Site> Payments isn't set up for this app's owner (Payments tab), and no other processor is chosen.

Stripe appends ?session_id={CHECKOUT_SESSION_ID} to the success URL (before the hash); PayPal appends ?token=<order id>. The pending payment is recorded before the buyer leaves.

confirm

No sign-in needed.

{ "provider": "pay", "action": "confirm", "session_id": "cs_…" }

or { "provider": "pay", "action": "confirm", "paypal_order": "<order id>" }.

{ "paid": true, "status": "paid", "amountCents": 4900, "currency": "usd", "description": "Beginners' course" }

Reads the session from the processor (captures the PayPal order), records the payment, and links it to an account. Failures: session_id or paypal_order required, That payment belongs to another app., Payments aren't set up for this app's owner.

Linking: the account that started the checkout wins; otherwise an existing account with the buyer's email; otherwise, in an app with open sign-up and room for another user, an account is created for that email so the purchase is already theirs when they first sign in. Invite-only and approval apps leave the payment waiting under the email; it attaches when that address gets an account.

purchases

Sign-in required: 401 Sign in to this app first.

{ "provider": "pay", "action": "purchases" }
{
  "purchases": [ { "id": "…", "productIds": ["…"], "description": "Beginners' course", "amountCents": 4900, "refundedCents": 0, "currency": "usd", "status": "paid", "processor": "ripping", "createdAt": "…" } ],
  "owned": ["…"],
  "subscription": { "active": true, "status": "active", "priceId": "price_…", "subscriptionId": "sub_…", "currentPeriodEnd": "…", "cancelAtPeriodEnd": false }
}

Up to 200 non-pending payments, newest first. Payments made under the person's email before they had an account are claimed here.

Refunds

Refunds are issued by the owner on Ripping, not from the app. They are full refunds only, through whichever processor took the money; the payment's status becomes refunded and refundedCents equals amountCents, so the product is no longer owned.

Fees

With Ripping Payments, Ripping's fee is a percentage of the total (2% unless configured otherwise, capped at 20%), taken as a Stripe application fee. The owner's own Stripe or PayPal carries no Ripping fee.

Stripe prices directly

The payments provider is an older path for apps whose owner uses Ripping Payments and manages products in Stripe itself: checkout with line_items: [{ price: "price_…", quantity }], subscription for the signed-in person's status, and portal. It needs a signed-in user and counts against the monthly call allowance. The SaaS and course scaffolds use it. See Connected services.

Testing in the editor

Inside Ripping's editor preview, checkout() never reaches a processor. It draws a payment sheet of its own with the same products and prices, and Pay records the purchase with a test flag; Cancel goes to cancelTo. Nothing is charged. After Pay the app lands on returnTo and confirmPayment() answers { paid: true } for that test, so the return route works the same way it will in production.

Test purchases:

  • show on the app's Sales tab with a Test badge and are left out of every total;
  • count in usePurchases() / owns() only inside the editor preview, so gating can be tried; a published app never sees them;
  • can be "refunded" from the Sales tab, which just changes their state.

Under the hood the sheet calls test-checkout and test-confirm on the pay provider with a preview_key that only exists inside the editor; without a valid key both answer 403 { "error": "Test payments only work inside the editor preview." }. There is nothing to configure and no test mode to switch on.

Refunds, from the app's side

Refunds are the owner's action (Sales tab), not the app's. A Purchase shows what came back: refundedCents grows with each refund, status stays paid until the whole amount is returned and then becomes refunded. owned drops a one-time product only when it is fully refunded.

Promo codes

function checkPromo(code: string, items: { product: string; quantity?: number }[], email?: string): Promise<PromoQuote>;
type PromoQuote = { valid: true; code: string; description: string; discountCents: number; trialDays: number; amountCents: number; currency: string };
// then
checkout(items, { code });

checkPromo throws with a message to show as it is: That code isn't valid, That code has expired, That code has been used up, You've already used that code, Sign in to use this code (once-per-buyer codes need a signed-in buyer), That code needs an order of at least 25.00, That code doesn't apply to anything in this order. The discount is worked out again at checkout from the owner's code, never from what the app sends. On the wire it is the pay provider's promo action:

{ "provider": "pay", "action": "promo", "code": "LAUNCH20", "items": [{ "product": "…", "quantity": 1 }] }

Buyer-chosen amounts

A product with pricing: "choose" is the one case where the app sends an amount: pass { product, amount } in cents, between the product's minCents and maxCents (amountCents is the owner's suggestion). Anything else is refused with The least you can pay for <name> is 5.00 or The most you can pay for <name> is 500.00. For every other product an amount is ignored; the catalogue prices it.

Chargebacks, from the app's side

A Purchase carries disputed: "open" | "lost" | null. While a dispute is open the purchase still counts as owned; when it is lost, nothing on that row is owned any more. The owner handles the dispute itself on the Sales tab.