Domain Integration Quick Start

How to check billing status from ANY Do.dev domain (send.dev, talk.dev, etc.)


CRITICAL: The #1 Integration Mistake

USE userId AS THE QUERY PARAMETER, NOT orgId

The billing-dev service keys user records by their WorkOS userId, not by organization ID. Using ?orgId=xxx when you should use ?userId=xxx will return "hobby" instead of the actual plan.

// WRONG - This will always return "hobby" for individual users
const url = `${BILLING_API_URL}/entitlement?orgId=${organizationId || user.id}`;

// CORRECT - Use userId as the query parameter name
const url = `${BILLING_API_URL}/entitlement?userId=${user.id}&email=${user.email}`;

Even if the VALUE is the same (user.id), the PARAMETER NAME matters!


The Golden Rules

  1. Billing status comes from billing-dev, NOT from user roles.

    • Never use hasRole("pro") or hasRole("early-adopter") for billing checks.
    • Always query billing-dev's entitlement endpoint.
  2. Use userId (WorkOS user ID) as the primary lookup method.

    • The orgId parameter is legacy and for organization-level billing only.
    • Individual user billing records are keyed by userId.
  3. Each environment has separate WorkOS user IDs.

    • Staging and production have DIFFERENT user IDs for the same person.
    • Users must exist in billing-dev for each environment separately.

Environment Setup

Add to your domain's .env.local:

# Use these variable names consistently across all domains
BILLING_DEV_URL=https://laudable-chicken-562.convex.site
BILLING_DEV_API_SECRET=<get-from-vault>
EnvironmentURLNotes
Development/Staginghttps://laudable-chicken-562.convex.siteDefault for local dev
Productionhttps://proper-dragon-211.convex.siteFor deployed apps

Alternative variable names (for backwards compatibility):

  • BILLING_API_URL - Same as BILLING_DEV_URL
  • BILLING_API_SECRET - Same as BILLING_DEV_API_SECRET

Architecture Overview

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   send.dev      │     │  transcribe.dev │     │    talk.dev     │
│                 │     │                 │     │                 │
│  WorkOS Auth    │     │  WorkOS Auth    │     │  WorkOS Auth    │
│  userId: abc123 │     │  userId: def456 │     │  userId: ghi789 │
└────────┬────────┘     └────────┬────────┘     └────────┬────────┘
         │                       │                       │
         └───────────────────────┼───────────────────────┘


                    ┌────────────────────────┐
                    │      billing-dev       │
                    │                        │
                    │  /entitlement?userId=X │  <-- USE userId!
                    │         ↓              │
                    │  user → membership → org│
                    │         ↓              │
                    │    { plan: "pro" }     │
                    └────────────────────────┘

Quick Copy-Paste Implementation

Step 1: Create the Billing Client

Create lib/billing-dev-client.ts:

/**
 * Billing Dev Client
 *
 * THE GOLDEN RULE:
 * Use `userId` as the query parameter, NOT `orgId`.
 * The billing-dev service keys records by WorkOS userId.
 */

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;

export type BillingPlan = "hobby" | "pro" | "team" | "enterprise";

export interface BillingInfo {
  plan: BillingPlan;
  features: string[];
  credits: number;
  hasOverageAccess: boolean;
  orgId: string | null;
  limits?: Record<string, number> | null;
  activeSubscriptions?: number;
}

interface BillingResponse<T> {
  success: boolean;
  data?: T;
  error?: string;
}

const DEFAULT_BILLING: BillingInfo = {
  plan: "hobby",
  features: [],
  credits: 0,
  hasOverageAccess: false,
  orgId: null,
};

function getAuthHeader(): HeadersInit {
  return {
    Authorization: `Bearer ${BILLING_DEV_API_SECRET}`,
    "Content-Type": "application/json",
  };
}

/**
 * Get billing info by WorkOS user ID (PREFERRED METHOD)
 *
 * This is THE CORRECT way to get billing info. Pass the userId from
 * WorkOS auth directly. Optionally pass email as fallback.
 *
 * @param userId - The WorkOS user ID (user.id from withAuth())
 * @param email - Optional email for fallback lookup
 */
