Customer Billing

ModelRelay provides a complete billing system for multi-tenant AI applications. Assign customers to tiers with spend limits, track usage, and integrate with Stripe for subscription management.

Billing Model Overview

flowchart TB
    subgraph project["Your Project"]
        subgraph free["Free Tier"]
            f1["$0/month"]
            f2["$5 credits"]
        end
        subgraph pro["Pro Tier"]
            p1["$29/month"]
            p2["$29 credits"]
        end
        subgraph enterprise["Enterprise Tier"]
            e1["$299/month"]
            e2["unlimited"]
        end
        free --> A["Customer A"]
        pro --> B["Customer B"]
        enterprise --> C["Customer C"]
    end

Key concepts:

Concept Description
Tier A pricing plan with spend limits and model access
Customer An customer in your system, assigned to a tier
Spend Limit The API usage budget for a billing period
Usage Tracked per-request based on token consumption

Tiers

Tiers define pricing plans with spend limits and model access. Before managing customers, you need at least one tier configured in your project.

See Tiers & Margins for complete documentation on:

  • Creating and configuring tiers
  • Setting per-model pricing and margins
  • Common tier patterns (free, pro, enterprise)
  • Spend limit strategies

Managing Customers

Creating Customers

Create customers programmatically when users sign up for your service:

import { ModelRelay } from "@modelrelay/sdk";

const mr = ModelRelay.fromSecretKey(process.env.MODELRELAY_SECRET_KEY!);

// Create a customer
const customer = await mr.customers.create({
  external_id: "user_123",        // Your user ID
  email: "user@example.com",
});

console.log(customer.customer.id);                    // ModelRelay customer ID
console.log(customer.subscription?.tier_code ?? ""); // Optional tier code
customer, err := client.Customers.Create(ctx, sdk.CustomerCreateRequest{
    ExternalID: sdk.NewCustomerExternalID("user_123"),
    Email:      "user@example.com",
})
if err != nil {
    return err
}

fmt.Println(customer.Customer.ID) // ModelRelay customer ID
tier := ""
if customer.Subscription != nil {
    tier = customer.Subscription.TierCode.String()
}
fmt.Println(tier) // Optional tier code
use modelrelay::{Client, CustomerCreateRequest};

let customer = client.customers().create(CustomerCreateRequest {
    external_id: "user_123".to_string(),
    email: "user@example.com".to_string(),
    metadata: None,
}).await?;

println!("{}", customer.customer.id); // ModelRelay customer ID
if let Some(subscription) = &customer.subscription {
    println!("{}", subscription.tier_code); // Optional tier code
}

Upserting Customers

Use upsert to create or update customers by their external ID:

// Creates if not exists, updates if exists
const customer = await mr.customers.upsert({
  external_id: "user_123",
  email: "user@example.com",
});
customer, err := client.Customers.Upsert(ctx, sdk.CustomerUpsertRequest{
    ExternalID: sdk.NewCustomerExternalID("user_123"),
    Email:      "user@example.com",
})
// Creates if not exists, updates if exists
let customer = client.customers().upsert(CustomerUpsertRequest {
    external_id: "user_123".to_string(),
    email: "user@example.com".to_string(),
    metadata: None,
}).await?;

Listing Customers

const customers = await mr.customers.list();

for (const customer of customers) {
  console.log(`${customer.external_id}: ${customer.tier_code}`);
}
customers, err := client.Customers.List(ctx)
if err != nil {
    return err
}

for _, customer := range customers {
    fmt.Printf("%s: %s\n", customer.ExternalID, customer.TierCode)
}
let customers = client.customers().list().await?;

for customer in customers {
    println!("{}: {}", customer.external_id, customer.tier_code);
}

Stripe Integration

ModelRelay integrates with Stripe for subscription billing. There are two checkout flows depending on your use case.

Flow 1: Customer-First Checkout

Use this when you know the customer’s identity before checkout. There are two approaches:

