> ## Documentation Index
> Fetch the complete documentation index at: https://docs.modulex.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Subscriptions & Stripe

> The ModuleX subscription lifecycle: Stripe checkout and the customer portal, plan transitions, the signature-verified Stripe webhook, lifecycle states, and the Python-SDK-only subscriptions resource. Full request/response schemas and error codes.

export const MediaEmbed = ({id, type = 'screenshot', caption = '', ext, ratio = '16 / 9'}) => {
  const isVideo = type === 'video' || type === 'app_video';
  const resolvedExt = ext || (isVideo ? 'mp4' : type === 'screenshot' ? 'webp' : 'svg');
  const src = 'https://media.modulex.dev/' + id + '.' + resolvedExt;
  const [status, setStatus] = useState('loading');
  const [isDev, setIsDev] = useState(false);
  const [inView, setInView] = useState(false);
  const boxRef = useRef(null);
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const h = window.location.hostname;
    setIsDev(h === 'localhost' || h === '127.0.0.1' || h.endsWith('.mintlify.app'));
  }, []);
  useEffect(() => {
    if (inView) return;
    if (typeof IntersectionObserver === 'undefined') {
      setInView(true);
      return;
    }
    const el = boxRef.current;
    if (!el) return;
    const io = new IntersectionObserver(entries => {
      if (entries.some(e => e.isIntersecting)) {
        setInView(true);
        io.disconnect();
      }
    }, {
      rootMargin: '300px'
    });
    io.observe(el);
    return () => io.disconnect();
  }, [inView]);
  if (status === 'missing') {
    if (!isDev) return null;
    return <div style={{
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
      gap: '0.4rem',
      padding: '1rem 1.25rem',
      margin: '1.25rem 0',
      width: '100%',
      aspectRatio: ratio,
      boxSizing: 'border-box',
      border: '1px dashed rgba(128,128,128,0.45)',
      borderRadius: '0.75rem',
      background: 'rgba(128,128,128,0.06)',
      color: 'currentColor',
      fontSize: '0.85rem',
      lineHeight: 1.45
    }}>
        <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: '0.5rem',
      opacity: 0.75
    }}>
          <span aria-hidden="true">🎬</span>
          <code style={{
      fontSize: '0.75rem'
    }}>{id}</code>
          <span style={{
      fontSize: '0.65rem',
      textTransform: 'uppercase',
      letterSpacing: '0.04em',
      padding: '0.1rem 0.4rem',
      borderRadius: '0.4rem',
      background: 'rgba(128,128,128,0.18)'
    }}>
            {type}
          </span>
        </div>
        <div style={{
      opacity: 0.9
    }}>{caption || 'Media not uploaded yet.'}</div>
        <div style={{
      fontSize: '0.7rem',
      opacity: 0.5
    }}>
          Upload to R2 as <code>{id}.{resolvedExt}</code> — preview only, hidden in production.
        </div>
      </div>;
  }
  const mediaStyle = {
    display: status === 'loaded' ? 'block' : 'none',
    width: '100%',
    height: 'auto',
    borderRadius: '0.75rem'
  };
  const media = isVideo ? <video src={inView ? src : undefined} autoPlay loop muted playsInline preload="metadata" onLoadedData={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} /> : <img src={inView ? src : undefined} alt={caption} onLoad={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} />;
  return <figure style={{
    margin: '1.25rem 0'
  }}>
      <div ref={boxRef} style={status === 'loaded' ? {
    width: '100%'
  } : {
    width: '100%',
    aspectRatio: ratio,
    borderRadius: '0.75rem',
    background: 'rgba(128,128,128,0.06)'
  }}>
        {media}
      </div>
      {status === 'loaded' && caption ? <figcaption style={{
    marginTop: '0.5rem',
    textAlign: 'center',
    fontSize: '0.85rem',
    opacity: 0.7
  }}>
          {caption}
        </figcaption> : null}
    </figure>;
};

A ModuleX subscription is a Stripe-backed plan attached to your [organization](/concepts/organizations-roles). It sets the monthly [credit](/billing/credits) allowance and the [limits](/billing/usage-gating) 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.

