Run a marketplace
Requires: A publishable key with
READ_PUBLICfor browsing; a secret key withWRITE_SALESfor checkout andMEMBER_AUTH(plus the member's session) for purchases.
This guide builds a full storefront on your own domain: a browse page with filters, a product page with variants, a basket, checkout, and an account page with purchases and downloads. Guests who buy without an account manage their order from the link in their order email.
It builds on Embed in your own code: the browser
talks to your server, your server talks to Cobuntu, and the key never leaves the
server. It uses cobuntuForRequest() from that guide (the client, with the
member's session when there is one).
browse products.list + products.facets ──▶ ProductFilters + ProductGrid
product products.get ──▶ ProductView
basket checkout.quote ──▶ Basket
pay checkout.create ──▶ Stripe ──▶ checkoutSessions.get
account me.purchases, me.purchaseDownload, me.invoice, me.receipt ──▶ PurchasesList
guest sales.manage, sales.manageReceipt, products.refund (token)Key and scopes
| Scope | For |
|---|---|
READ_PUBLIC | Browse, facets and product pages. A publishable key is enough. |
WRITE_SALES | Checkout, quote, buyer refunds and the guest order routes. |
MEMBER_AUTH | me.purchases, downloads, invoices and receipts. Secret key only. |
One secret key with all three, held on your server, covers everything here.
All amounts are integers in the smallest currency unit (cents for EUR).
1. Browse
// app/shop/page.tsx (server component)
import { fromProductSearchParams, toProductQuery, toProductFacetsQuery } from "@cobuntu/embed-react/products";
import { cobuntuForRequest } from "@/lib/session";
import { ShopBrowser } from "./ShopBrowser";
export default async function Shop({ searchParams }: { searchParams: Promise<Record<string, string>> }) {
const state = fromProductSearchParams(await searchParams);
const cobuntu = cobuntuForRequest();
const [list, facets] = await Promise.all([
cobuntu.products.list({ ...toProductQuery(state), limit: 24 }),
cobuntu.products.facets(toProductFacetsQuery(state)),
]);
return <ShopBrowser initial={state} list={list} facets={facets} />;
}ShopBrowser renders ProductFilters and ProductGrid and writes the state to
the URL with toProductSearchParams (example).
What the list gives you:
- Filters and sort run over the whole visible set before paging. Walk
nextCursoruntilhasMoreis false. - Card price: the cheapest live variant, else the product price.
price,minPriceandmaxPricefilter on it, andcardPrice()in the React package computes it the same way. - Seller:
seller.typeiscommunitywhen the community itself sells it,memberwhen one of its members does. - Visibility: anonymous visitors see public products. With a member session members also see members-only products, so a list never shows a card the product page then refuses.
- Facets count each category, sub-category, type, price and seller over the same set, narrowed only by the search and price range.
Bad params are 400 INVALID_INPUT with details.field naming the param.
2. The product page
// app/shop/[sku]/page.tsx (server component)
import { notFound } from "next/navigation";
import { isCobuntuError } from "@cobuntu/embed";
import { cobuntuForRequest } from "@/lib/session";
import { ProductPage } from "./ProductPage";
export default async function Product({ params }: { params: Promise<{ sku: string }> }) {
const { sku } = await params;
try {
return <ProductPage product={await cobuntuForRequest().products.get(sku)} />;
} catch (e) {
if (isCobuntuError(e) && e.status === 404) notFound();
throw e;
}
}Each variant carries what a buy box needs:
| Field | |
|---|---|
id | Send it as tierId at checkout. |
product.price, product.currency | The variant's list price. |
capacity, remaining, soldOut | Stock, counted the same way checkout enforces it. capacity: null is uncapped. |
isOnSale, state | The sales window. A scheduled variant still shows, as not on sale. |
memberPrice, memberPriceMode | The signed-in member's own price (only with a session). |
publicMemberOffers | Prices for members of each tier, shown to everyone. |
priceMode, pwywMinAmount | Pay what you want, and its minimum. |
hasForm | Fetch events.form(tierId) and send formAnswers (one-item orders only). |
attributes | { key, value } pairs such as Colour or Size. |
ProductView renders all of it and hands back a ready basket line from Add to
basket and Buy now.
3. Basket and quote
Keep the basket on your site (a cookie or local storage). Price it on the server:
// app/api/quote/route.ts
import { cobuntuForRequest } from "@/lib/session";
export async function POST(req: Request) {
const { items } = await req.json();
try {
return Response.json(await cobuntuForRequest().checkout.quote({ items }));
} catch (e) {
return Response.json({ error: (e as Error).message }, { status: 400 });
}
}The quote applies member prices, pay-what-you-want amounts, stock and postage
exactly as checkout will. total excludes tax, which is computed at payment.
Pass the route to Basket as quote.
An item is { productId | sku, tierId?, quantity?, customAmount?, formAnswers? }.
A product with one variant needs no tierId; with several, a missing one is
TIER_REQUIRED. Up to 20 lines, quantity 1 to 10.
If you already use the cross-origin cart, send
{ cartToken } instead of items.
4. Checkout
// app/api/checkout/route.ts
import { isCobuntuError, returnUrl } from "@cobuntu/embed";
import { cobuntuForRequest } from "@/lib/session";
import { SITE } from "@/lib/cobuntu";
export async function POST(req: Request) {
const { items, email } = await req.json();
try {
const result = await cobuntuForRequest().checkout.create({
items,
customer: email ? { email } : undefined, // guests: receives the order email
successUrl: returnUrl(SITE, "/shop/thanks", { session: "{CHECKOUT_SESSION_ID}" }),
cancelUrl: returnUrl(SITE, "/shop/basket"),
});
return Response.json(result); // redirect the browser to result.checkoutUrl
} catch (e) {
if (isCobuntuError(e)) return Response.json({ code: e.code, ...e.details }, { status: e.status });
throw e;
}
}- An all-free order is booked at once:
isFreeCheckout: trueand nocheckoutUrl. - On the thanks page,
checkoutSessions.get(session)sayspaidorpending. - Physical items are bought on their own (
PHYSICAL_CART_MUST_BE_ALONE). The address is collected at payment and limited to the seller's country. If you already know the country, sendshippingAddress: { country }: a country the seller does not ship to is refused up front withSHIPPING_COUNTRY_NOT_SERVEDanddetails.allowedCountries. - Codes worth handling:
SOLD_OUT(details.tierId),MEMBERS_ONLY,SESSION_REQUIRED(subscriptions and instalment plans need an account),ALREADY_PURCHASED,CUSTOM_AMOUNT_TOO_LOW,FORM_ANSWERS_REQUIRED(details.missingFields).
Baskets with tickets
Event tickets go in the same basket as products, so a buyer can book two events and a book in one go. A ticket line names the tier:
const items = [
{ sku: "acme-notebook", quantity: 1 },
{
tierId: "tier_general",
quantity: 2,
attendees: [{}, { name: "Sam Lee", email: "sam@example.com", formAnswers: { f_diet: "veg" } }],
},
];
await cobuntu.checkout.create({
items,
events: { evt_1: { chatOptOut: true } },
donation: { amount: 500, eventId: "evt_1" },
taxId: { value: "PT123456789", country: "PT" },
successUrl,
cancelUrl,
});attendees[i]is place i of the line. Missing places default to the buyer; the first place per event is the buyer's own, the rest are companions.- Form answers for a tier go per place in
attendees[].formAnswers. A missing answer isFORM_ANSWERS_REQUIREDwithdetails.missingFieldsanddetails.ticketIndex(the place across the whole order). - Up to 10 places across all events (
TOO_MANY_TICKETS). The same tier twice isDUPLICATE_TIER: raise the quantity instead. eventsholds per-event options (chatOptOut,invitationToken) keyed by event id. One donation per order, optionally tied to an event in the basket.- Tiers that need approval are charged now and held until the host decides. A declined place is refunded, net of the payment fee.
taxIdis optional. It is used only when both fields are filled and prints on the invoice. Show it withVatInputfrom@cobuntu/embed-react.
The quote prices ticket lines like any other. Each quote line has index (its
position in items), kind (TICKET or PRODUCT), eventId and
requiresApproval, so match lines by index.
Paying in steps
Some baskets cannot be paid in one checkout: an instalment or recurring ticket is
a subscription, and a physical item ships on its own. The quote always answers,
with groups:
"groups": [
{ "lines": [1, 2], "mode": "payment", "reason": "MAIN", "requiresAccount": false, "total": 4500 },
{ "lines": [0], "mode": "subscription", "reason": "INSTALMENTS", "requiresAccount": true, "total": 9000 }
]One group means one checkout, as before. With more, show the buyer the steps ("Payment 1 of 2") and pay them one at a time:
- Send the MAIN group's items (with its matching
eventsanddonation) as onecheckout.create. - Keep the other lines in the basket. After the buyer returns from payment, bring them back to the basket for the next step.
- A group with
requiresAccount: trueneeds a signed-in buyer: ask them to sign in first (a guest getsSESSION_REQUIRED).
If you call checkout.create with the whole basket anyway, it answers
400 BASKET_NEEDS_SPLIT with the same groups. In @cobuntu/embed that is a
BasketNeedsSplitError; basketSplitGroups(e) returns the groups MAIN first, and
splitBasket(items, groups) from @cobuntu/embed-react gives each step's items.
5. The account page
// app/account/purchases/page.tsx (server component)
const { purchases } = await cobuntuForRequest().me.purchases({ limit: 20 });Each purchase lists its downloads. A LINK deliverable carries its url. A
FILE goes through your server, because the download needs the secret key and
counts against the variant's download limit:
// app/api/download/[saleId]/[attachmentId]/route.ts
import { cobuntuForRequest } from "@/lib/session";
export async function GET(_req: Request, { params }: { params: Promise<{ saleId: string; attachmentId: string }> }) {
const { saleId, attachmentId } = await params;
const link = await cobuntuForRequest().me.purchaseDownload(saleId, attachmentId);
return Response.json(link); // { url, expiresAt, remainingDownloads }; the url lasts about 5 minutes
}Invoices and receipts work per sale, for products, tickets and memberships alike:
// app/api/invoice/[saleId]/route.ts
export async function GET(_req: Request, { params }: { params: Promise<{ saleId: string }> }) {
const { saleId } = await params;
return Response.redirect(await cobuntuForRequest().me.invoice(saleId)); // INVOICE_NOT_AVAILABLE until issued
}
// app/api/receipt/[saleId]/route.ts
export async function GET(_req: Request, { params }: { params: Promise<{ saleId: string }> }) {
const { saleId } = await params;
const pdf = await cobuntuForRequest().me.receipt(saleId);
return new Response(pdf, { headers: { "Content-Type": "application/pdf", "Cache-Control": "private, no-store" } });
}downloads is empty while a purchase grants no access (refunded, or awaiting
approval). Download codes: DOWNLOAD_LIMIT_REACHED (details.maxDownloads),
ACCESS_REVOKED, NOT_APPROVED, NOT_A_FILE.
6. Refunds
refundable and refundWindowEndsAt on each purchase tell you when to offer it.
await cobuntuForRequest().products.refund(purchase.product.sku!, { saleId: purchase.saleId, reason: "Arrived damaged" });amount (smallest unit) asks for a partial refund; omit it for the full amount.
Eligibility is checked again on the server: NOT_ELIGIBLE carries the reason
(window over, free order, already refunded), REFUND_PENDING means one is already
open.
7. Guest orders
A guest who bought without an account gets a Manage your order link in the
order email. For a site that runs on Embed only, the link points at
<your site>/orders/manage?token=..., so serve that page:
// app/orders/manage/page.tsx (server component)
import { cobuntu } from "@/lib/cobuntu";
export default async function ManageOrder({ searchParams }: { searchParams: Promise<{ token?: string }> }) {
const { token } = await searchParams;
const order = await cobuntu.sales.manage(token ?? "");
// order.product, order.amount, order.refundable, order.invoiceUrl, order.hasReceipt
}- Receipt:
cobuntu.sales.manageReceipt(token)returns the PDF. - Refund:
cobuntu.products.refund(sku, { saleId: order.saleId, reason, token }). - Link codes:
INVALID_LINK,LINK_EXPIRED(after 90 days),UNSUPPORTED_LINK,LINK_MISMATCH,LINK_NOT_FOUND.
Members get no link: they see their orders under me.purchases.
Money rules
Nothing here adds money logic of its own. Checkout and quote use the same revenue split, commission, member pricing, stock and postage as purchases in the community app, and refunds follow the same refund rules and fee policy.