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.

// Client
import { useSubscription, useCheckout, PricingTable } from 'deepspace'

// Server (worker)
import {
  requireSubscription, getSubscription,
  cancelSubscription, refundInvoice,
  SubscriptionAuthError, SubscriptionRequiredError,
  RefundError, CancelSubscriptionError,
} from 'deepspace/server'

useSubscription()

type SubscriptionStatus =
  | 'none' | 'trialing' | 'active' | 'past_due' | 'canceled'
  | 'incomplete' | 'incomplete_expired' | 'unpaid' | 'paused'

type PlanPrice = { interval: 'month' | 'year'; priceCents: number; currency?: string }

type PlanInfo = {
  slug: string
  rank: number
  name: string
  trialDays?: number | null
  prices: PlanPrice[]
}

type SubscribeResult = {
  url: string | null
  immediate: boolean
  requiresPayment?: boolean
  hostedInvoiceUrl?: string | null
}

function useSubscription(): {
  // State
  tier: string                              // current plan slug; 'free' if no plan
  status: SubscriptionStatus
  entitled: boolean                          // true iff status is active or trialing
  interval: 'month' | 'year' | null
  currentPeriodEnd: number | null            // unix seconds
  cancelAtPeriodEnd: boolean
  trialEndsAt: number | null                 // unix seconds
  plans: PlanInfo[]                          // catalog from /me; pass straight to <PricingTable>
  isLoading: boolean
  error: string | null

  // Predicates
  hasTier:   (slug: string) => boolean       // strict slug match AND entitled
  isAtLeast: (slug: string) => boolean       // rank ≥ target AND entitled

  // Actions - both navigate the browser to Stripe Checkout / portal automatically
  subscribe:  (planSlug: string, opts?: { interval?: 'month' | 'year'; returnUrl?: string; cancelUrl?: string }) => Promise<SubscribeResult>
  openPortal: (returnUrl?: string) => Promise<{ url: string }>

  // Refresh
  refresh: () => Promise<void>
}
subscribe and openPortal redirect the page when they receive a URL from the server. Even though they return a promise, the page will usually navigate away before it resolves - only await them when you specifically need to inspect the result (e.g., immediate: true in-place plan changes).
Gate features on hasTier / isAtLeast, never on tier alone. A past_due Pro subscriber keeps tier === 'pro' but entitled === false.

useCheckout({ productId?, ... })

type ChargeOnceResult = { url: string }

type Purchase = {
  id: string
  productId: string | null
  name: string
  amount: number      // cents, post-tax (Stripe's amount_total)
  currency: string
  paidAt: string      // ISO timestamp
}

function useCheckout(options?: { productId?: string }): {
  chargeOnce: (opts:
    | { productId: string; returnUrl?: string; cancelUrl?: string }
    | { amount: number; name: string; description?: string; returnUrl?: string; cancelUrl?: string; productId?: never }
  ) => Promise<ChargeOnceResult>
  isLoading: boolean
  error: string | null

  purchases: Purchase[]
  /** True iff `options.productId` was passed AND a matching purchase exists. */
  owned: boolean
  ownsProduct: (productId: string) => boolean
  refresh: () => Promise<void>
}
Two modes:
  • Product mode - pass { productId } to both the hook and chargeOnce. The server resolves the amount and name from your declared product catalog. Entitlement is checked via owned / ownsProduct.
  • Ad-hoc mode - pass { amount, name } (and optionally description) to chargeOnce. The purchase has productId: null and cannot be used to gate features.
chargeOnce redirects the browser to Stripe Checkout on success, so callers usually don’t see the promise resolve - await is only meaningful when you want to inspect the URL or surface an error before redirecting.

<PricingTable plans={...} onSelect={...} />

A ready-made pricing UI wired to the plan catalog. Stateless - pass the plan catalog in and call subscribe() from your onSelect handler.
import { PricingTable, useSubscription } from 'deepspace'

