Billing API Reference for Tanstack Start Supabase SaaS Kit

Complete API reference for Makerkit's billing service. Create checkouts, manage subscriptions, report usage, and handle billing operations programmatically.

The Billing Gateway Service provides a unified API for all billing operations, regardless of which payment provider you use (Stripe, Lemon Squeezy, or Paddle). This abstraction lets you switch providers without changing your application code.

Getting the Billing Service

import { createBillingGatewayService } from '@kit/billing-gateway';
// Get service for the configured provider
const service = createBillingGatewayService(
process.env.VITE_BILLING_PROVIDER
);
// Or specify a provider explicitly
const stripeService = createBillingGatewayService('stripe');

For most operations, get the provider from the user's subscription record:

import { createAccountsApi } from '@kit/accounts/api';
const accountsApi = createAccountsApi(supabaseClient);
const subscription = await accountsApi.getSubscription(accountId);
const provider = subscription?.billing_provider ?? 'stripe';
const service = createBillingGatewayService(provider);

Create Checkout Session

Start a new subscription or one-off purchase.

const { checkoutToken } = await service.createCheckoutSession({
accountId: 'uuid-of-account',
plan: billingConfig.products[0].plans[0], // From billing.config.ts
returnUrl: 'https://yourapp.com/billing/return',
customerEmail: 'user@example.com', // Optional
customerId: 'cus_xxx', // Optional, if customer already exists
enableDiscountField: true, // Optional, show coupon input
variantQuantities: [ // Optional, for per-seat billing
{ variantId: 'price_xxx', quantity: 5 }
],
});

Parameters:

FieldTypeRequiredDescription
accountIdstringYesUUID of the account making the purchase
planPlanYesPlan object from your billing config
returnUrlstringYesURL to redirect after checkout
customerEmailstringNoPre-fill customer email
customerIdstringNoExisting customer ID (skips customer creation)
enableDiscountFieldbooleanNoShow coupon/discount input
variantQuantitiesarrayNoOverride quantities for line items

Returns:

{
checkoutToken: string // Token to open checkout UI
}

Example: Server Function

import { createServerFn } from '@tanstack/react-start';
import * as z from 'zod';
import { authFunctionMiddleware } from '@kit/function-middleware/functions';
import { createBillingGatewayService } from '@kit/billing-gateway';
import billingConfig from '#/config/billing.config.ts';
export const createCheckoutFunction = createServerFn({ method: 'POST' })
.middleware(authFunctionMiddleware)
.validator(z.object({ planId: z.string(), accountId: z.string() }))
.handler(async ({ data, context }) => {
const plan = billingConfig.products
.flatMap((p) => p.plans)
.find((p) => p.id === data.planId);
if (!plan) {
throw new Error('Plan not found');
}
const service = createBillingGatewayService(billingConfig.provider);
const { checkoutToken } = await service.createCheckoutSession({
accountId: data.accountId,
plan,
returnUrl: `${process.env.VITE_SITE_URL}/billing/return`,
customerEmail: context.user.email,
});
return { checkoutToken };
});

Call it from a client component with useServerFn and TanStack Query:

import { useServerFn } from '@tanstack/react-start';
import { useMutation } from '@tanstack/react-query';
import { createCheckoutFunction } from './checkout.functions';
function useCreateCheckout() {
const createCheckout = useServerFn(createCheckoutFunction);
return useMutation({
mutationFn: (data: { planId: string; accountId: string }) =>
createCheckout({ data }),
});
}

Retrieve Checkout Session

Check the status of a checkout session after redirect.

const session = await service.retrieveCheckoutSession({
sessionId: 'cs_xxx', // From URL params after redirect
});

Returns:

{
checkoutToken: string | null,
status: 'complete' | 'expired' | 'open',
isSessionOpen: boolean,
customer: {
email: string | null
}
}

Example: Return route

Fetch the session in a route loader (via a server function) and read the result in the component with Route.useLoaderData():

apps/web/src/routes/_authenticated/settings/billing/return.tsx

import { createFileRoute } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import * as z from 'zod';
import { createBillingGatewayService } from '@kit/billing-gateway';
const retrieveSession = createServerFn({ method: 'GET' })
.validator(z.object({ sessionId: z.string() }))
.handler(async ({ data }) => {
const service = createBillingGatewayService('stripe');
const session = await service.retrieveCheckoutSession({
sessionId: data.sessionId,
});
return { status: session.status };
});
export const Route = createFileRoute('/_authenticated/settings/billing/return')({
validateSearch: (search: Record<string, unknown>) => ({
session_id:
typeof search.session_id === 'string' ? search.session_id : undefined,
}),
loaderDeps: ({ search }) => ({ sessionId: search.session_id }),
loader: ({ deps }) => {
if (!deps.sessionId) {
return { status: null };
}
return retrieveSession({ data: { sessionId: deps.sessionId } });
},
component: BillingReturnPage,
});
function BillingReturnPage() {
const { status } = Route.useLoaderData();
if (!status) {
return <div>Invalid session</div>;
}
if (status === 'complete') {
return <div>Payment successful!</div>;
}
return <div>Payment pending or failed</div>;
}

Create Billing Portal Session

Open the customer portal for subscription management.

const { url } = await service.createBillingPortalSession({
customerId: 'cus_xxx', // From billing_customers table
returnUrl: 'https://yourapp.com/billing',
});
// Redirect user to the portal URL

Parameters:

FieldTypeRequiredDescription
customerIdstringYesCustomer ID from billing provider
returnUrlstringYesURL to redirect after portal session

Example: Server Function

