NEWSupero Studio — generate, control, and deploy production apps.Start free →HIRINGSee the roles →

Order-to-cash: six vendors, no vendor SDKs

Cart, checkout, payment, inventory and the six vendors around them — wired as configuration instead of glue code.

IntegrationsBuild estimate 1 day11 min readAugust 25, 2026

Six vendors, zero vendor SDKs, and no state machine you wrote yourself. The lifecycle is a base schema you extend, the vendors are catalog entries you configure, and the side effects are workflows bound to data events.

The brief

What we are building

A back office for a mid-size retailer that sells online and in two showrooms. Customers browse a catalog, build a cart, check out, pay by card or UPI, and get a confirmation. Staff see the order land, allocate stock, mark it fulfilled, and occasionally refund it. Finance wants an invoice raised automatically; operations wants a Slack ping when a high-value order arrives.

That is the classic order-to-cash shape, and it is the classic integration swamp: a payment gateway, a transactional email provider, an SMS provider, a chat provider, an accounting system, and object storage — each with its own SDK, auth model, retry semantics and webhook signature scheme. Plus the part nobody enjoys: an order state machine that must never let a cart check out twice or a payment capture twice.

6
external vendors, zero vendor SDKs installed
0
lines of state-machine code written
~1 day
typical build for one developer
The solution

What it does

Storefront + admin, one app

Roles decide the surface. Staff land on the admin console; customers land on the storefront — the platform derives ui_view at login, so no role-string checks in your UI code.

A real cart

Line items, quantity upsert, recomputed totals, and a cart that can only be checked out once. open → checked_out is enforced server-side.

Cards and UPI

Hosted Stripe Checkout for cards and wallets; Razorpay for UPI and net-banking. Both are catalog services — you configure keys, not SDKs.

Stock with real reservations

The inventory service tracks total and available quantity per SKU and holds reservations against an order until they are committed or released — a reservation short of stock is refused rather than going negative.

Confirmations that fire themselves

Email, SMS and a Slack ops alert run as workflow steps bound to a data event — no controller code calls them.

Invoices without a nightly job

An invoice is raised as a workflow step the moment payment lands, then stamped back onto the order so the UI can show it.

How it fits together

Architecture at a glance

One governed boundary — and why the vendors are not on it
YOUR DATA & SOURCESWHAT CONSUMES ITSupero projecttenant-isolated · role-enforcedcart · order · payment · inventoryworkflows + event bindingsProduct catalognative schemasExisting ERP stockdatabase connectorSupplier price fileCSV · S3Storefront eventsinbound webhookStorefront (web)customer ui_viewAdmin consoleadmin ui_viewTyped SDKsTS · PythonIn-app AI agentschema-derived tools
The vendors are not on this diagram on purpose — from the app's point of view Stripe, SendGrid and Twilio are operations on the services catalog, not systems it integrates with.
What you author vs. what the platform owns
YOU AUTHORApp bundleschemas.pyconfig.pysetup.pyui_schemas.pyYOU CONFIGUREServices & keyscart · orderpayment · inventorystripe · billing · razorpayemail · sms · slack · s3PLATFORM OWNSExecution & governancestate machinesidempotencyRBAC + tenancyaudit log
The mechanism

What actually happens on the wire

Two flows carry most of the app. The first is checkout-to-paid; the second is the automation that fires afterwards. Note that only lifecycle mutations go through /api/v1/services/execute — reads and lists stay ordinary CRUD.

