Configure One-Off Payments for Lifetime Deals and Add-Ons
Implement one-time purchases in your SaaS for lifetime access, add-ons, or credits. Learn how to configure one-off payments with Stripe, Lemon Squeezy, or Paddle in Makerkit.
One-off payments are single charges for non-recurring products: lifetime access, add-ons, credit packs, or physical goods. Unlike subscriptions, one-off purchases are stored in the orders table.
Use Cases
- Lifetime access: One-time purchase for perpetual access
- Add-ons: Additional features or capacity
- Credit packs: Buy credits/tokens in bulk
- Digital products: Templates, courses, ebooks
- One-time services: Setup fees, consulting
Schema Configuration
Define a one-time payment plan:
apps/web/config/billing.config.ts
{ id: 'lifetime', name: 'Lifetime Access', description: 'Pay once, access forever', currency: 'USD', badge: 'Best Value', features: [ 'All Pro features', 'Lifetime updates', 'Priority support', ], plans: [ { id: 'lifetime-deal', name: 'Lifetime Access', paymentType: 'one-time', // Not recurring // No interval for one-time lineItems: [ { id: 'price_lifetime_xxx', // Provider Price ID name: 'Lifetime Access', cost: 299, type: 'flat', // Only flat is supported for one-time }, ], }, ],}Key differences from subscriptions:
paymentTypeis'one-time'instead of'recurring'- No
intervalfield - Line items must be
type: 'flat'(no metered or per-seat)
Provider Setup
Stripe
- Create a product in Stripe Dashboard
- Add a One-time price
- Copy the Price ID to your billing schema
Lemon Squeezy
- Create a product with Single payment pricing
- Copy the Variant ID to your billing schema
Paddle
- Create a product with one-time pricing
- Copy the Price ID to your billing schema
Database Storage
One-off purchases are stored differently than subscriptions:
| Entity | Table | Description |
|---|---|---|
| Subscriptions | subscriptions, subscription_items | Recurring payments |
| One-off | orders, order_items | Single payments |
Orders Table Schema
orders├── id (text) - Order ID from provider├── account_id (uuid) - Purchasing account├── billing_customer_id (int) - Customer reference├── status (payment_status) - 'pending', 'succeeded', 'failed'├── billing_provider (enum) - 'stripe', 'lemon-squeezy', 'paddle'├── total_amount (numeric) - Total charge├── currency (varchar)└── created_at, updated_atorder_items├── id (text) - Item ID├── order_id (text) - Reference to order├── product_id (text)├── variant_id (text)├── price_amount (numeric)└── quantity (integer)Checking Order Status
Query orders to check if a user has purchased a product:
import { getSupabaseServerClient } from '@kit/supabase/server-client';export async function hasLifetimeAccess(accountId: string): Promise<boolean> { const supabase = getSupabaseServerClient(); const { data: order } = await supabase .from('orders') .select('id, status') .eq('account_id', accountId) .eq('status', 'succeeded') .single(); return !!order;}// Check for specific productexport async function hasPurchasedProduct( accountId: string, productId: string): Promise<boolean> { const supabase = getSupabaseServerClient(); const { data: order } = await supabase .from('orders') .select(` id, order_items!inner(product_id) `) .eq('account_id', accountId) .eq('status', 'succeeded') .eq('order_items.product_id', productId) .single(); return !!order;}Gating Features
Use order status to control access:
// Server Componentimport { hasLifetimeAccess } from '~/lib/orders';export default async function PremiumFeature({ accountId,}: { accountId: string;}) { const hasAccess = await hasLifetimeAccess(accountId); if (!hasAccess) { return <UpgradePrompt />; } return <PremiumContent />;}RLS Policy Example
Gate database access based on orders:
-- Function to check if account has a successful orderCREATE OR REPLACE FUNCTION public.has_lifetime_access(p_account_id UUID)RETURNS BOOLEANSET search_path = ''AS $$BEGIN RETURN EXISTS ( SELECT 1 FROM public.orders WHERE account_id = p_account_id AND status = 'succeeded' );END;$$ LANGUAGE plpgsql SECURITY DEFINER;-- Example policyCREATE POLICY premium_content_access ON public.premium_content FOR SELECT TO authenticated USING ( public.has_lifetime_access(account_id) );Handling Webhooks
One-off payment webhooks work similarly to subscriptions:
apps/web/app/api/billing/webhook/route.ts
await service.handleWebhookEvent(request, { onCheckoutSessionCompleted: async (orderOrSubscription, customerId) => { // Check if this is an order (one-time) or subscription if ('order_id' in orderOrSubscription) { // One-time payment logger.info({ orderId: orderOrSubscription.order_id }, 'Order completed'); // Provision access, send receipt, etc. await provisionLifetimeAccess(orderOrSubscription.target_account_id); await sendOrderReceipt(orderOrSubscription); } else { // Subscription logger.info('Subscription created'); } }, onPaymentFailed: async (sessionId) => { // Handle failed one-time payments await notifyPaymentFailed(sessionId); },});Stripe-Specific Events
For one-off payments, add these webhook events in Stripe:
checkout.session.completedcheckout.session.async_payment_failedcheckout.session.async_payment_succeeded
Async payment methods
Some payment methods (bank transfers, certain local methods) are asynchronous. Listen for async_payment_succeeded to confirm these payments.
Mixing Orders and Subscriptions
You can offer both one-time and recurring products:
products: [ // Subscription product { id: 'pro', name: 'Pro', plans: [ { id: 'pro-monthly', paymentType: 'recurring', interval: 'month', lineItems: [{ id: 'price_monthly', cost: 29, type: 'flat' }], }, ], }, // One-time product { id: 'lifetime', name: 'Lifetime', plans: [ { id: 'lifetime-deal', paymentType: 'one-time', lineItems: [{ id: 'price_lifetime', cost: 299, type: 'flat' }], }, ], },]Check for either type of access:
export async function hasAccess(accountId: string): Promise<boolean> { const supabase = getSupabaseServerClient(); // Check subscription const { data: subscription } = await supabase .from('subscriptions') .select('id') .eq('account_id', accountId) .eq('status', 'active') .single(); if (subscription) return true; // Check lifetime order const { data: order } = await supabase .from('orders') .select('id') .eq('account_id', accountId) .eq('status', 'succeeded') .single(); return !!order;}Billing Mode Configuration
By default, Makerkit checks subscriptions for billing status. To use orders as the primary billing mechanism (versions before 2.12.0):
BILLING_MODE=one-timeWhen set, the billing section will display orders instead of subscriptions.
Version 2.12.0+
From version 2.12.0 onwards, orders and subscriptions can coexist. The BILLING_MODE setting is only needed if you want to exclusively use one-time payments.
Add-On Purchases
Sell additional items to existing subscribers:
// Add-on product{ id: 'addon-storage', name: 'Extra Storage', plans: [ { id: 'storage-10gb', name: '10GB Storage', paymentType: 'one-time', lineItems: [ { id: 'price_storage_10gb', name: '10GB Storage', cost: 19, type: 'flat' }, ], }, ],}Track purchased add-ons:
export async function getStorageLimit(accountId: string): Promise<number> { const supabase = getSupabaseServerClient(); // Base storage from subscription const baseStorage = 5; // GB // Additional storage from orders const { data: orders } = await supabase .from('orders') .select('order_items(product_id)') .eq('account_id', accountId) .eq('status', 'succeeded'); const additionalStorage = orders?.reduce((total, order) => { const hasStorage = order.order_items.some( item => item.product_id === 'storage-10gb' ); return hasStorage ? total + 10 : total; }, 0) ?? 0; return baseStorage + additionalStorage;}Testing One-Off Payments
- Test checkout:
- Navigate to your pricing page
- Select the one-time product
- Complete checkout with test card
4242 4242 4242 4242
- Verify database:SELECT * FROM orders WHERE account_id = 'your-account-id';SELECT * FROM order_items WHERE order_id = 'order-id';
- Test access gating:
- Verify features are unlocked after purchase
- Test with accounts that haven't purchased
Related Documentation
- Billing Schema - Configure pricing
- Webhooks - Handle payment events
- Stripe Setup - Provider configuration
- Credit-Based Billing - Token/credit systems