Billing-Dev Integration Guide
Last Updated: January 11, 2026
This document covers the centralized billing system for the do.dev ecosystem. It explains what has been built, what needs to be built, and how to integrate billing into subdomain applications (send.dev, talk.dev, local.dev, doc.dev).
Table of Contents
- Architecture Overview
- ⚠️ CRITICAL: User ID Lookup Strategy
- Plan Tiers
- WorkOS vs billing-dev
- What's Been Built
- What Needs to Be Built
- API Reference
- Integrating Subdomains
- Stripe Configuration
- Environment Variables
- Database Schema
- Reference Implementation
- Troubleshooting
Architecture Overview
┌─────────────────────────────────────────────────────────────────────┐
│ USER / BROWSER │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ do.dev (Main App) │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Billing Pages │ │ useBillingDev │ │ /api/billing-dev│ │
│ │ (UI) │→ │ (React Hook) │→ │ (API Route) │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
▼ HTTP API
┌─────────────────────────────────────────────────────────────────────┐
│ billing-dev (Convex Backend) │
│ │
│ Convex Site URL: https://laudable-chicken-562.convex.site │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ /entitlement │ │ /usage/record│ │ /keys/validate│ │
│ │ /entitlement │ │ │ │ │ │
│ │ /check │ │ │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ Tables: organizations, products, subscriptions, entitlements, │
│ usageTypes, usageEvents, dailyUsage, monthlyUsage │
└─────────────────────────────────────────────────────────────────────┘
│
▼ Webhooks
┌─────────────────────────────────────────────────────────────────────┐
│ Stripe │
│ │
│ Products: Pro ($20/mo), Team ($39/mo), Add-ons │
│ Metered: API Calls, Emails, Voice Minutes, Storage, AI Tokens │
└─────────────────────────────────────────────────────────────────────┘
│
▼ HTTP API
┌─────────────────────────────────────────────────────────────────────┐
│ Subdomain Apps │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ send.dev │ │ talk.dev │ │ local.dev│ │ doc.dev │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ Each subdomain calls billing-dev to: │
│ 1. Check entitlements before allowing access │
│ 2. Record usage for metered billing │
│ 3. Validate API keys │
└─────────────────────────────────────────────────────────────────────┘Key Design Decisions
- Centralized Billing: All billing logic lives in billing-dev, not in individual apps
- Organization-Based: Billing is tied to organizations, not individual users
- Subscription + Metered: Base plans include limits; usage beyond limits is pay-as-you-go
- Feature Flags: Entitlements grant access to features (e.g.,
send.access,talk.numbers) - No Direct Stripe: Subdomain repos should NEVER use Stripe SDK directly - all Stripe interactions go through billing-dev
⚠️ CRITICAL: User ID Lookup Strategy
All billing-dev API calls should use userId (WorkOS user ID) as the PRIMARY identifier.
The Golden Rule
Billing status comes from billing-dev, NOT from user roles.
Never use hasRole("pro") or hasRole("early-adopter") for billing checks.Lookup Parameters
| Parameter | Purpose | Required |
|---|---|---|
userId | WorkOS user ID (from user.id) | PRIMARY - Always use this |
email | User email for fallback lookup | Optional but recommended |
orgId | Legacy org ID | DEPRECATED - Only for backward compatibility |
Why userId?
- Each environment has unique user IDs: The same person has different
userIdvalues in staging vs production - Consistent across all endpoints: All billing-dev endpoints support userId lookup
- Future-proof: New integrations should ONLY use userId, never orgId
Correct Usage Pattern
// ✅ CORRECT - Use userId from WorkOS auth
const result = await createPortalSession({
userId: user.id, // WorkOS user ID
email: user.email, // For fallback lookup
returnUrl,
});
// ❌ WRONG - Don't use orgId for new integrations
const result = await createPortalSession({
orgId: someOrgId, // DEPRECATED
returnUrl,
});Plan Tiers
All do.dev applications use the same plan tiers:
| Tier | Monthly | Daily API Calls | Monthly API Calls | Notes |
|---|---|---|---|---|
hobby | $0 | 100 | 1,000 | Default for new users |
pro | $20 | 10,000 | 100,000 | Main paid tier |
developer | $20 | 10,000 | 100,000 | Alias for pro (legacy) |
business | $99 | 100,000 | 1,000,000 | High-volume |
enterprise | Custom | Unlimited | Unlimited | Contact sales |
Legacy Plan Mapping
For backward compatibility, map legacy plan names:
// In API routes and billing code
const planMap: Record<string, string> = {
"free": "hobby", // Legacy "free" → "hobby"
"early_adopter": "pro", // Deprecated tier → "pro"
};
function normalizePlan(plan: string): string {
return planMap[plan] || plan;
}Plan Limits by Domain
Each subdomain may have different limits per plan. Define these in your app's lib/ directory:
// lib/plan-limits.ts
export const PLAN_LIMITS = {
hobby: {
dailyLimit: 100,
monthlyLimit: 1000,
perMinute: 10,
},
pro: {
dailyLimit: 10000,
monthlyLimit: 100000,
perMinute: 60,
},
// ... other tiers
} as const;WorkOS vs billing-dev
There are two sources of plan/entitlement data:
- WorkOS User Metadata (
raw?.plan,raw?.roles) - Stored in the auth session - billing-dev (
/entitlementendpoint) - Authoritative billing database
When to Use Which
| Use Case | Source | Why |
|---|---|---|
| UI plan display (dashboard, pricing page) | WorkOS raw?.plan | Fast, already in auth session |
| Feature gating in API routes | billing-dev /entitlement/check | Authoritative, real-time |
| Usage recording | billing-dev /usage/record | Only place usage is tracked |
| Checkout/subscription changes | billing-dev /checkout, /portal | Stripe integration lives here |
Checking Plan in React Components
Use the useAuth() hook to access WorkOS metadata:
"use client"
import { useAuth } from "@/lib/use-auth"
export function PricingPage() {
const { raw } = useAuth()
// Check both plan and roles for Pro status (belt and suspenders)
const isPro = raw?.plan === "pro" || raw?.roles?.includes("pro")
const currentPlan = raw?.plan || "hobby"
return (
<div>
{isPro ? (
<span>You're on Pro!</span>
) : (
<a href="/dashboard?upgrade=pro">Upgrade to Pro</a>
)}
</div>
)
}Keeping WorkOS and billing-dev in Sync
When Stripe webhooks update billing-dev (via customer.subscription.updated), they should ALSO update WorkOS user metadata. This is handled in do-dev's webhook processing:
// When subscription changes in Stripe webhook:
// 1. Update billing-dev (automatic via webhook)
// 2. Update WorkOS metadata
await workos.userManagement.updateUser(userId, {
metadata: {
plan: newPlan,
roles: newPlan === "pro" ? ["pro"] : [],
},
});What's Been Built
billing-dev Backend (Convex)
| Component | Status | Location |
|---|---|---|
| Schema with products, subscriptions, entitlements | ✅ Complete | convex/schema.ts |
| Product catalog seed data | ✅ Complete | convex/products.ts |
| Entitlement queries & mutations | ✅ Complete | convex/entitlements.ts |
| HTTP endpoints for external access | ✅ Complete | convex/http.ts |
| Plan name migration (free→hobby) | ✅ Complete | convex/migrations.ts |
Stripe Configuration
| Item | Status | Details |
|---|---|---|
| Pro Monthly | ✅ Created | price_1SfLPpBMKDQ3bUd4UfHgE0S8 - $20/mo |
| Team Monthly | ✅ Created | price_1SmYeJBMKDQ3bUd42VTtsBp6 - $39/mo |
| send.dev Advanced | ✅ Created | price_1SmYDaBMKDQ3bUd4a3iRZlQX - $10/mo add-on |
| talk.dev Numbers | ✅ Created | price_1SmYDeBMKDQ3bUd4hw5RkVlX - $5/mo add-on |
| Voice Minutes | ✅ Created | price_1SmYDCBMKDQ3bUd4ltKEBR5q - $0.02/min metered |
| API Calls | ✅ Exists | Metered usage |
| Email Sends | ✅ Exists | Metered usage |
| Storage | ✅ Exists | Metered usage |
| AI Tokens | ✅ Exists | Metered usage |
| Webhook Endpoint | ✅ Created | https://laudable-chicken-562.convex.site/stripe/webhook |
do-dev Integration
| Component | Status | Location |
|---|---|---|
| billing-dev-client.ts | ✅ Complete | apps/do-dev/lib/billing-dev-client.ts |
| /api/billing-dev/entitlement | ✅ Complete | apps/do-dev/app/api/billing-dev/entitlement/route.ts |
| useBillingDev hook | ✅ Complete | apps/do-dev/hooks/useBillingDev.ts |
| Billing overview page | ✅ Updated | apps/do-dev/app/dashboard/billing/overview/page.tsx |
| Environment variables | ✅ Set | .env.local |
Environment Variables Set
billing-dev Convex:
INTERNAL_API_SECRET=ed96eaf5aa06964de198b165b2a282a06e1812185f0d6baf0cb5c183a5df074c
STRIPE_SECRET_KEY=sk_test_51JltnDBMKDQ3bUd4...
STRIPE_WEBHOOK_SECRET=whsec_RfM4L7jR2N1qJpAgBLD7rr0jcyTzRAQG
STRIPE_PRICE_PRO=price_1SfLPpBMKDQ3bUd4UfHgE0S8
STRIPE_PRICE_TEAM=price_1SmYeJBMKDQ3bUd42VTtsBp6do-dev:
BILLING_DEV_URL=https://laudable-chicken-562.convex.site
BILLING_DEV_API_SECRET=ed96eaf5aa06964de198b165b2a282a06e1812185f0d6baf0cb5c183a5df074cWhat Needs to Be Built
High Priority
| Task | Description | Effort |
|---|---|---|
| Stripe Webhook Handler | Process subscription events, update Convex | Medium |
| Checkout Flow | Create Stripe checkout sessions from billing-dev | Medium |
| Customer Portal | Redirect to Stripe billing portal | Low |
| Usage Recording | Implement /usage/record endpoint | Medium |
| Credit Purchases | Allow buying credits for pay-as-you-go | Medium |
Medium Priority
| Task | Description | Effort |
|---|---|---|
| Subscription Sync | Sync existing Stripe subscriptions to billing-dev | Medium |
| Invoice Generation | Create invoices for usage | High |
| Usage Dashboard | Show usage breakdown per domain | Medium |
| Billing Alerts | Email when approaching limits | Low |
| Organization Mapping | Map do-dev org IDs to billing-dev org IDs | Low |
Subdomain Integrations
| Subdomain | Status | Priority |
|---|---|---|
| send.dev | 🔲 Not Started | High |
| talk.dev | 🔲 Not Started | High |
| local.dev | 🔲 Not Started | Medium |
| doc.dev | 🔲 Not Started | Medium |
| customers.dev | 🔲 Not Started | Low |
API Reference
Base URL
https://laudable-chicken-562.convex.siteAuthentication
All API calls require the Authorization header:
Authorization: Bearer ed96eaf5aa06964de198b165b2a282a06e1812185f0d6baf0cb5c183a5df074cEndpoints
GET /entitlement
Get full entitlement info for a user/organization.
Query Parameters:
userId(preferred): WorkOS user ID - use this for new integrationsemail(optional): User email for fallback lookuporgId(deprecated): Legacy organization ID
Response:
{
"orgId": "k5780q24xykqptp67wz3v09b0h7ybshy",
"plan": "pro",
"credits": 0,
"limits": {
"apiCalls": 10000,
"emails": 5000,
"storage": 1000,
"voiceMinutes": 1000,
"aiTokens": 100000,
"teamMembers": 1
},
"features": ["dashboard", "send.access"],
"hasOverageAccess": false,
"planStartedAt": 1704067200000,
"activeSubscriptions": 1
}Example (recommended - using userId):
curl -H "Authorization: Bearer $API_SECRET" \
"https://laudable-chicken-562.convex.site/entitlement?userId=user_01KC4EJ7NRCBH5ZNVS5KCWSHFT&email=user@example.com"Example (legacy - using orgId):
curl -H "Authorization: Bearer $API_SECRET" \
"https://laudable-chicken-562.convex.site/entitlement?orgId=k5780q24xykqptp67wz3v09b0h7ybshy"GET /entitlement/check
Check if an organization has access to a specific feature.
Query Parameters:
orgId(required): The organization IDfeature(required): The feature to check (e.g.,send.access,talk.numbers)
Response:
{
"hasAccess": true,
"feature": "send.access",
"plan": "pro"
}Example:
curl -H "Authorization: Bearer $API_SECRET" \
"https://laudable-chicken-562.convex.site/entitlement/check?orgId=xxx&feature=send.access"GET /health
Health check endpoint (no auth required).
Response:
{"status": "ok"}POST /usage/record (TODO)
Record usage for metered billing.
Body:
{
"orgId": "xxx",
"usageType": "email_send",
"quantity": 100,
"domain": "send"
}POST /keys/validate (TODO)
Validate an API key.
Body:
{
"apiKey": "do_live_xxx",
"domain": "send"
}GET /subscription
Get subscription status for a user.
Query Parameters:
userId(preferred): WorkOS user ID - use this for new integrationsemail(optional): User email for fallback lookuporgId(deprecated): Legacy organization ID
Response:
{
"hasSubscription": true,
"plan": "pro",
"status": "active",
"currentPeriodStart": 1704067200000,
"currentPeriodEnd": 1706745600000,
"cancelAtPeriodEnd": false,
"cancelAt": null
}Example:
curl -H "Authorization: Bearer $API_SECRET" \
"https://laudable-chicken-562.convex.site/subscription?userId=user_01KC4EJ7NRCBH5ZNVS5KCWSHFT&email=user@example.com"POST /checkout
Create a Stripe checkout session for subscription.
Body:
{
"userId": "user_01KC4EJ7NRCBH5ZNVS5KCWSHFT",
"customerEmail": "user@example.com",
"priceId": "price_1SfLPpBMKDQ3bUd4UfHgE0S8",
"successUrl": "https://send.dev/dashboard/billing?checkout=success",
"cancelUrl": "https://send.dev/dashboard/billing?checkout=cancelled",
"mode": "subscription",
"metadata": {
"planId": "pro",
"source": "send.dev"
}
}Response:
{
"sessionId": "cs_test_xxx",
"url": "https://checkout.stripe.com/xxx"
}POST /portal
Create a Stripe customer portal session.
Body:
{
"userId": "user_01KC4EJ7NRCBH5ZNVS5KCWSHFT",
"email": "user@example.com",
"returnUrl": "https://send.dev/dashboard/billing/overview"
}Response:
{
"url": "https://billing.stripe.com/xxx"
}Note: The user must have a Stripe customer ID (from completing checkout) to use the portal.
POST /cancel
Cancel a subscription.
Body:
{
"userId": "user_01KC4EJ7NRCBH5ZNVS5KCWSHFT",
"email": "user@example.com",
"cancelAtPeriodEnd": true
}Response:
{
"subscriptionId": "sub_xxx",
"cancelAtPeriodEnd": true,
"cancelAt": 1706745600000,
"currentPeriodEnd": 1706745600000
}Integrating Subdomains
This section provides step-by-step instructions for integrating billing-dev into subdomain applications.
Step 1: Create Billing Client
Create a billing client in your subdomain app. Always use userId (WorkOS user ID) as the primary identifier.
// lib/billing-dev-client.ts
const BILLING_DEV_URL = process.env.BILLING_DEV_URL || "https://laudable-chicken-562.convex.site";
const BILLING_DEV_API_SECRET = process.env.BILLING_DEV_API_SECRET;
interface BillingDevResponse<T> {
success: boolean;
data?: T;
error?: string;
}
interface EntitlementResponse {
orgId: string;
plan: "hobby" | "pro" | "team" | "enterprise";
credits: number;
limits: {
apiCalls?: number;
emails?: number;
storage?: number;
voiceMinutes?: number;
aiTokens?: number;
} | null;
features: string[];
hasOverageAccess: boolean;
}
interface SubscriptionResponse {
hasSubscription: boolean;
plan: string;
status?: string;
currentPeriodEnd?: number;
currentPeriodStart?: number;
cancelAtPeriodEnd?: boolean;
cancelAt?: number | null;
}
interface CheckoutSessionResult {
sessionId: string;
url: string;
}
interface PortalSessionResult {
url: string;
}
function getAuthHeader(): HeadersInit {
return {
Authorization: `Bearer ${BILLING_DEV_API_SECRET}`,
"Content-Type": "application/json",
};
}
/**
* Get entitlements for a user
* @param userId - WorkOS user ID (from user.id) - PRIMARY identifier
* @param email - User email for fallback lookup
*/
export async function getEntitlementByUserId(
userId: string,
email?: string
): Promise<BillingDevResponse<EntitlementResponse>> {
try {
const params = new URLSearchParams({ userId });
if (email) params.append("email", email);
const response = await fetch(
`${BILLING_DEV_URL}/entitlement?${params.toString()}`,
{ headers: getAuthHeader(), cache: "no-store" }
);
if (!response.ok) {
const error = await response.json().catch(() => ({ error: "Unknown error" }));
return { success: false, error: error.error || `HTTP ${response.status}` };
}
return { success: true, data: await response.json() };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : "Network error" };
}
}
/**
* Get subscription status for a user
* @param userId - WorkOS user ID (from user.id) - PRIMARY identifier
* @param email - User email for fallback lookup
*/
export async function getSubscriptionByUserId(
userId: string,
email?: string
): Promise<BillingDevResponse<SubscriptionResponse>> {
try {
const params = new URLSearchParams({ userId });
if (email) params.append("email", email);
const response = await fetch(
`${BILLING_DEV_URL}/subscription?${params.toString()}`,
{ headers: getAuthHeader(), cache: "no-store" }
);
if (!response.ok) {
const error = await response.json().catch(() => ({ error: "Unknown error" }));
return { success: false, error: error.error || `HTTP ${response.status}` };
}
return { success: true, data: await response.json() };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : "Network error" };
}
}
/**
* Create a checkout session for subscription
* @param userId - WorkOS user ID (from user.id) - PRIMARY identifier
*/
export async function createCheckoutSession(params: {
userId: string;
email: string;
priceId: string;
successUrl: string;
cancelUrl: string;
mode?: "subscription" | "payment";
metadata?: Record<string, string>;
}): Promise<BillingDevResponse<CheckoutSessionResult>> {
try {
const body = {
userId: params.userId,
customerEmail: params.email,
priceId: params.priceId,
successUrl: params.successUrl,
cancelUrl: params.cancelUrl,
mode: params.mode || "subscription",
metadata: params.metadata,
};
const response = await fetch(`${BILLING_DEV_URL}/checkout`, {
method: "POST",
headers: getAuthHeader(),
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: "Unknown error" }));
return { success: false, error: error.error || `HTTP ${response.status}` };
}
return { success: true, data: await response.json() };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : "Network error" };
}
}
/**
* Create a portal session for managing subscription
* @param userId - WorkOS user ID (from user.id) - PRIMARY identifier
* @param email - User email for fallback lookup
*/
export async function createPortalSession(params: {
userId: string;
email?: string;
returnUrl: string;
}): Promise<BillingDevResponse<PortalSessionResult>> {
try {
const body = {
userId: params.userId,
email: params.email,
returnUrl: params.returnUrl,
};
const response = await fetch(`${BILLING_DEV_URL}/portal`, {
method: "POST",
headers: getAuthHeader(),
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: "Unknown error" }));
return { success: false, error: error.error || `HTTP ${response.status}` };
}
return { success: true, data: await response.json() };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : "Network error" };
}
}
/**
* Cancel a subscription
* @param userId - WorkOS user ID (from user.id) - PRIMARY identifier
* @param email - User email for fallback lookup
*/
export async function cancelSubscription(params: {
userId: string;
email?: string;
cancelAtPeriodEnd?: boolean;
}): Promise<BillingDevResponse<{ subscriptionId: string; cancelAtPeriodEnd: boolean; cancelAt: number | null; currentPeriodEnd: number }>> {
try {
const body = {
userId: params.userId,
email: params.email,
cancelAtPeriodEnd: params.cancelAtPeriodEnd ?? true,
};
const response = await fetch(`${BILLING_DEV_URL}/cancel`, {
method: "POST",
headers: getAuthHeader(),
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: "Unknown error" }));
return { success: false, error: error.error || `HTTP ${response.status}` };
}
return { success: true, data: await response.json() };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : "Network error" };
}
}Step 2: Add Environment Variables
Add to your subdomain's .env.local:
# Billing Dev Service
BILLING_DEV_URL=https://laudable-chicken-562.convex.site
BILLING_DEV_API_SECRET=ed96eaf5aa06964de198b165b2a282a06e1812185f0d6baf0cb5c183a5df074cStep 3: Create Access Check Middleware
Create middleware to check access before allowing API requests:
// middleware/billing.ts
import { NextRequest, NextResponse } from "next/server";
import { getEntitlementByUserId } from "@/lib/billing-dev-client";
const DOMAIN = "send"; // Change per subdomain: "send", "talk", "local", "doc"
const FEATURE = `${DOMAIN}.access`; // e.g., "send.access"
/**
* Check if user has billing access to this subdomain
* @param userId - WorkOS user ID (from user.id)
* @param email - User email for fallback lookup
*/
export async function checkBillingAccess(
userId: string,
email?: string
): Promise<NextResponse | null> {
const result = await getEntitlementByUserId(userId, email);
if (!result.success || !result.data) {
return NextResponse.json(
{
error: "billing_error",
message: "Unable to verify billing status",
},
{ status: 500 }
);
}
const hasAccess = result.data.features.includes(FEATURE);
if (!hasAccess) {
return NextResponse.json(
{
error: "subscription_required",
message: `Upgrade to Pro to access ${DOMAIN}.dev`,
upgradeUrl: "https://do.dev/pricing",
},
{ status: 403 }
);
}
return null; // Access granted
}
/**
* Check usage limits for a user
* @param userId - WorkOS user ID (from user.id)
* @param email - User email for fallback lookup
*/
export async function checkUsageLimits(
userId: string,
email: string | undefined,
usageType: string,
quantity: number = 1
): Promise<{ allowed: boolean; remaining: number; requiresCredits: boolean }> {
const result = await getEntitlementByUserId(userId, email);
if (!result.success || !result.data) {
return { allowed: false, remaining: 0, requiresCredits: false };
}
const entitlement = result.data;
const limit = entitlement.limits?.[usageType as keyof typeof entitlement.limits] || 0;
// TODO: Get current usage from billing-dev
const currentUsage = 0; // Placeholder
const remaining = Math.max(0, limit - currentUsage);
const wouldExceed = currentUsage + quantity > limit;
if (wouldExceed) {
// Check if user can use credits for overage
if (entitlement.hasOverageAccess && entitlement.credits > 0) {
return { allowed: true, remaining, requiresCredits: true };
}
return { allowed: false, remaining, requiresCredits: false };
}
return { allowed: true, remaining, requiresCredits: false };
}Step 4: Use in API Routes
Example API route with billing check using WorkOS auth:
// app/api/emails/send/route.ts (for send.dev)
import { NextRequest, NextResponse } from "next/server";
import { withAuth } from "@workos-inc/authkit-nextjs";
import { checkBillingAccess, checkUsageLimits } from "@/middleware/billing";
export async function POST(request: NextRequest) {
// Get user from WorkOS auth
const { user } = await withAuth();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Check billing access using userId (not orgId!)
const accessDenied = await checkBillingAccess(user.id, user.email);
if (accessDenied) return accessDenied;
// Check usage limits
const { allowed, remaining, requiresCredits } = await checkUsageLimits(
user.id,
user.email,
"emails",
1 // sending 1 email
);
if (!allowed) {
return NextResponse.json(
{
error: "limit_exceeded",
message: "Email limit exceeded. Add credits to continue.",
remaining,
},
{ status: 429 }
);
}
// Perform the action
const result = await sendEmail(/* ... */);
// Record usage
await recordUsage({
orgId,
usageType: "email_send",
quantity: 1,
domain: "send",
});
return NextResponse.json({ success: true, ...result });
}Step 5: Add Billing UI Components (Optional)
If your subdomain has a dashboard, add billing status display:
// components/BillingStatus.tsx
"use client";
import { useEffect, useState } from "react";
interface BillingStatus {
plan: string;
features: string[];
limits: Record<string, number>;
credits: number;
}
export function BillingStatus({ orgId }: { orgId: string }) {
const [status, setStatus] = useState<BillingStatus | null>(null);
useEffect(() => {
async function fetchStatus() {
const response = await fetch(`/api/billing/status?orgId=${orgId}`);
if (response.ok) {
setStatus(await response.json());
}
}
fetchStatus();
}, [orgId]);
if (!status) return null;
return (
<div className="p-4 bg-white rounded-lg border">
<div className="text-sm text-gray-500">Current Plan</div>
<div className="text-lg font-semibold capitalize">{status.plan}</div>
{status.plan === "hobby" && (
<a
href="https://do.dev/pricing"
className="mt-2 inline-block text-blue-600 hover:underline"
>
Upgrade to Pro
</a>
)}
</div>
);
}Feature Flags by Subdomain
Each subdomain should check for its specific features:
| Subdomain | Required Feature | Add-on Features |
|---|---|---|
| send.dev | send.access | send.advanced_templates, send.dedicated_ip, send.custom_domains |
| talk.dev | talk.access | talk.numbers, talk.inbound_calls, talk.sms |
| local.dev | local.access | - |
| doc.dev | doc.access | - |
| customers.dev | customers.access | - |
Usage Types by Subdomain
| Subdomain | Usage Types to Track |
|---|---|
| send.dev | api_call, email_send, storage |
| talk.dev | api_call, voice_minute |
| local.dev | api_call, storage |
| doc.dev | api_call, storage, ai_tokens |
Stripe Configuration
Products Created
| Product | Type | Price ID | Price |
|---|---|---|---|
| Pro Monthly | Base Plan | price_1SfLPpBMKDQ3bUd4UfHgE0S8 | $20/mo |
| Team Monthly | Base Plan | price_1SmYeJBMKDQ3bUd42VTtsBp6 | $39/mo |
| send.dev Advanced | Add-on | price_1SmYDaBMKDQ3bUd4a3iRZlQX | $10/mo |
| talk.dev Numbers | Add-on | price_1SmYDeBMKDQ3bUd4hw5RkVlX | $5/mo |
| Voice Minutes | Metered | price_1SmYDCBMKDQ3bUd4ltKEBR5q | $0.02/min |
| API Calls | Metered | price_1SfLPqBMKDQ3bUd4x9QLTOsJ | $1.00/1000 |
| Email Sends | Metered | price_1SfLPqBMKDQ3bUd4OB4pKmgw | $2.00/1000 |
| Storage | Metered | price_1SfLPqBMKDQ3bUd4OtN04Mie | $0.25/GB |
| AI Tokens | Metered | price_1SfLPrBMKDQ3bUd4dLnxe5Kp | $0.50/1000 |
Webhook Configuration
Endpoint: https://laudable-chicken-562.convex.site/stripe/webhook
Events to Listen For:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.paidinvoice.payment_failed
Environment Variables
billing-dev (Convex Dashboard)
INTERNAL_API_SECRET=your_internal_api_secret_here
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
STRIPE_PRICE_PRO=price_your_pro_price_id
STRIPE_PRICE_TEAM=price_your_team_price_id
WORKOS_CLIENT_ID=client_your_workos_client_iddo-dev / Subdomains
BILLING_DEV_URL=https://your-deployment.convex.site
BILLING_DEV_API_SECRET=your_billing_api_secret_hereDatabase Schema
Key Tables in billing-dev
organizations
Stores organization billing info (plan, credits, Stripe customer ID).
products
Product catalog with base plans and add-ons.
{
slug: "pro",
name: "Pro",
type: "base_plan", // or "addon", "usage"
stripePriceId: "price_xxx",
price: 2000, // $20 in cents
includedLimits: {
apiCalls: 10000,
emails: 5000,
storage: 1000,
voiceMinutes: 1000,
aiTokens: 100000,
teamMembers: 1,
},
features: ["dashboard", "send.access", "talk.access", ...],
}subscriptions
Active subscriptions linking orgs to products.
entitlements
Feature access granted to organizations.
{
orgId: "xxx",
feature: "send.access",
source: "plan", // or "addon", "trial", "manual"
sourceId: "subscription_id",
expiresAt: null, // or timestamp for trials
}usageTypes
Defines billable usage categories.
usageEvents / dailyUsage / monthlyUsage
Tracks usage for metered billing.
Reference Implementation
telco-dev
The telco-dev repository serves as a reference implementation for subdomain billing integration.
Location: /Users/tim/code/working/dodotdev/telco-dev
Key Files
| File | Purpose |
|---|---|
lib/workos.ts | Plan definitions with PLAN_LIMITS, entitlement helpers |
lib/use-auth.ts | React hook exposing raw?.plan, raw?.roles from WorkOS |
app/pricing/page.tsx | Pricing page with current plan detection |
app/dashboard/page.tsx | Dashboard showing plan status |
app/api/user/stats/route.ts | API route returning user's plan and usage |
api/src/types/index.ts | ApiKeyTier type definition |
api/src/routes/keys.ts | mapToApiKeyTier() with legacy mapping |
Pattern: Plan Detection in Pricing Page
// app/pricing/page.tsx
export default function PricingPage() {
const { raw } = useAuth()
const isPro = raw?.plan === "pro" || raw?.roles?.includes("pro")
const currentPlan = raw?.plan || "hobby"
const tiers = [
{
name: "Hobby",
cta: currentPlan === "hobby" ? "Your Plan" : "Get Started Free",
isCurrentPlan: currentPlan === "hobby",
},
{
name: "Pro",
cta: isPro ? "Your Plan" : "Upgrade to Pro",
isCurrentPlan: isPro,
},
// ...
]
}Pattern: API Key Tier Mapping
// api/src/routes/keys.ts
function mapToApiKeyTier(tier: string | undefined): ApiKeyTier {
const tierMap: Record<string, ApiKeyTier> = {
hobby: "hobby",
free: "hobby", // Legacy mapping
pro: "pro",
developer: "developer",
business: "business",
enterprise: "enterprise",
};
return tierMap[tier || "hobby"] || "hobby";
}Pattern: Default Tier for New Keys
// app/api/user/keys/route.ts
const response = await callWorkerApi("/internal/keys", {
method: "POST",
body: JSON.stringify({
name: name.trim(),
user_id: user.id,
email: user.email,
tier: "hobby", // Default tier for new users
}),
});What telco-dev Does NOT Do
telco-dev demonstrates the correct pattern of not using Stripe directly:
- No
stripepackage inpackage.json - No Stripe API calls
- No webhook handlers for Stripe events
- All billing operations go through billing-dev or WorkOS metadata
Troubleshooting
"Unauthorized" from billing-dev
- Check
BILLING_DEV_API_SECRETmatchesINTERNAL_API_SECRETin billing-dev - Ensure
Authorization: Bearer xxxheader is being sent - Check for typos in the URL
"Organization not found"
The org ID being passed doesn't exist in billing-dev. Options:
- Create the org in billing-dev first
- Map do-dev org IDs to billing-dev org IDs
Features not showing
- Run
npx convex run products:seedProductsto seed product data - Run
npx convex run entitlements:createForPlanto create entitlements for existing orgs - Check the org's plan matches a product with features
Stripe webhooks not working
- Verify webhook secret matches in Convex env vars
- Check Stripe dashboard for webhook delivery status
- Ensure the
/stripe/webhookendpoint is implemented inconvex/http.ts
"no_stripe_customer" from /portal
User has no Stripe customer ID. This happens when:
- User never completed checkout (no subscription)
- User's billing record wasn't linked to their userId
Solution: User must complete a checkout flow first. The portal is only for managing existing subscriptions.
"missing_required_fields" or "missing_org_id"
Your client is not sending the required parameters.
For new integrations (use userId):
// ✅ CORRECT
const result = await createPortalSession({
userId: user.id, // WorkOS user ID from withAuth()
email: user.email, // For fallback lookup
returnUrl: "https://yourapp.dev/dashboard/billing",
});Common mistakes:
- Sending
orgIdinstead ofuserId(orgId is deprecated) - Missing
returnUrlin portal requests - Not passing
emailfor fallback lookup
"missing_user_id_or_org_id"
Neither userId nor orgId was provided. Always use userId from WorkOS auth:
const { user } = await withAuth();
// user.id is the WorkOS user ID - use this!Quick Reference
Test the API
# Health check
curl https://laudable-chicken-562.convex.site/health
# Get entitlement (using userId - RECOMMENDED)
curl -H "Authorization: Bearer $API_SECRET" \
"https://laudable-chicken-562.convex.site/entitlement?userId=user_01KC4EJ7NRCBH5ZNVS5KCWSHFT&email=user@example.com"
# Get subscription status
curl -H "Authorization: Bearer $API_SECRET" \
"https://laudable-chicken-562.convex.site/subscription?userId=user_01KC4EJ7NRCBH5ZNVS5KCWSHFT&email=user@example.com"
# Create portal session
curl -X POST -H "Authorization: Bearer $API_SECRET" \
-H "Content-Type: application/json" \
-d '{"userId":"user_01KC4EJ7NRCBH5ZNVS5KCWSHFT","email":"user@example.com","returnUrl":"https://app.dev/billing"}' \
"https://laudable-chicken-562.convex.site/portal"Convex Commands
cd /Users/tim/code/working/dodotdev/billing-dev
# Deploy
npx convex dev --once
# Run migrations
npx convex run migrations:migratePlanNames
# Seed products
npx convex run products:seedProducts
npx convex run products:seedUsageTypes
# Check env vars
npx convex env listVersion History
| Date | Changes |
|---|---|
| 2026-01-11 | BREAKING: All endpoints now use userId (WorkOS user ID) as PRIMARY identifier. Added /subscription, /checkout, /portal, /cancel endpoint docs. Updated example client code to use userId pattern. orgId is now deprecated for new integrations. |
| 2026-01-06 | Added Plan Tiers section, WorkOS vs billing-dev guidance, Reference Implementation (telco-dev). |
| 2026-01-06 | Initial documentation. Schema, products, Stripe setup complete. do-dev integration complete. |