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
- Upgrade the Clerk account to PRO or purchase an embed entitlement, then retry.
- Check the entitlements row for the user in the Convex dashboard (query entitlements by userId) to see if embedAccess/validUntil is what you expect.
- If the user paid but the row is stale, retrigger the billing webhook (Dodo) so the entitlement row is rewritten with embedAccess.
- 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
- Hide/disable the create-embed-key UI for accounts without embed access
- Gate on the merged entitlement, not raw stored features — old rows omit embedAccess
- Check entitlement validUntil freshness before enabling the flow
- Confirm billing webhooks (Dodo) have written the entitlements row after purchase
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)