StarterApp Docs
Billing

One-Time Payments

Process single purchases without subscriptions

One-time payments let you sell digital products, add-ons, and services without subscription complexity. UseAutumn handles Stripe checkout for one-time purchases using the same patterns as subscriptions.

Pro Tip

The only difference between subscriptions and one-time products is the absence of an interval in the priceItem(). Everything else works identically.

Product Configuration

Define one-time products in autumn.config.ts by omitting the interval field:

import { feature, product, priceItem, featureItem } from "atmn";

// Define a feature that grants permanent access
export const premiumTemplates = feature({
  id: "premium_templates",
  name: "Premium Templates",
  type: "boolean",
});

// One-time product - no interval means one-time payment
export const templatePack = product({
  id: "starterapp-template-pack",
  name: "Template Pack",
  items: [
    // $49 one-time payment (no interval = one-time)
    priceItem({
      price: 49,
    }),
    // Grant permanent access to templates
    featureItem({
      feature_id: premiumTemplates.id,
      included_usage: 1,
    }),
  ],
});

Push the configuration to UseAutumn:

npx atmn push

Heads up

One-time purchases grant permanent access. Once purchased, users retain access indefinitely unless manually revoked.

Checkout Implementation

Client-Side Checkout Button

Trigger checkout for one-time products using the same billing.checkout() method:

'use client';
import { useState } from 'react';
import { useServices } from '~/lib/providers/services-context';
import { Button } from '@workspace/ui/components/button';

export function BuyTemplatePackButton() {
  const { billing } = useServices();
  const [loading, setLoading] = useState(false);

  async function handlePurchase() {
    setLoading(true);
    try {
      await billing.checkout({
        productId: 'starterapp-template-pack',
        successUrl: window.location.origin + '/dashboard/templates',
        cancelUrl: window.location.origin + '/pricing',
      });
    } catch (error) {
      console.error('Purchase failed:', error);
    } finally {
      setLoading(false);
    }
  }

  return (
    <Button onClick={handlePurchase} disabled={loading}>
      {loading ? 'Loading...' : 'Buy Template Pack - $49'}
    </Button>
  );
}

Check Purchase Status

Use billing.useCustomer() to check if user has already purchased:

'use client';
import { useServices } from '~/lib/providers/services-context';
import { BuyTemplatePackButton } from './buy-template-pack-button';

export function TemplatePackStatus() {
  const { billing } = useServices();
  const { customer, loading } = billing.useCustomer();

  if (loading) {
    return <div>Loading...</div>;
  }

  // Check if user has purchased template pack
  const hasPurchased = customer?.products?.some(
    (p) => p.product_id === 'starterapp-template-pack'
  );

  if (hasPurchased) {
    return (
      <div className="rounded-lg border border-green-600 bg-green-50 p-4">
        <p className="font-medium text-green-900">āœ“ Template Pack Purchased</p>
        <p className="text-sm text-green-700">You have lifetime access</p>
      </div>
    );
  }

  return <BuyTemplatePackButton />;
}

Server-Side Access Control

Gate content behind one-time purchases using ctx.check() in Convex:

import { userQuery } from "./_helpers/builders";
import { AppError } from "./_helpers/errors";

export const getPremiumTemplates = userQuery({
  args: {},
  handler: async (ctx) => {
    // Check if user has purchased template pack
    const result = await ctx.check({ featureId: "premium_templates" });

    if (!result.data?.allowed) {
      throw new AppError("PLAN_REQUIRED", "Purchase Template Pack to access");
    }

    // User has access, return premium templates
    return await ctx.db.query("premiumTemplates").collect();
  },
});

Then use in Server Components:

export const revalidate = 0;
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";

import { api } from "@convex/_generated/api";
import { createAuth } from "@convex/lib/auth";
import { getToken } from "@convex-dev/better-auth/nextjs";
import { fetchQuery } from "convex/nextjs";
import { redirect } from "next/navigation";
import { getCurrentSession } from "@workspace/app-shell/lib/auth/server";

export default async function TemplatesPage() {
  const session = await getCurrentSession();
  if (!session) redirect("/sign-in");

  const token = await getToken(createAuth as Parameters<typeof getToken>[0]);
  if (!token) redirect("/sign-in");

  try {
    const templates = await fetchQuery(
      api.templates.getPremiumTemplates,
      {},
      { token }
    );

    return (
      <div>
        <h1>Premium Templates</h1>
        {/* Render templates */}
      </div>
    );
  } catch (error) {
    // User hasn't purchased, redirect to pricing
    redirect("/pricing?product=template-pack");
  }
}

One-Time vs Subscription Differences

One-Time Payments

  • No interval in priceItem()
  • User pays once
  • Access is permanent
  • No recurring charges
  • Cannot be canceled (already paid)

Subscriptions

  • Has interval: "month" or "year"
  • User pays repeatedly
  • Access ends if canceled
  • Recurring billing
  • Can be canceled anytime

Handling Refunds

One-time payments can be refunded through the Stripe dashboard. When refunded:

  1. Stripe webhook notifies UseAutumn
  2. UseAutumn revokes feature access
  3. ctx.check() returns allowed: false
  4. User loses access immediately

Pro Tip

Set up refund policies in your Stripe dashboard settings. UseAutumn automatically handles access revocation when refunds are processed.

Testing One-Time Purchases

Use Stripe test cards to test one-time purchases in development:

Card Number: 4242 4242 4242 4242
Expiry: Any future date (e.g., 12/34)
CVC: Any 3 digits (e.g., 123)

The checkout flow works identically to subscriptions, but without recurring charges.

Common Patterns