Guides
Commerce
Run a marketplace

Run a marketplace

Requires: A publishable key with READ_PUBLIC for browsing; a secret key with WRITE_SALES for checkout and MEMBER_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

ScopeFor
READ_PUBLICBrowse, facets and product pages. A publishable key is enough.
WRITE_SALESCheckout, quote, buyer refunds and the guest order routes.
MEMBER_AUTHme.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 nextCursor until hasMore is false.
  • Card price: the cheapest live variant, else the product price. price, minPrice and maxPrice filter on it, and cardPrice() in the React package computes it the same way.
  • Seller: seller.type is community when the community itself sells it, member when 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
idSend it as tierId at checkout.
product.price, product.currencyThe variant's list price.
capacity, remaining, soldOutStock, counted the same way checkout enforces it. capacity: null is uncapped.
isOnSale, stateThe sales window. A scheduled variant still shows, as not on sale.
memberPrice, memberPriceModeThe signed-in member's own price (only with a session).
publicMemberOffersPrices for members of each tier, shown to everyone.
priceMode, pwywMinAmountPay what you want, and its minimum.
hasFormFetch 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: true and no checkoutUrl.
  • On the thanks page, checkoutSessions.get(session) says paid or pending.
  • 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, send shippingAddress: { country }: a country the seller does not ship to is refused up front with SHIPPING_COUNTRY_NOT_SERVED and details.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 is FORM_ANSWERS_REQUIRED with details.missingFields and details.ticketIndex (the place across the whole order).
  • Up to 10 places across all events (TOO_MANY_TICKETS). The same tier twice is DUPLICATE_TIER: raise the quantity instead.
  • events holds 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.
  • taxId is optional. It is used only when both fields are filled and prints on the invoice. Show it with VatInput from @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:

  1. Send the MAIN group's items (with its matching events and donation) as one checkout.create.
  2. Keep the other lines in the basket. After the buyer returns from payment, bring them back to the basket for the next step.
  3. A group with requiresAccount: true needs a signed-in buyer: ask them to sign in first (a guest gets SESSION_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.