StarterApp Docs
Packages

@workspace/billing

UseAutumn server helpers for subscription management

Stripe integration through UseAutumn for subscriptions, feature gating, and usage tracking. This package provides server-side billing primitives that work directly with BetterAuth user IDs.

Package Exports

Core Server Functions

getAutumnConfig()

Returns validated Autumn configuration from server environment.

Config usage
import { getAutumnConfig } from "@workspace/billing/server";

const config = getAutumnConfig();
// { secretKey: string }

Environment Requirement

Requires AUTUMN_SECRET_KEY in server environment. In development, defaults to am_sk_dev_placeholder. In production, must be a real Autumn API key.

createServerAutumnClient()

Creates an Autumn SDK instance configured with server credentials.

import { createServerAutumnClient } from "@workspace/billing/server";

export async function GET() {
  const client = createServerAutumnClient();

  // Use client.check(), client.report(), etc.
  const { data } = await client.check({
    customer_id: userId,
    feature_id: "api-calls"
  });

  return Response.json({ allowed: data?.allowed });
}

Client Methods

Prop

Type

checkFeatureAccess()

Convenience function that wraps client.check() with error handling.

async function checkFeatureAccess(
  userId: string,
  featureId: string
): Promise<{
  allowed: boolean;
  product_name: string | null;
  usage: any | null;
}>
import { headers } from "next/headers";
import { auth } from "@workspace/auth/server";
import { checkFeatureAccess } from "@workspace/billing";
import { secureUserJson, secureErrorJson } from "@workspace/security";

export async function POST(request: Request) {
  const session = await auth.api.getSession({
    headers: await headers()
  });

  if (!session?.user?.id) {
    return secureErrorJson(
      { message: "Unauthorized", code: "UNAUTHORIZED" },
      { status: 401 }
    );
  }

  const access = await checkFeatureAccess(
    session.user.id,
    "premium-feature"
  );

  if (!access.allowed) {
    return secureErrorJson(
      { message: "Upgrade required", code: "PAYMENT_REQUIRED" },
      { status: 402 }
    );
  }

  // Process premium feature...
  return secureUserJson({ success: true });
}
{
  allowed: boolean;        // True if user has access
  product_name: string | null;  // Currently always null
  usage: any | null;       // Usage data if available
}

On error, returns { allowed: false, product_name: null, usage: null }.

Shared Configuration

BILLING_PRODUCT_IDS

Product identifiers that match the Autumn configuration.

import { BILLING_PRODUCT_IDS } from "@workspace/billing";

const productId = BILLING_PRODUCT_IDS.paid;
// "starterapp-demo-paid"

const premiumId = BILLING_PRODUCT_IDS.premium;
// "starterapp-demo-premium"

Keep in Sync

These constants must match product IDs in autumn.config.ts and the Autumn dashboard. Inconsistent IDs cause checkout failures.

BILLING_PLAN_DETAILS

UI metadata for pricing tables. Consumed by @workspace/ui/components/pricing-plan-grid.

import { BILLING_PLAN_DETAILS } from "@workspace/billing";

// Array of ProductDetails objects
type ProductDetails = {
  id: string;
  name: string;
  description: string;
  price: {
    primaryText: string;    // "$10"
    secondaryText: string;  // "per month"
  };
  buttonText: string;
  recommendText?: string;
  everythingFrom?: string;
  items: Array<{ primaryText: string }>;
};

Type Exports

import type {
  BillingError,
  BillingCustomer,
  SubscriptionStatus
} from "@workspace/billing";

BillingError

type BillingError = {
  kind: "Billing";
  message: string;
  code?: string;
  cause?: unknown;
};

BillingCustomer

type BillingCustomer = {
  userId: string;
  autumnCustomerId: string;
  email?: string;
  createdAt: number;
  updatedAt: number;
};

SubscriptionStatus

type SubscriptionStatus = {
  hasAccess: boolean;
  productId?: string;
  status?: "active" | "canceled" | "past_due" | "unpaid";
  currentPeriodEnd?: number;
};

Architecture

Identity Mapping

UseAutumn uses BetterAuth user IDs directly as customer IDs. No separate customer table required.

User authenticates via BetterAuth, receives userId

Autumn webhook handler identifies users via auth.api.getSession()

Billing operations use userId as customer_id in Autumn API calls

This one-to-one mapping eliminates synchronization bugs between authentication and billing systems.

Server-Only Design

All billing logic runs server-side to prevent tampering. Client components receive only display data, never billing state or access control logic.

// ❌ Never do this
"use client";
import { checkFeatureAccess } from "@workspace/billing";

// ✅ Always server-side
export async function GET() {
  const access = await checkFeatureAccess(userId, featureId);
  // ...
}