<Note>
  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](#subscriptions-are-pythonsdkonly) below and the [SDK parity matrix](/sdks/parity).
</Note>

## 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](/security/roles-permissions): only owners and admins exist. Every request must carry the organization context header. See [Org context & X-Organization-ID](/security/org-context) and [Roles & permissions](/security/roles-permissions).

<ParamField header="Authorization" type="string" required>
  A bearer token: either a `mx_live_…` API key or a Clerk session token. Example: `Authorization: Bearer mx_live_…`. See [Authentication](/api-reference/authentication).
</ParamField>

<ParamField header="X-Organization-ID" type="string" required>
  The UUID of the organization whose subscription you are managing. Missing this header on an org-scoped route returns `400`/`422`.
</ParamField>

<Info>
  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](/api-reference/environments).
</Info>

## 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.

| State              | What it means                                                 | Entitlements                                                                                                                     |
| ------------------ | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `trialing`         | Inside the 7-day trial (Pro/Max only, once per organization). | Plan entitlements apply.                                                                                                         |
| `active`           | Paid and current.                                             | Plan entitlements apply.                                                                                                         |
| `past_due`         | A renewal payment failed; inside the dunning grace window.    | Plan entitlements apply until the grace window closes, then operations are suspended.                                            |
| `unpaid`           | Payment unresolved after dunning.                             | In the active set, but `operations_suspended` is set — the [usage gate](/billing/usage-gating) denies managed work with a `402`. |
| `canceled` / other | Not in the active set.                                        | Organization falls back to Free.                                                                                                 |

<Note>
  **`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](/billing/trials-dunning).
</Note>

```mermaid theme={null}
stateDiagram-v2
    [*] --> trialing: checkout (eligible)
    [*] --> active: checkout (no trial)
    trialing --> active: trial ends, payment ok
    trialing --> canceled: trial ends, no payment method
    active --> past_due: renewal payment fails
    past_due --> active: payment recovered
    past_due --> unpaid: grace window passes
    unpaid --> active: payment recovered
    active --> canceled: cancel at period end
    canceled --> [*]
```

<MediaEmbed id="MX-MEDIA-1290" type="image" caption={"A clean state diagram of the ModuleX subscription lifecycle from checkout to cancellation, showing trial, active, past_due, unpaid, and canceled."} />

## 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](#staying-in-sync-the-stripe-webhook).

### `POST /subscriptions/checkout-link`

Creates a Stripe Checkout session (`mode=subscription`) for a plan and billing interval. Requires admin or owner.

<Warning>
  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`.
</Warning>

<ParamField query="interval" type="string" required>
  The billing interval: `month` or `year`.
</ParamField>

<ParamField query="plan_slug" type="string">
  The plan to subscribe to: `pro` or `max`. Preferred over `plan_id`. One of `plan_slug` or `plan_id` is required.
</ParamField>

<ParamField query="plan_id" type="string" deprecated>
  A legacy database plan id. Deprecated — use `plan_slug`.
</ParamField>