function Pricing() {
  const { plans, tier, subscribe } = useSubscription()
  return (
    <PricingTable
      plans={plans}
      currentTier={tier}
      onSelect={(slug, interval) => subscribe(slug, { interval })}
    />
  )
}
PropTypeDescription
plansPlanInfo[]Plans from useSubscription().plans.
interval'month' | 'year' (optional)Which interval to show prices for. Defaults to 'month'.
currentTierstring (optional)The viewer’s current plan slug - disables that plan’s button and labels it “Current plan”.
onSelect(planSlug: string, interval: 'month' | 'year') => void | Promise<void>Called when the user clicks a plan’s button. Wire this to useSubscription().subscribe.

Server helpers

requireSubscription(c, opts)

Throws if the caller’s subscription doesn’t meet the requirement. Use inside Hono route handlers.
type SubscriptionRead = {
  tier: string
  status: SubscriptionStatus
  currentPeriodEnd: number | null
  cancelAtPeriodEnd: boolean
  trialEndsAt: number | null
  plans: PlanInfo[]
}

function requireSubscription(
  c: Context,
  opts: { tier?: string; atLeast?: string },
): Promise<SubscriptionRead>

class SubscriptionAuthError extends Error {       // 401-shaped - identity failure
  readonly status: number
}
class SubscriptionRequiredError extends Error {   // 402-shaped - tier failure
  readonly required: string
  readonly current: string
}
Both tier and atLeast are optional. Pass tier for a strict slug match, atLeast for a rank-or-higher check, or both. The helper always enforces the entitlement gate first (status must be active or trialing - free always passes).
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
})

getSubscription(c)

Read-only variant. Throws SubscriptionAuthError for 401/403 from the api-worker, or a generic Error for other failures.
function getSubscription(c: Context): Promise<SubscriptionRead>

cancelSubscription(c, opts)

Cancel one user or every user on a retired plan. Requires the inbound request to carry the app-owner’s JWT - the api-worker rejects anyone else.
function cancelSubscription(
  c: Context,
  opts: {
    userId?: string       // mutually exclusive with planSlug
    planSlug?: string     // mutually exclusive with userId
    atPeriodEnd?: boolean // default true
    reason?: string       // optional audit string
  },
): Promise<{
  success: boolean
  canceled: number
  failures: Array<{ stripeSubscriptionId: string; error: string }>
  atPeriodEnd: boolean
  hasMore: boolean
}>

class CancelSubscriptionError extends Error {
  readonly status: number
}
Batched at 50. Loop the call while hasMore is true - cancel_at_period_end is idempotent so re-flagging is a no-op.

refundInvoice(c, opts)

Refund a charge by its local invoice ID (not the Stripe inv_xxx ID).
function refundInvoice(
  c: Context,
  opts: {
    invoiceId: string
    amount?: number                                    // cents; full refund if omitted
    reason?: 'requested_by_customer' | 'duplicate' | 'fraudulent'
    requestNonce?: string                              // idempotency key; auto-generated if omitted
  },
): Promise<{
  success: boolean
  stripeRefundId: string
  amountRefunded: number
  status: 'pending' | 'succeeded' | 'failed' | 'canceled' | 'requires_action' | null
}>

class RefundError extends Error {
  readonly status: number
}
Constraints: 90-day window, 50 per 24h per app, no overdraw. As with cancelSubscription, the inbound request must carry the app-owner’s JWT.

Plan manifest types

Declared in src/subscriptions.ts and src/products.ts:
type SubscriptionPlan = {
  slug: string
  name: string
  priceCents: number        // monthly; 0 = free tier
  yearlyCents?: number
  trialDays?: number        // max 90
  taxCode?: string
}

type OneTimeProduct = {
  productId: string         // stable entitlement key
  name: string
  amountCents: number       // min 100 ($1.00)
  description?: string
}

See also