Skip to main content

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.

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.

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

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

export const oneTimeProducts = [
  { productId: 'pro_unlock', name: 'Pro Unlock', amountCents: 1999, description: '...' },
] as const
See the plan manifest types for the full field list.
npx deepspace deploy
The CLI warns about grandfathered subscribers when you change prices.

Subscribe a user

Call subscribe() from useSubscription. The hook navigates the browser to Stripe Checkout; the user lands back on your app with their subscription active.
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 /> and wire its onSelect to subscribe:
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.
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.

Gate a server route

The client checks are for UX only - anyone can call your API directly. Gate sensitive routes with requireSubscription from 'deepspace/server':
// 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:
import { getAuthToken } from 'deepspace'

const r = await fetch('/api/premium', {
  headers: { Authorization: `Bearer ${await getAuthToken()}` },
})
Use getSubscription for the read-only variant that returns the subscription object without throwing.

One-time charges

useCheckout 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.
Pass the same productId to the hook and to chargeOnce. The hook exposes owned so you can gate UI before the user pays.
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.

Cancel a subscription

cancelSubscription cancels one user, or every user on a given plan slug. The inbound request must carry the app-owner’s JWT.
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 refunds a charge by its local invoice ID. Wrap it behind your own admin check.
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

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.
currentPeriodEnd and trialEndsAt are Unix milliseconds. Pass them straight to new Date() - no multiplication needed.
Subscription minimums are 3/monthand3/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.
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.
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.
Developer Stripe Connect onboarding happens at dashboard.deep.space/earnings, outside your app. Don’t build any Stripe Connect UI yourself. Funds accumulate on the platform balance until onboarding completes.

Next steps