---
doc:
  title: Selling things
guide:
  slug: selling
  brief: taking money for something an app sells
  description: >-
    Taking money for something: how a seller connects their own Stripe
    account to a space, the ./api/pay/checkout endpoint a page posts a cart
    to and what it returns, why a page never posts a price, the order row and
    the buyer's letter that land when the money moves, who can read an order
    afterwards, and why a card number never reaches your app.
---

# Selling things

Apps on Plus can take payments. The seller connects a Stripe account of their
own to their space, once. After that any app in that space can post a cart to
`./api/pay/checkout`, and that endpoint returns the address of a Stripe payment
page to send the buyer to. When the money moves, an `order` row appears in that
app's store and the buyer gets a letter from the app's own address.

There is no key to set, no webhook to receive and no `worker.js` to write. The
charge is made ON the seller's Stripe account, in the seller's name — this
platform passes the charge along and takes a fee out of it, the way an app store
does; what that fee is, is on the <https://yaks.fyi/pricing> page. The rest
settles into the seller's own balance, on their own payout schedule, and refunds
and disputes are theirs to handle in their own Stripe dashboard.

Back to the map: <https://yaks.fyi/docs.md>

## What never happens

**A card number never reaches this platform, and never reaches your app.** The
page where somebody types one is Stripe's own, at `checkout.stripe.com`. Your
app sends a list of products and gets back a link; afterwards it learns an
amount, an email address and a session id, and nothing else about the payment.

That is not a convention you keep — it is the reason the shape is this way. A
form on your own page that collects a card is a form you are then responsible
for, under rules this platform cannot help you meet. If you find yourself
writing an `<input>` for a card number, the design has gone wrong.

Two more, and both are load-bearing:

- **A page never posts a price.** It posts which product and how many; the
  endpoint reads `price_cents` off the row itself. A price posted from a browser
  is a price the buyer can edit, and the whole of the defence is that the number
  never travels.
- **No order is written by the page**, and none by the address Stripe sends the
  buyer back to. A buyer who closes that tab has still paid. The order is
  written when Stripe reports that the money moved, and by nothing else.

Both hold whatever the app's `access`. On an `open` app a visitor adds rows of
their own, but a `product` is written only by the owner and editors, and an
`order`'s columns only by the platform.

## The seller connects an account

Plus is required for setup and new checkout sessions. After a downgrade,
existing orders, refunds and disputes still update, and the Stripe account stays
connected. See <https://yaks.fyi/pricing> for plans.

Once per space, not per app: `space_sell`, or the button on the space's own
page. It opens Stripe's own onboarding — their business details, their bank
account — and hands them back when it is done. Stripe decides when they are
ready, and the space knows it as `stripe.charges_enabled`.

Until then the checkout endpoint refuses by name, so a page can show "this shop
is not open yet" instead of failing at the moment somebody tries to buy. Nothing
about the app has to change when they finish; the same page starts working.

What the seller keeps: their account, their money, their customer relationship,
their refunds. What they never do: paste a secret key anywhere. There is no
`app_secret_set` in this recipe.

## What the app sells

A product is an entity like any other: `doc` for the words, and `product` for
the rest. **`product` is one of the platform's own components** — like `task`
and `comment` — so every app already has it and no `vocab.json` declares it. It
has to be the platform's: the checkout endpoint reads `price_cents` off this
row, and a component the platform charges money against is one the platform
defines.

    product { price_cents, sizes, stock, image }

`sizes` is the variants you offer, comma separated, and what a page turns into
the `options` on a cart line. `stock` is your own count — nothing on the
platform decrements it.

    { "entity": { "eid": "$tee" },
      "alias": { "name": "shirt:everyday-charcoal" },
      "doc": { "title": "Everyday Tee — Charcoal",
               "body": "Heavyweight cotton, boxy cut." },
      "product": { "price_cents": 2800, "sizes": "S, M, L, XL",
                   "stock": 24, "image": "tee-charcoal.svg" } }

