S
supero.docs
Documentation/Application Architectures/Multi-Vendor Marketplace
Solution Architecture

Multi-Vendor Marketplace

Two audiences on one dataset: sellers who list and shoppers who buy, both modelled by extending customer:base_customer, with one catalog type that references its seller. What the platform provides, and where the two-sided economics remain yours.

A marketplace where seller and shopper both extend the customer base type over one shared catalog
Publish this elsewhere:

The Project

A marketplace is a storefront with the seller side made into data. Instead of one catalog owned by the operator, there are many catalogs owned by parties the operator onboards, approves and can suspend. The shopper experience is nearly identical to a single-seller store; almost all of the additional work sits on the other side of the transaction.
Reach for this shape when the answer to "who owns this listing" is a record rather than a constant, and when at least some of the following are true.
  • •Third parties create and price their own listings, and you review them before they go live.
  • •A shopper's cart can legitimately contain items from more than one seller.
  • •Reviews attach to a listing, and you care whether the reviewer actually bought it.
  • •Sellers need a profile with public branding — a store name, a logo, a banner, contact details.
  • •You need to answer "what did this seller sell last month" as a query, not a report you assemble by hand.

ℹ️ The exemplar behind this page

This page follows bundle_m70-voltmart, a generated multi-seller electronics marketplace in the namespace voltmart. It carries 33 schemas and imports eleven services. Where the page describes platform behaviour, that application demonstrates it; where the page says the work is yours, that application had to do it too.

Architecture & Design

The interesting move in a marketplace model is smaller than people expect. There is no seller base type and no vendor service. Both parties are customers: seller and shopper each extend customer:base_customer, and they differ only in the fields they add and the references that point at them.
json
{
  "name": "seller",
  "namespace": "voltmart",
  "schema_type": "object",
  "schema_content": {
    "name": "seller",
    "parent_type": "tenant",
    "extends": "customer:base_customer",
    "attributes": [
      {"name": "store_name",    "type": "string"},
      {"name": "store_logo",    "type": "Image"},
      {"name": "store_banner",  "type": "Image"},
      {"name": "description",   "type": "string"},
      {"name": "contact_email", "type": "string"},
      {"name": "contact_phone", "type": "string"},
      {"name": "is_approved",   "type": "boolean"}
    ]
  }
}
The shopper is the same declaration with two fields instead of seven. Ownership of a listing is then a reference on the catalog type rather than a separate per-seller schema: voltmart defines one product type extending product:base_product, and that type references Seller with the role actor. One catalog, partitioned by a reference — not thirty catalogs.
Type in voltmartExtendsNotes
sellercustomer:base_customerstore branding plus is_approved
shoppercustomer:base_customerthe buying side of the same base type
productproduct:base_productreferences Seller as actor; adds brand, model, condition, stock_quantity, a photo list and is_visible
cartcart:base_cartstatus of open, checked_out, abandoned or expired
cart_itemcart:base_cart_itemparented to cart; references Product as primary_item
orderorder:base_orderreferences Shopper as actor; adds order_number and a shipping address
order_itemorder:base_order_itemparented to order
paymentpayment:base_paymentreferences Order as primary_item; adds transaction_id and paid_at
reviewfeedback:base_feedbackreferences Product as primary_item and Shopper as actor; adds is_verified_purchase
attachmentattachment:base_attachmentfile records for listing media
Two further schemas, product_category and product_condition, are plain value lists backing the ProductCategory and ProductCondition field types. The remaining schemas in the bundle arrive with the services — the email service brings campaign, template, sender, message and suppression, Stripe Checkout brings session and payment_link, and the workflow engine brings its three record types.

⚠️ Seller is a record, not a role

The platform role set is fixed. Eight are yours to assign: domain_admin, domain_user, project_admin, project_user, developer, tenant_admin, tenant_user and viewer. Two more, platform_admin and platform_user, are Supero-operator roles and are not assignable to your end users. There is no seller role and no vendor role, and adding one to your schema does not create one. A seller is a record that a user account is associated with, and what that user may read or write is decided by access policy — not by the existence of the seller row. Read Roles & Permissions before you design the onboarding flow, not after.

The End-to-End Flow

