1. Create Schema file

It costs 1 USD per org in Clerk, so I'm creating an org in Convex.

export const users = defineTable({
  // Clerk fields
  clerkId: v.string(), // Clerk user ID (sub from JWT)
  email: v.string(),
  emailVerified: v.optional(v.boolean()),
  name: v.optional(v.string()),
  image: v.optional(v.string()),

  // Organization (auto-generated UUID)
  organizationId: v.string(), // Auto-generated UUID
  organizationName: v.optional(v.string()),

  // Role - 6 distinct roles
  role: v.union(
    v.literal("StoreOwner"),
    v.literal("StoreTeam"),
    v.literal("AgencyAdmin"),
    v.literal("AgencyTeam"),
    v.literal("MeyooFounder"),
    v.literal("MeyooTeam"),
  ),

  // Subscription
  plan: v.optional(
    v.union(
      v.literal("free"),
      v.literal("starter"),
      v.literal("growth"),
      v.literal("business"),
      v.literal("enterprise"),
    ),
  ),

  // Business profile
  businessType: v.optional(v.string()),
  businessCategory: v.optional(v.string()),
  industry: v.optional(v.string()),

  // Contact information
  mobileNumber: v.optional(v.string()),
  mobileCountryCode: v.optional(v.string()),

  // Onboarding
  onboardingStep: v.optional(v.number()),
  isOnboarded: v.optional(v.boolean()),
  onboardingData: v.optional(
    v.object({
      referralSource: v.optional(v.string()),
      setupDate: v.optional(v.string()),
      completedSteps: v.optional(v.array(v.string())),
    }),
  ),

  // Settings
  timezone: v.optional(v.string()),
  locale: v.optional(v.string()),

  // Preferences
  emailNotifications: v.optional(v.boolean()),
  marketingEmails: v.optional(v.boolean()),
  twoFactorEnabled: v.optional(v.boolean()),

  // Integration flags
  hasShopifyConnection: v.optional(v.boolean()),
  hasMetaConnection: v.optional(v.boolean()),
  hasGoogleConnection: v.optional(v.boolean()),
  isInitialSyncComplete: v.optional(v.boolean()),

  primaryCurrency: v.optional(v.string()),

  // User status
  status: v.optional(
    v.union(
      v.literal("active"),
      v.literal("inactive"),
      v.literal("suspended"),
      v.literal("deleted"),
    ),
  ),
  deletedAt: v.optional(v.number()), // Track when user was deleted
  lastLoginAt: v.optional(v.number()),
  loginCount: v.optional(v.number()),

  // For agencies - which organizations they can access
  agencyOrganizations: v.optional(v.array(v.string())),

  // Metadata
  createdAt: v.number(),
  updatedAt: v.optional(v.number()),
})
  .index("by_clerk_id", ["clerkId"])
  .index("by_email", ["email"])
  .index("by_organization", ["organizationId"])
  .index("by_isOnboarded_and_onboardingStep", ["isOnboarded", "onboardingStep"])
  .index("by_role", ["role"]);

2 Create http.ts File in convex

import { httpRouter } from "convex/server";

import { handleUser } from "./webhooks/clerkHandler";

const http = httpRouter();

// Single Clerk webhook endpoint for all user events
http.route({
  path: "/clerk/handle-user",
  method: "POST",
  handler: handleUser,
});

export default http;

3. Webhook handler

Convex/webhooks/clerkHandler

I m doing alot of console.log , feel free to remove it

import { Webhook } from "svix";

import { httpAction } from "../_generated/server";
import { internal } from "../_generated/api";

/**
 * Clerk Webhook Handler
 * Processes user lifecycle events from Clerk
 */

// Clerk webhook event types
type ClerkWebhookEvent = {
  data: Record<string, any>;
  object: "event";
  type:
    | "user.created"
    | "user.updated"
    | "user.deleted"
    | "session.created"
    | "session.ended";
};

/**
 * Handle all user-related webhooks from Clerk
 */
