Webhook sync
Mirror Logto users into a queryable Convex table you own, with verified webhooks, soft deletes, and RBAC.
If you want a queryable users table (to list users, store roles, or join app
data), add a webhook sync. The table is yours; convex-logto owns
nothing. A runnable version of everything here is in the tanstack-router-spa
example.
1. Schema
Two kinds of fields share the row, with different owners; this split is what keeps the sync from fighting your app:
users: defineTable({
authId: v.string(), // == identity.subject (the Logto user id)
email: v.optional(v.string()), // Logto-owned (synced)
name: v.optional(v.string()), // Logto-owned (synced)
role: v.union(v.literal("user"), v.literal("admin")), // app-owned (RBAC)
status: v.union(
v.literal("active"),
v.literal("suspended"),
v.literal("deleted"),
), // Logto-owned lifecycle ("deleted" is a tombstone, not a row removal)
}).index("by_authId", ["authId"]),2. Map events to your table
import { logtoSync, type LogtoSyncHandler } from "convex-logto";
import type { DataModel } from "./_generated/dataModel";
import type { QueryCtx, MutationCtx } from "./_generated/server";
const byAuthId = (ctx: QueryCtx | MutationCtx, authId: string) =>
ctx.db
.query("users")
.withIndex("by_authId", (q) => q.eq("authId", authId))
.unique();
// Mirror only the fields the event carries. Present means set it (a `null`
// clears it); absent means leave it. Never write `role`; that's app-owned.
const syncedFields = (u: {
primaryEmail?: string | null;
name?: string | null;
isSuspended?: boolean;
}) => ({
...(u.primaryEmail !== undefined ? { email: u.primaryEmail ?? undefined } : {}),
...(u.name !== undefined ? { name: u.name ?? undefined } : {}),
...(u.isSuspended !== undefined
? { status: u.isSuspended ? ("suspended" as const) : ("active" as const) }
: {}),
});
// The webhook only SYNCS rows that already exist; it never creates them (the next
// section explains why). No row yet means there's nothing to sync.
const syncRow: LogtoSyncHandler<DataModel> = async (ctx, u) => {
const row = await byAuthId(ctx, u.id);
if (!row || row.status === "deleted") return; // nothing to sync / don't resurrect a tombstone
await ctx.db.patch(row._id, syncedFields(u)); // Logto-owned fields only; role untouched
};
export const { sync } = logtoSync<DataModel>({
"User.Data.Updated": syncRow,
"User.SuspensionStatus.Updated": syncRow,
// Soft delete: scrub PII but keep the row (status "deleted") so authz fails
// closed and anything referencing the user by id doesn't dangle. `User.Deleted`
// carries no entity, just the id, so `u` here is the minimal `{ id }`.
"User.Deleted": async (ctx, u) => {
const row = await byAuthId(ctx, u.id);
if (row)
await ctx.db.patch(row._id, {
status: "deleted",
email: undefined,
name: undefined,
});
},
});The webhook never creates rows; it only syncs.
User.Createdfires only for users created after you add the webhook, never for users who already existed in Logto or who arrive from another app on the same Logto. A webhook-created row would also have to invent the app-ownedrole. So a row the webhook alone makes is both unreliable and wrongly-owned.
Create the row from an authenticated mutation instead; it runs as the signed-in user, so it always fires. Let the webhook keep it in sync afterward:
import { mutation } from "./_generated/server";
export const ensureUser = mutation({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("unauthenticated");
const existing = await ctx.db
.query("users")
.withIndex("by_authId", (q) => q.eq("authId", identity.subject))
.unique();
if (existing) return; // already there
await ctx.db.insert("users", {
authId: identity.subject,
email: identity.email,
name: identity.name,
role: "user",
status: "active",
});
},
});Call it once on first authenticated load: an onboarding screen, or an effect.
3. Register the route
import { httpRouter } from "convex/server";
import { registerLogtoWebhook } from "convex-logto";
import { internal } from "./_generated/api";
const http = httpRouter();
registerLogtoWebhook(http, internal.logto.sync); // serves POST /logto/webhook
export default http;Beyond the HMAC signature, the route enforces a freshness window on the
delivery's createdAt (minutes, which retires replayed captures; Logto's own retries
land within seconds) and caps the body at 1 MB.
In session mode, also pass the component:
import { components, internal } from "./_generated/api";
registerLogtoWebhook(http, internal.logto.sync, { sessions: components.logto });That adds two things. Exactly-once handling. The route deduplicates
deliveries by raw-body hash, so a retry whose 200 got lost doesn't re-run your
handlers. Session revocation. User.Deleted, and a suspension flipping ON,
kill all of that user's sessions before your sync handlers run, so reactive
clients drop to signed-out live. This is worth registering even with no
handlers mapped (logtoSync({})); the session example
does exactly that. Bridge mode has no sessions to revoke,
so it omits the option.
4. Create the webhook in Logto
Logto Console → Webhooks → Create webhook, pointed at
<your-convex-site-url>/logto/webhook. That's your Convex HTTP Actions URL,
the .convex.site one, not .convex.cloud, e.g.
https://happy-otter-123.convex.site/logto/webhook. Find it in the Convex dashboard
on your deployment's Settings page (it lists both URLs); each deployment has its
own, so use the dev URL while developing and the prod URL in production.
Under the Data mutation events, subscribe to the ones you handle:
User.Data.Updated, User.SuspensionStatus.Updated, and User.Deleted. Subscribing
to User.Created as well is harmless; with no handler mapped for it the route
answers 200 and does nothing. New rows come from your authenticated mutation
(section 2), not the webhook.
Then copy the Signing key. It is a secret, like the app secret, and lives on the deployment:
npx convex env set LOGTO_WEBHOOK_SIGNING_KEY <signing-key>The route verifies the signature with Web Crypto (HMAC-SHA256) inside the Convex runtime.
Confirm it's wired. The definitive check is a real event. Change a test user's
profile in Logto (fires User.Data.Updated), then watch your Convex logs
(dashboard → Logs, or npx convex logs) for POST /logto/webhook. Logto's
Send test payload button is a quick reachability check too, though it sends a
synthetic payload, so read the status it gets back rather than expecting a 200:
| Status | Meaning |
|---|---|
200 | Verified and dispatched to your sync mutation (or a deduplicated retry). |
400 | Body wasn't valid JSON, wasn't a recognized User.* event, named no user, or its createdAt fell outside the freshness window. |
401 | Signature didn't match; wrong (or unset) LOGTO_WEBHOOK_SIGNING_KEY. |
413 | Body over 1 MB. |
500 | LOGTO_WEBHOOK_SIGNING_KEY isn't set on the deployment. |
The route never refuses a verified delivery over a field the library doesn't
read. Logto retries a 5xx and not a 4xx, so a 400 is permanent, and the
same route revokes sessions for deleted and suspended users, which makes "reject
the odd-looking delivery" the wrong default. Acceptance turns on the event, the
createdAt freshness window and a usable user id. The route drops a field whose
type drifts out of the published LogtoUserEntity shape from the user your
handler receives, and the delivery still arrives verbatim as the third argument.
Fields Logto adds later pass straight through.
RBAC
Gate on the row as well as the token. getUserIdentity() means "the token still
validates", not "the account is still active":
import type { QueryCtx, MutationCtx } from "./_generated/server";
export async function requireActiveUser(ctx: QueryCtx | MutationCtx) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("unauthenticated");
const user = await ctx.db
.query("users")
.withIndex("by_authId", (q) => q.eq("authId", identity.subject))
.unique();
if (user?.status !== "active") throw new Error("forbidden"); // no row / suspended / deleted
return user;
}
export async function requireRole(
ctx: QueryCtx | MutationCtx,
role: "user" | "admin",
) {
const user = await requireActiveUser(ctx);
if (user.role !== role) throw new Error("forbidden");
return user;
}Use requireRole(ctx, "admin") for privileged work. For ordinary reads, prefer a
nullable variant (return null instead of throwing) so a just-signed-up user
isn't locked out in the moment between sign-in and the mutation creating their
row.
Keep the ability map in your app. The package stays out of your authorization policy.