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 })}
/>
)
}
| Prop | Type | Description |
|---|
plans | PlanInfo[] | Plans from useSubscription().plans. |
interval | 'month' | 'year' (optional) | Which interval to show prices for. Defaults to 'month'. |
currentTier | string (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