koala73/worldmonitor · error · ConvexError

EMBED_ACCESS_REQUIRED

EMBED_ACCESS_REQUIRED

Error message

EMBED_ACCESS_REQUIRED

What it means

createEmbedKey is gated by the shared hasAccountEmbedAccess predicate, which is fail-closed: it requires a verified Clerk PRO plan or an active paid embed entitlement (merged with planKey via mergeEntitlementFeatures, honoring validUntil). ConvexError("EMBED_ACCESS_REQUIRED") is thrown when the authenticated user holds no such access. Note the gate is deliberately NOT the apiAccess flag — embed keys are mintable by every paid tier.

Solutions

  1. Upgrade the Clerk account to PRO or purchase an embed entitlement, then retry.
  2. Check the entitlements row for the user in the Convex dashboard (query entitlements by userId) to see if embedAccess/validUntil is what you expect.
  3. If the user paid but the row is stale, retrigger the billing webhook (Dodo) so the entitlement row is rewritten with embedAccess.
  4. Verify your client is authenticated as the intended user — resolveUserIdentity/requireUserId resolve the JWT identity, and a wrong signed-in account fails the gate.
Defensive patterns

Strategy: try-catch

Validate before calling

// Best-effort pre-check from app state (server gate is authoritative)
const canMint = user.plan === "pro" || (entitlement?.features?.embedAccess &&
  (!entitlement.validUntil || entitlement.validUntil > Date.now()));
if (!canMint) showUpgradePrompt();

Type guard

function hasEmbedAccess(plan: string | undefined, ent: { features: Record<string, boolean>; validUntil?: number } | null): boolean {
  if (plan === "pro") return true;
  return !!ent && ent.features.embedAccess === true &&
    (ent.validUntil === undefined || ent.validUntil > Date.now());
}

Try / catch

try {
  await api.embedKeys.createEmbedKey(args);
} catch (e) {
  if (e instanceof ConvexError && e.data === "EMBED_ACCESS_REQUIRED") {
    // route to upgrade/purchase flow for embed access
  } else throw e;
}

Prevention

When it happens

Trigger: An authenticated user calls createEmbedKey while: they have no entitlements row at all; their entitlement row predates the embedAccess feature (row omits the field) and their plan is not PRO; embedAccess is false in both the stored features and the plan defaults; the entitlement's validUntil is in the past; or identity.plan is not PRO.

Common situations: Free-tier user attempting to mint an embed key; a paying customer whose Dodo billing webhook has not yet written/updated the entitlements row (new deploy wrote no embedAccess field on their old row); an expired subscription whose validUntil lapsed; testing with a Clerk account whose plan metadata is not PRO.

Understand the failure class

Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.

Related errors


AI-assisted analysis of koala73/worldmonitor@7d06c8633d (2026-09-15). Data as JSON: /api/errors/cbd23aaba7d1fecc. Report an issue: GitHub.

Appendix: source

Thrown at convex/embedKeys.ts:82

    const entitlement = await ctx.db
      .query("entitlements")
      .withIndex("by_userId", (q) => q.eq("userId", userId))
      .first();
    // Merge before gating, unlike createApiKey. `apiAccess` has existed since
    // the first entitlement row, so reading it raw is safe; `embedAccess` is
    // new, so EVERY row written before this deploy omits it and the predicate
    // is fail-closed on `undefined`. Gating on the stored value alone would
    // lock every existing paid subscriber out of the feature until a Dodo
    // billing event happened to rewrite their row.
    const merged = entitlement
      ? {
          features: mergeEntitlementFeatures(entitlement.planKey, entitlement.features),
          validUntil: entitlement.validUntil,
        }
      : null;
    if (!hasAccountEmbedAccess(identity?.plan, merged, Date.now())) {
      throw new ConvexError("EMBED_ACCESS_REQUIRED");
    }

    if (!args.name.trim()) {
      throw new ConvexError("INVALID_NAME");
    }
    if (!/^wme_[a-f0-9]{5}$/.test(args.keyPrefix)) {
      throw new ConvexError("INVALID_PREFIX");
    }
    if (!/^[a-f0-9]{64}$/.test(args.keyHash)) {
      throw new ConvexError("INVALID_HASH");
    }
    const allowedOrigins = normalizeAllowedOrigins(args.allowedOrigins);

    const active = await ctx.db
      .query("embedKeys")
      .withIndex("by_userId_revokedAt", (q) =>
        q.eq("userId", userId).eq("revokedAt", undefined),
      )

View on GitHub (pinned to 7d06c8633d)