`price_cents` is whole cents, always. `2800` is $28.00, and a price written as
`28` is a shop selling shirts for twenty-eight cents. The `alias` is what makes
a seed file safe to load twice (<https://yaks.fyi/docs/store.md>).

The page draws them out of the store like anything else:

    import { subscribe } from './api/client.js'

    subscribe('.product!&.doc?', draw)

## The checkout endpoint

    POST ./api/pay/checkout
    { "items": [ { "product": "<eid>", "qty": 2, "options": "M" } ],
      "email": "ana@example.com",
      "success": "?ordered={CHECKOUT_SESSION_ID}",
      "cancel": "" }

    → 200 { "url": "https://checkout.stripe.com/c/pay/cs_test_…" }

Same origin, under the app's own `/api/`, so a page reaches it with an ordinary
`fetch` and an app's `worker.js` reaches it through `env.STORE`. It is callable
by a guest — the person buying has no yaks.app account and never will — which is
the whole point of it being the platform's endpoint and not something an app has
to be trusted with.

- **`items`** is what to sell: the product's eid, how many, and `options` for a
  variant — a size, a colour — which is appended to the line the buyer reads on
  Stripe's page. The endpoint reads the title and the price off each row. A
  product it cannot find, or one priced at zero, is refused before Stripe is
  asked. An order runs to about ten different lines: the cart travels as one
  Stripe metadata value on its way to the webhook, and those hold 500
  characters. A bigger cart is refused, with how many fit, rather than
  truncated.
- **`email`** is optional and only fills the field in for them.
- **`success`** and **`cancel`** are relative to the app's own root, so nothing
  in your page hard-codes the app's name and an installed copy sends its buyers
  back to itself. `{CHECKOUT_SESSION_ID}` is a literal Stripe substitutes for
  the session id on the way back. Leave both out and the app's root is used.

From a page, the whole of it:

    let r = await fetch('./api/pay/checkout', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({
        items: cart.map((l) => ({ product: l.product, qty: l.qty,
                                  options: l.options })),
        success: '?ordered={CHECKOUT_SESSION_ID}',
      }),
    })
    let out = await r.json()
    if (!r.ok) return say(out.error.message)
    location.href = out.url

A refusal comes back the way every refusal on yaks.app does —
`{"error": {"code": …, "message": …}}` — so showing `out.error.message` gives
the person an explanation rather than a status code. The one worth handling by
name is the space that has not connected an account yet.

## What lands when they pay

Stripe tells the platform, and the platform writes one row into the app's own
store:

    order { session, account, items, total_cents, fee_cents, email, status }

`order` is one of the platform's own components, so every app already has it and
no `vocab.json` declares it. Its columns are the platform's alone: no page,
member or visitor writes one, and the owner may delete an order row. `status` is
`paid`, and follows the charge after that: `partially_refunded` or `refunded`
when the seller gives money back, `disputed` while a buyer's dispute is open,
then `paid` again if the seller wins it or `lost` if the buyer's bank takes the
money back. The buyer gets a confirmation from the app's own address,
`<space>.<app>@yaks.fyi`, with the items and the total on it
(<https://yaks.fyi/docs/mail.md>), and a reply to it lands back in the app's
store as mail the seller can read.

It is a row, so the seller's own view is a query:

    import { me, query } from './api/client.js'

    let mine = await me()
    if (mine.writes) draw(await query('.order!&.doc?'))

`.order.status=paid`, `.order.total_cents>=5000`, a bare word for full text —
the whole filter grammar works on them (<https://yaks.fyi/docs/querying.md>).
From an agent's side it is the same filter through `graph_query`, so "what sold
this week" is one call with no page open.

**Who else can read them is the app's `access`, not the page's `if`.** An order
carries a buyer's email address, and an app anyone can read is an app where
anyone can read the orders. If that is not wanted, set the app `private` and let
a `worker.js` hand the product list to strangers — the shape is the RSVP pattern
in <https://yaks.fyi/docs/code.md>.

## Trying it before you go live

Stripe's test mode is a whole parallel world: the seller onboards a test
account, and card `4242 4242 4242 4242` with any future expiry pays with it. Buy
something from the deployed app and watch the order land in the store and the
letter go out. Nothing about the app changes between test and live — the key is
the platform's, and which mode it is in is the platform's business.

A whole shop written out — storefront with sizes and a cart, seeded products,
the seller's order list, `NOTES.md`, no `vocab.json` at all and not one line of
Stripe code — is in this repository at `workers/yak/examples/shop/`. Copy it and
change the shirts.

## What is underneath, in one paragraph

Direct charges on a Stripe Connect connected account. The platform's own key
makes the Checkout Session, addressed to the seller's account, with an
application fee on it; the charge lives on the seller's account, so their name
is on the statement, they pay Stripe's processing fee, and they own the refund
and the dispute. Nothing in that paragraph is something an app has to know or
can change — it is on this page because somebody will ask.

## What selling does not do

Subscriptions and recurring charges, shipping rates and tax calculation,
discount codes, and refunding from inside an app: a refund is made in the
seller's own Stripe dashboard, and the `order` row follows it. Say so plainly
rather than building a button that does not work.
