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
userIdAS THE QUERY PARAMETER, NOTorgIdThe billing-dev service keys user records by their WorkOS userId, not by organization ID. Using
?orgId=xxxwhen you should use?userId=xxxwill 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
-
Billing status comes from billing-dev, NOT from user roles.
- Never use
hasRole("pro")orhasRole("early-adopter")for billing checks. - Always query billing-dev's entitlement endpoint.
- Never use
-
Use
userId(WorkOS user ID) as the primary lookup method.- The
orgIdparameter is legacy and for organization-level billing only. - Individual user billing records are keyed by
userId.
- The
-
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>| Environment | URL | Notes |
|---|---|---|
| Development/Staging | https://laudable-chicken-562.convex.site | Default for local dev |
| Production | https://proper-dragon-211.convex.site | For deployed apps |
Alternative variable names (for backwards compatibility):
BILLING_API_URL- Same asBILLING_DEV_URLBILLING_API_SECRET- Same asBILLING_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":
-
Check the query parameter name:
- Are you using
?userId=xxx(correct) or?orgId=xxx(wrong)?
- Are you using
-
Check the user ID value:
- Is it the WorkOS user ID (e.g.,
user_01KC99T5...)? - Run:
console.log("User ID:", user.id)
- Is it the WorkOS user ID (e.g.,
-
Check environment variables:
- Is
BILLING_DEV_URLset? - Is
BILLING_DEV_API_SECRETset? - Did you restart the dev server after adding them?
- Is
-
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' -
Check the network request:
- Open browser DevTools → Network tab
- Look for the
/api/billing-dev/entitlementrequest - 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:
- They complete Stripe checkout on do.dev
- They are manually provisioned via debug endpoints
Adding a New Domain's Users
-
Get the WorkOS userId from browser DevTools:
// In console on the new domain after login: // Check JWT claims or auth context sub: 'user_01KC99T5BMAKD1CPADRSJI3CVX' -
Check if user exists:
curl 'https://laudable-chicken-562.convex.site/entitlement?userId=USER_ID' \ -H 'Authorization: Bearer $BILLING_DEV_API_SECRET' -
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):
| Parameter | Type | Description |
|---|---|---|
userId | string | PREFERRED - WorkOS user ID |
email | string | Optional fallback for lookup |
orgId | string | Legacy - 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:
userIdororgId- User/org identifierfeature- Feature name to check
Response:
{
"hasAccess": true,
"feature": "send.access",
"plan": "pro"
}Feature Names
Standard feature names across domains:
| Feature | Description |
|---|---|
send.access | Access to Send.dev |
talk.access | Access to Talk.dev |
transcribe.access | Access to Transcribe.dev |
ai.access | AI features |
priority.support | Priority support |
advanced.analytics | Advanced analytics |
Plan Hierarchy
hobby < pro < team < enterprise| Plan | Price | Description |
|---|---|---|
hobby | Free | Dashboard only, limited features |
pro | $29/mo | All domains, individual use |
team | $99/mo | Pro + SSO + team features |
enterprise | Custom | Unlimited + custom integrations |
Roles vs Billing
| Use Roles For | Use Billing For |
|---|---|
| Staff access | Pro features |
| Admin privileges | Paid tier gates |
| Root permissions | Usage limits |
| Internal tools | Feature 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