export async function getEntitlementByUserId(
  userId: string,
  email?: string
): Promise<BillingResponse<BillingInfo>> {
  if (!BILLING_DEV_API_SECRET) {
    console.warn("[BillingDev] BILLING_DEV_API_SECRET not configured");
    return { success: false, error: "Not configured" };
  }

  try {
    // CRITICAL: Use userId as the query parameter name!
    const params = new URLSearchParams({ userId });
    if (email) params.append("email", email);

    const url = `${BILLING_DEV_URL}/entitlement?${params.toString()}`;

    const response = await fetch(url, {
      method: "GET",
      headers: getAuthHeader(),
      cache: "no-store", // Always fetch fresh
    });

    if (!response.ok) {
      const error = await response.json().catch(() => ({ error: "Unknown error" }));
      return { success: false, error: error.error || `HTTP ${response.status}` };
    }

    const data = await response.json();
    return { success: true, data };
  } catch (error) {
    console.error("[BillingDev] Error:", error);
    return { success: false, error: error instanceof Error ? error.message : "Network error" };
  }
}

/**
 * LEGACY: Get billing info by org ID
 * Use getEntitlementByUserId instead!
 */
export async function getEntitlementByOrgId(orgId: string): Promise<BillingResponse<BillingInfo>> {
  // ... similar implementation with ?orgId=xxx
}

/**
 * Check if user has a paid plan
 */
export async function isPro(userId: string, email?: string): Promise<boolean> {
  const result = await getEntitlementByUserId(userId, email);
  if (!result.success || !result.data) return false;
  return ["pro", "team", "enterprise"].includes(result.data.plan);
}

/**
 * Check if user has a specific feature
 */
export async function hasFeature(
  userId: string,
  feature: string,
  email?: string
): Promise<boolean> {
  const result = await getEntitlementByUserId(userId, email);
  if (!result.success || !result.data) return false;
  return result.data.features.includes(feature);
}

Step 2: Create the API Route

Create app/api/billing-dev/entitlement/route.ts:

import { NextResponse } from "next/server";
import { withAuth } from "@workos-inc/authkit-nextjs";
import { getEntitlementByUserId } from "@/lib/billing-dev-client";

export async function GET() {
  try {
    const { user } = await withAuth();

    if (!user) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    // CRITICAL: Pass user.id and user.email - these are the WorkOS user identifiers
    const result = await getEntitlementByUserId(user.id, user.email);

    if (result.success && result.data) {
      return NextResponse.json(result.data);
    }

    // Fallback to hobby if billing-dev fails
    return NextResponse.json({
      plan: "hobby",
      features: [],
      credits: 0,
      hasOverageAccess: false,
      orgId: null,
      source: "fallback",
    });
  } catch (error) {
    console.error("[billing-dev/entitlement] Error:", error);
    return NextResponse.json({ error: "Authentication failed" }, { status: 401 });
  }
}

Step 3: Create the React Hook

Create hooks/useBillingDev.ts:

import { useState, useEffect, useCallback } from "react";

interface BillingState {
  plan: string;
  features: string[];
  credits: number;
  isLoading: boolean;
  error: string | null;
  isPro: boolean;
}

export function useBillingDev() {
  const [state, setState] = useState<BillingState>({
    plan: "hobby",
    features: [],
    credits: 0,
    isLoading: true,
    error: null,
    isPro: false,
  });

  const fetchEntitlement = useCallback(async () => {
    try {
      const res = await fetch("/api/billing-dev/entitlement");
      if (!res.ok) throw new Error("Failed to fetch");

      const data = await res.json();
      setState({
        plan: data.plan || "hobby",
        features: data.features || [],
        credits: data.credits || 0,
        isLoading: false,
        error: null,
        isPro: ["pro", "team", "enterprise"].includes(data.plan),
      });
    } catch (error) {
      setState(prev => ({
        ...prev,
        isLoading: false,
        error: error instanceof Error ? error.message : "Unknown error",
      }));
    }
  }, []);

  useEffect(() => {
    fetchEntitlement();
  }, [fetchEntitlement]);

  return state;
}

Common Mistakes & Fixes

Mistake 1: Using orgId instead of userId

// WRONG - Uses orgId query parameter
const billingOrgId = organizationId || user.id;
const url = `${BILLING_API_URL}/entitlement?orgId=${billingOrgId}`;
// Result: Returns "hobby" because orgId lookup fails

// CORRECT - Uses userId query parameter
const params = new URLSearchParams({ userId: user.id });
if (user.email) params.append("email", user.email);
const url = `${BILLING_API_URL}/entitlement?${params.toString()}`;
// Result: Returns actual plan (e.g., "pro")

Mistake 2: Missing environment variables

# Check your .env.local has these:
BILLING_DEV_URL=https://laudable-chicken-562.convex.site
BILLING_DEV_API_SECRET=<your-secret>