Checkout → pay → confirm
StorefrontbrowserSupero APIapi.supero.devServicescart · order · paymentStripehosted checkoutRecordsCRUD storeBUILD THE CART1cart.add_to_cart {product_uuid, quantity, unit_price}2route + validate input3upsert line · create customer + cart if absent4record_uuid, running totals5cart.recompute_totals {cart_uuid}recompute_totals is naturally idempotent — safe to call on every quantity changeCHECK OUT6cart.checkout_cart {cart_uuid, extending schemas}7guard: is `open` a legal source state?8cart → checked_out · create Order (pending)9order_uuid, status: checked_outTAKE THE MONEY10stripe_checkout.create_checkout_session11create session (keys read from the credential store)12hosted checkout URL → redirect13customer pays · session completed14payment.authorize_payment → capture_payment15order.place_order {order_uuid}16order pending → confirmedthe confirmed write is a data event — see the next diagram
A retry of checkout_cart on an already-checked-out cart returns the same order rather than creating a second one. add_to_cart is the one to be careful with: send the line’s intended quantity and pass a stable client_line_id so a retry is treated as the same add rather than a second line.
The automation that fires itself
Order recordns:orderEvent binding@updateWorkflowwf:workflowsCatalog servicesemail · sms · slack · billing1order written → @update:shop:order2resolve input_map (order.customer.email hydrates 2 hops)3validate input_schema before any step runs4condition: payment_state == paid ?PARALLEL STEP5email.send_email · sms.send_sms · slack.send_message6per-step status + duration, each logged7billing.create_invoice {customer, amount, due_date}8crud_operation: stamp workflow_status = processedthe run is a wf:workflow_instance record — query it like any other data
Nothing in your application code calls email, SMS or Slack. The binding fires on the data write itself, which means the same automation runs whether the order was confirmed from the storefront, the admin console, the API, or the AI agent.
The state machine you did not write
ORDER LIFECYCLE · ENFORCED SERVER-SIDEpendingcreated at checkoutconfirmedplace_orderfulfilledfulfill_order · terminalcancelledcancel_order · terminalrefundedno order-level verb
cancel_order is legal from pending and confirmed. refunded is a declared order status with no order-level transition into it — refunds are driven on the payment record via payment.refund_payment, and you stamp the order yourself. An illegal operation comes back as HTTP 200 with success:false, error_code: INVALID_STATE_TRANSITION and status_code: 409 in the body — plus a remediation block naming exactly which operations are legal right now.
Under the hood

How it works

The data model is four lines of inheritance

You do not model a cart. You declare that your cart extends the platform's cart, and inherit its fields, its status field and its state machine — then add whatever is yours.

python
# schemas.py — the whole commerce lifecycle, opted into by name
Cart      = {"schema_type": "object", "name": "Cart",     "namespace": "shop",
             "parent_type": "tenant", "extends": "cart:base_cart",
             "fields": [{"name": "gift_wrap", "type": "boolean", "default-value": False}]}

CartItem  = {"schema_type": "object", "name": "CartItem", "namespace": "shop",
             "parent_type": "tenant", "extends": "cart:base_cart_item", "fields": []}

Order     = {"schema_type": "object", "name": "Order",    "namespace": "shop",
             "parent_type": "tenant", "extends": "order:base_order",
             "fields": [{"name": "channel", "type": "string",
                         "values": ["web", "showroom-1", "showroom-2"]},
                        # base_order has no payment field — declare your own and
                        # stamp it when the payment captures.
                        {"name": "payment_state", "type": "string",
                         "values": ["unpaid", "paid", "refunded"],
                         "default-value": "unpaid"},
                        {"name": "workflow_status", "type": "string"},
                        {"name": "processed_at", "type": "datetime"}],
             "references": [{"name": "Customer", "cardinality": "one",
                             "back_ref_name": "orders"}]}

OrderItem = {"schema_type": "object", "name": "OrderItem", "namespace": "shop",
             "parent_type": "tenant", "extends": "order:base_order_item", "fields": []}

ALL_SCHEMAS    = [Product, Category, Cart, CartItem, Order, OrderItem, Payment, Inventory]
PUBLIC_SCHEMAS = ["product", "category"]        # readable without a login

The vendors are configuration, not dependencies

Seven services, one shape. Each is a service_id you import into the project and configure with keys held in a per-project credential store — encrypted at rest on Supero Cloud, masked on read, never in your repo. Swapping test keys for live keys is a config change with no redeploy and no code change. Note the scope: service credentials belong to the project, so every tenant in a project shares them.