<ResponseField name="url" type="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`.
</ResponseField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST 'https://api.modulex.dev/subscriptions/checkout-link?interval=month&plan_slug=pro' \
    -H 'Authorization: Bearer mx_live_…' \
    -H 'X-Organization-ID: 7f1c9a2e-3b4d-4e6a-9c12-8a5b6d2f0e11'
  ```

  ```python Python theme={null}
  import asyncio
  from modulex import Modulex


  async def main():
      async with Modulex(
          api_key="mx_live_…",
          organization_id="7f1c9a2e-3b4d-4e6a-9c12-8a5b6d2f0e11",
      ) as mx:
          # plan_slug and interval are sent as query parameters.
          link = await mx.subscriptions.checkout_link("pro", "month")
          print(link.url)  # redirect the owner/admin here


  asyncio.run(main())
  ```

  ```javascript JavaScript theme={null}
  // The JavaScript SDK has NO subscriptions resource.
  // Call the REST endpoint directly to create a checkout link.
  const res = await fetch(
    "https://api.modulex.dev/subscriptions/checkout-link?interval=month&plan_slug=pro",
    {
      method: "POST",
      headers: {
        Authorization: "Bearer mx_live_…",
        "X-Organization-ID": "7f1c9a2e-3b4d-4e6a-9c12-8a5b6d2f0e11",
      },
    },
  );
  const { url } = await res.json();
  // redirect the owner/admin to `url`
  ```
</CodeGroup>

<Note>
  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.
</Note>

<Accordion title="Errors for POST /subscriptions/checkout-link">
  | Status | Condition                                                                                                                   |
  | ------ | --------------------------------------------------------------------------------------------------------------------------- |
  | `400`  | Neither `plan_slug` nor `plan_id` supplied.                                                                                 |
  | `400`  | The plan is Enterprise — `"Enterprise plans are sales-led; contact sales."` See [Contact sales](/enterprise/contact-sales). |
  | `400`  | The plan is inactive — `"This plan is no longer available for purchase"`.                                                   |
  | `400`  | No Stripe price for the requested interval.                                                                                 |
  | `400`  | The organization has no Stripe customer configured.                                                                         |
  | `404`  | `"Subscription plan not found"`.                                                                                            |
  | `500`  | The Stripe API key is not configured.                                                                                       |
  | `502`  | A Stripe API error occurred (`"Stripe error: …"`).                                                                          |

  These are plain `{"detail": "…"}` (`HTTPException`) shapes, not the billing `DenialEnvelope`. See [Errors & status codes](/api-reference/errors).
</Accordion>

### 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](/billing/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).

<ParamField body="plan_slug" type="string" required>
  The target plan: `pro` or `max`. Enterprise transitions are rejected — Enterprise is sales-led.
</ParamField>

<ParamField body="interval" type="string" default="month">
  The target billing interval: `month` or `year`.
</ParamField>

The response depends on the direction:

<ResponseField name="action" type="string">
  One of `upgrade`, `downgrade`, or `noop`.
</ResponseField>

<ResponseField name="effective" type="string">
  `immediate` for upgrades, `period_end` for downgrades. Absent on a no-op.
</ResponseField>

<ResponseField name="effective_at" type="string">
  ISO 8601 timestamp when a `period_end` change takes effect (downgrades only).
</ResponseField>

<ResponseField name="target_plan_slug" type="string">
  The plan the downgrade will move to (downgrades only).
</ResponseField>

<ResponseField name="target_interval" type="string">
  The interval the downgrade will move to (downgrades only).
</ResponseField>

<ResponseField name="schedule_id" type="string">
  The Stripe `SubscriptionSchedule` id backing a scheduled downgrade (downgrades only).
</ResponseField>

<ResponseField name="message" type="string">
  Present on a no-op: `"Already on this plan + interval."`
</ResponseField>

The direction rule is the single source of truth for up- versus down-grade:

| From → To                   | Direction     | When it applies            |
| --------------------------- | ------------- | -------------------------- |
| Lower tier → higher tier    | **Upgrade**   | Immediately.               |
| Higher tier → lower tier    | **Downgrade** | At the period end.         |
| Same tier, monthly → annual | **Upgrade**   | Immediately.               |
| Same tier, annual → monthly | **Downgrade** | At the period end.         |
| Same tier, same interval    | **No-op**     | Returns the no-op message. |

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST 'https://api.modulex.dev/subscriptions/transition' \
    -H 'Authorization: Bearer mx_live_…' \
    -H 'X-Organization-ID: 7f1c9a2e-3b4d-4e6a-9c12-8a5b6d2f0e11' \
    -H 'Content-Type: application/json' \
    -d '{"plan_slug": "max", "interval": "month"}'
  ```

  ```python Python theme={null}
  # There is no SDK method for transitions in either SDK — call REST directly.
  import asyncio, httpx


  async def main():
      async with httpx.AsyncClient() as http:
          res = await http.post(
              "https://api.modulex.dev/subscriptions/transition",
              headers={
                  "Authorization": "Bearer mx_live_…",
                  "X-Organization-ID": "7f1c9a2e-3b4d-4e6a-9c12-8a5b6d2f0e11",
              },
              json={"plan_slug": "max", "interval": "month"},
          )
          print(res.json())  # e.g. {"action": "upgrade", "effective": "immediate"}


  asyncio.run(main())
  ```

  ```javascript JavaScript theme={null}
  // No subscriptions resource in the JS SDK — call REST directly.
  const res = await fetch("https://api.modulex.dev/subscriptions/transition", {
    method: "POST",
    headers: {
      Authorization: "Bearer mx_live_…",
      "X-Organization-ID": "7f1c9a2e-3b4d-4e6a-9c12-8a5b6d2f0e11",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ plan_slug: "max", interval: "month" }),
  });
  console.log(await res.json());
  ```
