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:
1a. Self-Service Checkout (Recommended)
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]
- Create a tier with
billing_mode: "paygo" - Customer tops up their balance via Stripe checkout
- Each API request deducts from their balance
- 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
- Customer Tokens — Issue tokens for customer access
- First Request — Make your first API call
- Streaming — Real-time response streaming