Skip to main content

Overview

The Users API contains internal mutations used by the Clerk webhook integration to synchronize user data between Clerk and Convex. These endpoints are not directly accessible from client code and can only be called internally by the Convex backend.
These mutations are marked as internalMutation and cannot be called from client applications. They are automatically invoked by the Clerk webhook handler.

User Schema

Users are stored with the following structure:
users: defineTable({
  clerkId: v.string(),
  email: v.string(),
  firstName: v.optional(v.string()),
  lastName: v.optional(v.string()),
  imageUrl: v.optional(v.string()),
}).index("by_clerk_id", ["clerkId"])
_id
Id<'users'>
Convex-generated unique identifier
clerkId
string
The unique Clerk user ID (indexed for fast lookups)
email
string
User’s primary email address from Clerk
firstName
string | undefined
User’s first name (optional)
lastName
string | undefined
User’s last name (optional)
imageUrl
string | undefined
URL to the user’s profile image from Clerk (optional)

Internal Mutations

upsertUser

Creates a new user or updates an existing user based on Clerk webhook data. This mutation is called when Clerk sends user.created or user.updated events.
import { internal } from "./_generated/api";

// Called internally by webhook handler
await ctx.runMutation(internal.users.upsertUser, {
  clerkId: data.id,
  email: primaryEmail?.email_address ?? "",
  firstName: data.first_name ?? undefined,
  lastName: data.last_name ?? undefined,
  imageUrl: data.image_url ?? undefined,
});
clerkId
string
required
The unique Clerk user ID from the webhook payload
email
string
required
User’s primary email address
firstName
string
User’s first name from Clerk profile
lastName
string
User’s last name from Clerk profile
imageUrl
string
URL to user’s profile image hosted by Clerk
Implementation:
export const upsertUser = internalMutation({
  args: {
    clerkId: v.string(),
    email: v.string(),
    firstName: v.optional(v.string()),
    lastName: v.optional(v.string()),
    imageUrl: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    const existing = await ctx.db
      .query("users")
      .withIndex("by_clerk_id", (q) => q.eq("clerkId", args.clerkId))
      .unique();

    if (existing) {
      await ctx.db.patch(existing._id, {
        email: args.email,
        firstName: args.firstName,
        lastName: args.lastName,
        imageUrl: args.imageUrl,
      });
    } else {
      await ctx.db.insert("users", {
        clerkId: args.clerkId,
        email: args.email,
        firstName: args.firstName,
        lastName: args.lastName,
        imageUrl: args.imageUrl,
      });
    }
  },
});
Behavior:
  1. User exists: Updates all fields (email, firstName, lastName, imageUrl) with new values from Clerk
  2. User doesn’t exist: Creates a new user record with all provided fields
Called by webhook for:
  • user.created events - Creates new user
  • user.updated events - Updates existing user

deleteUser

Deletes a user from the Convex database when they are deleted from Clerk. This mutation is called when Clerk sends a user.deleted event.
import { internal } from "./_generated/api";

// Called internally by webhook handler
await ctx.runMutation(internal.users.deleteUser, {
  clerkId: data.id,
});
clerkId
string
required
The Clerk user ID to delete
Implementation:
export const deleteUser = internalMutation({
  args: {
    clerkId: v.string(),
  },
  handler: async (ctx, args) => {
    const user = await ctx.db
      .query("users")
      .withIndex("by_clerk_id", (q) => q.eq("clerkId", args.clerkId))
      .unique();

    if (user) {
      await ctx.db.delete(user._id);
    }
  },
});
Behavior:
  1. Looks up the user by their Clerk ID
  2. If found, deletes the user record
  3. If not found, silently succeeds (idempotent operation)
Called by webhook for:
  • user.deleted events
This mutation only deletes the user record itself. It does not cascade delete related records like userQuests or userLocations. You may want to implement cleanup logic for those tables separately.

Webhook Integration

These mutations are called from the Clerk webhook handler in convex/http.ts:
const clerkWebhook = httpAction(async (ctx, request) => {
  // ... webhook verification ...

  const { type, data } = event;

  if (type === "user.created" || type === "user.updated") {
    const primaryEmail = data.email_addresses.find(
      (e) => e.id === data.primary_email_address_id,
    );

    await ctx.runMutation(internal.users.upsertUser, {
      clerkId: data.id,
      email: primaryEmail?.email_address ?? "",
      firstName: data.first_name ?? undefined,
      lastName: data.last_name ?? undefined,
      imageUrl: data.image_url ?? undefined,
    });
  } else if (type === "user.deleted") {
    await ctx.runMutation(internal.users.deleteUser, {
      clerkId: data.id,
    });
  }

  return new Response(null, { status: 200 });
});
See the Authentication documentation for complete webhook setup instructions.

Getting Current User

While there’s no public getCurrent query, you can get the current user using the requireUser utility function in your own queries and mutations:
import { query } from "./_generated/server";
import { requireUser } from "./_utils/user";

export const myQuery = query({
  args: {},
  handler: async (ctx) => {
    const user = await requireUser(ctx);
    // user contains: _id, clerkId, email, firstName, lastName, imageUrl
    return user;
  },
});
The requireUser function:
  1. Validates the user is authenticated (has valid JWT token)
  2. Looks up the user by their Clerk ID from the JWT
  3. Returns the full user object
  4. Throws "Not authenticated" if no JWT token
  5. Throws "User not found" if user doesn’t exist in database
Implementation:
export const requireUser = async (ctx: QueryCtx | MutationCtx) => {
  const identity = await requireAuth(ctx);

  const user = await ctx.db
    .query("users")
    .withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject))
    .unique();

  if (!user) throw new ConvexError("User not found");

  return user;
};

Data Flow

Best Practices

  1. Never call these mutations directly - They are internal and should only be invoked by the webhook handler
  2. Use requireUser for authentication - All client-facing queries and mutations should use requireUser(ctx) to enforce authentication and get the current user
  3. Handle missing users gracefully - If a user is authenticated but not found in the database, it means the webhook hasn’t synced yet. The requireUser function will throw an error in this case
  4. Monitor webhook delivery - Ensure Clerk webhooks are being delivered successfully. Failed webhooks will result in users being authenticated but not found in the database
  5. Consider cascade deletions - When implementing deleteUser, consider whether you want to delete or archive related records (quests, locations, etc.)

Build docs developers (and LLMs) love