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

  1. Architecture Overview
  2. ⚠️ CRITICAL: User ID Lookup Strategy
  3. Plan Tiers
  4. WorkOS vs billing-dev
  5. What's Been Built
  6. What Needs to Be Built
  7. API Reference
  8. Integrating Subdomains
  9. Stripe Configuration
  10. Environment Variables
  11. Database Schema
  12. Reference Implementation
  13. 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

  1. Centralized Billing: All billing logic lives in billing-dev, not in individual apps
  2. Organization-Based: Billing is tied to organizations, not individual users
  3. Subscription + Metered: Base plans include limits; usage beyond limits is pay-as-you-go
  4. Feature Flags: Entitlements grant access to features (e.g., send.access, talk.numbers)
  5. 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

ParameterPurposeRequired
userIdWorkOS user ID (from user.id)PRIMARY - Always use this
emailUser email for fallback lookupOptional but recommended
orgIdLegacy org IDDEPRECATED - Only for backward compatibility

Why userId?

  1. Each environment has unique user IDs: The same person has different userId values in staging vs production
  2. Consistent across all endpoints: All billing-dev endpoints support userId lookup
  3. 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:

TierMonthlyDaily API CallsMonthly API CallsNotes
hobby$01001,000Default for new users
pro$2010,000100,000Main paid tier
developer$2010,000100,000Alias for pro (legacy)
business$99100,0001,000,000High-volume
enterpriseCustomUnlimitedUnlimitedContact 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:

  1. WorkOS User Metadata (raw?.plan, raw?.roles) - Stored in the auth session
  2. billing-dev (/entitlement endpoint) - Authoritative billing database

When to Use Which

Use CaseSourceWhy
UI plan display (dashboard, pricing page)WorkOS raw?.planFast, already in auth session
Feature gating in API routesbilling-dev /entitlement/checkAuthoritative, real-time
Usage recordingbilling-dev /usage/recordOnly place usage is tracked
Checkout/subscription changesbilling-dev /checkout, /portalStripe 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)

ComponentStatusLocation
Schema with products, subscriptions, entitlements✅ Completeconvex/schema.ts
Product catalog seed data✅ Completeconvex/products.ts
Entitlement queries & mutations✅ Completeconvex/entitlements.ts
HTTP endpoints for external access✅ Completeconvex/http.ts
Plan name migration (free→hobby)✅ Completeconvex/migrations.ts

Stripe Configuration

ItemStatusDetails
Pro Monthly✅ Createdprice_1SfLPpBMKDQ3bUd4UfHgE0S8 - $20/mo
Team Monthly✅ Createdprice_1SmYeJBMKDQ3bUd42VTtsBp6 - $39/mo
send.dev Advanced✅ Createdprice_1SmYDaBMKDQ3bUd4a3iRZlQX - $10/mo add-on
talk.dev Numbers✅ Createdprice_1SmYDeBMKDQ3bUd4hw5RkVlX - $5/mo add-on
Voice Minutes✅ Createdprice_1SmYDCBMKDQ3bUd4ltKEBR5q - $0.02/min metered
API Calls✅ ExistsMetered usage
Email Sends✅ ExistsMetered usage
Storage✅ ExistsMetered usage
AI Tokens✅ ExistsMetered usage
Webhook Endpoint✅ Createdhttps://laudable-chicken-562.convex.site/stripe/webhook

do-dev Integration

ComponentStatusLocation
billing-dev-client.ts✅ Completeapps/do-dev/lib/billing-dev-client.ts
/api/billing-dev/entitlement✅ Completeapps/do-dev/app/api/billing-dev/entitlement/route.ts
useBillingDev hook✅ Completeapps/do-dev/hooks/useBillingDev.ts
Billing overview page✅ Updatedapps/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_1SmYeJBMKDQ3bUd42VTtsBp6

do-dev:

BILLING_DEV_URL=https://laudable-chicken-562.convex.site
BILLING_DEV_API_SECRET=ed96eaf5aa06964de198b165b2a282a06e1812185f0d6baf0cb5c183a5df074c

What Needs to Be Built

High Priority

TaskDescriptionEffort
Stripe Webhook HandlerProcess subscription events, update ConvexMedium
Checkout FlowCreate Stripe checkout sessions from billing-devMedium
Customer PortalRedirect to Stripe billing portalLow
Usage RecordingImplement /usage/record endpointMedium
Credit PurchasesAllow buying credits for pay-as-you-goMedium

Medium Priority

TaskDescriptionEffort
Subscription SyncSync existing Stripe subscriptions to billing-devMedium
Invoice GenerationCreate invoices for usageHigh
Usage DashboardShow usage breakdown per domainMedium
Billing AlertsEmail when approaching limitsLow
Organization MappingMap do-dev org IDs to billing-dev org IDsLow

Subdomain Integrations

SubdomainStatusPriority
send.dev🔲 Not StartedHigh
talk.dev🔲 Not StartedHigh
local.dev🔲 Not StartedMedium
doc.dev🔲 Not StartedMedium
customers.dev🔲 Not StartedLow

API Reference

Base URL

https://laudable-chicken-562.convex.site

Authentication

All API calls require the Authorization header:

Authorization: Bearer ed96eaf5aa06964de198b165b2a282a06e1812185f0d6baf0cb5c183a5df074c

Endpoints

GET /entitlement

Get full entitlement info for a user/organization.