service_idProviderUsed forConfig fields
stripe_checkoutStripeHosted card/wallet checkoutstripe_api_key, stripe_publishable_key
billingStripe BillingInvoices raised from the workflowstripe_api_key, stripe_webhook_secret
billing_razorpayRazorpayUPI, wallets, net-bankingrazorpay_key_id, razorpay_key_secret
emailSendGridOrder confirmation, invoicesendgrid_api_key, default_from_email
smsTwilioDispatch notificationtwilio_account_sid, twilio_auth_token
slackSlackHigh-value order alert to #opsslack_bot_token
amazon_s3AWS S3Product media in your own bucketaws_access_key_id, aws_secret_access_key, default_bucket

The automation is a definition, not a service

There is no workflow engine to deploy. workflows is a tenant service like any other — imported last in your services list — and a workflow is a record you can query. Here is the one from the second sequence diagram, in full:

python
{
  "workflow_id": "order_confirmed_fanout",
  "display_name": "Order Confirmed — notify, invoice, stamp",
  "status": "Active", "enabled": True, "on_error": "continue",
  "input_schema": {
      "order_uuid":     {"type": "string", "required": True},
      "payment_state":  {"type": "string", "required": True},
      "customer_email": {"type": "string", "required": False},
      "customer_uuid":  {"type": "string", "required": False},
      "phone":          {"type": "string", "required": False},
      "order_total":    {"type": "float",  "required": False},
      "due_date":       {"type": "string", "required": False},
  },
  "steps": [
    {"id": "paid?", "type": "condition",
     "condition": "{{input.payment_state}} == paid",
     "then_steps": [
       {"id": "tell_everyone", "type": "parallel", "steps": [
         {"id": "mail",  "type": "service_call", "service": "email", "operation": "send_email",
          "on_error": "continue",
          "input_map": {"to_email": "{{input.customer_email}}",
                        "subject":  "Your order is confirmed",
                        "body_html": "<p>Thanks — we're packing it now.</p>"}},
         {"id": "text",  "type": "service_call", "service": "sms", "operation": "send_sms",
          "on_error": "continue",
          "input_map": {"to_number": "{{input.phone}}", "body": "Order confirmed."}},
         {"id": "ops",   "type": "service_call", "service": "slack", "operation": "send_message",
          "on_error": "continue",
          "input_map": {"channel": "#ops", "text": "New order {{input.order_uuid}}"}},
       ]},
       # billing keeps its own customer record — create it, then bill it.
       {"id": "bill_cust", "type": "service_call", "service": "billing",
        "operation": "create_customer",
        "input_map": {"email": "{{input.customer_email}}"}},
       {"id": "invoice", "type": "service_call", "service": "billing", "operation": "create_invoice",
        # note the double .output.output hop for a service_call result
        "input_map": {"customer_uuid": "{{steps.bill_cust.output.output.record_uuid}}",
                      "amount": "{{input.order_total}}", "currency": "USD",
                      "due_date": "{{input.due_date}}"}},
       # last step stamps the record so the UI can render a status chip
       {"id": "stamp", "type": "crud_operation", "operation": "update",
        "object_type": "shop:order", "record_uuid": "{{input.order_uuid}}",
        "data": {"workflow_status": "processed", "processed_at": "{{context.timestamp}}"}},
     ],
     "else_steps": []}
  ],
}

And the binding that makes it fire on its own. customer.email is a two-hop path: the platform sees customer is a reference on the order, fetches that record, and reads email off it — which is why the Order schema above declares that reference. A path that does not resolve is silently omitted rather than failing the trigger, so a missing reference shows up as a workflow that runs and quietly does nothing.

json
[
  {"event": "@update:shop:order",
   "workflow_id": "order_confirmed_fanout",
   "input_map": {"order_uuid":     "uuid",
                 "payment_state":  "payment_state",
                 "order_total":    "total_amount",
                 "due_date":       "due_date",
                 "customer_email": "customer.email",
                 "phone":          "customer.phone"}}
]
Step by step

