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
inpriceItem()
- 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:
- Stripe webhook notifies UseAutumn
- UseAutumn revokes feature access
ctx.check()
returnsallowed: false
- 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
Related Documentation
- Subscriptions - Recurring revenue models
- Usage-Based Billing - Pay-per-use patterns
- Feature Gating - Access control implementation