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"])
Convex-generated unique identifier
The unique Clerk user ID (indexed for fast lookups)
User’s primary email address from Clerk
User’s first name (optional)
User’s last name (optional)
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,
});
The unique Clerk user ID from the webhook payload
User’s primary email address
User’s first name from Clerk profile
User’s last name from Clerk profile
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:
- User exists: Updates all fields (email, firstName, lastName, imageUrl) with new values from Clerk
- 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,
});
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:
- Looks up the user by their Clerk ID
- If found, deletes the user record
- If not found, silently succeeds (idempotent operation)
Called by webhook for:
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:
- Validates the user is authenticated (has valid JWT token)
- Looks up the user by their Clerk ID from the JWT
- Returns the full user object
- Throws
"Not authenticated" if no JWT token
- 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
-
Never call these mutations directly - They are internal and should only be invoked by the webhook handler
-
Use requireUser for authentication - All client-facing queries and mutations should use
requireUser(ctx) to enforce authentication and get the current user
-
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
-
Monitor webhook delivery - Ensure Clerk webhooks are being delivered successfully. Failed webhooks will result in users being authenticated but not found in the database
-
Consider cascade deletions - When implementing
deleteUser, consider whether you want to delete or archive related records (quests, locations, etc.)