How to build it on Supero

Describe it, and read what came back

In App Studio, describe the product in plain language: "A retail back office. Customers browse products by category, add to cart, check out and pay by card or UPI. Staff allocate stock and mark orders fulfilled. Send an email and SMS on confirmation and a Slack alert for orders over $1,000."

Supero infers the schema, picks a starting set of transactional services, and recommends the integrations to wire — pre-selected, never applied silently. Read the proposed schema before you accept it — the inference is good at shape and unreliable at cardinality, which is the thing to check.

⏱ ~10 minutes

Pin the lifecycle to platform services

Make sure each lifecycle entity extends its platform base rather than re-declaring a status field — that is the difference between inheriting a tested state machine and owning a buggy one. cart, order, inventory (and approval, loyalty_points, document_signature) are two-schema services: a header and a line/step/item schema.

bash
# what am I allowed to call, and from which state?
curl https://api.supero.dev/api/v1/services/transactional-catalog \
  -H "X-API-Key: $SUPERO_API_KEY"

That one call returns, per transactional service, its operations with an integer idempotency category and a state → allowed-operations map. Drive your action buttons from it instead of hardcoding a transition map in the browser.

⏱ ~20 minutes

Import and configure the seven services

Add each service_id to config.py's services list — with "workflows" last — then set keys from the Services page in the dashboard, or over the API. A service the workflow calls but you never imported fails only at run time, so check the list against your workflow steps.

bash
curl -X POST https://api.supero.dev/api/v1/services/import \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "service_id": "email",
    "domain": "acme-corp",
    "project_uuid": "8f2a1c40-...",
    "config_data": { "sendgrid_api_key": "SG.xxxx", "sandbox_mode": true }
  }'

⏱ ~45 minutes, mostly waiting on vendor dashboards

Register the workflows and their bindings

Pass both to AppSetup(...).run() so they live in source control alongside the schema:

python
def main():
    setup = AppSetup(AppConfig(), ALL_SCHEMAS, PUBLIC_SCHEMAS)
    setup.run(
        seed_fn=seed_catalog,
        policies=POLICIES,
        workflow_definitions=[ORDER_CONFIRMED_FANOUT, WELCOME_ON_SIGNUP],
        event_bindings=EVENT_BINDINGS,
    )

Then dry-run the graph before you let it touch a vendor. validate_workflow walks the step graph without executing anything — it catches unknown step types, duplicate step ids, bad on_error values and missing per-type fields, and reports which services the workflow references. It does not confirm those services are imported or that the operations exist; a misspelled operation surfaces only at run time.

⏱ ~30 minutes

Wire the storefront to the operations

Register your extending schemas once, then call operations by name. The client injects the qualified schema names into every call so you never repeat them.

javascript
client.transactional.registerExtensions({
  cart:  { extending_schema: 'shop:cart',
           cart_item_extending_schema: 'shop:cart_item',
           customer_extending_schema:  'shop:customer' },   // needed by add_to_cart
  order: { extending_schema: 'shop:order',
           order_item_extending_schema: 'shop:order_item' },
});

await client.transactional.cart.addToCart({ productUuid: sku, quantity: 1, unitPrice: 42.0 });
const { order_uuid } = await client.transactional.cart.checkout(cartUuid);
await client.transactional.order.fulfill(order_uuid, 'shipped via courier');

Failures arrive as typed errors you can branch on rather than status codes you have to parse: TxnStateConflictError (409 — inspect err.currentState), TxnNotFoundError, TxnBadRequestError, TxnPlatformError (transient), TxnSoftFailureError (a 200 body with success:false).

⏱ ~1 hour

Set access policy per role

One PolicyDef per role: a default_access fallback plus per-entity overrides. A rule can scope rows to their owner, which is how "my orders" stops being forgeable — the platform stamps the caller rather than trusting a client-supplied id.