export const handleUser = httpAction(async (ctx, request) => {
  const webhookSecret = process.env.CLERK_WEBHOOK_SECRET;

  if (!webhookSecret) {
    console.error("CLERK_WEBHOOK_SECRET not configured");

    return new Response("Webhook secret not configured", { status: 500 });
  }

  // Get the headers
  const svixId = request.headers.get("svix-id");
  const svixTimestamp = request.headers.get("svix-timestamp");
  const svixSignature = request.headers.get("svix-signature");

  // If any are missing, reject the request
  if (!svixId || !svixTimestamp || !svixSignature) {
    return new Response("Missing svix headers", { status: 400 });
  }

  // Get the body
  const body = await request.text();

  // Verify the webhook signature
  const wh = new Webhook(webhookSecret);
  let evt: ClerkWebhookEvent;

  try {
    evt = wh.verify(body, {
      "svix-id": svixId,
      "svix-timestamp": svixTimestamp,
      "svix-signature": svixSignature,
    }) as ClerkWebhookEvent;
  } catch (err: any) {
    console.error("Error verifying webhook:", err.message);

    return new Response("Invalid signature", { status: 400 });
  }

  // Process the webhook based on type
  const { type, data } = evt;

  console.log(`Processing Clerk webhook: ${type}`);

  try {
    switch (type) {
      case "user.created": {
        // Extract user data
        const userData = {
          clerkId: data.id,
          email: data.email_addresses?.[0]?.email_address,
          emailVerified:
            data.email_addresses?.[0]?.verification?.status === "verified",
          name:
            `${data.first_name || ""} ${data.last_name || ""}`.trim() ||
            data.username,
          image: data.image_url,
          publicMetadata: data.public_metadata || {},
        };

        // Create user in Convex
        await ctx.runMutation(
          internal.core.users.createUserFromClerk,
          userData,
        );

        console.log(`Created user ${data.id}`);
        break;
      }

      case "user.updated": {
        // Extract updated user data
        const userData = {
          clerkId: data.id,
          email: data.email_addresses?.[0]?.email_address,
          emailVerified:
            data.email_addresses?.[0]?.verification?.status === "verified",
          name:
            `${data.first_name || ""} ${data.last_name || ""}`.trim() ||
            data.username,
          image: data.image_url,
          publicMetadata: data.public_metadata || {},
        };

        // Update user in Convex
        await ctx.runMutation(
          internal.core.users.updateUserFromClerk,
          userData,
        );

        console.log(`Updated user ${data.id}`);
        break;
      }

      case "user.deleted": {
        // Delete user from Convex (soft delete)
        await ctx.runMutation(internal.core.users.deleteUserFromClerk, {
          clerkId: data.id,
        });

        console.log(`Deleted user ${data.id}`);
        break;
      }

      case "session.created": {
        // Track user login activity
        const user = await ctx.runQuery(internal.core.users.getUserByClerkId, {
          clerkId: data.user_id,
        });

        if (user && user.organizationId) {
          // Emit login event for activity tracking
          await ctx.runMutation(internal.engine.events.emitEvent, {
            type: "user:login",
            organizationId: user.organizationId,
            userId: user._id,
            metadata: {
              sessionId: data.id,
              clientId: data.client_id,
            },
          });
        }

        console.log(`Session created for user ${data.user_id}`);
        break;
      }

      case "session.ended": {
        // Track user logout activity
        const user = await ctx.runQuery(internal.core.users.getUserByClerkId, {
          clerkId: data.user_id,
        });

        if (user && user.organizationId) {
          // Emit logout event
          await ctx.runMutation(internal.engine.events.emitEvent, {
            type: "user:logout",
            organizationId: user.organizationId,
            userId: user._id,
            metadata: {
              sessionId: data.id,
            },
          });
        }

        console.log(`Session ended for user ${data.user_id}`);
        break;
      }

      default:
        console.log(`Unhandled webhook type: ${type}`);
    }

    return new Response("Webhook processed successfully", { status: 200 });
  } catch (error: any) {
    console.error(`Error processing webhook ${type}:`, error);

    // Return 200 to prevent retries for processing errors
    // (only return error status for signature/validation issues)
    return new Response("Webhook processing failed", { status: 200 });
  }
});

4. All the internal Funtion for clerk used in handler

Note: it has some extra funtions feel free to ignore that ,

import { v } from "convex/values";

import {
  query,
  mutation,
  internalQuery,
  internalMutation,
} from "../_generated/server";

/**
 * User management and Clerk webhook handling
 */

