Documentation/Building Your App/Subscriptions & Memberships
Subscriptions & Memberships
Compose recurring billing and access/role membership for your app's own users from two platform services — recurring_plan and membership — plus your own schema, a billing loop, and Stripe Checkout for sign-up.
Overview
Two platform services cover most of what a subscription or membership feature needs: recurring_plan bills a subscriber on a schedule, and membership tracks whether someone belongs to — and what role they hold in — an org, team, group, or plan. Neither is a complete subscription system on its own. You compose them with your own schema and a small billing job, and this page walks through that composition: modeling tiers and subscribers, running the plan lifecycle, running the billing loop, gating access on payment state, scoping reads so a subscriber only sees their own plan, and wiring Stripe Checkout for sign-up.
ℹ️ Not your Supero account bill
This page covers billing your app's own end users — the subscribers of the app you're building. Your own Supero account plan and usage are a separate topic covered on the Plans & Billing page.
| If you need to... | Use |
|---|---|
| Charge a subscriber on a recurring schedule — a SaaS seat, a monthly gym fee, a retainer | recurring_plan |
| Track membership in an org, team, or group and what role someone holds there, whether or not money is involved | membership |
| A paid membership tier that also gates access to the app | Both, paired one-to-one — recurring_plan owns the billing clock, membership owns the access relationship |
recurring_plan, membership, and payment are internal transactional services from the managed services catalog: self-contained state machines over your own data, imported into your project and extended with your own schema — no external account required. stripe_checkout, used later on this page for sign-up, is an integration service: it needs your own Stripe credentials configured before your app can call it.
💡 Building with App Studio
Describe the behavior in plain English — "members pay a monthly fee" or "organizations have owners and members" — and App Studio picks the right service and generates the extending schemas for you. This page is for reviewing or hand-writing that composition yourself.
Modeling Tiers and Subscribers
Most subscription apps need two layers of schema: a catalog of the tiers or plans a customer can choose from, and one record per subscriber tracking which tier they picked and where their billing clock stands. The catalog is a plain schema you own outright; the per-subscriber record extends recurring_plan:base_plan.
The tier catalog
An ordinary object schema — no extends. It holds the price, interval, and anything else that describes an offering.
json
{
"name": "membership_tier",
"parent_type": "tenant",
"attributes": [
{ "name": "tier_name", "type": "string", "mandatory": true },
{ "name": "billing_amount", "type": "float", "mandatory": true },
{ "name": "billing_interval", "type": "string", "values": ["day", "week", "month", "year"], "mandatory": true },
{ "name": "currency", "type": "string", "default-value": "USD" },
{ "name": "stripe_price_id", "type": "string" },
{ "name": "status", "type": "string", "values": ["active", "retired"], "default-value": "active" }
]
}The per-subscriber plan
extends: "recurring_plan:base_plan" gives every plan record the billing clock and status field for free — you only declare the fields specific to your app. Link the subscriber and the chosen tier with flat uuid fields rather than schema references: they stay simple and directly queryable by the billing job, which reads and writes this record on every cycle.
json
{
"name": "member_plan",
"parent_type": "tenant",
"extends": "recurring_plan:base_plan",
"attributes": [
{ "name": "customer_uuid", "type": "string", "mandatory": true },
{ "name": "tier_uuid", "type": "string", "mandatory": true },
{ "name": "period_key", "type": "string" },
{ "name": "last_billed_period_key", "type": "string" },
{ "name": "last_billed_payment_uuid", "type": "string" }
]
}| Field inherited from base_plan | Set by |
|---|---|
| status | start_plan, pause_plan, resume_plan, cancel_plan, renew_period |
| billing_interval, billing_amount, currency | you, at create time |
| started_at, current_period_start, current_period_end, next_billing_at | start_plan, then advanced by renew_period each cycle |
| cancelled_at, expires_at | cancel_plan |
| owner_username | stamped automatically on create — see Owner-Scoped Reads below |
The Plan Lifecycle
A plan record is created inactive. Every state change after that goes through a recurring_plan operation, invoked via POST /api/v1/services/execute with the service_id, the operation, and an input object carrying plan_uuid and extending_schema (the qualified namespace:name of your extending schema, e.g. myapp:member_plan).
| State | Meaning |
|---|---|
| inactive | Created, not yet billing — the initial state. |
| active | Billing normally; the subscriber should have access. |
| paused | On hold — no billing, no clock advance. |
| cancelled | Ended. Terminal. |
| expired | Ended. Terminal. |
| Operation | Transition | Effect |
|---|---|---|
| start_plan | inactive or paused → active | Stamps started_at and computes the first period boundaries from billing_interval. |
| pause_plan | active → paused | Stamps paused_at. |
| resume_plan | paused → active | Stamps resumed_at and recomputes next_billing_at from now. |
| cancel_plan | inactive, active, or paused → cancelled | Stamps cancelled_at. See immediate vs. end-of-period below. |
| renew_period | active → active (or → cancelled, see below) | Advances current_period_start/end and next_billing_at by one billing_interval. |
bash
curl -X POST https://api.supero.dev/api/v1/services/execute \
-H "X-API-Key: ak_..." \
-H "Content-Type: application/json" \
-d '{
"service_id": "recurring_plan",
"operation": "start_plan",
"domain": "acme-corp",
"project_uuid": "proj_8f2a1c",
"input": {
"plan_uuid": "<member_plan uuid>",
"extending_schema": "myapp:member_plan"
}
}'json
{
"success": true,
"output": {
"record_uuid": "...",
"status": "active",
"started_at": "2026-07-18T00:00:00Z",
"current_period_start": "2026-07-18T00:00:00Z",
"current_period_end": "2026-08-18T00:00:00Z",
"next_billing_at": "2026-08-18T00:00:00Z"
}
}⚠️ Immediate vs. end-of-period cancellation
cancel_plan takes an immediate flag. immediate: true cancels right now — status flips to cancelled immediately. immediate: false (the default) schedules the cancellation for the end of the current period: cancelled_at and expires_at are stamped but status stays active, so the subscriber keeps access through the period they already paid for. The flip to cancelled happens the next time renew_period runs and detects the pending cancellation — your billing loop must check for cancelled_at and skip billing that period rather than charging it.
💡 resume_plan does not prorate
resume_plan recomputes the period from the moment you call it, not from where pause left off — a paused-then-resumed plan gets no automatic credit for the paused time. If your app needs to prorate that gap, calculate and apply the credit yourself, for example as a discount on the next payment.
The Billing Loop
recurring_plan does not charge anyone on its own — renew_period only moves the clock forward. Billing a period end to end (create a charge, collect it, then advance the plan) is your app's job, typically run as a scheduled job that scans active plans whose next_billing_at has passed.
- •Read the plan by its uuid and compute a period key from it, e.g. {plan_uuid}:{current_period_start} — this is what makes a retried tick safe.
- •If the plan already carries this period key in last_billed_period_key, the period is already billed — stop.
- •If cancelled_at is set, do not bill: call renew_period and let it flip the plan to cancelled.
- •Otherwise create a payment record (extending payment:base_payment) for the period, status pending.
- •Authorize, then capture the payment.
- •Stamp the plan with last_billed_period_key and last_billed_payment_uuid so a retried tick dedupes at step 2.
- •Call renew_period to advance the clock — only after the capture succeeds.
python
import supero
from supero.service_lib import ServiceLib
org = supero.connect(domain_name="acme-corp", api_key="ak_...")
sl = ServiceLib(api_key="ak_...", domain="acme-corp", project_uuid=PROJECT_UUID)
PLAN_SCHEMA = "myapp:member_plan"
PAYMENT_SCHEMA = "myapp:member_payment"
def bill_period(plan_uuid):
plan = org.crud.get(PLAN_SCHEMA, plan_uuid)
if plan["status"] != "active":
return "skipped"
period_start = plan.get("current_period_start", "")
period_key = f"{plan_uuid}:{period_start}"
if plan.get("last_billed_period_key") == period_key:
return "deduped" # already billed this period
if plan.get("cancelled_at"):
sl.recurring_plan.renew_period(plan_uuid, extending_schema=PLAN_SCHEMA)
return "cancelled" # deferred cancel takes effect here; do not charge
payment = org.crud.create(PAYMENT_SCHEMA,
name=f"pay-{period_key}",
status="pending",
amount=plan["billing_amount"],
currency=plan["currency"],
plan_uuid=plan_uuid,
period_key=period_key,
)
sl.payment.authorize(payment["uuid"], extending_schema=PAYMENT_SCHEMA)
sl.payment.capture(payment["uuid"], extending_schema=PAYMENT_SCHEMA)
org.crud.update(PLAN_SCHEMA, plan_uuid,
last_billed_period_key=period_key,
last_billed_payment_uuid=payment["uuid"])
sl.recurring_plan.renew_period(plan_uuid, extending_schema=PLAN_SCHEMA)
return "billed"
⚠️ renew_period is not safe to retry blindly
Each successful call advances current_period_start/end by one interval — calling it twice for the same tick bills the subscriber one period short (or, paired with a duplicate charge, double-bills them). Only call it once capture has actually succeeded, and use the period-key marker above to make a re-delivered tick a no-op rather than a second bill.
| Payment state | Meaning |
|---|---|
| pending | Created, not yet sent to the processor. |
| authorized | Processor confirmed the charge; not yet collected. |
| captured | Money collected — this period is paid. |
| failed | The charge was declined; terminal for this payment record — raise a fresh one to retry (see Pairing Billing With Access). |
Pairing Billing With Access
membership carries the access relationship separately from the billing clock, so you can suspend a member's access on a failed payment without losing (or re-creating) their membership record. Extend membership:base_membership with a flat plan_uuid so the two records can find each other.
json
{
"name": "member_access",
"parent_type": "tenant",
"extends": "membership:base_membership",
"attributes": [
{ "name": "plan_uuid", "type": "string", "mandatory": true }
]
}| State | Meaning |
|---|---|
| pending | Created, not yet active — the initial state. |
| active | Member has access. |
| suspended | Temporarily gated — reversible via reinstate_member. |
| expelled | Permanently removed. Terminal. |
| transferred | Ownership moved to another target (e.g. merging orgs). Terminal. |
| expired | Access window lapsed. |
| Operation | Transition | Notes |
|---|---|---|
| enroll_member | pending → active | Stamps joined_at. |
| suspend_member | active → suspended | Stamps suspended_at; pass suspension_reason. |
| reinstate_member | suspended → active | Clears the suspension. |
| expel_member | pending, active, suspended, or expired → expelled | Works from almost any non-terminal state. |
| transfer_member | active or suspended → transferred | Strict: does not accept pending — enroll first if you need to transfer a brand-new membership. |
Dunning: what to do when a payment fails
fail_payment on the payment record is terminal — there is no failed-to-retry edge on the payment itself. Retrying means raising a fresh payment for the same period_key. Gate access with membership around that retry window:
- •Payment declines: call payment.fail on the payment record, then membership.suspend_member (suspension_reason="payment_failed") on the linked member_access record. Access is gated; the relationship is intact.
- •Retry: create a new payment record for the same period_key (the failed one stays failed), authorize and capture it.
- •Retry succeeds: call membership.reinstate_member.
- •Plan is cancelled: call membership.expel_member once cancel_plan has taken effect.
Owner-Scoped Reads: "My Subscription"
recurring_plan:base_plan declares an owner_username field. Because extends inherits the base's fields, member_plan (and any other schema extending recurring_plan:base_plan) carries owner_username automatically — you do not need to declare it yourself.
Pair the field with an access-policy rule scoped to your subscriber-facing role, giving your admin-facing role an unfiltered rule on the same entity so staff can see every subscriber's plan:
python
from supero.app_setup import PolicyDef, PolicyRule
access_policies = [
PolicyDef(role="tenant_admin", default_access="none", rules=[
PolicyRule(entity="member_plan", can_read=True, can_create=True,
can_update=True, can_delete=True),
]),
PolicyDef(role="tenant_user", default_access="none", rules=[
PolicyRule(entity="member_plan", can_read=True,
filter_field="owner_username", filter_match="$user.name"),
]),
]Once a role has a record filter like this declared on an entity, the platform stamps the matching field to the calling principal's value automatically when they create a record — a client cannot override it — and enforces the same filter on every subsequent read and update. A subscriber's own create produces a plan row owned by them, and their reads only ever return that row: the "My Subscription" pattern, with no extra code in your app.
💡 Same pattern for membership
member_access does not inherit owner_username from membership:base_membership — declare it yourself as a flat string field if you want the same "My Membership" scoping there.
Signing Customers Up With Stripe Checkout
stripe_checkout is the managed integration for a hosted payment page. Import and configure it like any catalog service, then create a checkout session in subscription mode to start sign-up.
bash
curl -X POST https://api.supero.dev/api/v1/services/import \
-H "X-API-Key: ak_..." \
-H "Content-Type: application/json" \
-d '{"service_id": "stripe_checkout", "domain": "acme-corp", "project_uuid": "proj_8f2a1c"}'bash
curl -X PUT https://api.supero.dev/api/v1/services/stripe_checkout/config \
-H "X-API-Key: ak_..." \
-H "Content-Type: application/json" \
-d '{"config_data": {"stripe_api_key": "sk_live_..."}}'Create the subscriber's member_plan (status inactive) and member_access membership (status pending) records first, then pass the plan's uuid as client_reference_id when you create the checkout session, so you can tie the completed session back to it.
bash
curl -X POST https://api.supero.dev/api/v1/services/execute \
-H "X-API-Key: ak_..." \
-H "Content-Type: application/json" \
-d '{
"service_id": "stripe_checkout",
"operation": "create_checkout_session",
"domain": "acme-corp",
"project_uuid": "proj_8f2a1c",
"input": {
"mode": "subscription",
"price_id": "price_pro_monthly",
"interval": "month",
"success_url": "https://acme-corp.supero.live/welcome?session_id={CHECKOUT_SESSION_ID}",
"cancel_url": "https://acme-corp.supero.live/pricing",
"customer_email": "[email protected]",
"client_reference_id": "<member_plan uuid>"
}
}'json
{
"success": true,
"output": {
"record_uuid": "...",
"provider_session_id": "cs_test_...",
"checkout_url": "https://checkout.stripe.com/c/pay/cs_test_...",
"status": "created"
}
}| Field | Description |
|---|---|
| mode | "subscription" for recurring billing (also supports "payment" for a one-time charge and "setup" for saving a card). |
| price_id | An existing Stripe Price ID — the documented way to drive a subscription session. |
| interval | Billing interval for the subscription: day, week, month, or year. |
| success_url / cancel_url | Where Stripe redirects the customer after checkout. Append ?session_id={CHECKOUT_SESSION_ID} to success_url to read back the session. |
| customer_email | Pre-fills the checkout page. |
| client_reference_id | Your own reference — the uuid of the member_plan record for this sign-up. |
Redirect the customer to checkout_url. When they complete checkout — in your success_url handler, or from a Stripe webhook you verify with the stripe_webhook_secret you configured on the service — look up the plan by client_reference_id and call start_plan and enroll_member together to activate billing and access at once.
Next Steps
Related pages:
- •Schema Reference — the fields[]/extends format used to build the tier, plan, payment, and membership schemas on this page.
- •Access Control (RBAC) — the full role and policy model, including custom roles and field-level rules beyond owner-scoped reads.
- •Managing Data — creating, reading, and updating records through the CRUD API, SDKs, and admin console.
- •Build Your First App (App Studio) — how App Studio picks services like recurring_plan and membership from a plain-English description and generates the extending schemas for you.
- •Transactional Services — the recurring_plan, membership, and payment state machines in full.
- •Membership & Recurring Billing — the same pattern as an end-to-end worked solution.
On this page