python
POLICIES = [
    PolicyDef(role="tenant_admin", default_access="full", rules=[]),
    PolicyDef(role="tenant_user",  default_access="none", rules=[
        PolicyRule(entity="product",  can_read=True),
        PolicyRule(entity="category", can_read=True),
        PolicyRule(entity="order",    can_read=True,
                   filter_field="owner_username", filter_match="$user.name"),
    ]),
]

⏱ ~20 minutes

Preview, gate, deploy

Run the live preview first — the whole stack in the browser with no deploy. Then run the quality gates (a bundle preflight, a smoke test that the deployed URL actually loads and reads data, and the behavioural suite) before you take a URL public.

When it is ready, ship it: a managed supero.live URL, your own AWS account, your own GCP project, or a self-hosted platform if compliance requires it. Be precise about what that means — in the bring-your-own-cloud options only the app’s UI container runs in your account; auth, data, logic and AI generation stay on the Supero backend. Full sovereignty is the self-hosted platform, where the integrations catalog, connectors and AI agent are separately licensed add-ons.

⏱ ~30 minutes — plus most of a day reading the generated schema, seeding real-shaped data, testing as a non-admin and iterating on the storefront. That is where the rest of the day goes.

Know the edges

What this build does not give you

  • Payment reconciliation is yours. The confirmation hop runs in the browser, so a customer who closes the tab after paying leaves an order in pending until your own sweep catches it. Budget for it; it is the thing that quietly breaks first.
  • Vendor credentials are per project. Add a second brand under the same project and both share one Stripe account. A brand that needs its own gateway needs its own project.
  • No built-in fraud, tax or shipping-rate logic. Those are integrations you add or services you write, not things the lifecycle gives you.
  • Partial refunds are a payment-record concern. order declares a refunded status but no verb transitions into it — you drive payment.refund_payment and stamp the order yourself.
  • The generated storefront is a starting point. Nobody ships generated styling on a customer-facing surface. That is the line item most likely to double.
The economics

Time to deliver

PhaseOn SuperoRails + Stripe + six SDKs
Data model + CRUD APIs + admin screens~30 min (generated)1–2 weeks
Cart/order/payment state machines + idempotency0 — inherited via extends2–3 weeks
Seven service integrations + credential handling~45 min of configuration3–4 weeks
Transactional email/SMS/Slack fan-out~30 min (one workflow)1 week
Multi-tenancy, RBAC, audit logging0 — platform default3–6 weeks
Admin console~1–2 h on the generated surface2–3 weeks
Customer-facing storefront~1–2 h generated; 1–3 days if you restyle it2–3 weeks
Payment reconciliation sweep (yours to build)~2 hSame — nobody gets this free
Total~1 day~3 months

What you never wrote

  • A transition table, and the 409 that has to come back when someone double-clicks Checkout.
  • Idempotency keys, and the decision about which operations are safe to retry (the platform classifies every operation as state-guarded, naturally idempotent, or cumulative).
  • Six vendor SDK upgrade paths, and six auth models to keep current.
  • A secrets story — the per-project credential store is where keys already live.
  • Tenant scoping on every query, and the test that proves it does not leak.
  • A retry/compensation path for the half-failed notification fan-out.
The takeaway

Integration complexity is mostly accidental

Nothing above removed the essential difficulty of commerce. You still have to decide what a refund means for your business and when stock is really committed. What it removed is the accidental difficulty: six SDK upgrade paths, six auth models, and a hand-written state machine.

The lifecycle became a base schema you extend. The vendors became rows in a catalog. The side effects became a definition bound to a data event. That is the pattern, and it generalises.

Delivery estimates in this guide are for the scope described, built by one experienced developer with any vendor sandbox credentials already in hand. Treat them as a realistic starting point, not a commitment — and check the documentation for the authoritative behaviour of any API named here.

Build this on Supero

Describe it and get schemas, APIs, SDKs and a governed multi-tenant backend — or point your own AI editor at Supero over MCP and build it there.