# NOT these (outdated names):
# BILLING_API_URL=...
# BILLING_API_SECRET=...

Mistake 3: Using roles for billing checks

// WRONG - Roles are for permissions, not billing
const hasAccess = hasRole("pro");
const hasAccess = hasRole("early-adopter");

// CORRECT - Use billing-dev
const hasAccess = await isPro(user.id, user.email);
const hasAccess = await hasFeature(user.id, "send.access", user.email);

Mistake 4: Caching issues

// WRONG - May return stale data
const response = await fetch(url);

// CORRECT - Always fetch fresh billing data
const response = await fetch(url, { cache: "no-store" });

Debugging Checklist

When billing returns "hobby" but should be "pro":

  1. Check the query parameter name:

    • Are you using ?userId=xxx (correct) or ?orgId=xxx (wrong)?
  2. Check the user ID value:

    • Is it the WorkOS user ID (e.g., user_01KC99T5...)?
    • Run: console.log("User ID:", user.id)
  3. Check environment variables:

    • Is BILLING_DEV_URL set?
    • Is BILLING_DEV_API_SECRET set?
    • Did you restart the dev server after adding them?
  4. Check if user exists in billing-dev:

    curl 'https://laudable-chicken-562.convex.site/entitlement?userId=USER_ID' \
      -H 'Authorization: Bearer $BILLING_DEV_API_SECRET'
  5. Check the network request:

    • Open browser DevTools → Network tab
    • Look for the /api/billing-dev/entitlement request
    • Check the response - does it show an error?

User Provisioning for New Domains

When adding a new domain, users may not exist in billing-dev yet.

How Users Get Created

Users are created in billing-dev when:

  1. They complete Stripe checkout on do.dev
  2. They are manually provisioned via debug endpoints

Adding a New Domain's Users

  1. Get the WorkOS userId from browser DevTools:

    // In console on the new domain after login:
    // Check JWT claims or auth context
    sub: 'user_01KC99T5BMAKD1CPADRSJI3CVX'
  2. Check if user exists:

    curl 'https://laudable-chicken-562.convex.site/entitlement?userId=USER_ID' \
      -H 'Authorization: Bearer $BILLING_DEV_API_SECRET'
  3. If user doesn't exist, create via debug endpoint:

    curl -X POST 'https://laudable-chicken-562.convex.site/debug/create-user' \
      -H 'Authorization: Bearer $BILLING_DEV_API_SECRET' \
      -H 'Content-Type: application/json' \
      -d '{
        "authId": "user_01KC99T5BMAKD1CPADRSJI3CVX",
        "email": "user@example.com",
        "name": "User Name",
        "orgId": "existing-org-id-if-known"
      }'

API Reference

GET /entitlement

Get user's billing entitlements.

Query Parameters (in priority order):

ParameterTypeDescription
userIdstringPREFERRED - WorkOS user ID
emailstringOptional fallback for lookup
orgIdstringLegacy - organization ID lookup

Response:

{
  "orgId": "org_123",
  "plan": "pro",
  "credits": 10000,
  "limits": {
    "apiCalls": 50000,
    "emails": 10000
  },
  "features": ["send.access", "ai.access"],
  "hasOverageAccess": true,
  "activeSubscriptions": 1
}

GET /entitlement/check

Check if user has access to a specific feature.

Query Parameters:

  • userId or orgId - User/org identifier
  • feature - Feature name to check

Response:

{
  "hasAccess": true,
  "feature": "send.access",
  "plan": "pro"
}

Feature Names

Standard feature names across domains:

FeatureDescription
send.accessAccess to Send.dev
talk.accessAccess to Talk.dev
transcribe.accessAccess to Transcribe.dev
ai.accessAI features
priority.supportPriority support
advanced.analyticsAdvanced analytics

Plan Hierarchy

hobby < pro < team < enterprise
PlanPriceDescription
hobbyFreeDashboard only, limited features
pro$29/moAll domains, individual use
team$99/moPro + SSO + team features
enterpriseCustomUnlimited + custom integrations

Roles vs Billing

Use Roles ForUse Billing For
Staff accessPro features
Admin privilegesPaid tier gates
Root permissionsUsage limits
Internal toolsFeature access

Roles = Administrative permissions (user, staff, admin, root) Billing = Payment tiers and entitlements (hobby, pro, team, enterprise)


Need Help?

  • Dev API: https://laudable-chicken-562.convex.site
  • Prod API: https://proper-dragon-211.convex.site
  • Full docs: BILLING_INTEGRATION.md

On this page