When customers authenticate in your app, your backend mints a customer token. The frontend can initiate checkout directly using that token. Ensure the customer exists (create it via POST /customers) before minting tokens.

sequenceDiagram
    participant User
    participant App as Your App
    participant Backend as Your Backend
    participant MR as ModelRelay
    participant Stripe

    User->>App: Login
    App->>Backend: Request customer token
    Backend->>MR: POST /auth/customer-token
    MR-->>Backend: Customer token
    Backend-->>App: Customer token
    App->>MR: POST /customers/me/checkout
    MR-->>App: session.url
    App->>User: Redirect to Stripe
    User->>Stripe: Complete payment
    Stripe->>MR: Webhook: checkout.completed
    MR->>MR: Link subscription to customer
    Stripe->>User: Redirect to success_url
// Frontend: using a customer bearer token
const mr = ModelRelay.fromToken(customerToken);

const session = await mr.customers.meCheckout({
  tier_code: "pro",
  success_url: "https://myapp.com/success",
  cancel_url: "https://myapp.com/pricing",
});

// Redirect to Stripe checkout
window.location.href = session.url;

Key benefits:

  • Frontend can initiate checkout with a customer token
  • Backend keeps control over customer identity
  • Works with any auth system

1b. Server-Side Checkout

Use this when your backend manages the customer lifecycle:

sequenceDiagram
    participant User
    participant App as Your App
    participant MR as ModelRelay
    participant Stripe

    Note over App: Customer already exists
    App->>MR: Create checkout session (customer_id)
    MR-->>App: session.url
    App->>User: Redirect to Stripe
    User->>Stripe: Complete payment
    Stripe->>MR: Webhook: checkout.completed
    MR->>MR: Link subscription to customer
    Stripe->>User: Redirect to success_url
// 1. Customer already exists
const customer = await mr.customers.get("cust_uuid");

// 2. Create checkout session for this customer
const session = await mr.customers.subscribe(customer.customer.id, {
  tier_id: "tier_uuid",
  success_url: "https://myapp.com/success?session={CHECKOUT_SESSION_ID}",
  cancel_url: "https://myapp.com/cancel",
});

// 3. Redirect user to Stripe
window.location.href = session.url;
// 1. Customer already exists
customer, err := client.Customers.Get(ctx, customerUUID)
if err != nil {
    return err
}

// 2. Create checkout session for this customer
session, err := client.Customers.Subscribe(ctx, customer.Customer.ID, sdk.CustomerSubscribeRequest{
    TierID:     uuid.MustParse("tier_uuid"),
    SuccessURL: "https://myapp.com/success?session={CHECKOUT_SESSION_ID}",
    CancelURL:  "https://myapp.com/cancel",
})
if err != nil {
    return err
}

// 3. Redirect user to Stripe
http.Redirect(w, r, session.URL, http.StatusSeeOther)
use modelrelay::CustomerSubscribeRequest;

// 1. Customer already exists
let customer = client.customers().get(&customer_uuid).await?;

// 2. Create checkout session for this customer
let session = client.customers().subscribe(
    customer.customer.id,
    CustomerSubscribeRequest {
        tier_id: tier_uuid,
        success_url: "https://myapp.com/success?session={CHECKOUT_SESSION_ID}".to_string(),
        cancel_url: "https://myapp.com/cancel".to_string(),
    },
).await?;

// 3. Redirect user to Stripe
// Return session.url to your frontend for redirect

Checking Subscription Status

const status = await mr.customers.getSubscription("cust_uuid");

if (status.active) {
  console.log("Subscription active until", status.current_period_end);
} else {
  console.log("No active subscription");
}
status, err := client.Customers.GetSubscription(ctx, customerUUID)
if err != nil {
    return err
}

if status.Active {
    fmt.Println("Subscription active until", status.CurrentPeriodEnd)
} else {
    fmt.Println("No active subscription")
}
let status = client.customers().get_subscription(&customer_uuid).await?;

