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:

  1. Check if users have access (entitlements)
  2. Report usage back to do.dev
  3. 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-key

API 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 | doc
  • userEmail: (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

  1. Purpose: Credits are prepaid balance for pay-as-you-go usage
  2. When Used: When plan limits are exceeded (overages)
  3. Pricing: Plan subscribers get 50% discount on overage rates

Credit Amounts

AmountBest For
$10Light overage buffer
$25Regular users (recommended)
$50Heavy users
$100Teams 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 TypeFree TierSubscriber 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:

  1. Allow the action (don't block users)
  2. Log the usage locally (queue for later reporting)
  3. 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_KEY securely (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:3005

Mock 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_KEY to 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

On this page