StarterApp Docs
Billing

Usage-Based Billing

Track consumption and charge based on actual usage

Usage-based billing lets customers pay for what they consume. UseAutumn tracks usage through Convex actions and automatically enforces limits based on subscription tier.

Pro Tip

Use ctx.track() to record consumption and ctx.check() to verify remaining quota before performing expensive operations.

Feature Configuration

Define usage-tracked features in autumn.config.ts with type: "single_use":

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

// Define usage-tracked features
export const apiCalls = feature({
  id: "api_calls",
  name: "API Calls",
  type: "single_use", // Enables usage tracking
});

export const aiTokens = feature({
  id: "ai_tokens",
  name: "AI Tokens",
  type: "single_use",
});

// Product with usage limits
export const paidPlan = product({
  id: "starterapp-demo-paid",
  name: "Paid Plan",
  items: [
    priceItem({
      price: 10,
      interval: "month",
    }),
    // 10,000 API calls per month
    featureItem({
      feature_id: apiCalls.id,
      included_usage: 10_000,
      interval: "month", // Resets monthly
    }),
    // 50,000 AI tokens per month
    featureItem({
      feature_id: aiTokens.id,
      included_usage: 50_000,
      interval: "month",
    }),
  ],
});

Push configuration to UseAutumn:

npx atmn push

Heads up

Usage tracking only works for features defined in autumn.config.ts. Push configuration before testing.

Track Usage in Convex

Use ctx.track() in Convex actions to record consumption:

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

export const callExternalApi = userAction({
  args: { endpoint: v.string() },
  handler: async (ctx, { endpoint }) => {
    // Check if user has remaining API calls
    const result = await ctx.check({ featureId: "api_calls" });

    if (!result.data?.allowed) {
      throw new AppError("USAGE_EXCEEDED", "API call limit reached");
    }

    // Track usage (decrement available calls)
    await ctx.track({ featureId: "api_calls", value: 1 });

    // Perform the actual API call
    const data = await fetch(endpoint);

    return { success: true, data };
  },
});

Track Variable Consumption

For features with variable costs (e.g., AI tokens), track actual usage:

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

export const generateText = userAction({
  args: { prompt: v.string() },
  handler: async (ctx, { prompt }) => {
    // Check if user has token quota
    const result = await ctx.check({ featureId: "ai_tokens" });

    if (!result.data?.allowed) {
      throw new AppError("USAGE_EXCEEDED", "Token limit reached. Upgrade for more.");
    }

    // Call AI service
    const response = await callOpenAI(prompt);
    const tokensUsed = response.usage.total_tokens;

    // Track actual tokens consumed
    await ctx.track({ featureId: "ai_tokens", value: tokensUsed });

    return { text: response.choices[0].text };
  },
});

Display Usage to Users

Show remaining quota using billing.useCustomer():

'use client';
import { useServices } from '~/lib/providers/services-context';

export function UsageMeter({ featureId }: { featureId: string }) {
  const { billing } = useServices();
  const { customer, loading } = billing.useCustomer();

  if (loading) {
    return <div className="h-4 w-full animate-pulse bg-gray-200 rounded" />;
  }

  // Find the feature in customer's products
  const product = customer?.products?.[0];
  if (!product) {
    return <div className="text-sm text-muted-foreground">No active plan</div>;
  }

  // UseAutumn doesn't expose per-feature usage in customer object
  // You need to implement this via a Convex query that calls ctx.check()
  return (
    <div className="space-y-2">
      <div className="flex justify-between text-sm">
        <span>API Calls</span>
        <span>Check usage via Convex query</span>
      </div>
    </div>
  );
}

Get Usage from Convex

Create a query to fetch current usage:

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

export const getUsage = userQuery({
  args: { featureId: v.string() },
  handler: async (ctx, { featureId }) => {
    const result = await ctx.check({ featureId });

    return {
      allowed: result.data?.allowed || false,
      // Note: UseAutumn's check response structure may vary
      // Consult their docs for exact response shape
    };
  },
});

Then use in client:

'use client';
import { api } from "@convex/_generated/api";
import { useQuery } from "convex/react";

export function UsageMeterWithData({ featureId }: { featureId: string }) {
  const usage = useQuery(api.usage.getUsage, { featureId });

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

  return (
    <div className="rounded-lg border p-4">
      <p className="text-sm font-medium">
        {usage.allowed ? "✓ Quota Available" : "⚠️ Limit Reached"}
      </p>
    </div>
  );
}

Overage Handling

Block actions when limit is reached:

export const sendEmail = userAction({
  args: { to: v.string(), body: v.string() },
  handler: async (ctx, { to, body }) => {
    const result = await ctx.check({ featureId: "emails" });

    if (!result.data?.allowed) {
      throw new AppError(
        "USAGE_EXCEEDED",
        "Email limit reached. Upgrade to send more emails."
      );
    }

    await ctx.track({ featureId: "emails", value: 1 });
    // Send email
  },
});

Overage Billing (Pay-as-you-go)

Define overage pricing in autumn.config.ts:

export const paidPlanWithOverage = product({
  id: "starterapp-paid-overage",
  name: "Paid Plan with Overage",
  items: [
    priceItem({ price: 10, interval: "month" }),
    featureItem({
      feature_id: apiCalls.id,
      included_usage: 10_000,
      interval: "month",
    }),
    // Note: UseAutumn overage syntax may vary - check their docs
    // This is a conceptual example
  ],
});

Heads up

Check UseAutumn documentation for exact overage configuration syntax. Implementation details vary by pricing model.

Credit Top-Ups

Let users purchase additional usage without changing plans:

export const apiCallPack = product({
  id: "api-call-pack-1000",
  name: "1,000 API Calls",
  items: [
    priceItem({ price: 5 }), // One-time $5
    featureItem({
      feature_id: apiCalls.id,
      included_usage: 1_000,
      // No interval = permanent credits
    }),
  ],
});

Purchase from client:

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

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

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

  return (
    <Button onClick={handlePurchase} disabled={loading}>
      {loading ? 'Loading...' : 'Buy 1,000 Credits - $5'}
    </Button>
  );
}

Usage Patterns

Common Errors