The buying half is the storefront flow unchanged: the cart, order and payment services do not know or care that listings have different owners. The selling half is CRUD plus whatever policy you attach to it. Everything transactional goes through POST /api/v1/services/execute with a service_id, an operation and an input naming the extending schema.
  1. 1

    Onboard a seller

    A seller record is created with is_approved false. Nothing in the platform enforces that flag — see the next section — so this step is where your own gate belongs, whether that is an admin action, an approval workflow, or an access policy that only permits listing creation once the flag is set.

  2. 2

    List a product

    The seller creates product records referencing themselves as actor. price, currency, sku and the active/inactive/discontinued status all come from base_product; brand, model, condition, stock_quantity and the photo gallery are voltmart's own additions.

  3. 3

    Browse

    Shoppers read the catalog through the ordinary CRUD surface with cursor-based pagination — a limit and a cursor, never page and page_size, which are silently ignored. Filtering to one seller is a query on the reference.

  4. 4

    Add to cart

    cart / add_to_cart. The first call materialises the cart and ensures the shopper has a customer profile, which means the shopper's access policy has to permit that create. Items from different sellers land in the same cart with no complaint.

  5. 5

    Check out

    cart / checkout_cart converts the cart into one order and copies the cart_item rows into order_item rows. One cart produces exactly one order, regardless of how many sellers contributed items to it.

  6. 6

    Take payment

    stripe_checkout / create_checkout_session, then the payment service tracks it: authorize_payment, capture_payment, and refund_payment if needed. The payment references the order, and carries a single amount.

  7. 7

    Fulfil

    order / place_order, fulfill_order and cancel_order run the order state machine — pending, confirmed, fulfilled, cancelled, refunded — stamping confirmed_at, fulfilled_at and cancelled_at as they go. An illegal transition returns 409 with INVALID_STATE_TRANSITION rather than being applied.

  8. 8

    Review

    The shopper writes a review referencing the product and themselves. base_feedback provides the draft, published, hidden, flagged and deleted states, so moderation is a transition rather than a delete.

What You Get Without Building It

VoltMart imports eleven services: cart, order, payment, product, customer, feedback, notification, attachment, email, stripe_checkout and workflows. What that covers:
  • •The entire buy-side transaction — cart materialisation, totals, cart-to-order conversion, the payment lifecycle — with idempotent operations and guarded transitions.
  • •Two distinct customer types over one base type, so both parties inherit the same profile machinery without a shared table you have to discriminate on.
  • •Stripe Checkout wired as an integration: stripe_api_key and stripe_webhook_secret held in the keystore and masked on read, rather than an SDK in your code.
  • •SendGrid email with campaign, template, sender, message and suppression modelled, plus the notification service for in-app messages.
  • •An attachment type for listing media, with file storage on a bucket you control.
  • •An admin panel generated per type — separate seller and shopper sections, plus product, order, payment, cart and review — from the base admin UI schemas, and a public landing page built from hero, catalog, product-detail, cart and order-detail blocks.
  • •Metric cards and charts as records: VoltMart ships total revenue, active orders, active products, a revenue trend and orders by status.
  • •Tenancy, ownership and the platform roles underneath all of it, with fq_name as the record address rather than a tenant column in your schema.
For the shopper, that is close to the whole application. For the operator, it is the substrate — see below.

How to Implement in Supero

Two kinds of work. First you model the shape above and point each type at its base type; then you write the policy the base types deliberately leave to you.

Model it

  1. 1

    Build the schema

    Describe the marketplace in App Studio — it generates the schemas and a reviewable UI — or author it over MCP from your editor. Aim at the object model above: a type opts into a service by extending its base type (cart:base_cart, order:base_order, payment:base_payment, and customer:base_customer twice, for buyer and seller). Schemas are live on the CRUD API the moment they upload — there is no separate build step.

  2. 2

    Configure the services

    Imported services arrive uncredentialed. Put each provider’s keys in the keystore — here that is Stripe for checkout, plus email/SMS for notifications — see Email & Notifications. Until a service is credentialed, its calls fail with a clear error rather than silently doing nothing.

What you write yourself

A marketplace is defined by the things a single-seller store does not have to think about, and those are precisely the things the platform does not decide for you. Read this section before you scope the project.

⚠️ There are no payouts, no commission and no split settlement

