# mongrl: the guides > mongrl is a headless marketplace API. You build the storefront (any stack); mongrl runs sellers, listings, search, checkout with Stripe, orders, payouts, shipping and messages behind it. Each marketplace is run by its team in the mongrl console. Rules for building on mongrl: - Call the API from your storefront's server, never from the browser: it holds the secret key (`sk_…`) and each user's session token (`X-User-Session`). Browsers can't call the API across origins. - Generate a typed client from the OpenAPI spec (https://dev.mongrl.com/openapi.json) instead of writing requests by hand. Never guess an endpoint or field. - Money is an integer in minor units with an ISO 4217 currency (`{"amount": 125000, "currency": "SEK"}` is 1 250,00 kr). - Errors are RFC 9457 problems (`application/problem+json`): show `detail` to people and map `errors[].location` onto form fields. - Send an `Idempotency-Key` header on writes you might retry, above all placing an order. - Orders move by named transitions: offer exactly the ones in `available_transitions`. - The thing on sale is a "listing". Emailed links land on pages the storefront must build (see "Pages your shop needs"). The exact endpoints and fields are in https://dev.mongrl.com/openapi.json. # What mongrl is > The pieces of a mongrl marketplace and who does what, before you write any code. mongrl is a headless marketplace API. You build the storefront people see, in any stack you like. mongrl runs everything behind it: accounts, listings, search, checkout with Stripe, orders, payouts, shipping and messages. The marketplace's team runs the day-to-day in the **console**, mongrl's back office. ## The pieces | Thing | What it is | |---|---| | **Marketplace** | One shop, with its own currencies, languages, commission, shipping table and rules. Every key, user and listing belongs to exactly one marketplace. | | **Listing** | The thing on sale: title, description, photos, price, catalogue terms (brand, designer…) and the marketplace's own fields. Always "listing", never "item" or "product". | | **Seller** | A user who lists. Any user can sell; they set up payouts with Stripe before they're paid. | | **Buyer** | A user who buys. Same accounts as sellers. | | **Order** | One purchase of one listing. It moves through states (`pending_payment`, `purchased`, `delivered`, `received`…) by named transitions. | | **Operator** | Someone on the marketplace's team, working in the console: they value and approve listings, ship orders, answer messages and set the rules. Roles are `admin` and `viewer`. | | **Console** | The marketplace's back office at `console.mongrl.com`. It's built on the same API, with its own sign-in. You don't build it. | ## Who builds what - **You** build the storefront: pages, design, domain, your server. Call the API from your server with your secret key. - **mongrl** runs the API, the database, payments, emails, timers (expired payments, automatic receipt, payouts) and the console. - **The marketplace's team** sets things up in the console: currencies, commission, what a listing needs, shipping and delivery, showrooms and warehouses, API keys. ## What a storefront usually has 1. **Browse:** the shop, search and filters, listing pages, brand and designer pages. See [Browsing and search](https://dev.mongrl.com/guides/browsing.md). 2. **Accounts:** sign-up, sign-in by password or emailed link, email confirmation, password reset. See [Accounts and sign-in](https://dev.mongrl.com/guides/accounts.md). 3. **Checkout:** delivery, vouchers, the Stripe payment form, then the order page. See [Checkout and orders](https://dev.mongrl.com/guides/checkout.md). 4. **Selling:** the sell form, photos, valuations, the seller's listings and sales. See [Selling](https://dev.mongrl.com/guides/selling.md) and [Payouts](https://dev.mongrl.com/guides/payouts.md). 5. **Messages:** questions about a listing, order messages and support. See [Messages](https://dev.mongrl.com/guides/messages.md). 6. **The pages emails link to.** See [Pages your shop needs](https://dev.mongrl.com/guides/storefront-pages.md). ## How the API behaves - REST over HTTPS with JSON, described by one [OpenAPI 3.1 spec](https://dev.mongrl.com/openapi.json) that's generated from the API's code, so it's always current. - Money is an integer in minor units with its currency. Errors are [RFC 9457 problems](https://dev.mongrl.com/reference/conventions.md#errors). Writes accept an [Idempotency-Key](https://dev.mongrl.com/reference/conventions.md#idempotency). - Everything is versioned under `/v1`. Changes within `v1` only add things: new endpoints, new optional fields. Next: the [Quickstart](https://dev.mongrl.com/start/quickstart.md). --- # Quickstart > Get a key, generate a typed client from the spec and run your first search from your server. From nothing to listings on a page in four steps. The examples use TypeScript, but any language that can send HTTPS requests works. ## 1. Get a key Keys come from the marketplace's team. In the console, **API keys → New key**, they create: - a **secret key** (`sk_test_…` or `sk_live_…`) for your server, and - optionally a **publishable key** (`pk_…`) for native apps. A key is shown once. Keep the secret key out of your code and out of browsers: put it in an environment variable such as `MONGRL_SECRET_KEY`. | Environment | API | Keys | |---|---|---| | Test | `https://api.staging.mongrl.com` | `sk_test_…`, `pk_test_…` | | Live | `https://api.mongrl.com` | `sk_live_…`, `pk_live_…` | Check a key: ```sh curl https://api.staging.mongrl.com/v1/marketplace \ -H "Authorization: Bearer $MONGRL_SECRET_KEY" ``` The answer is your marketplace: its currencies, languages, what a listing needs and its shipping table. ## 2. Generate a typed client The [OpenAPI spec](https://dev.mongrl.com/openapi.json) describes every endpoint and field. Generate a client from it instead of writing requests by hand: the compiler then catches wrong paths and fields. ```sh npm install -D @hey-api/openapi-ts curl -o openapi.json https://dev.mongrl.com/openapi.json ``` ```ts // openapi-ts.config.ts export default defineConfig({ input: './openapi.json', output: { path: 'src/api/generated' }, plugins: ['@hey-api/client-fetch'], }) ``` ```sh npx openapi-ts ``` Each operation becomes a typed function named after its operation id: `listListings`, `getListing`, `placeOrder`… Commit the spec with your code and update it on purpose, so a new API version never changes your build by surprise. ## 3. Call it from your server Make the client on the server, per request, with your secret key and (when someone is signed in) their session. Never import it into code that runs in the browser. ```ts // src/server/mongrl.ts (server only) export function mongrl(session?: string) { return createClient({ baseUrl: process.env.MONGRL_API_URL, // https://api.staging.mongrl.com auth: () => process.env.MONGRL_SECRET_KEY, // sent as Authorization: Bearer sk_… headers: session ? { 'X-User-Session': session } : undefined, }) } ``` ```ts const { data, error } = await listListings({ client: mongrl(), query: { q: 'lounge chair', currency: 'EUR', limit: 24 }, }) if (error) throw new Error(error.detail) for (const l of data.items ?? []) { console.log(l.title, l.display_price?.amount, l.images?.[0]?.urls?.medium) } ``` `display_price` is the price in the currency you asked for. Amounts are minor units: `125000` with `SEK` is 1 250,00 kr. ## 4. Sign a user in Requests for a user (their orders, their listings, buying) also send the user's session token. Your server gets it when the user signs in and keeps it in an HttpOnly cookie: ```ts const { data } = await signIn({ client: mongrl(), body: { email, password } }) // Store data.session.token in an HttpOnly, Secure, SameSite=Lax cookie. const orders = await listMyOrders({ client: mongrl(tokenFromCookie) }) ``` [Authentication](https://dev.mongrl.com/guides/authentication.md) explains keys and sessions in full. ## Next - Building with Claude, Cursor or another agent? Start with [Build with an AI agent](https://dev.mongrl.com/start/agents.md). - Otherwise, read the guide for the part you're building: [Browsing](https://dev.mongrl.com/guides/browsing.md), [Accounts](https://dev.mongrl.com/guides/accounts.md), [Checkout](https://dev.mongrl.com/guides/checkout.md) or [Selling](https://dev.mongrl.com/guides/selling.md). --- # Build with an AI agent > Point Claude, Cursor or any coding agent at mongrl and get a storefront that runs against real types, not guesses. mongrl is built to be built on by coding agents. Everything an agent needs is machine-readable, in one place, and kept in sync with the API by the build. ## What to give your agent | Resource | Use it for | |---|---| | [`/llms.txt`](https://dev.mongrl.com/llms.txt) | The entry point: what mongrl is, the rules, and links to every page as Markdown. | | [`/llms-full.txt`](https://dev.mongrl.com/llms-full.txt) | Every guide and the full API reference in one file, for agents with a big context. | | [`/llms-small.txt`](https://dev.mongrl.com/llms-small.txt) | The same, trimmed, for smaller contexts. | | [`/openapi.json`](https://dev.mongrl.com/openapi.json) | The public API spec. Generate the client from it. | | [`/AGENTS.md`](https://dev.mongrl.com/AGENTS.md) | Rules to keep in the storefront repo, so every session follows them. | | Any page + `.md` | One topic as Markdown, e.g. [`/guides/checkout.md`](https://dev.mongrl.com/guides/checkout.md.md). | ## Set up the repo ```sh curl -o AGENTS.md https://dev.mongrl.com/AGENTS.md curl -o openapi.json https://dev.mongrl.com/openapi.json ``` If your agent reads `CLAUDE.md` rather than `AGENTS.md`, add a `CLAUDE.md` that says `@AGENTS.md`. Then put the keys in your environment, never in the repo: ```sh MONGRL_API_URL=https://api.staging.mongrl.com MONGRL_SECRET_KEY=sk_test_… STRIPE_PUBLISHABLE_KEY=pk_test_… # the marketplace's Stripe account ``` ## A first prompt ```text Read https://dev.mongrl.com/llms.txt and AGENTS.md. We're building the storefront for our marketplace on mongrl, with . 1. Generate a typed client from openapi.json with @hey-api/openapi-ts. 2. Add a server-only mongrl client (secret key from MONGRL_SECRET_KEY, the user's session from an HttpOnly cookie as X-User-Session). 3. Build the shop page: search with q, category and brand filters, sort, and cursor paging, showing display_price in the visitor's currency. 4. Build the listing page from getListing. Use only endpoints and fields from openapi.json. Show problem `detail`s from errors to people. Stop and show me the shop page before going further. ``` Then go area by area, one guide per step, so the agent loads only what it needs: 1. Accounts: "Follow https://dev.mongrl.com/guides/accounts.md: sign-up, sign-in with password and with emailed links, confirm email, reset password." 2. Checkout: "Follow https://dev.mongrl.com/guides/checkout.md and https://dev.mongrl.com/guides/delivery.md." 3. Selling: "Follow https://dev.mongrl.com/guides/selling.md and https://dev.mongrl.com/guides/payouts.md." 4. Messages and the pages emails link to: "Follow https://dev.mongrl.com/guides/messages.md and https://dev.mongrl.com/guides/storefront-pages.md." ## Why agents get it right here - **One spec, generated from code.** The spec on this site is built from the API's own code on every release. An agent coding against it codes against what the API does. - **Types, not guesses.** With a generated client, a wrong field or path fails to compile instead of failing in production. - **Errors that say how to fix them.** Validation errors name the field (`body.price.amount`) and the problem, so an agent can correct its own request. - **Safe retries.** Idempotency keys make a repeated write run once, so an agent (or a flaky network) can't buy twice. - **Test keys.** `sk_test_…` keys and Stripe test cards let an agent run the whole flow without touching real orders or money. --- # Authentication > Secret and publishable keys, user sessions, and why everything goes through your server. Every request carries an API key. Requests for a signed-in user also carry that user's session. Both are sent by **your server**. ## Keys ```http Authorization: Bearer sk_test_51Hx… ``` | Key | Prefix | Where it lives | What it can do | |---|---|---|---| | Secret | `sk_live_…`, `sk_test_…` | Your server's environment | Everything a storefront does, including acting for a signed-in user. | | Publishable | `pk_live_…`, `pk_test_…` | Native apps | Public data only: the marketplace, listings, search, categories, catalogue terms, locations. | - **Live** keys work on `https://api.mongrl.com`, **test** keys on `https://api.staging.mongrl.com`. - A key belongs to one marketplace, so it also says which marketplace you mean. There's no marketplace id to send. - The marketplace's team creates and revokes keys in the console. A key is shown once. - A key that doesn't work (unknown, revoked, malformed) gets `401`, never anonymous access. - `GET /v1/marketplace` is the quickest way to check a key. ## Why your server makes the calls The API doesn't accept requests from web pages on other sites, and the secret key must never reach a browser. So your storefront's server calls mongrl, and your pages call your server. That's also where sessions stay safe: in an HttpOnly cookie that scripts can't read. ```text browser ──your cookie──▶ your server ──sk_… + X-User-Session──▶ mongrl API ``` Frameworks with server functions or route handlers (TanStack Start, Next.js, Remix, SvelteKit, Nuxt) make this short: one server-only module builds the client per request. ## User sessions Signing in returns a session: ```json { "user": { "id": "4c1e…", "email": "mia@example.com", "first_name": "Mia" }, "session": { "token": "q8Jx2mR…", "expires_at": "2026-11-18T10:00:00Z" } } ``` 1. Store `session.token` in a cookie: `HttpOnly; Secure; SameSite=Lax; Path=/`. Give it a long life (90 days); the API decides when the session ends. 2. On requests for that user, send it as `X-User-Session: ` together with your secret key. 3. A session ends 30 days after it was last used, and 90 days after sign-in at the latest. 4. Signing out is `DELETE /v1/sessions/current`. Then clear the cookie. 5. A `401` on a user request means the session has ended: clear the cookie and send them to sign in. The token is shown only when the session is created. mongrl stores just a hash of it. ## Which endpoints need what | Endpoints | Key | Session | |---|---|---| | `/v1/marketplace`, `/v1/listings`, `/v1/categories`, `/v1/catalog-terms`, `/v1/locations` | secret or publishable | no | | `/v1/users`, `/v1/sessions…`, `/v1/password-resets…`, `/v1/email-verifications` | secret | no (they create one) | | `/v1/me…` (profile, listings, orders, favourites, messages, payouts) | secret | yes | | `/v1/images/{id}/{file}` | none | no | Listing responses include `favorited` (has this user saved it?) only when you send a session. ## Common mistakes - **Calling the API from client-side code.** Move the call into a server function. - **Putting the token in localStorage.** Use an HttpOnly cookie. - **Sharing one client between requests with a session in it.** Build the client per request, so one user's session never leaks into another's request. --- # Accounts and sign-in > Sign-up, sign-in with a password or an emailed link, email confirmation, password reset and the profile. Buyers and sellers share one kind of account. People can use a password, emailed sign-in links, or both. All of these calls come from your server with your secret key ([Authentication](https://dev.mongrl.com/guides/authentication.md)). ## Sign up ```http POST /v1/users { "email": "mia@example.com", "password": "correct horse battery", "first_name": "Mia", "locale": "sv" } ``` - The answer is `201` with `user` and `session`: they're signed in. Store the session ([how](https://dev.mongrl.com/guides/authentication.md#user-sessions)). - `password` is optional. Without one, the user signs in with emailed links. Passwords are 8 to 128 characters. - `locale` is one of the marketplace's languages (`GET /v1/marketplace` → `locales`). Emails go out in it. - mongrl emails a link to confirm the address. Until then `user.email_verified` is `false`; buying and selling still work. - An address already in use gives `409`. ## Sign in with a password ```http POST /v1/sessions { "email": "mia@example.com", "password": "correct horse battery" } ``` After 10 wrong passwords in 15 minutes the account is locked for the rest of that window: `429` with a `Retry-After` header. Show the problem's `detail`. ## Sign in with an emailed link 1. `POST /v1/sessions/email-link` with `{ "email": "…" }`. It always answers `202`, so it never reveals who has an account. At most 5 emails an hour go to one address. 2. mongrl emails a link to **your** page: `https:////auth/sign-in?token=…`. 3. That page sends the token: `POST /v1/sessions/from-link` with `{ "token": "…" }`. The answer is `user` and `session`. Links work once and for 15 minutes. Following one also confirms the email address. A used or expired link gives `422`: offer to send a new one. A blocked user gets `403`. Accounts the marketplace's team creates in the console get a welcome email with the same kind of link, valid for 7 days. ## Confirm the email address The confirmation link goes to `https:////auth/verify-email?token=…`. That page calls `POST /v1/email-verifications` with `{ "token": "…" }`. Links last 24 hours; `POST /v1/me/verification-email` sends a new one to the signed-in user. ## Reset a password 1. `POST /v1/password-resets` with `{ "email": "…" }`. Always `202`. 2. The link goes to `https:////auth/reset-password?token=…` and lasts an hour. 3. That page asks for the new password and calls `POST /v1/password-resets/confirm` with `{ "token": "…", "password": "…" }`. A reset signs the user out everywhere. Sign them in again with the new password. ## The profile - `GET /v1/me` returns the signed-in user: name, email, `email_verified`, `has_password`, `locale`, `bio`, `phone`. - `PATCH /v1/me` changes only the fields you send. - `display_name` and `bio` are public (shown with a seller's listings); `phone` is private. ## Sign out `DELETE /v1/sessions/current` with the user's session, then clear your cookie. ## Checklist - [ ] Sign-up, sign-in and sign-out pages, plus "email me a link". - [ ] `//auth/sign-in`, `//auth/verify-email` and `//auth/reset-password` read `?token=` and call the API. See [Pages your shop needs](https://dev.mongrl.com/guides/storefront-pages.md). - [ ] The marketplace's team has set the shop's address in the console (**Settings → Shop and emails**), so emailed links point at your site. --- # Browsing and search > The shop page, search, filters, sorting, prices in the visitor's currency, listing pages, photos, categories, brands and favourites. Everything a visitor sees before they sign in works with your secret key alone (a publishable key works too, from a native app). ## Start from the marketplace `GET /v1/marketplace` tells you how to build the shop: | Field | Use it for | |---|---| | `currencies` | The currency picker. The first is the default. | | `locales` | Languages. The first is the default. | | `term_kinds` | Which catalogue filters exist, e.g. `brand`, `designer`. | | `listings.attributes_schema` | A JSON Schema of the marketplace's own listing fields (condition, dimensions…). Filter on them with `attr`. | | `shipping` | Delivery prices by size and zone, for "delivery from…" hints. See [Shipping and delivery](https://dev.mongrl.com/guides/delivery.md). | Cache it for a few minutes; it changes when the team edits Settings. ## Search and filter ```http GET /v1/listings?q=lounge+chair&term=&attr=condition:excellent&min_price=500000¤cy=EUR&sort=newest&limit=24 ``` | Parameter | What it does | |---|---| | `q` | Words in the title, catalogue terms or description. Accents don't matter and word beginnings match: "henn" finds Henningsen. | | `category` | This category and everything under it. | | `term` | Catalogue term ids; repeat it. Terms of one kind match any (brand A **or** B); different kinds must all match (brand A **and** designer C). | | `attr` | `key:value` on the marketplace's own fields; repeat it. Values of one key match any. | | `min_price`, `max_price` | Minor units of `currency`, which is then required. Listings in other currencies are compared at the day's rate. | | `seller` | One seller's listings, for a seller page. | | `currency` | Adds `display_price` in this currency (one of the marketplace's). | | `sort` | `relevance` (the default with `q`), `newest` (the default without), `price_asc`, `price_desc`, `popular` (most saved). | | `limit`, `cursor` | Paging. Pass `next_cursor` back as `cursor`; there's no more when it's absent. | The answer has `items`, `total` (how many match) and `next_cursor`. Only listings on sale are returned. ## Prices Every listing has `price`: what the seller set, in the listing's currency. With `currency`, it also has `display_price`, converted at the day's rate. - Show `display_price` when you asked for one, otherwise `price`. - Converted prices are approximate. The buyer pays what the [quote](https://dev.mongrl.com/guides/checkout.md#1-quote) says. - Format with `Intl.NumberFormat(locale, { style: 'currency', currency })` after dividing by 100 for currencies with two decimals. ```ts const money = ({ amount, currency }: { amount: number; currency: string }, locale = 'sv-SE') => new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount / 100) ``` A listing on sale may have a `price_schedule`: the price falls in steps while it doesn't sell. The current `price` is already the dropped one, so you don't need to work it out. ## The listing page `GET /v1/listings/{id}?currency=EUR` returns one listing on sale (`404` otherwise). Useful fields: - `title`, `description`, `terms` (by kind, each with `name` and `slug`), `attributes`, `category_id`. - `images` in display order; the first is the main photo. - `delivery`: how it can reach the buyer. See [Shipping and delivery](https://dev.mongrl.com/guides/delivery.md). - `site`: the showroom or warehouse it's stored at, when it is (`buyer_pickup` says whether buyers may collect it there). - `favorite_count`, and `favorited` when you send the user's session. - `seller_id`, for a link to the seller's other listings (`?seller=`). ## Photos Each image has `status` and `urls`: | Size | Use | |---|---| | `thumb` | 400 px square, for grids | | `medium` | 800 px wide, for listing pages on phones | | `large` | 1600 px wide, for zoom | The URLs are public and can be cached forever; a changed photo gets a new URL. Only `ready` images have sizes. ## Categories and catalogue terms - `GET /v1/categories?locale=sv` returns the whole tree at once, with `parent_id` and `position`. Build the menu from it. - `GET /v1/catalog-terms?kind=brand&in_use=true` returns every brand with listings on sale, each with `listing_count`, for a filter list. - `GET /v1/catalog-terms?kind=designer&q=hen` filters by the start of the name, a page at a time, for a brand or designer index. - `GET /v1/catalog-terms/{kind}/{slug}` returns one term, for a brand page at `/brands/`. Then list its listings with `term=`. ## Showrooms `GET /v1/locations` returns the marketplace's active showrooms and warehouses: address, opening hours and whether buyers may collect there. Use it for a "visit us" page. ## Favourites With the user's session: - `PUT /v1/me/favorites/{listing id}` saves, `DELETE` removes. Both are safe to repeat. - `GET /v1/me/favorites?currency=EUR` lists them, newest first, with cursor paging. Show the heart from `favorited` on listings you fetched with the session. --- # Selling > The sell form, drafts, photos, publishing, valuations, storage and the seller's listings. Any signed-in user can sell. A listing starts as a draft, gets photos, and is published: straight on sale, to the marketplace's team for approval, or to be valued first. All calls send the user's session. ## What the marketplace asks for Read `GET /v1/marketplace` → `listings` before building the form: | Field | Meaning | |---|---| | `min_images` | Photos needed to go on sale (at least 1 to be valued). | | `require_category` | A category is needed. | | `required_terms` | Term kinds needed, e.g. `["brand"]`. | | `attributes_schema` | A JSON Schema for the marketplace's own fields (condition, dimensions…). Render a field per property and send the values in `attributes`. | | `valuations` | Sellers may ask the team to value the listing instead of setting a price. | | `require_approval` | The team reviews listings before they go on sale. | ## Statuses ```text draft ──publish──▶ published (no approval needed) draft ──publish──▶ pending_approval ──▶ published (the team approves) └─▶ denied (with review_note) draft ──publish {ask_valuation}──▶ pending_valuation ──▶ valuation_offered valuation_offered ──accept──▶ pending_approval valuation_offered ──counter──▶ pending_approval (the team approves, denies or offers again) published ──close──▶ closed ──publish──▶ … ``` `GET /v1/me/listings` returns the seller's listings in every status, latest change first, with cursor paging. ## 1. Start a draft ```http POST /v1/me/listings { "title": "Lounge chair", "locale": "sv" } ``` A draft can be incomplete. Save as the seller goes: `PUT /v1/me/listings/{id}` **replaces** the listing's content, so always send the whole form, not just the changed field. | Field | Notes | |---|---| | `title`, `description` | Plain text. `locale` is the language the seller writes in. | | `price` | `{ "amount": 650000, "currency": "SEK" }`, minor units, one of the marketplace's currencies. Leave it out when asking for a valuation. | | `category_id`, `terms` | `terms` maps a kind to a term id: `{ "brand": "…", "designer": "…" }`. | | `attributes` | The marketplace's own fields, matching `attributes_schema`. | | `location` | `{ "country": "SE", "postal_code": "…", "city": "…" }`. A country is needed to publish. | | `delivery` | How it can reach the buyer: `shipping` (with a `price`, or none for "ask for a quote"), `pickup`, `buyer_arranged`. At least one. See [Shipping and delivery](https://dev.mongrl.com/guides/delivery.md). | | `stock` | 1 unless the seller has several of the same. | ## 2. Add photos Send each photo as the request body, with its content type: ```http POST /v1/me/listings/{id}/images Content-Type: image/jpeg ``` - JPEG, PNG or WebP, up to 20 MB and 50 megapixels, at most 20 per listing. - The answer is `202` with the image in `processing`. Its sizes are ready within seconds; poll `GET /v1/me/listings/{id}` until `status` is `ready`. - `PUT /v1/me/listings/{id}/images/order` with every image id sets the order; the first is the main photo. - `DELETE /v1/me/listings/{id}/images/{image_id}` removes one. Upload from your server: the browser sends the file to your server, which streams it on with the user's session. ## 3. Publish ```http POST /v1/me/listings/{id}/publish { "ask_valuation": false, "wants_storage": false } ``` - With approval on, only the basics are checked (title, photos, country, price), because the team completes the listing. - `ask_valuation: true` (where `valuations` is on): no price needed; the listing goes to `pending_valuation`. - `wants_storage: true`: the seller asks the marketplace to collect the listing and store it until it sells. The team books the collection; until then it stays with the seller. - If anything is missing, the `422` lists **every** problem in `errors`, so you can mark all the fields at once. A closed listing can be published again the same way. ## Valuations When the team has valued it, the listing is `valuation_offered` with: - `offer`: the price offered, and `offer_note`, why. - `offer_schedule`: price drops offered with it, if any. The seller answers: - `POST /v1/me/listings/{id}/offer/accept`: the offer becomes the price. - `POST /v1/me/listings/{id}/offer/counter` with `{ "amount": 720000 }`: their own price, in the offer's currency. Either way the listing goes to `pending_approval`. A denied listing has `review_note` with the reason; show it, and don't offer to send it again. ## On sale - `price_schedule` lists the drops ahead: each step has `months`, `amount`, `on` (when it applies) and `done`. Show the seller what's coming. - `custody` says where it is (`seller`, `collecting`, `stored`), and `site` which showroom or warehouse. `fulfilment` says who delivers it (`seller` or `marketplace`). - Editing a listing on sale keeps it on sale, so it must stay complete. Sellers can't edit a listing while the team reviews it, and closed or denied listings can't be edited. - `POST /v1/me/listings/{id}/close` takes it off sale. `DELETE` removes a draft; a listing that was ever on sale is closed instead. ## When it sells The seller sees the order under `GET /v1/me/orders?as=seller`. If the seller delivers it themselves, they mark it sent with tracking details. See [Checkout and orders](https://dev.mongrl.com/guides/checkout.md#moving-an-order-on). They're paid once the buyer has it, after setting up [payouts](https://dev.mongrl.com/guides/payouts.md). ## Emails mongrl sends sellers When the team offers a price, denies a listing or puts it on sale, the seller gets an email linking to `https:////account/listings/`. Build that page. See [Pages your shop needs](https://dev.mongrl.com/guides/storefront-pages.md). --- # Checkout and orders > Quote, place the order, take the payment with Stripe, and follow the order until the seller is paid. One order buys one listing. Checkout is three calls and Stripe's payment form; after that, the order moves on by transitions until the buyer has it and the seller is paid. Every call sends the buyer's session. ## 1. Quote Show what the buyer will pay before they pay it: ```http POST /v1/me/orders/quote { "listing_id": "8f3c…", "currency": "EUR", "delivery": { "method": "shipping", "option": "standard", "address": { "name": "Mia Holm", "line1": "Hallvägen 29", "postal_code": "121 62", "city": "Johanneshov", "country": "SE", "phone": "+46…" } }, "voucher_code": "SPRING10" } ``` ```json { "currency": "EUR", "total": 61900, "line_items": [ { "code": "item", "label": "Eames lounge chair", "unit_price": 59000, "quantity": 1, "amount": 59000 }, { "code": "shipping", "label": "Shipping", "unit_price": 8800, "quantity": 1, "amount": 8800 }, { "code": "voucher", "label": "Voucher SPRING10", "unit_price": -5900, "quantity": 1, "amount": -5900 } ] } ``` - Show `line_items` as they come, by `label`, and `total` as the amount to pay. Don't add things up yourself. - Quote again whenever the delivery, address or voucher changes. Placing the order prices it the same way. - `currency` is what the buyer pays in: one of the marketplace's, the listing's when left out. Line item codes: | Code | Meaning | |---|---| | `item` | The listing. | | `shipping` | Delivery, priced. | | `collect` | Collected at the showroom or warehouse where it's stored. Free. | | `shipping_address_needed` | Ask for the address; the price comes with it. Placing without one fails. | | `shipping_quote` | Delivery can't be priced here: the marketplace sends a quote after the order. | | `voucher` | A discount (negative). | A voucher that doesn't apply gives `422` with the reason on `body.voucher_code`. Show it next to the voucher field. ## 2. Place the order ```http POST /v1/me/orders Idempotency-Key: 6f1d3c5e-… { the same body as the quote } ``` ```json { "order": { "id": "1a2b…", "state": "pending_payment", "total": 61900, "currency": "EUR", "available_transitions": [] }, "payment": { "client_secret": "pi_3Q…_secret_…" } } ``` - This reserves the listing and starts the payment. The buyer has **15 minutes** to pay; then the order becomes `payment_expired` and the listing is released. - Send an `Idempotency-Key` (a new UUID for each press of "Pay"), so a retried request can't place two orders. - `409` means it can't be bought right now: sold, reserved by someone else, or the delivery isn't possible. Show the `detail` and go back to the listing. ## 3. Take the payment with Stripe Payments run on the marketplace's Stripe account. Use Stripe's [Payment Element](https://docs.stripe.com/payments/payment-element) with the marketplace's Stripe **publishable** key (the team gives it to you; it's public) and the order's `client_secret`. Cards, Klarna and the other methods the marketplace has turned on in Stripe appear by themselves. ```ts const stripe = await loadStripe(STRIPE_PUBLISHABLE_KEY) const elements = stripe.elements({ clientSecret: payment.client_secret }) elements.create('payment').mount('#payment') // On "Pay": const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: `${location.origin}/orders/${order.id}` }, }) if (error) showError(error.message) // otherwise Stripe sends the buyer to return_url ``` When Stripe confirms the payment, mongrl moves the order to `purchased`. That happens in the background, a moment after the buyer lands on your order page: read the order (`GET /v1/me/orders/{id}`) and, while it's still `pending_payment`, check again every couple of seconds. In the test environment, pay with Stripe's test card `4242 4242 4242 4242`, any future date and any CVC. ## Order states ```text (new) ─request-payment─▶ pending_payment ─confirm-payment─▶ purchased └─expire-payment (15 min)─▶ payment_expired purchased ─mark-delivered─▶ delivered ─mark-received / auto (14 days)─▶ received purchased ─mark-received-early─▶ received purchased ─cancel / auto-cancel (14 days)─▶ canceled delivered ─dispute─▶ disputed ─resolve-received─▶ received └─resolve-canceled / auto (60 days)─▶ canceled ``` `payment_expired`, `received` and `canceled` are final. Each order has `history`: every transition with when it happened, for a timeline on the order page. ## Moving an order on Each order has `available_transitions`: what **this user** may do now. Show a button for each and nothing else. ```http POST /v1/me/orders/{id}/transitions/mark-delivered { "data": { "tracking_number": "JJSE00012345", "carrier": "PostNord" } } ``` | Transition | Who | When | |---|---|---| | `mark-delivered` | the seller | It's sent or handed over. `data` can carry `tracking_number` and `carrier`; they appear in `order.delivery`. | | `mark-received` | the buyer | They have it. The seller is paid. | | `mark-received-early` | the buyer | They have it before it was marked sent (e.g. picked up). The seller is paid. | | `dispute` | the buyer | Something's wrong after delivery. The marketplace's team resolves it. | When the marketplace delivers a listing itself (`fulfilment: marketplace`, e.g. stored listings), its team marks the order sent in the console. The timers keep orders moving if nobody acts: an order that's never sent is canceled and refunded after 14 days; a delivered order counts as received after 14 days. ## Listing orders - `GET /v1/me/orders` lists the buyer's purchases; `?as=seller` lists their sales. Filter with `state`; page with `limit` and `offset`. - Each order has `line_items`, `total`, `delivery` and, for the seller, `payout`: `subtotal`, `commission`, `storage_fee`, `amount` and its `status` (`pending` until the buyer has it, then `paid`; `waiting_for_payout_details` until the seller sets up [payouts](https://dev.mongrl.com/guides/payouts.md)). ## Emails mongrl sends Buyers and sellers get emails when an order is paid, sent and received, each linking to `https:////orders/`. Build that page. See [Pages your shop needs](https://dev.mongrl.com/guides/storefront-pages.md). ## Checklist - [ ] Checkout: delivery choice, address, voucher, live quote, "Pay" with the Payment Element. - [ ] `Idempotency-Key` on `placeOrder`. - [ ] The order page waits for `purchased` after Stripe returns, then shows the timeline and the buttons from `available_transitions`. - [ ] A delivery-time picker when the order offers one. See [Shipping and delivery](https://dev.mongrl.com/guides/delivery.md#booking-a-delivery-time). --- # Shipping and delivery > How a listing reaches the buyer, how delivery is priced from the marketplace's shipping table, and letting buyers book a delivery time. A listing reaches the buyer in one of a few ways, and who moves it depends on the listing. mongrl prices it; your checkout offers the choices and shows the quote. ## Who moves it Every listing has `fulfilment`: - **`seller`**: the seller ships it or hands it over. The ways they offer are in the listing's `delivery`. - **`marketplace`**: the marketplace's team delivers it, typically because it's stored at their showroom or warehouse (`custody: stored`, `site`). ## The delivery methods | `delivery.method` | Offered when | Price | |---|---|---| | `shipping` | Seller listings with `delivery.shipping`, and every marketplace listing. | The seller's `shipping.price`, or the marketplace's shipping table (below). No price means "we'll send a quote". | | `pickup` | Seller listings with `delivery.pickup`: the buyer picks it up from the seller. | Free | | `buyer_arranged` | Seller listings with `delivery.buyer_arranged`: the buyer sends their own courier. | Free | | `collect` | Marketplace listings stored at a site where `site.buyer_pickup` is true: the buyer collects it there. | Free | When a listing offers only one method you can leave `method` out. Offer the choices from the listing, then quote with the buyer's pick. ## The shipping table Listings the marketplace moves are priced from its table, in `GET /v1/marketplace` → `shipping`: - **`sizes`**: size classes, e.g. small parcel, armchair, sofa. Each listing has a `shipping_size`. - **`zones`**: where buyers are, by `countries` and optional `postal_prefixes`. Each zone has a `delivery_time` to show buyers, e.g. "2–5 working days". - **`options`**: delivery services, e.g. standard and white glove, with an `about` text. Send the buyer's pick as `delivery.option` (the first when left out). - **`delivery`**: prices by option, size and zone. A missing price means a quote. - **`collection`**: what it costs to collect a listing that's still at the seller's, by size and the seller's zone. mongrl adds it to the shipping line by itself. You don't need to work prices out: send the address and option in the [quote](https://dev.mongrl.com/guides/checkout.md#1-quote) and show what comes back. Use the table only for hints such as "delivery from €49" or the delivery time. The address needs at least `country` (and `postal_code` where zones use prefixes) to be priced. Without it the quote says `shipping_address_needed`. ## Booking a delivery time Some marketplaces let buyers choose when the delivery comes (`shipping.booking`: weekdays, time windows, how far ahead). It applies to paid orders that the marketplace ships to a zone it books. ```http GET /v1/me/orders/{id}/delivery-booking ``` ```json { "booked": null, "changeable": true, "slots": [ { "date": "2026-10-23", "window": "am", "label": "08–12", "full": false }, { "date": "2026-10-23", "window": "pm", "label": "13–17", "full": true } ] } ``` - `409` means this order has no time to book (not paid yet, collected, or the carrier arranges it). Show nothing. - Show `slots` grouped by `date`; `full` ones can't be chosen. - Book with `PUT /v1/me/orders/{id}/delivery-booking` and `{ "date": "2026-10-23", "window": "am" }`. - `changeable` turns false once it's too close to change. The booked time is in `booked`. ## Storage fees (sellers) When a seller asks the marketplace to store their listing (`wants_storage` when publishing), storage can cost a monthly fee by size after `free_storage_weeks`. It's taken from the seller's payout when it sells and shown on the order as `payout.storage_fee`. Show sellers `shipping.storage_fees` and the free weeks before they choose storage. --- # Payouts > Let sellers set up payouts with Stripe's embedded onboarding, and show them where their money is. Sellers are paid through Stripe Connect on the marketplace's Stripe account. A seller sets up payouts once, inside your shop, with Stripe's embedded onboarding form. Until they do, sales still happen; the payout waits. ## Check where they are ```http GET /v1/me/payouts ``` The answer has `status`: | `status` | Show | |---|---| | `none` | "Set up payouts". | | `action_required` | "Finish setting up payouts". `due` lists what Stripe still needs. | | `pending` | "Stripe is checking your details". | | `ready` | Nothing to do: they'll be paid. | ## Run the onboarding form 1. Ask whether they sell as a private person or a company, and where they live. 2. Start the session from your server: ```http POST /v1/me/payouts/session { "country": "SE", "business_type": "individual" } ``` The answer has a `client_secret`. `country` and `business_type` are used the first time only. 3. Show Stripe's [account onboarding component](https://docs.stripe.com/connect/supported-embedded-components/account-onboarding) with it, asking only for what's needed now: ```ts import { loadConnectAndInitialize } from '@stripe/connect-js' const connect = loadConnectAndInitialize({ publishableKey: STRIPE_PUBLISHABLE_KEY, fetchClientSecret: async () => (await startPayoutSetup()).client_secret, // your server calls POST /v1/me/payouts/session }) const onboarding = connect.create('account-onboarding') onboarding.setCollectionOptions({ fields: 'currently_due', futureRequirements: 'omit' }) onboarding.setOnExit(() => refreshPayoutStatus()) document.querySelector('#payouts')?.append(onboarding) ``` 4. When they leave the form, read `GET /v1/me/payouts` again. ## When sellers are paid - An order's `payout` shows the seller's side: `subtotal`, `commission`, `storage_fee` and `amount`. - It's `pending` until the buyer has the listing (`received`), then `paid`. - `waiting_for_payout_details` means the sale is done but the seller hasn't finished setting up payouts. It's paid as soon as they do. - `held_for_chargeback` means the buyer's bank disputed the payment. The marketplace's team handles it. --- # Messages > Questions about a listing, messages about an order, and support from the marketplace's team. Users message each other and the marketplace in conversations. There are three kinds, and there's one conversation per pair and subject, so starting one again continues it. | `kind` | Between | Start it with | |---|---|---| | `listing` | A buyer and the seller, before buying | `listing_id` | | `order` | The buyer and the seller of an order | `order_id` | | `support` | A user and the marketplace's team | `support: true` | ## Start or continue a conversation ```http POST /v1/me/conversations { "listing_id": "8f3c…", "body": "Is the leather original?" } ``` The answer has the `conversation` and the new `message`. If one already exists, the message joins it. ## Show the inbox - `GET /v1/me/conversations` lists the user's conversations, latest first, each with `last_message`, `last_message_at` and `unread`. Page with `limit` and `offset`. - `GET /v1/me/conversations/{id}/messages` reads one, oldest first, and marks it read. Pass `after=` to get only newer messages. - `POST /v1/me/conversations/{id}/messages` with `{ "body": "…" }` replies. In a message, `from_marketplace: true` means the marketplace's team wrote it (support); otherwise `sender_id` is the user who did. In `support` conversations, `user_a` is the user asking. Show a badge from the sum of `unread`. There's no push: refresh the inbox when the user opens it, and while a conversation is open, ask for messages `after` the last one every few seconds. ## Emails When someone gets a message they're emailed a link to `https:////messages/`. Build that page. See [Pages your shop needs](https://dev.mongrl.com/guides/storefront-pages.md). --- # Content pages > Render the pages the marketplace's team builds in the console (the home page, About, How it works) from blocks filled in with live data. The marketplace's team builds pages in the console from blocks: a hero, listing grids, banners, category tiles and so on. Your storefront renders them, in its own design. You never hard-code the home page's content: the team changes it without a deploy. ## Fetch a page ```http GET /v1/pages/home?locale=en¤cy=EUR ``` `home` is the front page; other pages have their own address (`about`, `how-it-works`). Serve each at `/`. The answer has `title` and `description` (for `` and the meta description), `share_image` (for `og:image`) and `blocks`, in order. - `404` means the page isn't published. For `home`, fall back to a simple page of your own. - Pages come in the marketplace's languages; without one in `locale`, you get the default language's page. - `GET /v1/pages` lists every published page, for your sitemap. ## Render the blocks Render each block by its `type`, and **skip types you don't know**, so new block types never break your shop. Blocks arrive filled in: listings with their photos and prices, images with their sizes, categories with their names, the showroom with its address. A block with nothing left to show (its listings all sold, a missing image) is left out, so you don't need to check. | `type` | Show | Filled in | |---|---|---| | `hero` | `eyebrow`, `heading`, `text`, a search box when `search`, up to two `buttons`; beside it the `image` or the `listing` | `image`, `listing` | | `links` | A row of `links` | | | `listing_grid` | `heading`, a "view all" `link`, and the listing cards | `listings` | | `banner` | A full-width `image` (`phone_image` on phones), with `heading` and `text` on it, linking to `link` | `image`, `phone_image` | | `category_tiles` | `heading` and `tiles`: each tile's `image` and `label` (or its category's name), linking to the category | `tiles[].category`, `tiles[].image` | | `image_text` | The `image` on `image_side`, beside `eyebrow`, `heading`, `text` and `link` | `image` | | `featured_listing` | One `listing`, big, with `heading` and `text`. It may be sold (`stock` 0) when the team chose to keep it | `listing` | | `cta` | A band in `tone` (brand, light or dark) with `heading`, `text` and up to two `buttons` | | | `newsletter` | `heading`, `text`, an email field and `button_label` | | | `promises` | `items`, each a `title` and `text` | | | `showroom` | `heading`, `text`, and the `location`'s address and `opening_hours` | `location`, `image` | | `text` | `body`, Markdown | | | `faq` | `heading` and `questions`, each a `question` and a Markdown `answer` | | Links (`href`) are a path in your shop (`/shop?sort=newest`) or an `https://` address. Listings in blocks are the same as in search, so reuse your listing card. Buttons have a `style`: `primary` or `secondary`. Image URLs (`urls.thumb`, `medium`, `large`, `xlarge`) never change: cache them forever and use `srcset`. ## The newsletter block Send the address from your server: ```http POST /v1/newsletter/subscribers { "email": "mia@example.com", "first_name": "Mia", "locale": "en", "source": "home" } ``` It always answers `202` for a valid address (never revealing who's signed up), and a bad address gets `422`. mongrl keeps the list and adds the address to the marketplace's mailing tool. ## Preview The console's **Preview** opens `https://<your shop>/<slug>?preview=<token>`. When that query is there, fetch the draft instead: ```http GET /v1/pages/home/preview?token=<token>¤cy=EUR ``` Don't cache previews, and add `<meta name="robots" content="noindex">` to them. --- # Pages your shop needs > The pages mongrl's emails link to, and the settings that make the links work. mongrl sends the emails (sign-in links, confirmations, order and message notices), and their links point at **your** storefront. Build these pages, or the links lead nowhere. ## Set the shop's address first In the console, **Settings → Shop and emails**: the marketplace's team sets the shop's address (e.g. `https://www.example.com`) and who emails come from. Until the address is set, mongrl can't send emailed links: requesting one gives `409`. ## The pages `<lang>` is the user's language, lower-case (`sv`, `en`): their `locale`, or the marketplace's default. | Page | Linked from | What it does | |---|---|---| | `/<lang>/auth/sign-in?token=…` | Sign-in links, welcome emails | `POST /v1/sessions/from-link` with the token, store the session, go on to the account. | | `/<lang>/auth/verify-email?token=…` | Sign-up, "send it again" | `POST /v1/email-verifications` with the token, then say it's confirmed. | | `/<lang>/auth/reset-password?token=…` | Password reset emails | Ask for a new password, `POST /v1/password-resets/confirm`, then sign in. | | `/<lang>/orders/<order id>` | Order emails to buyers and sellers | Sign in if needed, then `GET /v1/me/orders/{id}`: state, timeline, buttons from `available_transitions`, delivery booking. | | `/<lang>/messages/<conversation id>` | New message emails | Sign in if needed, then the conversation. | | `/<lang>/account/listings/<listing id>` | Valuation offers, denials, listings put on sale | Sign in if needed, then `GET /v1/me/listings/{id}`: the offer to accept or answer, the reason it was denied, or that it's on sale. | Tokens in links work once. If the page loads twice (a mail scanner, a double click), the second try gets `422`: say the link has been used and offer a new one. ## If your shop doesn't use language prefixes Redirect them. A shop in one language can send `/en/…` to `/…` and keep the query string, so every emailed link still lands. ## Everything else is yours Shop, search, listing, brand and designer pages, checkout, the account, selling: their addresses are up to you. Keep them stable, because listing URLs get shared and indexed. --- # Conventions > Errors, money, ids and dates, paging, idempotency, rate limits, languages and versioning: how every endpoint behaves. ## Base URLs | Environment | URL | Keys | |---|---|---| | Test | `https://api.staging.mongrl.com` | `sk_test_…`, `pk_test_…` | | Live | `https://api.mongrl.com` | `sk_live_…`, `pk_live_…` | All requests and responses are JSON (`application/json`), except photo uploads (the image's bytes). See [Authentication](https://dev.mongrl.com/guides/authentication.md) for keys and sessions. ## Errors Errors are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problems, sent as `application/problem+json`: ```json { "title": "Unprocessable Entity", "status": 422, "detail": "the listing isn't ready to go on sale", "errors": [ { "location": "body.price", "message": "a price is needed", "value": null }, { "location": "body.images", "message": "add at least 3 photos" } ] } ``` - Show `detail` to people. It's written to be read. - `errors[].location` points at the field: `body.<field>`, `query.<name>` or `path.<name>`. Map it onto your form. - Validation errors list **every** problem at once, not just the first. | Status | Meaning | |---|---| | `400` | The request is malformed (bad JSON, a bad `Idempotency-Key`). | | `401` | No key, a key that doesn't work, or a user session that has ended. | | `403` | The key or user may not do this (e.g. a blocked user). | | `404` | Not found, or not yours. Other marketplaces' and users' things are always `404`. | | `409` | Not possible in the current state: sold, already in use, a transition that isn't available. | | `422` | The input is wrong. See `errors`. | | `429` | Too many attempts. Wait for `Retry-After` seconds. | | `503` | A dependency (such as payments) isn't available. Try again later. | ## Money Money is always an object with an integer amount in **minor units** and an ISO 4217 currency: ```json { "amount": 125000, "currency": "SEK" } ``` That's 1 250,00 kr. Never use floats for money. Totals and line items on orders are plain integers in the order's `currency`. ## Ids and dates - Ids are UUIDs (`"8f3c2a1e-…"`). Treat them as opaque strings. - Times are RFC 3339 in UTC (`"2026-10-19T14:05:00Z"`). Dates without a time are `YYYY-MM-DD`. ## Paging Two styles, depending on the list: | Lists | Style | |---|---| | Listings, favourites, a seller's listings, catalogue terms | **Cursor:** pass `next_cursor` back as `cursor`. No `next_cursor` means the last page. | | Orders, conversations | **Offset:** `limit` and `offset`. A page shorter than `limit` is the last. | | Messages in a conversation | **After:** `after=<message id>` returns only newer messages, oldest first. | `limit` is at most 100 (200 for catalogue terms and messages). Search also returns `total`, the number of matches. ## Idempotency Send `Idempotency-Key` on any `POST`, `PUT`, `PATCH` or `DELETE` you might retry, above all placing an order. Use a fresh UUID per user action. - A retry with the same key and the same request doesn't run again: it replays the first response, with `Idempotent-Replayed: true`. - The same key with a different request is `422`; while the first request still runs it's `409`. - Server errors (`5xx`) aren't stored, so retrying them runs the request again. - Keys last 24 hours and belong to your key (and user), so they never collide with anyone else's. ## Rate limits Limits protect people, not the API's capacity: - Sign-in: 10 wrong passwords in 15 minutes locks the account for the rest of that window. - Emailed links (sign-in, password reset, confirmation): 5 an hour per address. Over a limit you get `429` with `Retry-After`. ## Languages The marketplace's languages are in `GET /v1/marketplace` → `locales` (BCP 47, the first is the default). Pass `locale` where an endpoint takes one (categories, catalogue terms); a user's `locale` decides the language of their emails. ## Versioning Everything is under `/v1`. Within `v1`, changes only add things: new endpoints, new optional request fields, new response fields, new enum values. Write clients that ignore fields they don't know. Anything that would break a client gets a new version. ## The spec The [OpenAPI 3.1 spec](https://dev.mongrl.com/openapi.json) ([YAML](https://dev.mongrl.com/openapi.yaml)) is generated from the API's code and published with it, so it always matches the API. It covers everything a storefront can call; the console's own endpoints aren't in it.