Skip to main content
Documentation

Payments

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

On this page

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.

Do not install Stripe libraries. Declare the catalog in the manifest files and use the SDK hooks and server helpers - that is the entire integration surface. You don't write Stripe code, register webhooks, or hold API keys. You don't even need a Stripe account to start declaring plans - but paid checkout stays unavailable until you complete Stripe Connect onboarding in the DeepSpace dashboard: until then, checkout attempts fail with the typed owner_connect_not_ready error, and deepspace deploy warns that paid checkout is not yet available.

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
    taxCode: 'txcd_10000000', // optional; defaults to digital services
  },
] as const
ts

One-time products - src/products.ts#

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

Removing a row from oneTimeProducts deactivates the product on the next deploy - it can no longer be purchased, but it is not deleted: historical invoices still reference it, and existing purchases stay valid and keep gating features.

See the plan manifest types for the full field list.

npx deepspace deploy
bash

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

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 })}
    />
  )
}
tsx

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

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
})
ts

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()}` },
})
ts

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>
  )
}
tsx

Product entitlements survive across sessions and devices - the platform tracks them per user. owned is true only while a matching non-refunded purchase exists: a full refund revokes the entitlement, while a partial refund keeps it (the customer paid for something they didn't fully unwind).

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' })
ts

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)
})
ts

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

Both refundInvoice and cancelSubscription forward the caller's JWT; the platform rejects anyone but the app owner with a 403 and the error code not_app_owner. That platform check protects the money path - your own admin gate on the route protects everything else the route does, so keep both.

Common pitfalls#

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.

`currentPeriodEnd` is Unix milliseconds

currentPeriodEnd and trialEndsAt are Unix milliseconds. Pass them straight to new Date() - no multiplication needed.

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.

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.

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.

Connect onboarding lives in the dashboard, not your app

Developer Stripe Connect onboarding happens at dashboard.deep.space/earnings, outside your app. Don't build any Stripe Connect UI yourself. Paid checkout is blocked until onboarding completes - the platform requires an account that can accept both charges and payouts before it will start a checkout session, so there is no state where sales accrue ahead of onboarding.

Next steps#