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

# Wallet & top-ups

> The ModuleX prepaid wallet: balance, manual top-ups ($10–$1000), auto top-up thresholds, and the paid-only overage toggle — with request and response schemas, every error, and edge cases.

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>;
};

The **wallet** is your organization's prepaid balance. It covers managed usage that goes beyond your monthly plan [credit](/billing/credits) allowance, once **overage** is turned on. Your plan allowance is always spent first; the wallet only comes into play after it runs out.

This page is the technical reference for the wallet: how the balance is stored, how to read it, how manual top-ups and auto top-up work, and how the paid-only overage toggle behaves. For where the wallet fits in the larger billing model, start with [Billing & credits overview](/billing/overview).

<Note>
  The wallet and overage are **paid-plan features**. On the [Free plan](/billing/plans) there is no wallet spend — work stops when the allowance is exhausted. See [Usage gating & limits](/billing/usage-gating) for what happens at the gate.
</Note>

## How the wallet works

The wallet holds a balance in **US dollars** and converts to credits at the same fixed rate as your plan allowance: **100 credits = $1.00**, so **one credit is $0.01**. A \$10 top-up therefore adds 1,000 credits of spending power.

<Steps>
  <Step title="Turn on overage (paid only)">
    An owner or admin enables overage for the organization. Until overage is on, managed work stops once your plan allowance is empty — the wallet is not touched.
  </Step>

  <Step title="Fund the wallet">
    Add a prepaid balance with a manual top-up ($10–$1000) through Stripe Checkout, or let auto top-up keep it funded for you.
  </Step>

  <Step title="Spend the wallet on overage">
    Once your plan allowance is exhausted, admitted managed work is paid for from the wallet. Spending is reconciled against the wallet automatically as runs settle.
  </Step>
</Steps>

<MediaEmbed id="MX-MEDIA-1280" type="screenshot" caption={"The wallet section of the billing settings page in the ModuleX app, showing the balance, the overage toggle, and the top-up control."} />

### Where the balance lives

The wallet is one row per organization. Its balance can go **negative** because overage is reconciled after work settles, so a single burst of usage can briefly push the balance below zero.

<ResponseField name="balance" type="number (USD)">
  The wallet balance in dollars, stored as a high-precision decimal. May be **negative** after overage reconciliation. A missing wallet reads as `0` — never as unlimited.
</ResponseField>

<ResponseField name="extra_usage_enabled" type="boolean">
  Whether overage spending is turned on for the organization. When `false`, the wallet is never spent, even if it holds a balance.
</ResponseField>

<ResponseField name="auto_topup_threshold" type="number (USD) | null">
  The balance at which auto top-up fires, in dollars. Default **\$5.00**. `null` if not configured for the organization.
</ResponseField>

<ResponseField name="auto_topup_amount" type="number (USD) | null">
  How much auto top-up adds per charge, in dollars. Default **\$20.00**. `null` if not configured.
</ResponseField>

