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:

  • paymentType is 'one-time' instead of 'recurring'
  • No interval field
  • Line items must be type: 'flat' (no metered or per-seat)

Provider Setup

Stripe

  1. Create a product in Stripe Dashboard
  2. Add a One-time price
  3. Copy the Price ID to your billing schema

Lemon Squeezy

  1. Create a product with Single payment pricing
  2. Copy the Variant ID to your billing schema

Paddle

  1. Create a product with one-time pricing
  2. Copy the Price ID to your billing schema

Database Storage

One-off purchases are stored differently than subscriptions:

EntityTableDescription
Subscriptionssubscriptions, subscription_itemsRecurring payments
One-offorders, order_itemsSingle 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_at
order_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 product
export 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 Component
import { 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 order
CREATE OR REPLACE FUNCTION public.has_lifetime_access(p_account_id UUID)
RETURNS BOOLEAN
SET 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 policy
CREATE 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.completed
  • checkout.session.async_payment_failed
  • checkout.session.async_payment_succeeded

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-time

When set, the billing section will display orders instead of subscriptions.

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

  1. Test checkout:
    • Navigate to your pricing page
    • Select the one-time product
    • Complete checkout with test card 4242 4242 4242 4242
  2. Verify database:
    SELECT * FROM orders WHERE account_id = 'your-account-id';
    SELECT * FROM order_items WHERE order_id = 'order-id';
  3. Test access gating:
    • Verify features are unlocked after purchase
    • Test with accounts that haven't purchased