if status.active {
    println!("Subscription active until {:?}", status.current_period_end);
} else {
    println!("No active subscription");
}

PAYGO Billing

Pay-as-you-go (PAYGO) billing lets customers prepay a balance and pay for actual usage rather than a fixed monthly subscription. This is ideal for:

  • Usage-based pricing: Customers only pay for what they use
  • Variable workloads: No monthly limits or resets
  • Low-commitment plans: No recurring charges

How PAYGO Works

flowchart LR
    A[Customer] -->|Top-up| B[Balance]
    B -->|API Request| C[Usage Deducted]
    C -->|Low Balance| D[Top-up Reminder]
    C -->|Zero Balance| E[Access Blocked]
  1. Create a tier with billing_mode: "paygo"
  2. Customer tops up their balance via Stripe checkout
  3. Each API request deducts from their balance
  4. When balance reaches zero, API access is blocked until they top up

PAYGO vs Subscription

Feature Subscription PAYGO
Monthly price Fixed (e.g., $29/mo) None
Spend limit Resets monthly No limit (balance-based)
Usage tracking Against monthly limit Against balance
Billing cycle Monthly/Yearly None (prepaid)
Access when exceeded Blocked until reset Blocked until top-up

Customer Top-Up Flow

Customers can top up their balance using the self-service endpoint:

sequenceDiagram
    participant User
    participant App as Your App
    participant MR as ModelRelay
    participant Stripe

    User->>App: Click "Add Funds"
    App->>MR: POST /customers/me/topup
    MR-->>App: session.url
    App->>User: Redirect to Stripe
    User->>Stripe: Complete payment
    Stripe->>MR: Webhook: payment_intent.succeeded
    MR->>MR: Credit balance
    Stripe->>User: Redirect to success_url
// Frontend: using customer bearer token
const mr = ModelRelay.fromToken(customerToken);

// Create a $25 top-up session
const session = await mr.customers.meTopup({
  amount_cents: 2500,
  success_url: "https://myapp.com/billing?success=true",
  cancel_url: "https://myapp.com/billing",
});

// Redirect to Stripe checkout
window.location.href = session.url;
curl -X POST https://api.modelrelay.ai/api/v1/customers/me/topup \
  -H "Authorization: Bearer mr_ct_..." \
  -H "Content-Type: application/json" \
  -d '{
    "amount_cents": 2500,
    "success_url": "https://myapp.com/billing?success=true",
    "cancel_url": "https://myapp.com/billing"
  }'

Checking Balance

Customers can check their current balance:

const balance = await mr.customers.meBalance();

console.log(`Balance: $${(balance.balance_cents / 100).toFixed(2)}`);
console.log(`Reserved: $${(balance.reserved_cents / 100).toFixed(2)}`);
console.log(`Available: $${(balance.available_cents / 100).toFixed(2)}`);

if (balance.available_cents < 500) {
  showLowBalanceWarning();
}

Balance Transaction History

View the ledger of all balance changes:

const history = await mr.customers.meBalanceHistory();

for (const entry of history.entries) {
  const sign = entry.direction === "credit" ? "+" : "-";
  const amount = (entry.amount_cents / 100).toFixed(2);
  console.log(`${entry.created_at}: ${sign}$${amount} - ${entry.reason}`);
}

Transaction reasons include:

  • Top-up via Stripe: Customer added funds
  • API usage: Tokens consumed by requests
  • Refund: Stripe refund processed

PAYGO Error Handling

When a PAYGO customer’s balance is depleted, requests return HTTP 402:

try {
  const response = await mr.responses.text(
    "claude-sonnet-4-5",
    "You are helpful.",
    "Hello!"
  );
} catch (error) {
  if (error instanceof APIError && error.status === 402) {
    // Check if this is a PAYGO balance issue
    const balance = await mr.customers.meBalance();
    if (balance.available_cents <= 0) {
      showTopUpPrompt("Your balance is depleted. Please add funds to continue.");
    }
  }
}