</CodeGroup>

<Note>
  **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`.
</Note>

<Accordion title="Errors for POST /subscriptions/transition">
  | Status | Condition                                                                                             |
  | ------ | ----------------------------------------------------------------------------------------------------- |
  | `400`  | `plan_slug` is missing — `"plan_slug is required"`.                                                   |
  | `400`  | Target is Enterprise — `"Enterprise plans are sales-led; contact sales."`                             |
  | `400`  | No active subscription — `"No active subscription to transition; start a checkout first."`            |
  | `400`  | `"Target plan price not available"`.                                                                  |
  | `402`  | Upgrade payment failed (card declined) — flat `DenialEnvelope` with `code: "upgrade_payment_failed"`. |
  | `404`  | `"Plan not found"`.                                                                                   |
  | `500`  | The Stripe API key is not configured.                                                                 |
  | `502`  | `"Plan transition failed at the billing provider"`.                                                   |
</Accordion>

### `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.

<ResponseField name="canceled" type="boolean">
  `true` when a pending change was undone; `false` on a no-op.
</ResponseField>

<ResponseField name="message" type="string">
  Present on a no-op explaining that nothing was pending.
</ResponseField>

```bash cURL theme={null}
curl -X POST 'https://api.modulex.dev/subscriptions/transition/cancel' \
  -H 'Authorization: Bearer mx_live_…' \
  -H 'X-Organization-ID: 7f1c9a2e-3b4d-4e6a-9c12-8a5b6d2f0e11'
```

## 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.

<ResponseField name="url" type="string">
  The hosted Stripe customer portal URL. Redirect the user here.
