S
supero.docs
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 retainerrecurring_plan
Track membership in an org, team, or group and what role someone holds there, whether or not money is involvedmembership
A paid membership tier that also gates access to the appBoth, 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_planSet by
statusstart_plan, pause_plan, resume_plan, cancel_plan, renew_period
billing_interval, billing_amount, currencyyou, at create time
started_at, current_period_start, current_period_end, next_billing_atstart_plan, then advanced by renew_period each cycle
cancelled_at, expires_atcancel_plan
owner_usernamestamped 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).
StateMeaning
inactiveCreated, not yet billing — the initial state.
activeBilling normally; the subscriber should have access.
pausedOn hold — no billing, no clock advance.
cancelledEnded. Terminal.
expiredEnded. Terminal.
OperationTransitionEffect
start_planinactive or paused → activeStamps started_at and computes the first period boundaries from billing_interval.
pause_planactive → pausedStamps paused_at.
resume_planpaused → activeStamps resumed_at and recomputes next_billing_at from now.
cancel_planinactive, active, or paused → cancelledStamps cancelled_at. See immediate vs. end-of-period below.
renew_periodactive → 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 stateMeaning
pendingCreated, not yet sent to the processor.
authorizedProcessor confirmed the charge; not yet collected.
capturedMoney collected — this period is paid.
failedThe 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 }
  ]
}
StateMeaning
pendingCreated, not yet active — the initial state.
activeMember has access.
suspendedTemporarily gated — reversible via reinstate_member.
expelledPermanently removed. Terminal.
transferredOwnership moved to another target (e.g. merging orgs). Terminal.
expiredAccess window lapsed.
OperationTransitionNotes
enroll_memberpending → activeStamps joined_at.
suspend_memberactive → suspendedStamps suspended_at; pass suspension_reason.
reinstate_membersuspended → activeClears the suspension.
expel_memberpending, active, suspended, or expired → expelledWorks from almost any non-terminal state.
transfer_memberactive or suspended → transferredStrict: 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"
  }
}
FieldDescription
mode"subscription" for recurring billing (also supports "payment" for a one-time charge and "setup" for saving a card).
price_idAn existing Stripe Price ID — the documented way to drive a subscription session.
intervalBilling interval for the subscription: day, week, month, or year.
success_url / cancel_urlWhere Stripe redirects the customer after checkout. Append ?session_id={CHECKOUT_SESSION_ID} to success_url to read back the session.
customer_emailPre-fills the checkout page.
client_reference_idYour 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.