// User validator for returns types
const userValidator = v.object({
  _id: v.id("users"),
  _creationTime: v.number(),
  clerkId: v.string(),
  email: v.string(),
  emailVerified: v.optional(v.boolean()),
  name: v.optional(v.string()),
  image: v.optional(v.string()),
  organizationId: v.optional(v.string()),
  organizationName: v.optional(v.string()),
  role: v.union(
    v.literal("StoreOwner"),
    v.literal("StoreTeam"),
    v.literal("AgencyAdmin"),
    v.literal("AgencyTeam"),
    v.literal("MeyooFounder"),
    v.literal("MeyooTeam")
  ),
  plan: v.optional(
    v.union(
      v.literal("free"),
      v.literal("starter"),
      v.literal("growth"),
      v.literal("business"),
      v.literal("enterprise")
    )
  ),
  businessType: v.optional(v.string()),
  businessCategory: v.optional(v.string()),
  industry: v.optional(v.string()),
  mobileNumber: v.optional(v.string()),
  mobileCountryCode: v.optional(v.string()),
  onboardingStep: v.optional(v.number()),
  isOnboarded: v.optional(v.boolean()),
  onboardingData: v.optional(
    v.object({
      referralSource: v.optional(v.string()),
      setupDate: v.optional(v.string()),
      completedSteps: v.optional(v.array(v.string())),
    })
  ),
  timezone: v.optional(v.string()),
  locale: v.optional(v.string()),
  emailNotifications: v.optional(v.boolean()),
  marketingEmails: v.optional(v.boolean()),
  twoFactorEnabled: v.optional(v.boolean()),
  hasShopifyConnection: v.optional(v.boolean()),
  hasMetaConnection: v.optional(v.boolean()),
  hasGoogleConnection: v.optional(v.boolean()),
  isInitialSyncComplete: v.optional(v.boolean()),
  primaryCurrency: v.optional(v.string()),
  status: v.optional(
    v.union(
      v.literal("active"),
      v.literal("inactive"),
      v.literal("suspended"),
      v.literal("deleted")
    )
  ),
  deletedAt: v.optional(v.number()),
  lastLoginAt: v.optional(v.number()),
  loginCount: v.optional(v.number()),
  agencyOrganizations: v.optional(v.array(v.string())),
  createdAt: v.number(),
  updatedAt: v.optional(v.number()),
});

// ============ QUERIES ============

/**
 * Get current authenticated user
 */
export const getCurrentUser = query({
  args: {},
  returns: v.union(v.null(), userValidator),
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();

    if (!identity) return null;

    return await ctx.db
      .query("users")
      .withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject))
      .first();
  },
});

// ============ INTERNAL MUTATIONS (Clerk Webhooks) ============

/**
 * Create user from Clerk webhook
 */
export const createUserFromClerk = internalMutation({
  args: {
    clerkId: v.string(),
    email: v.optional(v.string()),
    emailVerified: v.boolean(),
    name: v.optional(v.string()),
    image: v.optional(v.string()),
    publicMetadata: v.any(),
  },
  returns: v.id("users"),
  handler: async (ctx, args) => {
    // Check if user already exists
    const existingUser = await ctx.db
      .query("users")
      .withIndex("by_clerk_id", (q) => q.eq("clerkId", args.clerkId))
      .first();

    if (existingUser) {
      console.log(`User ${args.clerkId} already exists`);

      return existingUser._id;
    }

    // Generate a unique organization ID for new user
    const organizationId = generateOrganizationId();

    // Create new user
    const userId = await ctx.db.insert("users", {
      clerkId: args.clerkId,
      email: args.email || "",
      emailVerified: args.emailVerified,
      name: args.name || "",
      image: args.image,
      organizationId,
      role: "StoreOwner", // Default role for new users
      status: "active",

      // Onboarding
      isOnboarded: false,
      onboardingStep: 0,
      hasShopifyConnection: false,
      hasMetaConnection: false,
      hasGoogleConnection: false,

      // Preferences
      timezone: "UTC",
      emailNotifications: true,
      marketingEmails: false,

      // Metadata
      createdAt: Date.now(),
      updatedAt: Date.now(),
    });

    console.log(
      `Created new user ${userId} with organization ${organizationId}`
    );

    return userId;
  },
});

/**
 * Update user from Clerk webhook
 */