</ResponseField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST 'https://api.modulex.dev/subscriptions/customer-portal' \
    -H 'Authorization: Bearer mx_live_…' \
    -H 'X-Organization-ID: 7f1c9a2e-3b4d-4e6a-9c12-8a5b6d2f0e11'
  ```

  ```python Python theme={null}
  import asyncio
  from modulex import Modulex


  async def main():
      async with Modulex(
          api_key="mx_live_…",
          organization_id="7f1c9a2e-3b4d-4e6a-9c12-8a5b6d2f0e11",
      ) as mx:
          portal = await mx.subscriptions.customer_portal()
          print(portal.url)  # redirect the owner/admin here


  asyncio.run(main())
  ```

  ```javascript JavaScript theme={null}
  // No subscriptions resource in the JS SDK — call REST directly.
  const res = await fetch(
    "https://api.modulex.dev/subscriptions/customer-portal",
    {
      method: "POST",
      headers: {
        Authorization: "Bearer mx_live_…",
        "X-Organization-ID": "7f1c9a2e-3b4d-4e6a-9c12-8a5b6d2f0e11",
      },
    },
  );
  const { url } = await res.json();
  ```
</CodeGroup>

<Note>
  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`](#post-subscriptionstransitioncancel) while it is still pending.
</Note>

<Accordion title="Errors for POST /subscriptions/customer-portal">
  | Status | Condition                                           |
  | ------ | --------------------------------------------------- |
  | `400`  | The organization has no Stripe customer configured. |
  | `404`  | `"Organization not found"`.                         |
  | `500`  | The Stripe API key is not configured.               |
  | `502`  | A Stripe API error occurred (`"Stripe error: …"`).  |
</Accordion>

## 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.

<ResponseField name="has_subscription" type="boolean">
  `false` when the organization has no active subscription (on Free). Then `subscription` and `plan` are `null`.
</ResponseField>

<ResponseField name="subscription" type="object">
  The active subscription, or `null`.

  <Expandable title="subscription">
    <ResponseField name="id" type="string" />

    <ResponseField name="status" type="string">
      One of the [lifecycle states](#lifecycle-states): `trialing`, `active`, `past_due`, `unpaid`, etc.
    </ResponseField>

    <ResponseField name="current_period_start" type="string">ISO 8601 timestamp.</ResponseField>
    <ResponseField name="current_period_end" type="string">ISO 8601 timestamp — the renewal date.</ResponseField>
    <ResponseField name="billing_interval" type="string">`month` or `year`.</ResponseField>
    <ResponseField name="current_price" type="number">The subscription price in dollars.</ResponseField>
    <ResponseField name="quantity" type="integer">Seat count (active members).</ResponseField>

    <ResponseField name="scheduled_change" type="object">
      A pending period-end plan/interval change, or `null`: `{ plan_slug, interval, effective_at }`.
    </ResponseField>

    <ResponseField name="cancel_at_period_end" type="boolean" />

    <ResponseField name="scheduled_cancel" type="object">
      A pending cancellation, or `null`: `{ cancel_at_period_end, effective_at }`.
    </ResponseField>

    <ResponseField name="created_at" type="string">ISO 8601 timestamp.</ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="plan" type="object">
  The plan backing the subscription, or `null`.

  <Expandable title="plan">
    <ResponseField name="id" type="string" />

    <ResponseField name="name" type="string">For example `Pro`.</ResponseField>
    <ResponseField name="plan_slug" type="string">For example `pro`.</ResponseField>

    <ResponseField name="sort_order" type="integer" />

    <ResponseField name="is_enterprise" type="boolean" />

    <ResponseField name="prices" type="array">
      Price entries. In this billing object each entry uses the field name **`price`** (see the field-name note below): `{ interval, price, currency }`.
    </ResponseField>

    <ResponseField name="features" type="array">Feature labels for the plan.</ResponseField>
  </Expandable>
</ResponseField>

<CodeGroup>
  ```bash cURL theme={null}
  curl 'https://api.modulex.dev/subscriptions/organization-billing' \
    -H 'Authorization: Bearer mx_live_…' \
    -H 'X-Organization-ID: 7f1c9a2e-3b4d-4e6a-9c12-8a5b6d2f0e11'
  ```

  ```python Python theme={null}
  import asyncio
  from modulex import Modulex


  async def main():
      async with Modulex(
          api_key="mx_live_…",
          organization_id="7f1c9a2e-3b4d-4e6a-9c12-8a5b6d2f0e11",
      ) as mx:
          billing = await mx.subscriptions.organization_billing()
          if billing.has_subscription:
              sub = billing.subscription
              print(sub.status, sub.billing_interval, sub.current_period_end)


  asyncio.run(main())
  ```

  ```javascript JavaScript theme={null}
  // No subscriptions resource in the JS SDK — call REST directly.
  const res = await fetch(
    "https://api.modulex.dev/subscriptions/organization-billing",
    {
      headers: {
        Authorization: "Bearer mx_live_…",
        "X-Organization-ID": "7f1c9a2e-3b4d-4e6a-9c12-8a5b6d2f0e11",
      },
    },
  );
  const billing = await res.json();
  ```
</CodeGroup>

<Accordion title="Example response (active subscription)">
  ```json theme={null}
  {
    "has_subscription": true,
    "subscription": {
      "id": "sub_1QabcDEfghIJklmn",
      "status": "active",
      "current_period_start": "2026-06-01T00:00:00+00:00",
      "current_period_end": "2026-07-01T00:00:00+00:00",
      "billing_interval": "month",
      "current_price": 25.0,
      "quantity": 3,
      "scheduled_change": null,
      "cancel_at_period_end": false,
      "scheduled_cancel": null,
      "created_at": "2026-05-01T09:12:44+00:00"
    },
    "plan": {
      "id": "00000000-0000-0000-0000-0000000000pr",
      "name": "Pro",
      "plan_slug": "pro",
      "sort_order": 0,
      "is_enterprise": false,
      "prices": [{ "interval": "month", "price": 25.0, "currency": "usd" }],
      "features": ["Live team collaboration", "Email support"]
    }
  }
  ```
</Accordion>

### `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.

<ResponseField name="plans" type="array">
  The plan catalog.

  <Expandable title="plan item">
    <ResponseField name="plan_slug" type="string" />

    <ResponseField name="name" type="string" />

    <ResponseField name="sort_order" type="integer" />

    <ResponseField name="is_enterprise" type="boolean" />

    <ResponseField name="selectable" type="boolean">`false` for Enterprise.</ResponseField>

    <ResponseField name="prices" type="array">
      Price entries. In the catalog each entry uses the field name **`amount`** (not `price`): `{ interval, amount, currency }`.
    </ResponseField>

    <ResponseField name="discounted_amount" type="object">
      Per-interval discounted prices when a coupon applies: `{ month, year }`.
    </ResponseField>

    <ResponseField name="badge" type="string">An optional display badge.</ResponseField>
    <ResponseField name="features" type="array">Feature labels.</ResponseField>
    <ResponseField name="relation" type="string">`current`, `upgrade`, or `downgrade` relative to this organization.</ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="total" type="integer">
  The number of plans returned.
</ResponseField>

<CodeGroup>
  ```bash cURL theme={null}
  curl 'https://api.modulex.dev/subscriptions/organization-plans' \
    -H 'Authorization: Bearer mx_live_…' \
    -H 'X-Organization-ID: 7f1c9a2e-3b4d-4e6a-9c12-8a5b6d2f0e11'
  ```

  ```python Python theme={null}
  import asyncio
  from modulex import Modulex


  async def main():
      async with Modulex(
          api_key="mx_live_…",
          organization_id="7f1c9a2e-3b4d-4e6a-9c12-8a5b6d2f0e11",
      ) as mx:
          catalog = await mx.subscriptions.organization_plans()
          for plan in catalog.plans:
              # catalog prices use the `amount` field
              prices = [(p.interval, p.amount) for p in plan.prices]
              print(plan.plan_slug, plan.relation, prices)


  asyncio.run(main())
  ```

  ```javascript JavaScript theme={null}
  // No subscriptions resource in the JS SDK — call REST directly.
  const res = await fetch(
    "https://api.modulex.dev/subscriptions/organization-plans",
    {
      headers: {
        Authorization: "Bearer mx_live_…",
        "X-Organization-ID": "7f1c9a2e-3b4d-4e6a-9c12-8a5b6d2f0e11",
      },
    },
  );
  const { plans, total } = await res.json();
  ```
</CodeGroup>

<Warning>
  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.
</Warning>

## 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.

<ParamField header="Stripe-Signature" type="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`.
</ParamField>

<Note>
  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](/sdks/parity) for the full list of routes with no SDK method.
</Note>

#### 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 }`.

| Event type                                                            | Effect                                                                                  |
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `customer.subscription.created` / `.updated` / `.deleted`             | Watermarked mirror upsert of the subscription.                                          |
| `invoice.payment_failed` / `invoice.payment_action_required`          | Dunning: anchor the grace window and schedule a suspend job.                            |
| `invoice.paid` / `invoice.payment_succeeded`                          | Restore: clear the dunning anchor and any suspension. No fresh credit grant.            |
| `subscription_schedule.*`                                             | Re-mirror the linked subscription and persist a portal-scheduled cancel.                |
| `payment_intent.succeeded`                                            | Apply a [wallet](/billing/wallet) top-up credit (idempotent on the payment-intent id).  |
| `charge.refunded`                                                     | Apply a wallet refund (idempotent on the charge id). Never touches subscription status. |
| `product.*` / `price.*` / `coupon.*` / `promotion_code.*`             | Re-sync the catalog mirror (best-effort).                                               |
| `checkout.session.completed` / `customer.subscription.trial_will_end` | Logged and acknowledged; the business effect is handled elsewhere.                      |
| Unknown                                                               | Acknowledged and ignored: `{ "received": true, "ignored": true, "type": … }`.           |

<Warning>
  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`.
</Warning>

<Note>
  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](/billing/trials-dunning) and [Credits & metering](/billing/credits).
</Note>

#### Retries and dead-lettering

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

<Steps>
  <Step title="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.
  </Step>

  <Step title="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, … }`.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Success">
    The event is recorded for deduplication and acknowledged with **200**: `{ "received": true, "type": … }`.
  </Step>
</Steps>

<Note>
  **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.
</Note>

## 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:

| Python method                                                              | REST endpoint                             | Notes                                                 |
| -------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------- |
| `client.subscriptions.organization_plans()`                                | `GET /subscriptions/organization-plans`   | Catalog with per-org `relation`. Prices use `amount`. |
| `client.subscriptions.organization_billing()`                              | `GET /subscriptions/organization-billing` | Current subscription + plan. Plan prices use `price`. |
| `client.subscriptions.checkout_link(plan_slug, interval, *, plan_id=None)` | `POST /subscriptions/checkout-link`       | Params sent as **query**, not body.                   |
| `client.subscriptions.customer_portal()`                                   | `POST /subscriptions/customer-portal`     | Returns a portal URL.                                 |

<Warning>
  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](/billing/wallet); the transition endpoints are above on this page.