Query Parameters:

  • userId (preferred): WorkOS user ID - use this for new integrations
  • email (optional): User email for fallback lookup
  • orgId (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 ID
  • feature (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 integrations
  • email (optional): User email for fallback lookup
  • orgId (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=ed96eaf5aa06964de198b165b2a282a06e1812185f0d6baf0cb5c183a5df074c

Step 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:

SubdomainRequired FeatureAdd-on Features
send.devsend.accesssend.advanced_templates, send.dedicated_ip, send.custom_domains
talk.devtalk.accesstalk.numbers, talk.inbound_calls, talk.sms
local.devlocal.access-
doc.devdoc.access-
customers.devcustomers.access-

Usage Types by Subdomain

SubdomainUsage Types to Track
send.devapi_call, email_send, storage
talk.devapi_call, voice_minute
local.devapi_call, storage
doc.devapi_call, storage, ai_tokens

Stripe Configuration

Products Created

ProductTypePrice IDPrice
Pro MonthlyBase Planprice_1SfLPpBMKDQ3bUd4UfHgE0S8$20/mo
Team MonthlyBase Planprice_1SmYeJBMKDQ3bUd42VTtsBp6$39/mo
send.dev AdvancedAdd-onprice_1SmYDaBMKDQ3bUd4a3iRZlQX$10/mo
talk.dev NumbersAdd-onprice_1SmYDeBMKDQ3bUd4hw5RkVlX$5/mo
Voice MinutesMeteredprice_1SmYDCBMKDQ3bUd4ltKEBR5q$0.02/min
API CallsMeteredprice_1SfLPqBMKDQ3bUd4x9QLTOsJ$1.00/1000
Email SendsMeteredprice_1SfLPqBMKDQ3bUd4OB4pKmgw$2.00/1000
StorageMeteredprice_1SfLPqBMKDQ3bUd4OtN04Mie$0.25/GB
AI TokensMeteredprice_1SfLPrBMKDQ3bUd4dLnxe5Kp$0.50/1000

Webhook Configuration

Endpoint: https://laudable-chicken-562.convex.site/stripe/webhook

Events to Listen For:

  • checkout.session.completed
  • customer.subscription.created
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.paid
  • invoice.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_id

do-dev / Subdomains

BILLING_DEV_URL=https://your-deployment.convex.site
BILLING_DEV_API_SECRET=your_billing_api_secret_here

Database 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

FilePurpose
lib/workos.tsPlan definitions with PLAN_LIMITS, entitlement helpers
lib/use-auth.tsReact hook exposing raw?.plan, raw?.roles from WorkOS
app/pricing/page.tsxPricing page with current plan detection
app/dashboard/page.tsxDashboard showing plan status
app/api/user/stats/route.tsAPI route returning user's plan and usage
api/src/types/index.tsApiKeyTier type definition
api/src/routes/keys.tsmapToApiKeyTier() 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 stripe package in package.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

  1. Check BILLING_DEV_API_SECRET matches INTERNAL_API_SECRET in billing-dev
  2. Ensure Authorization: Bearer xxx header is being sent
  3. Check for typos in the URL

"Organization not found"

The org ID being passed doesn't exist in billing-dev. Options:

  1. Create the org in billing-dev first
  2. Map do-dev org IDs to billing-dev org IDs

Features not showing

  1. Run npx convex run products:seedProducts to seed product data
  2. Run npx convex run entitlements:createForPlan to create entitlements for existing orgs
  3. Check the org's plan matches a product with features

Stripe webhooks not working

  1. Verify webhook secret matches in Convex env vars
  2. Check Stripe dashboard for webhook delivery status
  3. Ensure the /stripe/webhook endpoint is implemented in convex/http.ts

"no_stripe_customer" from /portal

User has no Stripe customer ID. This happens when:

  1. User never completed checkout (no subscription)
  2. 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:

  1. Sending orgId instead of userId (orgId is deprecated)
  2. Missing returnUrl in portal requests
  3. Not passing email for 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 list

Version History

DateChanges
2026-01-11BREAKING: 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-06Added Plan Tiers section, WorkOS vs billing-dev guidance, Reference Implementation (telco-dev).
2026-01-06Initial documentation. Schema, products, Stripe setup complete. do-dev integration complete.

On this page

Billing-Dev Integration GuideTable of ContentsArchitecture OverviewKey Design Decisions⚠️ CRITICAL: User ID Lookup StrategyThe Golden RuleLookup ParametersWhy userId?Correct Usage PatternPlan TiersLegacy Plan MappingPlan Limits by DomainWorkOS vs billing-devWhen to Use WhichChecking Plan in React ComponentsKeeping WorkOS and billing-dev in SyncWhat's Been Builtbilling-dev Backend (Convex)Stripe Configurationdo-dev IntegrationEnvironment Variables SetWhat Needs to Be BuiltHigh PriorityMedium PrioritySubdomain IntegrationsAPI ReferenceBase URLAuthenticationEndpointsGET /entitlementGET /entitlement/checkGET /healthPOST /usage/record (TODO)POST /keys/validate (TODO)GET /subscriptionPOST /checkoutPOST /portalPOST /cancelIntegrating SubdomainsStep 1: Create Billing ClientStep 2: Add Environment VariablesStep 3: Create Access Check MiddlewareStep 4: Use in API RoutesStep 5: Add Billing UI Components (Optional)Feature Flags by SubdomainUsage Types by SubdomainStripe ConfigurationProducts CreatedWebhook ConfigurationEnvironment Variablesbilling-dev (Convex Dashboard)do-dev / SubdomainsDatabase SchemaKey Tables in billing-devorganizationsproductssubscriptionsentitlementsusageTypesusageEvents / dailyUsage / monthlyUsageReference Implementationtelco-devKey FilesPattern: Plan Detection in Pricing PagePattern: API Key Tier MappingPattern: Default Tier for New KeysWhat telco-dev Does NOT DoTroubleshooting"Unauthorized" from billing-dev"Organization not found"Features not showingStripe webhooks not working"no_stripe_customer" from /portal"missing_required_fields" or "missing_org_id""missing_user_id_or_org_id"Quick ReferenceTest the APIConvex CommandsVersion History