The payment type carries one amount, one currency and one reference to one order. Nothing in the object model represents a platform fee, a seller balance, a payout run or a split of funds between the operator and the seller. Money arrives in the account behind your Stripe keys, in full, once. If your marketplace takes a cut and remits the rest, that is a set of types you add — a ledger entry, a payout, a fee schedule — plus a workflow that writes them, plus whatever settlement mechanism your payment provider offers. Do not budget for it as configuration.
DecisionThe state of it in the exemplar
Seller approvalis_approved is a boolean nobody checks. Nothing stops an unapproved seller from creating listings. The gate is an access policy or a workflow you write.
One order, many sellersA cart spanning three sellers checks out into one order with one status. Per-seller sub-orders, independent fulfilment and per-seller cancellation are a modelling decision — most often a child type under order — not a service feature.
Stockstock_quantity is mandatory with a minimum of zero, and that is all it is. Nothing decrements it at checkout and nothing refuses a purchase at zero. The platform takes no lock on inventory; overselling is prevented by logic you write.
Verified purchaseis_verified_purchase on a review is a flag you set. Correlating the reviewer with an order_item is your query.
Seller data isolationPreventing one seller from reading another's orders is an access-policy question, and it is the highest-consequence one in this shape. Model it explicitly and test it explicitly.
Shipping and taxThe order carries shipping_address, shipping_city, shipping_state, shipping_zip, and inherits tax_amount, shipping_amount and discount_amount. Rates, carriers and jurisdictions are yours.
Dispute and refund policyrefund_payment is an operation, and it is cumulative rather than idempotent — call it twice and it refunds twice, so a retried or double-submitted refund needs its own guard (a dedup key, a status check). capture_payment, by contrast, is a state transition: capturing an already-captured payment changes nothing. Who may refund, within what window, and whether a seller is debited, are all yours.
Most of these want a workflow rather than application code. A workflow is a record of type wf:workflow_definition, its steps are service_call, crud_operation, condition, parallel, transform or loop, and an event binding can fire it on a CRUD event — for example on creation of an order — with an input map from the event's fields to the workflow's inputs. Each run records a workflow_instance and one workflow_step_log per step, so a payout that failed halfway is diagnosable. See Workflows & Automation and the Workflow Engine Reference.

💡 Run the order lifecycle as a saga

A step can declare a compensating operation — the inverse it runs if a later step fails, for example refund_payment reversing a capture — and the workflow's error policy can be set to compensate. This turns the order lifecycle into a saga: reserve, charge, fulfil, and if fulfilment fails, the workflow walks back the completed steps rather than leaving an order charged-but-unshipped. Two honest edges to design around: the compensating walk runs once and then stops (it is not retried, so a failed rollback surfaces on the run's status for you to alert on, not through a built-in queue), and because refund is cumulative you still supply the idempotency guard above. Capacity and locking are likewise yours — a workflow is an executor, not a transaction.
One structural alternative worth considering before you start: if sellers should not see each other at all, tenants may be a better boundary than access policy. Building Multi-Tenant Apps covers the trade-off — isolation is stronger, but a shopper browsing across sellers becomes harder.

Deployment

The path from this shape to a running marketplace at a public URL, end to end:
  1. 1

    Preview while you iterate

    A preview deploy answers at https://preview-{domain}-{project}.supero.live for a short lease while you build and test the order flow. Publish Your App covers how preview and live differ.

  2. 2

    Go Live

    Go Live publishes to a permanent, Supero-hosted URL — https://live-{domain}-{project}.supero.live — with no cloud account to connect and nothing to provision. It is asynchronous: you get a 202 and a poll URL, then the live address. The full contract is in Publish Your App.

  3. 3

    Watch the logs

    When a deploy fails or the live app misbehaves, the **Cloud Deploy Logs** page in the dashboard shows the published service’s container logs — pick the Live lane to read the published app rather than the preview build (up to ~24 hours back, ~500 lines per request). For platform activity — requests, auth, errors across your domain — GET /api/v1/logs is a filtered read, and a single request traces across services at /api/v1/logs/trace/{correlation_id} when you set your own X-Correlation-ID. See Operations & Observability.

Final Thoughts

Reach for this shape when the answer to "who owns this listing" is a record rather than a constant. One structural alternative is worth weighing before you start: if sellers should not see each other at all, tenants may be a better boundary than access policy — the isolation is stronger, but a shopper browsing across sellers becomes harder.
If you take one thing into the build, take this: preventing one seller from reading another's orders is an access-policy question, and it is the highest-consequence one in this shape. Model it explicitly and test it explicitly — and remember there are no payouts, no commission and no split settlement in the object model, so any cut you take is a set of types you add.
And what you walk away with divides cleanly. What the platform provides — the buy-side transaction, the state machines, Stripe and SendGrid as configured integrations — is the substrate you build on. What remains yours is the schema, the access policy, and the two-sided economics above them.