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.
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.
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.
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.
Architecture at a glance
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.
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.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.
# 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 loginThe 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_id | Provider | Used for | Config fields |
|---|---|---|---|
stripe_checkout | Stripe | Hosted card/wallet checkout | stripe_api_key, stripe_publishable_key |
billing | Stripe Billing | Invoices raised from the workflow | stripe_api_key, stripe_webhook_secret |
billing_razorpay | Razorpay | UPI, wallets, net-banking | razorpay_key_id, razorpay_key_secret |
email | SendGrid | Order confirmation, invoice | sendgrid_api_key, default_from_email |
sms | Twilio | Dispatch notification | twilio_account_sid, twilio_auth_token |
slack | Slack | High-value order alert to #ops | slack_bot_token |
amazon_s3 | AWS S3 | Product media in your own bucket | aws_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:
{
"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.
[
{"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"}}
]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.
# 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.
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:
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.
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.
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.
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
pendinguntil 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.
orderdeclares arefundedstatus but no verb transitions into it — you drivepayment.refund_paymentand 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.
Time to deliver
| Phase | On Supero | Rails + Stripe + six SDKs |
|---|---|---|
| Data model + CRUD APIs + admin screens | ~30 min (generated) | 1–2 weeks |
| Cart/order/payment state machines + idempotency | 0 — inherited via extends | 2–3 weeks |
| Seven service integrations + credential handling | ~45 min of configuration | 3–4 weeks |
| Transactional email/SMS/Slack fan-out | ~30 min (one workflow) | 1 week |
| Multi-tenancy, RBAC, audit logging | 0 — platform default | 3–6 weeks |
| Admin console | ~1–2 h on the generated surface | 2–3 weeks |
| Customer-facing storefront | ~1–2 h generated; 1–3 days if you restyle it | 2–3 weeks |
| Payment reconciliation sweep (yours to build) | ~2 h | Same — 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.
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.
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.