</Warning>

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](/sdks/parity); installation and configuration are on the [Python SDK](/sdks/python) page.

## Error shapes you may see

Subscription endpoints can return **two different `402` shapes**, and you must branch on both:

<CodeGroup>
  ```json DenialEnvelope (gate/upgrade) theme={null}
  {
    "code": "upgrade_payment_failed",
    "layer": "credit",
    "key": "7f1c9a2e-3b4d-4e6a-9c12-8a5b6d2f0e11",
    "current": null,
    "limit": 5000.0,
    "reason": "upgrade_payment_failed"
  }
  ```

  ```json HTTPException (paid-plan required) theme={null}
  {
    "detail": { "reason": "paid_subscription_required" }
  }
  ```
</CodeGroup>

* 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](/billing/usage-gating) 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](/api-reference/errors).

<Note>
  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](/sdks/errors-retries).
</Note>

## 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.

| Variable                      | Default                 | Controls                                                     |
| ----------------------------- | ----------------------- | ------------------------------------------------------------ |
| `STRIPE_API_KEY`              | `""`                    | Stripe secret key. Missing → `500` on any Stripe call.       |
| `STRIPE_WEBHOOK_SECRET`       | `""`                    | Webhook signature secret. Missing → `500` on the webhook.    |
| `FRONTEND_URL`                | `http://localhost:3000` | Base for the checkout success/cancel and portal return URLs. |
| `WEBHOOK_TRANSIENT_RETRY_CAP` | `5`                     | Transient retries before an event is dead-lettered.          |

<Note>
  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](/billing/wallet) and [credits](/billing/credits) configuration rather than the subscription surface.
</Note>

## Related pages

<CardGroup cols={2}>
  <Card title="Plans & pricing" icon="layer-group" href="/billing/plans">
    Free, Pro, Max, and Enterprise — allowances, limits, and both annual prices.
  </Card>

  <Card title="Wallet & top-ups" icon="wallet" href="/billing/wallet">
    The prepaid wallet, the top-up checkout, and auto top-up.
  </Card>

  <Card title="Trials & dunning" icon="hourglass-half" href="/billing/trials-dunning">
    Trial eligibility, grace periods, suspension, and recovery.
  </Card>

  <Card title="Usage gating & limits" icon="shield-check" href="/billing/usage-gating">
    The admission gate and its 402, 403, and 429 responses.
  </Card>

  <Card title="Python SDK" icon="python" href="/sdks/python">
    Install and configure the async client that exposes the subscriptions resource.
  </Card>

  <Card title="SDK parity matrix" icon="table-cells" href="/sdks/parity">
    Which routes each SDK covers — and where the gaps are.
  </Card>
</CardGroup>
