koala73/worldmonitor · error · ConvexError
Invalid returnUrl: must use a trusted worldmonitor.app…
Error message
Invalid returnUrl: must use a trusted worldmonitor.app origin
What it means
Second returnUrl guard: the parsed origin must exactly match an entry of TRUSTED_RETURN_URL_ORIGINS in convex/payments/returnUrlOrigin.ts (worldmonitor.app plus the www/app/api/tech/finance/commodity/happy/energy subdomains) or the deployment's SITE_URL origin. It is deliberately NOT a *.worldmonitor.app suffix match, because vendor-owned CNAME subdomains (clerk., abacus.) must never become post-payment redirect targets; the file header documents real 500s (WORLDMONITOR-K7/-Q4) caused by allowlist drift when api.worldmonitor.app was missing.
Solutions
- Use an exact trusted origin, e.g. https://app.worldmonitor.app/dashboard
- For preview/self-hosted deployments set SITE_URL to the deployment's own origin — isTrustedReturnUrlOrigin accepts it as extraOrigin
- If a new first-party host genuinely serves the app, add it to TRUSTED_RETURN_URL_ORIGINS in convex/payments/returnUrlOrigin.ts and extend tests/checkout-return-url-origin.test.mts in both directions
- Never relax this to a suffix match to 'fix' the error
Example fix
// before
createCheckout({ productId, returnUrl: "http://worldmonitor.app/pro" });
// after
createCheckout({ productId, returnUrl: "https://www.worldmonitor.app/pro" }); Defensive patterns
Strategy: validation
Validate before calling
import { TRUSTED_RETURN_URL_ORIGINS } from "../convex/payments/returnUrlOrigin";
function trustedReturn(u: string): string | undefined {
try {
const { origin } = new URL(u);
return TRUSTED_RETURN_URL_ORIGINS.includes(origin) ? u : undefined;
} catch { return undefined; }
} Type guard
function isTrustedReturn(u: string): boolean {
try { return TRUSTED_RETURN_URL_ORIGINS.includes(new URL(u).origin); } catch { return false; }
} Try / catch
try {
await createCheckout({ productId, returnUrl });
} catch (e) {
if (e instanceof ConvexError && String(e.message).includes("trusted worldmonitor.app origin")) {
return createCheckout({ productId, returnUrl: "https://worldmonitor.app" });
}
throw e;
} Prevention
- Pin the client to the same enumerated origins as the server instead of deriving hosts dynamically
- Set SITE_URL correctly on every non-production deployment so its origin is accepted as extraOrigin
- When adding a first-party host, update TRUSTED_RETURN_URL_ORIGINS and tests/checkout-return-url-origin.test.mts together
When it happens
Trigger: returnUrl with http:// scheme, a non-standard port (https://worldmonitor.app:8443), a look-alike host (https://worldmonitor.app.evil.com), a vendor subdomain like https://clerk.worldmonitor.app, or any third-party origin. Also self-hosted/preview deployments where SITE_URL doesn't match the host the user is actually on.
Common situations: Local dev on http://localhost:5173 with SITE_URL unset; a new first-party subdomain attached in Vercel but never added to the allowlist (the exact K7/Q4 drift); security tests probing open redirects.
Related errors
- Invalid returnUrl: must be a valid absolute URL
- Checkout timed out. Please try again.
- INVALID_CHECKOUT_PRODUCT
- Redirect to disallowed domain
- userId is required
AI-assisted analysis of koala73/worldmonitor@e586b8b4b8 (2026-08-21).
Data as JSON: /api/errors/71a5f2298a25b780.
Report an issue: GitHub.
Appendix: source
Thrown at convex/payments/checkout.ts:290
user: UserInfo,
): Promise<
| (Awaited<ReturnType<typeof createDodoCheckoutSession>> & { anonymous_claim_token?: string })
| CheckoutRateLimitedOutcome
| CheckoutTimedOutOutcome
> {
// Validate returnUrl to prevent open-redirect attacks.
const siteUrl = process.env.SITE_URL ?? "https://worldmonitor.app";
let returnUrl = siteUrl;
if (args.returnUrl) {
let parsedReturnUrl: URL;
try {
parsedReturnUrl = new URL(args.returnUrl);
} catch {
throw new ConvexError("Invalid returnUrl: must be a valid absolute URL");
}
if (!isTrustedReturnUrlOrigin(parsedReturnUrl.origin, new URL(siteUrl).origin)) {
throw new ConvexError(
"Invalid returnUrl: must use a trusted worldmonitor.app origin",
);
}
returnUrl = parsedReturnUrl.toString();
}
// Completed edge idempotency replays return before reaching this boundary.
// Consume once per creation, outside the provider retry ladder. A failed
// admission mutation must propagate: unknown capacity cannot authorize work.
const denied: CheckoutRateLimitedOutcome | null = await ctx.runMutation(
internal.payments.checkout.admitCheckout, { userId: user.userId },
);
if (denied) return denied;
// Record Terms assent (#6976). Both checkout paths — the /pro pricing page
// and every dashboard CTA — funnel through here, so one call covers them all
// and no client can skip it: the buyer clicked a button that sits directly
// under "By subscribing you agree to the Terms of Service and Privacy Policy".View on GitHub (pinned to e586b8b4b8)