Cross-Domain Billing Integration Guide
This document explains how to integrate billing from subdomain applications (talk.dev, send.dev, local.dev) with the central do.dev billing system.
Architecture Overview
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ send.dev │ │ talk.dev │ │ local.dev │
│ (Convex) │ │ (Convex) │ │ (Convex) │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ WorkOS Auth (shared .dev cookie) │
└──────────────────┼──────────────────┘
│
▼
┌───────────────────────┐
│ do.dev │
│ Central Billing Hub │
│ ───────────────── │
│ • Subscriptions │
│ • Entitlements │
│ • Usage Tracking │
│ • Credits/Deposits │
│ • Stripe Integration │
└───────────────────────┘Key Principle: All billing logic lives in do.dev. Subdomains only:
- Check if users have access (entitlements)
- Report usage back to do.dev
- Respect limits returned by do.dev
Authentication
For Browser-Based Requests (Client Components)
WorkOS session cookies are shared across all .dev domains. Client-side requests automatically include credentials:
// In talk.dev client component
const checkAccess = async () => {
const response = await fetch("https://do.dev/api/billing/entitlement", {
method: "POST",
credentials: "include", // Important: includes WorkOS cookie
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ product: "talk" }),
});
return response.json();
};For Server-to-Server Requests (API Routes, Convex Actions)
Use an API key for server-to-server communication:
// In talk.dev API route or Convex action
const response = await fetch("https://do.dev/api/billing/entitlement", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.DODEV_BILLING_API_KEY,
"X-User-Email": userEmail, // Required: identify the user
},
body: JSON.stringify({ product: "talk" }),
});Environment Variable Required:
DODEV_BILLING_API_KEY=your-shared-secret-keyAPI Endpoints
1. Check Entitlement
Endpoint: POST https://do.dev/api/billing/entitlement
Check if a user has access to a product and get their limits.
Request:
{
"product": "talk" | "send" | "local" | "doc",
"userEmail": "user@example.com" // Optional if using cookie auth
}Response:
{
"hasAccess": true,
"plan": "early_adopter",
"limits": {
"api_calls": 10000,
"voice_minutes": 1000,
"storage_mb": 1024
},
"usage": {
"api_calls": 2500,
"voice_minutes": 150,
"storage_mb": 256
},
"remaining": {
"api_calls": 7500,
"voice_minutes": 850,
"storage_mb": 768
},
"credits": {
"balance": 25.00,
"currency": "usd"
},
"overageAllowed": true,
"overageRates": {
"api_calls": 0.001, // $0.001 per call
"voice_minutes": 0.02, // $0.02 per minute
"storage_mb": 0.05 // $0.05 per MB/month
}
}Usage Example:
// talk.dev - Before allowing a voice call
async function canMakeCall(userEmail: string): Promise<boolean> {
const entitlement = await checkEntitlement("talk", userEmail);
if (!entitlement.hasAccess) {
return false; // No subscription
}
if (entitlement.remaining.voice_minutes > 0) {
return true; // Within plan limits
}
if (entitlement.overageAllowed && entitlement.credits.balance > 0) {
return true; // Can use credits for overage
}
return false; // No minutes and no credits
}2. Report Usage
Endpoint: POST https://do.dev/api/billing/usage
Report usage from a subdomain. Call this after each billable action.
Request:
{
"product": "talk",
"userEmail": "user@example.com",
"usageType": "voice_minute" | "api_call" | "email_send" | "storage_mb" | "ai_token",
"quantity": 1,
"metadata": {
"callId": "call_123",
"duration": 60
}
}Response:
{
"success": true,
"recorded": true,
"newUsage": 151,
"limit": 1000,
"remaining": 849,
"isOverage": false,
"chargedCredits": 0
}When isOverage: true:
{
"success": true,
"recorded": true,
"newUsage": 1001,
"limit": 1000,
"remaining": 0,
"isOverage": true,
"chargedCredits": 0.02,
"creditsRemaining": 24.98
}Usage Example:
// talk.dev - After a voice call ends
async function recordCallUsage(userEmail: string, durationMinutes: number, callId: string) {
const result = await fetch("https://do.dev/api/billing/usage", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.DODEV_BILLING_API_KEY,
"X-User-Email": userEmail,
},
body: JSON.stringify({
product: "talk",
userEmail,
usageType: "voice_minute",
quantity: Math.ceil(durationMinutes), // Round up to nearest minute
metadata: { callId, duration: durationMinutes },
}),
});
const data = await result.json();
if (data.isOverage && data.creditsRemaining < 1) {
// Warn user: low credits
await notifyLowCredits(userEmail, data.creditsRemaining);
}
return data;
}3. Get Current Limits
Endpoint: GET https://do.dev/api/billing/limits
Get detailed limits and usage for a product (more detailed than entitlement check).
Query Parameters:
product: talk | send | local | docuserEmail: (optional if using cookie auth)
Response:
{
"plan": "early_adopter",
"billingPeriod": {
"start": "2024-01-01T00:00:00Z",
"end": "2024-01-31T23:59:59Z"
},
"limits": {
"api_calls": { "included": 10000, "used": 2500, "remaining": 7500 },
"voice_minutes": { "included": 1000, "used": 150, "remaining": 850 },
"storage_mb": { "included": 1024, "used": 256, "remaining": 768 }
},
"overageThisPeriod": {
"api_calls": 0,
"voice_minutes": 0,
"creditsUsed": 0
},
"credits": {
"balance": 25.00,
"autoReload": {
"enabled": true,
"threshold": 10.00,
"amount": 25.00
}
}
}4. Add Credits (Deposit)
Endpoint: POST https://do.dev/api/billing/credits/add
Redirect user to add credits to their account.
Request:
{
"amount": 25, // USD amount: 10, 25, 50, 100
"returnUrl": "https://talk.dev/settings/billing"
}Response:
{
"checkoutUrl": "https://checkout.stripe.com/..."
}Usage:
// Redirect user to add credits
const addCredits = async (amount: number) => {
const response = await fetch("https://do.dev/api/billing/credits/add", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
amount,
returnUrl: window.location.href,
}),
});
const { checkoutUrl } = await response.json();
window.location.href = checkoutUrl;
};Integration Patterns
Pattern 1: Gate Feature Access
// Before allowing access to a premium feature
export async function withBillingCheck(
userEmail: string,
product: string,
handler: () => Promise<Response>
): Promise<Response> {
const entitlement = await checkEntitlement(product, userEmail);
if (!entitlement.hasAccess) {
return new Response(JSON.stringify({
error: "subscription_required",
message: "Please subscribe to access this feature",
upgradeUrl: "https://do.dev/pricing"
}), { status: 402 });
}
return handler();
}Pattern 2: Check Before + Record After
// talk.dev voice call flow
export async function initiateCall(userEmail: string, toNumber: string) {
// 1. Check entitlement before allowing call
const entitlement = await checkEntitlement("talk", userEmail);
if (!entitlement.hasAccess) {
throw new Error("No active subscription");
}
if (entitlement.remaining.voice_minutes <= 0 &&
(!entitlement.overageAllowed || entitlement.credits.balance <= 0)) {
throw new Error("No voice minutes remaining. Please add credits.");
}
// 2. Make the call
const call = await twilioClient.calls.create({
to: toNumber,
from: process.env.TWILIO_NUMBER,
});
// 3. Record usage when call ends (via webhook)
// See: handleCallComplete()
return call;
}
// Twilio webhook when call completes
export async function handleCallComplete(callSid: string) {
const call = await twilioClient.calls(callSid).fetch();
const durationMinutes = Math.ceil(call.duration / 60);
await recordUsage("talk", call.userEmail, "voice_minute", durationMinutes, {
callSid,
duration: call.duration,
to: call.to,
});
}Pattern 3: Real-Time Usage Display
// React hook for displaying usage in UI
function useUsageLimits(product: string) {
const [limits, setLimits] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchLimits = async () => {
const response = await fetch(
`https://do.dev/api/billing/limits?product=${product}`,
{ credentials: "include" }
);
setLimits(await response.json());
setLoading(false);
};
fetchLimits();
// Refresh every 30 seconds
const interval = setInterval(fetchLimits, 30000);
return () => clearInterval(interval);
}, [product]);
return { limits, loading };
}
// Usage in component
function UsageBar({ type }: { type: string }) {
const { limits, loading } = useUsageLimits("talk");
if (loading) return <Skeleton />;
const usage = limits.limits[type];
const percent = (usage.used / usage.included) * 100;
return (
<div>
<div className="flex justify-between text-sm">
<span>{type}</span>
<span>{usage.used} / {usage.included}</span>
</div>
<div className="h-2 bg-gray-200 rounded">
<div
className={`h-full rounded ${percent > 90 ? 'bg-red-500' : 'bg-green-500'}`}
style={{ width: `${Math.min(100, percent)}%` }}
/>
</div>
</div>
);
}Credits/Deposit System
How Credits Work
- Purpose: Credits are prepaid balance for pay-as-you-go usage
- When Used: When plan limits are exceeded (overages)
- Pricing: Plan subscribers get 50% discount on overage rates
Credit Amounts
| Amount | Best For |
|---|---|
| $10 | Light overage buffer |
| $25 | Regular users (recommended) |
| $50 | Heavy users |
| $100 | Teams and power users |
Auto-Reload
Users can enable automatic credit reload:
- Threshold: When balance drops below $X
- Amount: Automatically charge $Y to card on file
- Notification: Email sent on each auto-reload
Overage Pricing
| Usage Type | Free Tier | Subscriber Rate (50% off) |
|---|---|---|
| API Calls | $0.002/call | $0.001/call |
| Voice Minutes | $0.04/min | $0.02/min |
| Email Sends | $0.002/email | $0.001/email |
| Storage | $0.10/GB/mo | $0.05/GB/mo |
| AI Tokens | $0.004/1K | $0.002/1K |
Error Handling
Error Responses
// 401 - Not authenticated
{
"error": "unauthorized",
"message": "Please sign in to continue"
}
// 402 - Payment required
{
"error": "subscription_required",
"message": "Please subscribe to access this feature",
"upgradeUrl": "https://do.dev/pricing"
}
// 402 - Credits depleted
{
"error": "insufficient_credits",
"message": "Please add credits to continue",
"creditsUrl": "https://do.dev/dashboard/billing/credits",
"currentBalance": 0.50,
"requiredAmount": 1.00
}
// 429 - Rate limited
{
"error": "rate_limited",
"message": "Too many requests. Please try again later.",
"retryAfter": 60
}
// 503 - Billing service unavailable
{
"error": "service_unavailable",
"message": "Billing service temporarily unavailable",
"fallback": "allow" // Hint: allow action, charge later
}Graceful Degradation
When billing service is unavailable, subdomains should:
- Allow the action (don't block users)
- Log the usage locally (queue for later reporting)
- Retry reporting when service recovers
async function reportUsageWithRetry(usage: UsageRecord) {
try {
await reportUsage(usage);
} catch (error) {
// Queue for retry
await localQueue.push({
type: "usage_report",
data: usage,
attempts: 0,
maxAttempts: 5,
});
// Still allow the action
console.warn("Usage report queued for retry:", usage);
}
}Security Considerations
API Key Security
- Store
DODEV_BILLING_API_KEYsecurely (never in client code) - Rotate keys periodically
- Use different keys per environment (dev/staging/prod)
Request Validation
do.dev validates all requests:
- API key authenticity
- User email belongs to authenticated user
- Product is valid
- Usage quantities are reasonable
Rate Limiting
- Entitlement checks: 100/minute per user
- Usage reports: 1000/minute per organization
- Credits operations: 10/minute per user
Testing
Test Mode
Use Stripe test mode for development:
# .env.local
DODEV_BILLING_API_KEY=test_key_xxx
NEXT_PUBLIC_DODEV_URL=http://localhost:3005Mock Responses
For local development without do.dev running:
// lib/billing-mock.ts
export const mockEntitlement = {
hasAccess: true,
plan: "early_adopter",
limits: { api_calls: 10000 },
usage: { api_calls: 0 },
remaining: { api_calls: 10000 },
credits: { balance: 25.00 },
overageAllowed: true,
};
export async function checkEntitlement(product: string, userEmail: string) {
if (process.env.NODE_ENV === "development" && process.env.MOCK_BILLING) {
return mockEntitlement;
}
// Real implementation...
}Checklist for New Subdomain Integration
- Add
DODEV_BILLING_API_KEYto environment variables - Create billing utility functions (
lib/billing.ts) - Add entitlement check to protected routes/features
- Implement usage reporting for billable actions
- Add credits/usage display in settings UI
- Handle billing errors gracefully
- Test with Stripe test mode
- Set up usage report retry queue
- Add low credits warning notifications
Support
For billing integration issues:
- Check do.dev logs for API errors
- Review Stripe dashboard for payment issues
- Contact: billing@do.dev