Usage Tracking

ModelRelay automatically tracks usage for every API request. Customers can query their own usage with a customer bearer token.

Customer Self-Service Usage

Customers can check their own usage using their customer token:

// Frontend: using a customer bearer token
const mr = ModelRelay.fromToken(customerToken);

// Get customer's own profile
const me = await mr.customers.me();
console.log(`Tier: ${me.tier_code}`);

// Get usage for current billing period
const usage = await mr.customers.meUsage();

console.log(`Billing period: ${usage.window_start} - ${usage.window_end}`);
console.log(`Requests: ${usage.requests}`);
console.log(`Tokens: ${usage.tokens}`);

// Total cost is always available
const totalCost = usage.total_cost_cents / 100;
console.log(`Total cost: $${totalCost.toFixed(2)}`);

// Spend limit (if configured on tier)
if (usage.spend_limit_cents) {
  console.log(`Spend limit: $${(usage.spend_limit_cents / 100).toFixed(2)}`);
}

// For tiers with spend limits
if (usage.spend_limit_cents) {
  const limit = usage.spend_limit_cents / 100;
  const remaining = (usage.spend_remaining_cents ?? 0) / 100;

  console.log(`Budget: $${totalCost.toFixed(2)} / $${limit.toFixed(2)} used`);
  console.log(`Remaining: $${remaining.toFixed(2)}`);

  if (usage.low) {
    console.log("⚠️ Usage is above 80%");
  }
}

// PAYGO wallet (overage)
if (usage.wallet_balance_cents !== undefined) {
  const balance = usage.wallet_balance_cents / 100;
  const reserved = (usage.wallet_reserved_cents ?? 0) / 100;
  console.log(`Wallet: $${balance.toFixed(2)} (reserved $${reserved.toFixed(2)})`);

  if (usage.overage_enabled) {
    console.log("Overage enabled: PAYGO wallet covers spend overages");
  }
}

// Daily breakdown
for (const day of usage.daily) {
  console.log(`${day.day}: ${day.requests} requests, ${day.tokens} tokens`);
}
// Using a customer bearer token
client, err := sdk.NewClientWithToken(customerToken)
if err != nil {
    return err
}

// Get customer's own profile
me, err := client.Customers.Me(ctx)
if err != nil {
    return err
}
fmt.Printf("Tier: %s\n", me.TierCode)

// Get usage for current billing period
usage, err := client.Customers.MeUsage(ctx)
if err != nil {
    return err
}

fmt.Printf("Billing period: %s - %s\n", usage.WindowStart, usage.WindowEnd)
fmt.Printf("Requests: %d\n", usage.Requests)
fmt.Printf("Tokens: %d\n", usage.Tokens)

// Total cost is always available
totalCost := float64(usage.TotalCostCents) / 100
fmt.Printf("Total cost: $%.2f\n", totalCost)

// Spend limit (if configured on tier)
if usage.SpendLimitCents != nil {
    fmt.Printf("Spend limit: $%.2f\n", float64(*usage.SpendLimitCents) / 100)
}

// For tiers with spend limits
if usage.SpendLimitCents != nil {
    limit := float64(*usage.SpendLimitCents) / 100
    remaining := float64(0)
    if usage.SpendRemainingCents != nil {
        remaining = float64(*usage.SpendRemainingCents) / 100
    }

    fmt.Printf("Budget: $%.2f / $%.2f used\n", totalCost, limit)
    fmt.Printf("Remaining: $%.2f\n", remaining)

    if usage.Low != nil && *usage.Low {
        fmt.Println("⚠️ Usage is above 80%")
    }
}