export const updateUserFromClerk = internalMutation({
  args: {
    clerkId: v.string(),
    email: v.optional(v.string()),
    emailVerified: v.boolean(),
    name: v.optional(v.string()),
    image: v.optional(v.string()),
    publicMetadata: v.any(),
  },
  returns: v.id("users"),
  handler: async (ctx, args) => {
    const user = await ctx.db
      .query("users")
      .withIndex("by_clerk_id", (q) => q.eq("clerkId", args.clerkId))
      .first();

    if (!user) {
      console.log(`User ${args.clerkId} not found for update`);
      // Create the user if it doesn't exist - inline the creation logic
      const organizationId = generateOrganizationId();
      const userId = await ctx.db.insert("users", {
        clerkId: args.clerkId,
        email: args.email || "",
        emailVerified: args.emailVerified,
        name: args.name,
        image: args.image,
        organizationId,
        role: "StoreOwner",
        status: "active" as const,
        isOnboarded: false,
        onboardingStep: 0,
        hasShopifyConnection: false,
        hasMetaConnection: false,
        hasGoogleConnection: false,
        timezone: "UTC",
        emailNotifications: true,
        marketingEmails: false,
        createdAt: Date.now(),
        updatedAt: Date.now(),
      });

      return userId;
    }

    const updates: Partial<{
      updatedAt: number;
      email: string;
      emailVerified: boolean;
      name: string | undefined;
      image: string | undefined;
    }> = {
      updatedAt: Date.now(),
    };

    if (typeof args.email === "string" && args.email.trim() !== "") {
      updates.email = args.email;
    }
    // Always trust Clerk's email verification status
    updates.emailVerified = args.emailVerified;
    if (typeof args.name === "string" && args.name.trim() !== "") {
      updates.name = args.name;
    }
    if (typeof args.image === "string" && args.image.trim() !== "") {
      updates.image = args.image;
    }
    // Note: publicMetadata is not stored in the database

    await ctx.db.patch(user._id, updates);

    console.log(`Updated user ${user._id}`);

    return user._id;
  },
});

/**
 * Delete user from Clerk webhook
 */
export const deleteUserFromClerk = internalMutation({
  args: {
    clerkId: v.string(),
  },
  returns: v.union(v.null(), v.id("users")),
  handler: async (ctx, args) => {
    const user = await ctx.db
      .query("users")
      .withIndex("by_clerk_id", (q) => q.eq("clerkId", args.clerkId))
      .first();

    if (!user) {
      console.log(`User ${args.clerkId} not found for deletion`);

      return null;
    }

    // Soft delete - mark as deleted instead of removing
    await ctx.db.patch(user._id, {
      status: "deleted" as const,
      deletedAt: Date.now(),
      updatedAt: Date.now(),
    });

    console.log(`Soft deleted user ${user._id}`);

    return user._id;
  },
});

// ============ INTERNAL QUERIES ============

/**
 * Get user by Clerk ID (internal)
 */
export const getUserByClerkId = internalQuery({
  args: {
    clerkId: v.string(),
  },
  returns: v.union(v.null(), userValidator),
  handler: async (ctx, args) => {
    return await ctx.db
      .query("users")
      .withIndex("by_clerk_id", (q) => q.eq("clerkId", args.clerkId))
      .first();
  },
});

/**
 * Get users by organization (internal)
 */
export const getUsersByOrganization = internalQuery({
  args: {
    organizationId: v.string(),
  },
  returns: v.array(userValidator),
  handler: async (ctx, args) => {
    return await ctx.db
      .query("users")
      .withIndex("by_organization", (q) =>
        q.eq("organizationId", args.organizationId)
      )
      .collect();
  },
});

// ============ HELPER FUNCTIONS ============

/**
 * Generate unique organization ID
 */
function generateOrganizationId(): string {
  // Generate UUID-like string for organization
  const segments = [
    Math.random().toString(36).substring(2, 10),
    Math.random().toString(36).substring(2, 6),
    Math.random().toString(36).substring(2, 6),
    Math.random().toString(36).substring(2, 6),
    Math.random().toString(36).substring(2, 14),
  ];

  return segments.join("-");
}

/**
 * Check if user has permission for action
 */
export function hasPermission(
  user: { role: string },
  action: "view" | "edit" | "delete" | "admin"
): boolean {
  switch (user.role) {
    case "StoreOwner":
    case "MeyooFounder":
      return true; // Full access

    case "StoreTeam":
      return action === "view" || action === "edit";

    case "AgencyAdmin":
      return action !== "delete"; // Can do everything except delete

    case "AgencyTeam":
      return action === "view";

    case "MeyooTeam":
      return action === "view" || action === "edit";

    default:
      return false;
  }
}