Case Study: A Subscription Business, End to End
IronWorks Three — 39 schemas, sixteen platform services, and a workflow that fires the moment a class is booked. Including the billing loop the platform does not run for you.
The Brief
app_m71-gym3 in ~/supero-test/e2e-tests/apps/app_m71-gym3 — "IronWorks Three", namespace ironworks_three, domain m71-corp. A real generated bundle with 39 schemas; everything named here is read out of it.The Model
"schema_source": "app" — and twenty-four arrived with the services it imports, marked "schema_source": "service". That ratio is the story of the build: most of the model was not written.{"name": "member_subscription",
"namespace": "ironworks_three",
"schema_type": "object",
"parent_type": "tenant",
"extends": "membership:base_membership",
"attributes": [
{"name": "billing_interval", "type": "string", "default": "month"},
{"name": "auto_renew", "type": "boolean", "default": True},
{"name": "subscribed_at", "type": "string"},
{"name": "owner_username", "type": "string", "role": "owner"}],
"references": [
{"name": "MembershipPlan", "cardinality": "one",
"back_ref_name": "subscriptions", "role": "primary_item"},
{"name": "MemberProfile", "cardinality": "one",
"back_ref_name": "subscriptions", "role": "actor"}]}joined_at, suspended_at, expires_at, suspension_reason — comes from the base type.| App type | Extends | Its own contribution |
|---|---|---|
membership_plan | recurring_plan:base_plan | plan_name, category (Basic, Premium, VIP), benefits, sort_order |
member_subscription | membership:base_membership | billing_interval, auto_renew, subscribed_at |
member_profile | customer:base_customer | phone, emergency_contact_name, fitness_goals, has_signed_waiver |
class | service:base_service | class_name, category, max_capacity, day_of_week, schedule_start_time |
class_booking | booking:base_booking | trainer_uuid, booked_at, start_time, end_time |
payment | payment:base_payment | paid_at, plus mandatory amount and currency |
cart, cart_item, order, order_item | cart:*, order:* | The shop by the door |
waiver | document_signature:base_document | waiver_text, version, title |
waiver_signature | document_signature:base_signature | signed_at, parented to the waiver it signs |
trainer | (no base type) | trainer_name, bio, photo, specialties, experience_years |
trainer is the reminder that not everything should extend something. A base type it does not need buys a state machine nobody will drive.config.py: customer, product, cart, payment, stripe_checkout, membership, booking, notification, attachment, document_signature, email, amazon_s3, service, recurring_plan, order, workflows. Importing them pulled in the twenty-four extra schemas — email alone adds campaign, message, sender, suppression and template.Building It in App Studio
- 1
Describe the business
App Studio takes the brief in plain language. The gym’s
config.pystill carries it: "Premium gym memberships, classes, and personal training." - 2
The services get picked
The generator records the implied services in
config.py. That list decides which base types are available to extend. - 3
The model comes back
Fifteen app schemas, each already extending the right base type.
classextendsservice:base_service,class_bookingextendsbooking:base_booking— a calendar, from a sentence about classes. - 4
Configure the integrations
They ship imported but uncredentialed, listed in
INTEGRATIONS_NEEDING_CONFIG. Until filled in, calls fail with a clear error rather than silently doing nothing. - 5
Put the keys in the keystore
Via the admin panel or the bundle’s
configure_service.py. Credentials live at/api/v1/keystore/platform-secrets, masked on read, never in the repository. - 6
Run it
The bundle is a working application:
schemas.py,ui_schemas.py,ui/app.js,setup.py,run.shanddocker-compose.on-prem.yml.
INTEGRATIONS_NEEDING_CONFIG = [
{"service_id": "stripe_checkout",
"config_keys": ["stripe_api_key", "stripe_webhook_secret"]},
{"service_id": "email",
"config_keys": ["sendgrid_api_key", "sendgrid_webhook_signing_key",
"default_from_email", "default_from_name"]},
{"service_id": "amazon_s3",
"config_keys": ["aws_access_key_id", "aws_secret_access_key",
"default_bucket"]},
]POST /api/v1/services/execute, with a service_id, an operation, and an input naming the extending_schema. There is no separate cart or membership API. An illegal transition returns 409 INVALID_STATE_TRANSITION.Tenancy and Access
fq_name — here ["m71-corp", "m71-gym3", <tenant>, <record>]. The identifier is uuid; there is no id. A gym chain puts each location in its own tenant from one deployment; app_m71-gym3 seeds a single default-tenant because it is a test bundle, but the isolation is there either way. See Projects & Tenants.domain_admin, domain_user, project_admin, project_user, developer, tenant_admin, tenant_user and viewer; platform_admin and platform_user are Supero-operator roles you will not hand out. There is no super_admin. The gym seeds three: developer, tenant_admin for the manager, tenant_user for a member.⚠️ Two things people get wrong here
X-Domain, X-Tenant or X-Project header — tenancy comes from the authenticated principal and the CRUD path /api/v1/crud/{domain}/{type}, never from something a client sets. And the auth headers are mutually exclusive: Authorization: Bearer <jwt> or X-API-Key: ak_..., not both.member_profile extends customer:base_customer, parented to user_account rather than the tenant — a member is a person who logs in, not a row with an email field. And member_subscription carries owner_username with "role": "owner", which is how a member’s own subscription is told from everyone else’s.The Booking Fires a Workflow
// app_m71-gym/event_bindings.json
[{"event": "@create:ironworks:class_booking",
"workflow_id": "notify_on_booking",
"input_map": {"booking_uuid": "uuid",
"class_name": "class_name"}}]@create:ironworks:class_booking is the syntax @<verb>:<namespace>:<schema> — when a class_booking is created in ironworks, fire notify_on_booking. The input_map carries two fields into the workflow: the booking’s uuid, and the class_name the confirmation quotes back.ℹ️ Which file this came from
app_m71-gym3 records its service list in config.py rather than a services.json, and ships no event_bindings.json. The binding above is quoted verbatim from its sibling app_m71-gym (namespace ironworks), whose services.json reads ["booking", "cart", "customer", "membership", "notification", "order", "payment", "recurring_plan"].wf:workflow_definition. Steps can be service_call, crud_operation, condition, parallel or transform, each with on_error of stop, continue, retry or compensate. Every run writes a workflow_instance and every step a workflow_step_log — queryable when a member says the email never arrived. The run stamps trigger_source: crud_event, not api or scheduled.What It Took
What came free
- •Twenty-four of the 39 schemas — imported service types with lifecycle fields and state machines behind them.
- •Six problems from one description: subscriptions, bookings, e-signature, cart and orders, payments, email.
- •Credential hygiene by default — Stripe and SendGrid keys in the keystore, masked on read.
- •Automation on a real event, with a per-step log you can query afterwards.
- •Tenancy and the platform roles, with
fq_nameas an immutable record address.
What had to be decided
- •That
trainergets no base type whileclassextendsservice:base_service. Extending everything is a real temptation and a real cost. - •That
waiver_signatureis parented towaiverrather than merely referencing it — parenting is what makes the signature belong to the document. - •That the plan vocabulary is
Basic,Premium,VIP, declared as field values so the tiers cannot drift. - •That
has_signed_waiveris a denormalised flag — convenient at check-in, now something you must keep true.
What would bite you
🚨 Nothing charges the member next month
billing_interval, auto_renew, next_billing_at and current_period_end. Not one causes anything to happen. Supero runs no billing scheduler: it does not charge on a date, retry a failed card, dun, or send a renewal reminder. There are no free trials either — no trial concept exists on the base plan. The renewal loop is a workflow you define; until you do, subscriptions sit where you last set them. Budget for this at the start of a subscription build.class.max_capacity is a field to check against, not a lock the platform takes — two members can pass the same check on the same 6am class. start_time and end_time make availability queryable, but the contention rule is yours. Neither is hard; both are invisible until the first oversubscribed morning. For the complete set of roles see Roles & Permissions Reference, and for the full workflow step and error contract see Workflow Engine Reference.On this page