import { createServerFn } from '@tanstack/react-start';
import * as z from 'zod';
import { createAccountsApi } from '@kit/accounts/api';
import { createBillingGatewayService } from '@kit/billing-gateway';
import { authFunctionMiddleware } from '@kit/function-middleware/functions';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export const openBillingPortalFunction = createServerFn({ method: 'POST' })
.middleware(authFunctionMiddleware)
.validator(z.object({ accountId: z.string() }))
.handler(async ({ data }) => {
const supabase = getSupabaseServerClient();
const api = createAccountsApi(supabase);
const customerId = await api.getCustomerId(data.accountId);
if (!customerId) {
throw new Error('No billing customer found');
}
const service = createBillingGatewayService('stripe');
const { url } = await service.createBillingPortalSession({
customerId,
returnUrl: `${process.env.VITE_SITE_URL}/billing`,
});
// Return the external portal URL so the client can navigate to it with
// window.location.assign(url).
return { url };
});

Cancel Subscription

Cancel a subscription immediately or at period end.

const { success } = await service.cancelSubscription({
subscriptionId: 'sub_xxx',
invoiceNow: false, // Optional: charge immediately for usage
});

Parameters:

FieldTypeRequiredDescription
subscriptionIdstringYesSubscription ID from provider
invoiceNowbooleanNoInvoice outstanding usage immediately

Example: Cancel at period end

import { createServerFn } from '@tanstack/react-start';
import * as z from 'zod';
import { createAccountsApi } from '@kit/accounts/api';
import { createBillingGatewayService } from '@kit/billing-gateway';
import { authFunctionMiddleware } from '@kit/function-middleware/functions';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export const cancelSubscriptionFunction = createServerFn({ method: 'POST' })
.middleware(authFunctionMiddleware)
.validator(z.object({ accountId: z.string() }))
.handler(async ({ data }) => {
const supabase = getSupabaseServerClient();
const api = createAccountsApi(supabase);
const subscription = await api.getSubscription(data.accountId);
if (!subscription) {
throw new Error('No subscription found');
}
const service = createBillingGatewayService(subscription.billing_provider);
await service.cancelSubscription({
subscriptionId: subscription.id,
});
return { success: true };
});

Report Usage (Metered Billing)

Report usage for metered billing subscriptions.

Stripe

Stripe uses customer ID and a meter event name:

await service.reportUsage({
id: 'cus_xxx', // Customer ID
eventName: 'api_requests', // Meter name in Stripe
usage: {
quantity: 100,
},
});

Lemon Squeezy

Lemon Squeezy uses subscription item ID:

await service.reportUsage({
id: 'sub_item_xxx', // Subscription item ID
usage: {
quantity: 100,
action: 'increment', // or 'set'
},
});

Parameters:

FieldTypeRequiredDescription
idstringYesCustomer ID (Stripe) or subscription item ID (LS)
eventNamestringStripe onlyMeter event name
usage.quantitynumberYesUsage amount
usage.action'increment' | 'set'NoHow to apply usage (LS only)

Example: Track API usage

import { createBillingGatewayService } from '@kit/billing-gateway';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function trackApiUsage(accountId: string, requestCount: number) {
const supabase = getSupabaseServerClient();
const api = createAccountsApi(supabase);
const subscription = await api.getSubscription(accountId);
if (!subscription || subscription.status !== 'active') {
return; // No active subscription
}
const service = createBillingGatewayService(subscription.billing_provider);
const customerId = await api.getCustomerId(accountId);
if (subscription.billing_provider === 'stripe') {
await service.reportUsage({
id: customerId!,
eventName: 'api_requests',
usage: { quantity: requestCount },
});
} else {
// Lemon Squeezy: need subscription item ID
const { data: item } = await supabase
.from('subscription_items')
.select('id')
.eq('subscription_id', subscription.id)
.eq('type', 'metered')
.single();
if (item) {
await service.reportUsage({
id: item.id,
usage: { quantity: requestCount, action: 'increment' },
});
}
}
}

Query Usage

Retrieve usage data for a metered subscription.

Stripe

const usage = await service.queryUsage({
id: 'meter_xxx', // Stripe Meter ID
customerId: 'cus_xxx',
filter: {
startTime: Math.floor(Date.now() / 1000) - 86400 * 30, // 30 days ago
endTime: Math.floor(Date.now() / 1000),
},
});

Lemon Squeezy

const usage = await service.queryUsage({
id: 'sub_item_xxx', // Subscription item ID
customerId: 'cus_xxx',
filter: {
page: 1,
size: 100,
},
});

Returns:

{
value: number // Total usage in period
}

Update Subscription Item

Update the quantity of a subscription item (e.g., seat count).

const { success } = await service.updateSubscriptionItem({
subscriptionId: 'sub_xxx',
subscriptionItemId: 'si_xxx',
quantity: 10,
});

Parameters:

FieldTypeRequiredDescription
subscriptionIdstringYesSubscription ID
subscriptionItemIdstringYesLine item ID within subscription
quantitynumberYesNew quantity (minimum 1)

Get Subscription Details

Retrieve subscription details from the provider.

const subscription = await service.getSubscription('sub_xxx');

Returns: Provider-specific subscription object.

Get Plan Details

Retrieve plan/price details from the provider.

const plan = await service.getPlanById('price_xxx');

Returns: Provider-specific plan/price object.

Error Handling

All methods can throw errors. Wrap calls in try-catch:

try {
const { checkoutToken } = await service.createCheckoutSession({
// ...
});
} catch (error) {
if (error instanceof Error) {
console.error('Billing error:', error.message);
}
// Handle error appropriately
}

Common errors:

  • Invalid API keys
  • Invalid price/plan IDs
  • Customer not found
  • Subscription not found
  • Network/provider errors