<ResponseField name="auto_topup_disabled" type="boolean">
  `true` when auto top-up has been turned off — including automatically, after too many failed charges (see [Auto top-up](#auto-top-up)).
</ResponseField>

<Note>
  Managing the wallet belongs to the organization, not to an individual. Reading and changing it is reserved for **owners and admins**; opening a top-up is **owner-only**. See [Roles & permissions](/security/roles-permissions) and [Organizations, roles & membership](/concepts/organizations-roles).
</Note>

## Authentication and the wallet endpoints

The wallet is managed through the `/subscriptions/wallet/*` endpoints. These are **organization-management** endpoints used by the app's billing settings, so they authenticate with your bearer token and require an organization context header:

* `Authorization: Bearer <token>` — your bearer token.
* `X-Organization-ID: <org_uuid>` — the organization whose wallet you are managing. **Required**; a missing or empty value is rejected.

<Warning>
  The wallet endpoints are **not** part of the public SDK surface. Neither the [JavaScript SDK](/sdks/javascript) nor the [Python SDK](/sdks/python) exposes a wallet method — see the [SDK ⇄ API parity matrix](/sdks/parity). The examples below show cURL only because there is no SDK equivalent to call. Day to day, the wallet is managed in the app's billing settings rather than from code.
</Warning>

All examples use the base URL `https://api.modulex.dev`. See [Base URLs & versioning](/api-reference/environments) for environments.

## Read the wallet

Returns the balance, the overage toggle, and the auto top-up configuration. Lazily creates the wallet row the first time it is read, so a brand-new organization gets a zero-balance wallet with overage off.

**`GET /subscriptions/wallet`** — owner or admin.

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api.modulex.dev/subscriptions/wallet \
    -H "Authorization: Bearer mx_live_8f3a2c1d9b7e4f60a1c2d3e4f5061728" \
    -H "X-Organization-ID: 7f1c9a3e-2b6d-4c8a-9e10-3f4a5b6c7d8e"
  ```
</CodeGroup>

<ResponseField name="200 OK" type="object">
  The wallet state.

  <Expandable title="response body">
    <ResponseField name="balance" type="number">Wallet balance in USD. May be negative.</ResponseField>
    <ResponseField name="extra_usage_enabled" type="boolean">Whether overage is on.</ResponseField>
    <ResponseField name="auto_topup_threshold" type="number | null">Auto top-up trigger balance in USD.</ResponseField>
    <ResponseField name="auto_topup_amount" type="number | null">Auto top-up charge in USD.</ResponseField>
    <ResponseField name="auto_topup_disabled" type="boolean">Whether auto top-up is off.</ResponseField>
  </Expandable>
</ResponseField>

```json Example 200 response theme={null}
{
  "balance": 12.5,
  "extra_usage_enabled": true,
  "auto_topup_threshold": 5.0,
  "auto_topup_amount": 20.0,
  "auto_topup_disabled": false
}
```

## The overage toggle

Overage is the switch that decides whether the wallet is ever spent. When it is **off**, your organization can only spend its plan allowance; once that runs out, managed work is declined at the gate. When it is **on**, work that exceeds the allowance is paid for from the wallet.

**`PATCH /subscriptions/wallet/extra-usage`** — owner or admin. **Paid-only.**

<ParamField body="extra_usage_enabled" type="boolean" required>
  Whether to turn overage on (`true`) or off (`false`). The value is coerced to a boolean.
</ParamField>

<ParamField body="enabled" type="boolean" deprecated>
  Legacy alias for `extra_usage_enabled`, accepted for backward compatibility. Prefer `extra_usage_enabled`, which matches the field name in the `GET` response.
</ParamField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X PATCH https://api.modulex.dev/subscriptions/wallet/extra-usage \
    -H "Authorization: Bearer mx_live_8f3a2c1d9b7e4f60a1c2d3e4f5061728" \
    -H "X-Organization-ID: 7f1c9a3e-2b6d-4c8a-9e10-3f4a5b6c7d8e" \
    -H "Content-Type: application/json" \
    -d '{"extra_usage_enabled": true}'
  ```
</CodeGroup>

<ResponseField name="200 OK" type="object">
  <Expandable title="response body">
    <ResponseField name="extra_usage_enabled" type="boolean">The new overage state.</ResponseField>
  </Expandable>
</ResponseField>

```json Example 200 response theme={null}
{ "extra_usage_enabled": true }
```

### Paid-only: the 402 you get on Free

Turning overage **on** requires an active paid subscription. If your organization is on the Free plan or has no active subscription, the request is declined:

```json 402 Payment Required theme={null}
{ "detail": { "reason": "paid_subscription_required" } }
```

<Warning>
  This `402` is a plain `HTTPException` shape — `{"detail": {"reason": "paid_subscription_required"}}` — **not** the flat `DenialEnvelope` shape that the usage gate returns. The two billing `402` contracts are different, and a client that handles billing errors must branch on both. The gate's wallet denials (`wallet_overage_disabled`, `wallet_insufficient`) use the `DenialEnvelope` shape covered in [Usage gating & limits](/billing/usage-gating) and [Errors & status codes](/api-reference/errors). Turning overage **off** is always allowed, on any plan.
</Warning>

### Turning overage on sets a baseline

When you switch overage from off to on, ModuleX takes a snapshot of how many credits the current billing period has already used and records it as the **overage baseline**. From that point forward, the wallet only pays for overage incurred *after* you turned it on — it never retroactively bills for plan-allowance overflow that built up while overage was off.

The reconciler bills wallet overage against a floor of `max(plan allowance, overage baseline)`, so flipping the toggle on does not produce a surprise charge for past usage. The baseline is re-snapshotted on every off-to-on transition, scoped to the current subscription and billing period.

## Manual top-ups

A manual top-up opens a one-time Stripe Checkout to add a prepaid balance to the wallet. The amount is yours to choose within a fixed range, and the credit is applied to the wallet only after the payment succeeds.

**`POST /subscriptions/wallet/topup`** — **owner-only.** **Paid-only.**

<ParamField body="amount_usd" type="number" required>
  The dollar amount to add to the wallet. Must be **at least $10 and at most $1,000**, finite, and have **no sub-cent precision** (no fraction smaller than a whole cent). The credited amount equals the amount paid; at $0.01 per credit, `amount_usd` × 100 credits are added — for example, $10 adds 1,000 credits.
</ParamField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/subscriptions/wallet/topup \
    -H "Authorization: Bearer mx_live_8f3a2c1d9b7e4f60a1c2d3e4f5061728" \
    -H "X-Organization-ID: 7f1c9a3e-2b6d-4c8a-9e10-3f4a5b6c7d8e" \
    -H "Content-Type: application/json" \
    -d '{"amount_usd": 50}'
  ```
</CodeGroup>

<ResponseField name="200 OK" type="object">
  The Checkout link to open. Opening it does not yet credit the wallet — see the note below.

  <Expandable title="response body">
    <ResponseField name="url" type="string">The Stripe Checkout URL to redirect the payer to.</ResponseField>
    <ResponseField name="amount_usd" type="number">The validated dollar amount that will be charged.</ResponseField>
    <ResponseField name="credits" type="integer">The number of credits the wallet will receive once the payment succeeds.</ResponseField>
  </Expandable>
</ResponseField>

```json Example 200 response theme={null}
{
  "url": "https://checkout.stripe.com/c/pay/cs_live_a1B2c3D4e5F6g7H8i9J0",
  "amount_usd": 50.0,
  "credits": 5000
}
```

<Warning>
  The top-up call returns a Checkout **link** — it does **not** credit the wallet. The balance is increased exactly once, after Stripe confirms the payment, through the `payment_intent.succeeded` webhook. That step is idempotent on the payment, so a duplicate or replayed event never double-credits. For the webhook contract, see [Subscriptions & Stripe](/billing/subscription-lifecycle).
</Warning>

### Top-up amounts at a glance

| `amount_usd` | Credits added | Notes          |
| ------------ | ------------- | -------------- |
| `10`         | 1,000         | Minimum top-up |
| `50`         | 5,000         |                |
| `100`        | 10,000        |                |
| `1000`       | 100,000       | Maximum top-up |

### Top-up errors

The amount is validated on the server before any Stripe or database work, so an invalid request never opens a Checkout.

<ResponseField name="400 Bad Request" type="object">
  Returned for an invalid amount or a misconfigured organization.

  <Expandable title="when you get a 400">
    <ResponseField name="amount_usd must be a number" type="detail">`amount_usd` is missing or not numeric.</ResponseField>
    <ResponseField name="amount_usd must be a finite number" type="detail">`amount_usd` is not finite (for example, infinity or NaN).</ResponseField>
    <ResponseField name="amount_usd must be between 10 and 1000" type="detail">The amount is below $10 or above $1,000.</ResponseField>
    <ResponseField name="amount_usd cannot have sub-cent precision" type="detail">The amount has a fraction smaller than one cent.</ResponseField>
    <ResponseField name="Organization is not configured with Stripe customer" type="detail">The organization has no Stripe customer record yet.</ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="402 Payment Required" type="object">
  The organization is on Free or has no active paid subscription: `{"detail": {"reason": "paid_subscription_required"}}`.
</ResponseField>

<ResponseField name="404 Not Found" type="object">
  The organization could not be found: `{"detail": "Organization not found"}`.
</ResponseField>

<ResponseField name="500 Internal Server Error" type="object">
  Stripe is not configured on the server (no API key): `{"detail": "Stripe API key is not configured"}`.
</ResponseField>

<ResponseField name="502 Bad Gateway" type="object">
  Stripe rejected the Checkout creation: `{"detail": "Stripe error: …"}`.
</ResponseField>

<Note>
  These `400` / `402` / `404` / `500` / `502` responses use the plain `{"detail": …}` shape (or `{"detail": {"reason": …}}` for the paid gate), because `/subscriptions/wallet/topup` is an organization-management route rather than a metered-run surface. It does **not** return the gate's `DenialEnvelope`. See [Errors & status codes](/api-reference/errors) for the full envelope taxonomy.
</Note>

## Auto top-up

Auto top-up keeps the wallet funded without manual action. When enabled, ModuleX charges the configured amount whenever the balance falls to the threshold, so overage usage is not interrupted.

| Setting                            | Default                    | Per-organization override | Meaning                                                                                                                                             |
| ---------------------------------- | -------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Threshold (`auto_topup_threshold`) | **\$5.00**                 | Yes                       | The balance at which a top-up fires. The trigger is proactive — it fires before the balance reaches zero, so a run is not paused waiting for funds. |
| Amount (`auto_topup_amount`)       | **\$20.00**                | Yes                       | How much each automatic charge adds to the wallet.                                                                                                  |
| Failure cutoff                     | **3 consecutive failures** | No                        | After three consecutive declined charges, auto top-up is turned off and `auto_topup_disabled` is set to `true`.                                     |

When an auto top-up charge succeeds, the credit lands through the same `payment_intent.succeeded` path as a manual top-up, and the consecutive-failure counter and the `auto_topup_disabled` flag are reset.

<Warning>
  Auto top-up settings appear in the wallet response (`auto_topup_threshold`, `auto_topup_amount`, `auto_topup_disabled`), and the defaults above are fixed in ModuleX's billing configuration. The exact API for changing the threshold and amount, and for re-enabling auto top-up after it has been disabled, is managed in the app's billing settings; a dedicated public endpoint for editing these values is not part of the documented wallet API at this time.
</Warning>

## Refunds

A refund issued through Stripe is mirrored back to the wallet as a negative ledger entry, decreasing the balance by the refunded amount. The mirror is idempotent on the Stripe charge, so a replayed refund event is applied only once. A refund **never** changes your subscription status or plan entitlements — it only adjusts the wallet balance.

## Edge cases and behavior to know

<AccordionGroup>
  <Accordion title="The balance can go negative">
    Overage is reconciled after managed work settles, so a burst of usage can push the wallet below zero before the next top-up. A negative balance is expected and self-corrects on your next top-up or auto top-up. Reading a wallet that has never existed returns `0`, never an unlimited balance.
  </Accordion>

  <Accordion title="Overage off but a balance present">
    If `extra_usage_enabled` is `false`, the wallet is never spent — even if it holds a positive balance. Once your plan allowance is gone, managed work is declined at the gate until you turn overage back on. The balance is preserved.
  </Accordion>

  <Accordion title="Plan allowance is always spent first">
    The wallet only covers usage **beyond** your monthly plan allowance. Within the allowance, no wallet spend occurs. See [Credits & metering](/billing/credits) for what consumes the allowance.
  </Accordion>

  <Accordion title="Top-up link does not equal credit">
    Creating a top-up returns a Checkout URL; the wallet is credited only after the payment succeeds and the `payment_intent.succeeded` webhook is processed. If a payer abandons Checkout, no credit is applied.
  </Accordion>

  <Accordion title="Wallet denials at the gate use a different shape">
    When overage is off or the wallet cannot cover a piece of work, the **usage gate** declines it with a flat `DenialEnvelope` `402` (`wallet_overage_disabled` or `wallet_insufficient`) — not the `{"detail": {"reason": "paid_subscription_required"}}` shape returned by the wallet management endpoints. See [Usage gating & limits](/billing/usage-gating).
  </Accordion>
</AccordionGroup>

## Related pages

<CardGroup cols={2}>
  <Card title="Credits & metering" icon="coins" href="/billing/credits">
    What a credit is and exactly what consumes credits and the wallet.
  </Card>

  <Card title="Usage gating & limits" icon="shield-check" href="/billing/usage-gating">
    The gate that decides whether the wallet is spent, and its 402/403/429 responses.
  </Card>

  <Card title="Plans & pricing" icon="layer-group" href="/billing/plans">
    Which plans include overage and the wallet, and what each allowance is.
  </Card>

  <Card title="Subscriptions & Stripe" icon="credit-card" href="/billing/subscription-lifecycle">
    The Stripe webhook contract that applies top-ups and refunds to the wallet.
  </Card>

  <Card title="Errors & status codes" icon="triangle-exclamation" href="/api-reference/errors">
    Every billing error shape, including both 402 contracts.
  </Card>

  <Card title="Billing & credits overview" icon="circle-info" href="/billing/overview">
    How plans, credits, the wallet, and the gate fit together.
  </Card>
</CardGroup>