// PAYGO wallet (overage)
if usage.WalletBalanceCents != nil {
    balance := float64(*usage.WalletBalanceCents) / 100
    reserved := float64(0)
    if usage.WalletReservedCents != nil {
        reserved = float64(*usage.WalletReservedCents) / 100
    }
    fmt.Printf("Wallet: $%.2f (reserved $%.2f)\n", balance, reserved)

    if usage.OverageEnabled != nil && *usage.OverageEnabled {
        fmt.Println("Overage enabled: PAYGO wallet covers spend overages")
    }
}

// Daily breakdown
for _, day := range usage.Daily {
    fmt.Printf("%s: %d requests, %d tokens\n", day.Day, day.Requests, day.Tokens)
}
use modelrelay::{Client, Config, AccessToken};

// Using a customer bearer token
let client = Client::new(Config {
    access_token: Some(AccessToken::parse(&customer_token)?),
    ..Default::default()
})?;

// Get customer's own profile
let me = client.customers().me().await?;
println!("Tier: {}", me.tier_code);

// Get usage for current billing period
let usage = client.customers().me_usage().await?;

println!("Billing period: {} - {}", usage.window_start, usage.window_end);
println!("Requests: {}", usage.requests);
println!("Tokens: {}", usage.tokens);

// Total cost is always available
let total_cost = usage.total_cost_cents as f64 / 100.0;
println!("Total cost: ${:.2}", total_cost);

// Spend limit (if configured on tier)
if let Some(limit) = usage.spend_limit_cents {
    println!("Spend limit: ${:.2}", limit as f64 / 100.0);
}

// For tiers with spend limits
if let Some(limit) = usage.spend_limit_cents {
    let limit = limit as f64 / 100.0;
    let remaining = usage.spend_remaining_cents.unwrap_or(0) as f64 / 100.0;

    println!("Budget: ${:.2} / ${:.2} used", total_cost, limit);
    println!("Remaining: ${:.2}", remaining);

    if usage.low.unwrap_or(false) {
        println!("⚠️ Usage is above 80%");
    }
}

// PAYGO wallet (overage)
if let Some(balance) = usage.wallet_balance_cents {
    let reserved = usage.wallet_reserved_cents.unwrap_or(0) as f64 / 100.0;
    println!("Wallet: ${:.2} (reserved ${:.2})", balance as f64 / 100.0, reserved);

    if usage.overage_enabled.unwrap_or(false) {
        println!("Overage enabled: PAYGO wallet covers spend overages");
    }
}

// Daily breakdown
for day in &usage.daily {
    println!("{}: {} requests, {} tokens", day.day, day.requests, day.tokens);
}

Usage Response Fields

Field Type Description
window_start Date Start of billing period
window_end Date End of billing period
requests number Total API requests
tokens number Total tokens consumed
total_cost_cents number Total cost incurred (always populated)
spend_limit_cents number? API usage budget for this billing window
spend_remaining_cents number? Remaining budget (spend_limit - total_cost)
percentage_used number? Usage as percentage (0-100)
low boolean? True if usage ≥ 80%
wallet_balance_cents number? PAYGO wallet balance
wallet_reserved_cents number? PAYGO wallet reserved amount
overage_enabled boolean? True if PAYGO wallet can cover subscription overages
daily array Daily usage breakdown

Customer Self-Service Tier Changes

Customers can change their own subscription tier using their customer bearer token:

POST /api/v1/customers/me/change-tier

Request:

{
  "tier_code": "pro"
}

Response:

{
  "subscription": {
    "tier_code": "pro",
    "tier_display_name": "Pro Plan",
    "price_amount_cents": 2999,
    "price_currency": "usd",
    "price_interval": "month",
    "subscription_status": "active",
    "current_period_start": "2024-01-01T00:00:00Z",
    "current_period_end": "2024-02-01T00:00:00Z"
  },
  "action": "upgraded"
}

The action field indicates what happened:

Action Description
upgraded Moved to a higher-priced tier
downgraded Moved to a lower-priced tier
scheduled_cancellation Paid → free: subscription cancels at period end
changed Moved between free tiers

