Skip to main content
A ModuleX subscription is a Stripe-backed plan attached to your organization. It sets the monthly credit allowance and the limits your organization runs against. This page covers the full lifecycle: how a subscription is created through Stripe Checkout, how plan changes and cancellations are scheduled, how ModuleX stays in sync through a signature-verified Stripe webhook, the states a subscription moves through, and the SDK surface.
The subscriptions resource exists in the Python SDK only. The JavaScript SDK has no subscriptions resource and no subscription methods. Wherever a checkout or portal link is needed from code, use the Python SDK or call the REST endpoints directly. See Subscriptions are Python-SDK-only below and the SDK parity matrix.

Who can manage subscriptions

Every /subscriptions/* endpoint is organization-scoped and reserved for owners and admins:
  • Most endpoints require admin or owner (the organization_admin_required dependency).
  • POST /subscriptions/wallet/topup requires owner specifically.
The member role is retired: only owners and admins exist. Every request must carry the organization context header. See Org context & X-Organization-ID and Roles & permissions.
string
required
A bearer token: either a mx_live_… API key or a Clerk session token. Example: Authorization: Bearer mx_live_…. See Authentication.
string
required
The UUID of the organization whose subscription you are managing. Missing this header on an org-scoped route returns 400/422.
These endpoints have no global /v1 prefix — each router carries its own. The subscription endpoints live under /subscriptions, and the Stripe webhook lives under /stripe. Trailing-slash redirects are off, so call paths exactly as written. See Base URLs & versioning.

Lifecycle states

A subscription’s status mirrors Stripe verbatim. ModuleX treats the following set as active (entitlements apply): trialing, active, past_due, unpaid. Anything outside that set falls the organization back to the Free plan.
operations_suspended is the flag that gates a paying-but-unpaid organization. It is set when status is unpaid, or when status is past_due and the failed-payment grace window (anchored on the first failure, plus 3 days) has passed. A suspended organization’s managed runs return a credit_exhausted 402 from the gate and do not fall back to the Free tier. Restoring payment clears the flag — see Trials & dunning.

Creating a subscription — Stripe Checkout

A new subscription starts with a Stripe Checkout session in subscription mode. ModuleX returns a hosted checkout URL; you redirect the owner or admin to it, and the subscription is created by Stripe and mirrored back through the webhook.

POST /subscriptions/checkout-link

Creates a Stripe Checkout session (mode=subscription) for a plan and billing interval. Requires admin or owner.
The plan, interval, and legacy plan id are sent as query parameters, not a JSON body. The Python SDK’s checkout_link() does the same — it passes params, not json.
string
required
The billing interval: month or year.
string
The plan to subscribe to: pro or max. Preferred over plan_id. One of plan_slug or plan_id is required.
string
deprecated
A legacy database plan id. Deprecated — use plan_slug.
string
The hosted Stripe Checkout URL. Redirect the user here to complete payment. On completion, Stripe redirects to {FRONTEND_URL}/settings/billing?checkout=success; on abandonment, to ?checkout=canceled.
The Stripe price is resolved live by lookup key (for example pro_monthly, max_annual), never a hard-coded id. The checkout quantity is the organization’s active member count (the owner is seat 1). The session stamps subscription_data.metadata = { organization_id, plan_slug } so the webhook can attribute the resulting subscription.
These are plain {"detail": "…"} (HTTPException) shapes, not the billing DenialEnvelope. See Errors & status codes.

Trials

A 7-day trial is added to the checkout automatically when both conditions hold:
  • The organization is trial-eligible: it has never trialed and the owner has never subscribed before. ModuleX tracks this with organizations.has_ever_trialed and users.has_ever_subscribed.
  • The synced Stripe price has trial_period_days > 0.
Trials apply to Pro and Max only. If a trial ends with no payment method on file, the subscription is canceled (trial_settings.end_behavior.missing_payment_method = "cancel"). Full eligibility and grace behavior is on Trials & dunning.

Changing plans — transitions

Once a subscription exists, an admin or owner changes plan or interval through a transition. ModuleX computes the direction (upgrade, downgrade, or no-op) and applies it either immediately or at the period end.

POST /subscriptions/transition

Self-serve plan change. Requires admin or owner. Seats are preserved across the change (the quantity is carried over).
string
required
The target plan: pro or max. Enterprise transitions are rejected — Enterprise is sales-led.
string
default:"month"
The target billing interval: month or year.
The response depends on the direction:
string
One of upgrade, downgrade, or noop.
string
immediate for upgrades, period_end for downgrades. Absent on a no-op.
string
ISO 8601 timestamp when a period_end change takes effect (downgrades only).
string
The plan the downgrade will move to (downgrades only).
string
The interval the downgrade will move to (downgrades only).
string
The Stripe SubscriptionSchedule id backing a scheduled downgrade (downgrades only).
string
Present on a no-op: "Already on this plan + interval."
The direction rule is the single source of truth for up- versus down-grade:
Upgrades modify the Stripe subscription immediately with proration_behavior="always_invoice" and payment_behavior="error_if_incomplete", under a per-organization advisory lock. If the card is declined, the change is rejected with a 402 upgrade_payment_failed DenialEnvelope (see below). After an upgrade, the entitlement cache is busted so the new plan takes effect at once.Downgrades (and annual → monthly) are applied as a two-phase Stripe SubscriptionSchedule. The organization keeps its current, higher entitlements until the period-end webhook fires — the entitlement cache is not busted on a downgrade. The pending change is mirrored in pending_plan_slug, pending_interval, and pending_effective_at.

POST /subscriptions/transition/cancel

Undoes a pending downgrade or a pending cancellation — whether it was scheduled from the app or from the Stripe customer portal. Requires admin or owner. It is idempotent: when nothing is pending it returns a no-op without error. ModuleX detects a pending state from pending_plan_slug (a scheduled downgrade) or from cancel_at_period_end / cancel_at / stripe_schedule_id (a scheduled cancel), releases the Stripe schedule and/or resumes the subscription, and clears the pending mirror columns. Because the current tier is never lost in this operation, the entitlement cache is not busted.
boolean
true when a pending change was undone; false on a no-op.
string
Present on a no-op explaining that nothing was pending.
cURL

The customer portal

The Stripe Billing Portal is where owners and admins update payment methods, view invoices, and cancel a subscription. ModuleX returns a one-time portal URL.

POST /subscriptions/customer-portal

Creates a Stripe Billing Portal session. Requires admin or owner. Returns to {FRONTEND_URL}/?org_settings=settings/billing when the user is done.
string
The hosted Stripe customer portal URL. Redirect the user here.
A cancellation made in the Stripe portal arrives back in ModuleX as a scheduled period-end cancel. You can undo it with POST /subscriptions/transition/cancel while it is still pending.

Reading subscription state

Two read endpoints expose the current plan and the catalog. Both are the read methods the Python SDK wraps.

GET /subscriptions/organization-billing

Returns the organization’s current subscription, its plan, and any scheduled change or cancellation. Requires admin or owner.
boolean
false when the organization has no active subscription (on Free). Then subscription and plan are null.
object
The active subscription, or null.
object
The plan backing the subscription, or null.

GET /subscriptions/organization-plans

Lists the available plans with a per-organization relation (whether each plan is the current one, an upgrade, or a downgrade), whether it is selectable, and any discounted price. Requires admin or owner. The Admin plan is never exposed, and no Stripe ids are leaked.
array
The plan catalog.
integer
The number of plans returned.
The price field name differs by endpoint. The catalog (organization-plans) uses amount, while the billing object (organization-billing) uses price. The Python SDK models reflect this: PlanPrice.amount for the catalog versus BillingPlanPrice.price for the billing plan. Read the correct field for the endpoint you called.

Staying in sync — the Stripe webhook

ModuleX does not poll Stripe for state. Stripe pushes events to a single webhook, and ModuleX mirrors the relevant ones into its own subscription, wallet, and catalog tables. The webhook is the source of every status change you observe through the read endpoints.

POST /stripe/webhook

The webhook receiver. It is not authenticated with a bearer token — it verifies a Stripe signature instead. It is idempotent, order-aware, and retryable.
string
required
The Stripe signature header. ModuleX verifies it against STRIPE_WEBHOOK_SECRET using the raw request body. Verification or parse failure returns 400 "Invalid webhook signature" with no error detail leaked. A missing STRIPE_WEBHOOK_SECRET returns 500.
This endpoint is a Stripe-to-server receiver, not a client call. Neither SDK exposes it, and you should never call it yourself — you configure its URL in your Stripe dashboard. See the SDK parity matrix for the full list of routes with no SDK method.

Handled events

Each delivered event is normalized once and dispatched by type. A redelivered event that was already processed short-circuits to { "received": true, "duplicate": true }.
Stripe emits customer.subscription.deleted for a cancellation — there is no customer.subscription.canceled event. If you wire up your own listeners against Stripe, handle .deleted.
A customer.subscription.deleted event mirrors the subscription’s terminal status; the organization then falls to Free because the deleted status is outside the active set. A restored payment after dunning clears suspension but does not re-grant credits or reset the monthly bucket — this is deliberate, to prevent farming a fresh allowance by lapsing and recovering. See Trials & dunning and Credits & metering.

Retries and dead-lettering

The webhook answers Stripe so that genuinely transient failures are retried and permanent ones are not:
1

Transient failure under the cap

An organization-not-found race (the customer-create event arrives before the org commit), or a database or Stripe error mid-processing, returns 503 so Stripe retries — up to WEBHOOK_TRANSIENT_RETRY_CAP (default 5) attempts.
2

Transient failure at the cap

Once the retry cap is reached, the event is dead-lettered, marked processed, and acknowledged with 200 so Stripe stops retrying: { "received": true, "dead_lettered": true, … }.
3

Permanent failure

A malformed payload (for example a missing customer) raises a permanent error: the event is dead-lettered, marked processed, and acknowledged with 200. No retry.
4

Success

The event is recorded for deduplication and acknowledged with 200: { "received": true, "type": … }.
Ordering. Subscription mirror upserts carry a watermark on the event’s created timestamp. A stale (older) event that arrives after a newer one is rejected without corrupting the newer state, so out-of-order delivery from Stripe cannot roll your subscription back.

Subscriptions are Python-SDK-only

The subscriptions resource is part of the Python SDK only. The JavaScript SDK has no subscriptions resource and exposes no subscription methods — confirmed against the SDK parity matrix. From JavaScript, call the REST endpoints directly (as shown in the JavaScript tabs above) or surface billing through the app’s customer portal. The Python SDK wraps four of the subscription endpoints:
The remaining /subscriptions/* endpoints have no SDK method in either SDK and must be called over REST:
  • GET /subscriptions/wallet
  • PATCH /subscriptions/wallet/extra-usage
  • POST /subscriptions/wallet/topup
  • POST /subscriptions/transition
  • POST /subscriptions/transition/cancel
The wallet endpoints are documented on Wallet & top-ups; the transition endpoints are above on this page.
Every method accepts a per-call organization_id that overrides the client default. All four methods are async and require an owner or admin token. The full method-to-route mapping, including the cross-SDK gaps, is on the SDK parity matrix; installation and configuration are on the Python SDK page.

Error shapes you may see

Subscription endpoints can return two different 402 shapes, and you must branch on both:
  • The flat DenialEnvelope ({ code, layer, key, current, limit, reason }) comes from the billing gate — for example an upgrade whose card was declined returns code: "upgrade_payment_failed". This is the same envelope the usage gate raises for 402 / 403 / 429.
  • The HTTPException shape ({"detail": {…}}) comes from the wallet paid-plan checks (PATCH /subscriptions/wallet/extra-usage, POST /subscriptions/wallet/topup), which return {"detail": {"reason": "paid_subscription_required"}} when an organization without a paid subscription tries to use a paid-only feature.
The other subscription errors (400, 404, 500, 502) use the plain {"detail": "…"} string shape. All three envelope shapes are catalogued on Errors & status codes.
In the Python SDK, a 402 raises PaymentRequiredError and, when the body carries a recognized billing layer, a more specific subclass (CreditExhaustedError, WalletError, or QuotaExceededError). A 403 raises PermissionError and a 429 raises RateLimitError. See Errors & retries.

Configuration reference

These environment variables govern the Stripe integration on the server. They are listed here so you understand the behavior; you set them on the deployment, not through the API.
The wallet top-up bounds (WALLET_TOPUP_MIN_USD = 10, WALLET_TOPUP_MAX_USD = 1000), the credit conversion rate ($0.01 per credit), and auto top-up defaults live with the wallet and credits configuration rather than the subscription surface.

Plans & pricing

Free, Pro, Max, and Enterprise — allowances, limits, and both annual prices.

Wallet & top-ups

The prepaid wallet, the top-up checkout, and auto top-up.

Trials & dunning

Trial eligibility, grace periods, suspension, and recovery.

Usage gating & limits

The admission gate and its 402, 403, and 429 responses.

Python SDK

Install and configure the async client that exposes the subscriptions resource.

SDK parity matrix

Which routes each SDK covers — and where the gaps are.