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

# Payments

> Charge users on a subscription or one-time basis with Stripe Checkout.

DeepSpace ships a Stripe-backed payment system. You declare your plans and products in two manifest files; the SDK gives you hooks for paywalls, checkouts, and self-service billing. The platform charges customers and routes funds to your connected Stripe account.

You don't write Stripe code, register webhooks, or hold API keys. You don't even need a Stripe account to start declaring plans - funds accumulate until you connect your Stripe account in the [DeepSpace dashboard](https://dashboard.deep.space/earnings).

## Declare what you sell

Two manifest files define the catalog. Edit, then run `npx deepspace deploy` to sync Products and Prices to Stripe.

### Subscription plans - `src/subscriptions.ts`

```ts theme={null}
export const subscriptionPlans = [
  { slug: 'free', name: 'Free', priceCents: 0 },
  {
    slug: 'pro',
    name: 'Pro',
    priceCents: 900,        // $9/month - minimum $3/mo
    yearlyCents: 9000,      // optional; minimum $12/year
    trialDays: 7,           // optional; max 365 days
  },
] as const
```

### One-time products - `src/products.ts`

```ts theme={null}
export const oneTimeProducts = [
  { productId: 'pro_unlock', name: 'Pro Unlock', amountCents: 1999, description: '...' },
] as const
```

See the [plan manifest types](/sdk-reference/client/payments#plan-manifest-types) for the full field list.

```bash theme={null}
npx deepspace deploy
```

The CLI warns about grandfathered subscribers when you change prices.

## Subscribe a user

Call `subscribe()` from [`useSubscription`](/sdk-reference/client/payments#usesubscription). The hook navigates the browser to Stripe Checkout; the user lands back on your app with their subscription active.

```tsx theme={null}
import { useSubscription } from 'deepspace'

function Paywall() {
  const sub = useSubscription()

  if (sub.isLoading) return null
  if (sub.isAtLeast('pro')) return <ProUI />
  return <button onClick={() => sub.subscribe('pro')}>Upgrade</button>
}
```

For a ready-made pricing UI, mount [`<PricingTable />`](/sdk-reference/client/payments#%3Cpricingtable-plans-onselect-%3E) and wire its `onSelect` to `subscribe`:

```tsx theme={null}
import { PricingTable, useSubscription } from 'deepspace'

function Pricing() {
  const { plans, tier, subscribe } = useSubscription()
  return (
    <PricingTable
      plans={plans}
      currentTier={tier}
      onSelect={(slug, interval) => subscribe(slug, { interval })}
    />
  )
}
```

`useSubscription` also exposes `openPortal()` for self-service billing - point a "Manage billing" button at it.

<Warning>
  **Gate features on `hasTier` / `isAtLeast`, never on `tier` alone.** A user whose card just failed has `tier: 'pro'` and `status: 'past_due'` - they keep the slug but lose entitlement. `sub.tier === 'pro'` leaks paid features to past-due, canceled, and unpaid users.
</Warning>

## Gate a server route

The client checks are for UX only - anyone can call your API directly. Gate sensitive routes with [`requireSubscription`](/sdk-reference/client/payments#requiresubscription-c-opts) from `'deepspace/server'`:

```ts theme={null}
// worker.ts
import { requireSubscription, SubscriptionAuthError, SubscriptionRequiredError } from 'deepspace/server'

app.get('/api/premium', async (c) => {
  try {
    await requireSubscription(c, { atLeast: 'pro' })
  } catch (e) {
    if (e instanceof SubscriptionAuthError)     return c.json({ error: 'unauthenticated' }, 401)
    if (e instanceof SubscriptionRequiredError) return c.json({ error: 'upgrade_required', required: e.required }, 402)
    throw e
  }
  // Protected logic here
})
```

The browser must attach the JWT to every gated request:

```ts theme={null}
import { getAuthToken } from 'deepspace'

const r = await fetch('/api/premium', {
  headers: { Authorization: `Bearer ${await getAuthToken()}` },
})
```

Use [`getSubscription`](/sdk-reference/client/payments#getsubscription-c) for the read-only variant that returns the subscription object without throwing.

## One-time charges

[`useCheckout`](/sdk-reference/client/payments#usecheckout-productid) handles non-recurring purchases in two modes - **product mode** for durable entitlements declared in `src/products.ts`, and **ad-hoc mode** for tips and donations.

<Tabs>
  <Tab title="Product mode">
    Pass the same `productId` to the hook and to `chargeOnce`. The hook exposes `owned` so you can gate UI before the user pays.

    ```tsx theme={null}
    import { useCheckout } from 'deepspace'

    function ProUnlock() {
      const co = useCheckout({ productId: 'pro_unlock' })

      if (co.owned) return <ProUI />
      return (
        <button onClick={() => co.chargeOnce({ productId: 'pro_unlock' })}>
          Buy
        </button>
      )
    }
    ```

    Product entitlements survive across sessions and devices - the platform tracks them per user.
  </Tab>

  <Tab title="Ad-hoc mode">
    For tips, donations, and "name your price" surfaces. The caller picks the amount.

    ```tsx theme={null}
    const co = useCheckout()

    await co.chargeOnce({
      amount: 500,
      name: 'Tip',
      description: 'Thanks!',
    })
    ```

    <Warning>
      **Ad-hoc charges can't be used to unlock features later.** Ad-hoc purchases have `productId: null`, so `ownsProduct(id)` will never return true for them. Use ad-hoc mode only when the transaction itself is the value. For durable entitlements, declare a row in `src/products.ts`.
    </Warning>
  </Tab>
</Tabs>

## Cancel a subscription

[`cancelSubscription`](/sdk-reference/client/payments#cancelsubscription-c-opts) cancels one user, or every user on a given plan slug. The inbound request must carry the app-owner's JWT.

```ts theme={null}
import { cancelSubscription } from 'deepspace/server'

app.post('/api/admin/cancel', async (c) => {
  // One user, end of current period (default):
  await cancelSubscription(c, { userId: 'user_abc' })
  return c.json({ ok: true })
})

// Or, for a one-off backfill - every user on a retired plan, batched 50 at a time:
// let res = await cancelSubscription(c, { planSlug: 'legacy_pro' })
// while (res.hasMore) res = await cancelSubscription(c, { planSlug: 'legacy_pro' })
```

Pass `atPeriodEnd: false` for immediate cancellation.

## Issue a refund

[`refundInvoice`](/sdk-reference/client/payments#refundinvoice-c-opts) refunds a charge by its local invoice ID. Wrap it behind your own admin check.

```ts theme={null}
import { refundInvoice } from 'deepspace/server'

app.post('/api/admin/refund', async (c) => {
  // Your admin check goes here.
  const { invoiceId } = await c.req.json<{ invoiceId: string }>()
  const r = await refundInvoice(c, {
    invoiceId,                       // local UUID, NOT stripe inv_xxx
    amount: 500,                     // optional partial in cents
    reason: 'requested_by_customer',
  })
  return c.json(r)
})
```

Constraints: 90-day refund window from `paidAt`, 50 refunds per 24h per app, no overdraw on partials. Dashboard-initiated refunds reconcile automatically.

## Common pitfalls

<AccordionGroup>
  <Accordion title="Tier ≠ entitled">
    A `past_due` subscriber keeps their tier slug but loses access. Always gate on `hasTier()` / `isAtLeast()` on the client, and `requireSubscription` on the server - all three check status, not just slug.
  </Accordion>

  <Accordion title="`currentPeriodEnd` is Unix milliseconds">
    `currentPeriodEnd` and `trialEndsAt` are Unix **milliseconds**. Pass them straight to `new Date()` - no multiplication needed.
  </Accordion>

  <Accordion title="Below-minimum prices fail at deploy">
    Subscription minimums are $3/month and $12/year. One-time minimum is \$1.00. Below these, Stripe's per-charge fee consumes the entire price. The deploy worker rejects the manifest before syncing to Stripe.
  </Accordion>

  <Accordion title="Never rename a plan's slug">
    The slug is the stable identifier for existing subscribers, server-side gates, and the underlying Stripe Product. Renaming on deploy is interpreted as "delete + create" - existing subscribers stay billed on the orphaned price. For branding changes, edit `name` instead.
  </Accordion>

  <Accordion title="Local state lags Checkout by 1–2 seconds">
    The Stripe webhook fires shortly after the user returns from Checkout. Call `sub.refresh()` / `co.refresh()` once on return. If state is still stale, refresh again on user action - don't write a tight retry loop.
  </Accordion>

  <Accordion title="Connect onboarding lives in the dashboard, not your app">
    Developer Stripe Connect onboarding happens at [dashboard.deep.space/earnings](https://dashboard.deep.space/earnings), outside your app. Don't build any Stripe Connect UI yourself. Funds accumulate on the platform balance until onboarding completes.
  </Accordion>
</AccordionGroup>

## Next steps

* [Payments reference](/sdk-reference/client/payments) - full prop, option, and return-shape tables.
* [Authentication](/guides/authentication) - gate UI behind sign-in.
* [Server actions](/guides/server-actions) - admin-only routes for cancellations and refunds.