Tier transitions:

From To Behavior
Paid → Paid Stripe updates subscription with proration
Free → Paid Creates subscription using payment method on file
Paid → Free Schedules cancellation at period end
Free → Free Updates tier locally

Error responses:

Status Error Description
400 already on this tier Customer is already on the requested tier
400 paygo subscriptions do not support tier changes PAYGO billing mode not supported
402 no payment method on file Free → paid requires a saved payment method
404 tier not found The requested tier_code doesn’t exist
409 tier has no billing pricing configured Target tier missing Stripe price

Example: Building an upgrade UI

// Frontend: let customers upgrade their plan
async function upgradeToPro(customerToken: string) {
  const response = await fetch("https://api.modelrelay.ai/api/v1/customers/me/change-tier", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${customerToken}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ tier_code: "pro" }),
  });

  if (!response.ok) {
    const error = await response.text();
    if (response.status === 402) {
      // No payment method - redirect to checkout
      window.location.href = "/checkout?tier=pro";
      return;
    }
    throw new Error(error);
  }

  const result = await response.json();
  console.log(`${result.action}: now on ${result.subscription.tier_code}`);
}

Note: For free → paid upgrades, the customer must have a payment method on file from a previous subscription. If not, use the checkout flow to collect payment details.

Quota Enforcement

ModelRelay enforces spend limits automatically. When a customer exceeds their tier’s credit allowance, requests are rejected with HTTP 402 (Payment Required).

Handling Quota Errors

import { ModelRelay, APIError } from "@modelrelay/sdk";

try {
  const response = await mr.responses.text(
    "claude-sonnet-4-5",
    "You are helpful.",
    "Hello!"
  );
} catch (error) {
  if (error instanceof APIError && error.status === 402) {
    // Customer has exceeded their quota
    console.log("Quota exceeded. Please upgrade your plan.");
    // Show upgrade prompt to user
  } else {
    throw error;
  }
}
response, err := client.Responses.Create(ctx, req)
if err != nil {
    var apiErr sdk.APIError
    if errors.As(err, &apiErr) && apiErr.Status == 402 {
        // Customer has exceeded their quota
        fmt.Println("Quota exceeded. Please upgrade your plan.")
        // Show upgrade prompt to user
        return
    }
    return err
}
use modelrelay::errors::{Error, APIError};

match ResponseBuilder::text_prompt("You are helpful.", "Hello!")
    .model("claude-sonnet-4-5")
    .send_text(&client.responses())
    .await
{
    Ok(response) => println!("{}", response),
    Err(Error::API(APIError { status: 402, .. })) => {
        // Customer has exceeded their quota
        println!("Quota exceeded. Please upgrade your plan.");
        // Show upgrade prompt to user
    }
    Err(e) => return Err(e.into()),
}

Best Practices

1. Map External IDs Early

Always set external_id to your user’s ID when creating customers. This makes lookups and upserts reliable:

// When user signs up
await mr.customers.upsert({
  external_id: user.id,  // Your user ID
  email: user.email,
});

2. Use Upsert for Idempotency

Use upsert instead of create to handle retries gracefully:

// Safe to retry - won't create duplicates
const customer = await mr.customers.upsert({
  external_id: userId,
  email: userEmail,
});

3. Check Usage Proactively

Show usage information in your UI before customers hit their limits:

const usage = await mr.customers.meUsage();

if (usage.percentage_used && usage.percentage_used > 80) {
  showUpgradePrompt();
}

4. Handle Trial Periods

Tiers can have trial periods. Check the subscription status to show appropriate UI:

const status = await mr.customers.getSubscription(customerId);

if (status.status === "trialing") {
  const trialEnd = new Date(status.current_period_end!);
  showTrialBanner(`Trial ends ${trialEnd.toLocaleDateString()}`);
}

Next Steps