convex-logto

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:

convex/schema.ts
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

convex/logto.ts
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.Created fires 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-owned role. 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:

convex/users.ts
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

convex/http.ts
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:

convex/http.ts
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 → WebhooksCreate 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:

StatusMeaning
200Verified and dispatched to your sync mutation (or a deduplicated retry).
400Body wasn't valid JSON, wasn't a recognized User.* event, named no user, or its createdAt fell outside the freshness window.
401Signature didn't match; wrong (or unset) LOGTO_WEBHOOK_SIGNING_KEY.
413Body over 1 MB.
500LOGTO_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":

convex/authz.ts
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.

On this page