Integration Patterns

Feature Gating

import { headers } from "next/headers";
import { auth } from "@workspace/auth/server";
import { checkFeatureAccess } from "@workspace/billing";
import { secureUserJson, secureErrorJson } from "@workspace/security";

export async function POST(request: Request) {
  const session = await auth.api.getSession({
    headers: await headers()
  });

  if (!session?.user?.id) {
    return secureErrorJson(
      { message: "Unauthorized", code: "UNAUTHORIZED" },
      { status: 401 }
    );
  }

  const access = await checkFeatureAccess(
    session.user.id,
    "data-export"
  );

  if (!access.allowed) {
    return secureErrorJson(
      {
        message: "This feature requires a Premium subscription",
        code: "PAYMENT_REQUIRED"
      },
      { status: 402 }
    );
  }

  const data = await generateExport(session.user.id);
  return secureUserJson({ downloadUrl: data.url });
}
import { headers } from "next/headers";
import { auth } from "@workspace/auth/server";
import { checkFeatureAccess } from "@workspace/billing";
import { redirect } from "next/navigation";

export default async function AnalyticsPage() {
  const session = await auth.api.getSession({
    headers: await headers()
  });

  if (!session?.user?.id) {
    redirect("/login");
  }

  const access = await checkFeatureAccess(
    session.user.id,
    "analytics"
  );

  if (!access.allowed) {
    return (
      <div>
        <h1>Premium Feature</h1>
        <p>Upgrade to access analytics.</p>
        <a href="/dashboard/billing">View Plans</a>
      </div>
    );
  }

  return <AnalyticsDashboard userId={session.user.id} />;
}
// ⚠️ Billing checks in middleware add latency
// Only use for critical path protection
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { auth } from "@workspace/auth/server";
import { checkFeatureAccess } from "@workspace/billing";

export async function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith("/dashboard/premium")) {
    const session = await auth.api.getSession({
      headers: request.headers
    });

    if (!session?.user?.id) {
      return NextResponse.redirect(new URL("/login", request.url));
    }

    const access = await checkFeatureAccess(
      session.user.id,
      "premium-access"
    );

    if (!access.allowed) {
      return NextResponse.redirect(
        new URL("/dashboard/billing", request.url)
      );
    }
  }

  return NextResponse.next();
}

Autumn Webhook Handler

The /api/autumn/[...all]/route.ts handler processes Stripe webhooks via UseAutumn:

app/api/autumn/[...all]/route.ts
import { Autumn } from "autumn-js";
import { auth } from "@workspace/auth/server";
import { loadServerEnv } from "@workspace/env/server";

const env = loadServerEnv();

const handler = Autumn.nextHandler({
  secretKey: env.AUTUMN_SECRET_KEY,
  identify: async (req) => {
    const session = await auth.api.getSession({
      headers: req.headers
    });
    return session?.user?.id ?? null;
  },
});

export { handler as GET, handler as POST };

Heads up

Never modify the identify function without understanding the security implications. This function determines which user receives billing access.

Error Handling

All billing functions handle errors gracefully, returning safe defaults:

Safe defaults
// checkFeatureAccess never throws
const access = await checkFeatureAccess(userId, featureId);
// On error: { allowed: false, product_name: null, usage: null }

For explicit error handling:

Explicit error handling
import { createServerAutumnClient } from "@workspace/billing/server";

const client = createServerAutumnClient();

try {
  const result = await client.check({
    customer_id: userId,
    feature_id: "feature-id"
  });

  if (!result.data?.allowed) {
    // Access denied
  }
} catch (error) {
  // Network error, invalid credentials, etc.
  // Fail closed: deny access
}

Testing

In test environments, use placeholder keys:

.env.test
# .env.test
AUTUMN_SECRET_KEY=am_sk_test_placeholder

Mock billing responses in tests:

Test mocks
import { vi } from "vitest";

vi.mock("@workspace/billing", () => ({
  checkFeatureAccess: vi.fn().mockResolvedValue({
    allowed: true,
    product_name: null,
    usage: null
  })
}));

Security Considerations

Server-Only Execution

Billing logic never runs in browsers. Client tampering cannot bypass payment gates.

Fail-Closed Design

Errors deny access by default. Network failures, invalid keys, or timeouts all result in allowed: false.

Real-Time Validation

Every feature access check hits the Autumn API. No client-side caching of subscription state.

Rate Limiting

Apply rate limits to billing-protected routes to prevent abuse through repeated access attempts.

The billing package abstracts Stripe complexity behind UseAutumn's feature-gating API. For custom billing logic or direct Stripe integration, extend these primitives rather than reimplementing payment handling.