# Sell tickets from your own site

The same flow Convova's checkout uses. Every call is a POST with a JSON body to
`https://convova.app/api/public/v1/events/{slug}/...`, no key, from a browser or a server.

## 1. Show what is for sale

`GET products` returns every visible product: `kind` is `ticket` or `add-on`. Show
a buy button only when `onSale` is true; it is false while the sales window is
closed, when there is no price, when sold out, and once the event has ended. A
sold-out or closed one may be listed as such. An add-on is chosen inside a ticket,
so render add-ons
under the ticket they attach to (`attachesTo` names the ticket type ids; empty
means any ticket). `perOrderMax` caps a ticket per order, `perTicketMax` caps an
add-on per ticket.

## 2. Quote the cart

```
POST orders/quote
{ "tickets": [ { "productId": "...", "addOns": [ { "productId": "...", "quantity": 1 } ] } ],
  "couponCode": "EARLY" }
```

Returns `PublicQuote`: the same tickets with names and amounts, `subtotal`,
`discountTotal`, `taxTotal`, `total`, all integers in `currency`. Quote again
whenever the cart changes; nothing is reserved yet. `POST coupons/validate` takes
the same body with the code required and answers whether it works for this cart and
what it saves; every refused code gets the same message. Quotes and coupon checks
allow 30 calls a minute per address; over that, 429.

## 3. Prove the buyer's email

```
POST checkout/start    { "email": "ana@example.com" }     → { "sent": true }
POST checkout/verify   { "email": "...", "code": "123456" } → { "buyerToken", "expiresAt" }
```

The code is six digits, lasts ten minutes, allows three tries, and can be sent
again after a minute. Keep the `buyerToken` in memory for this checkout only. A
buyer who is signed in to Convova in the same browser can skip this step; the
session proves them.

## 4. Create the order

```
POST orders
{ "tickets": [...], "couponCode": "EARLY", "buyerToken": "..." }
→ { "orderId", "checkoutUrl", "expiresAt", "status" }
```

This holds the units for about half an hour and returns Stripe's page in
`checkoutUrl`: send the buyer there. A free order has `checkoutUrl: null` and
`status: "paid"` at once. A buyer can have only a few open orders in an event;
over that, or over the per-IP rate, the answer is 429 with `Retry-After`.

## 5. Come back and check

```
GET  orders/{orderId}          → PublicOrder with status pending | paid | expired | refunded
POST orders/{orderId}/cancel   releases the hold when the buyer leaves Stripe's page
```

Poll the order after the buyer returns from Stripe until `status` is `paid` or
`expired`; payment is recorded by Stripe's webhook, never by the browser. The id is
the only key to the order and it carries no personal data. The tickets reach the
buyer by email, and they claim them in the Convova app.

## What can go wrong

- 401 on `orders`: the `buyerToken` is missing, expired, or for another event.
- 400 with a message: a product not on sale, an add-on on the wrong ticket, over
  `perOrderMax`, a cart over the size limit, or the event has ended.
- 409: the state moved under you; quote again.
- Nothing can be ordered once the event has ended.
