koala73/worldmonitor · error · ConvexError
Invalid returnUrl: must be a valid absolute URL
Error message
Invalid returnUrl: must be a valid absolute URL
What it means
The checkout action's _createCheckoutSession validates args.returnUrl as an open-redirect defense (convex/payments/checkout.ts:230-236). If new URL(args.returnUrl) throws — the string is not an absolute URL — the ConvexError 'Invalid returnUrl: must be a valid absolute URL' fires before any Dodo call. Relative paths like "/pro" are the canonical trigger because the URL constructor cannot parse them without a base.
Solutions
- Pass a fully-qualified absolute URL such as `${location.origin}/pro` — or omit returnUrl entirely, in which case it defaults to SITE_URL
- Validate client-side first with URL.canParse(returnUrl) (or a try/catch around new URL(...)) before invoking the action
- For self-hosted/preview deployments confirm SITE_URL is set so the default return target is correct
Example fix
// before
createCheckout({ productId, returnUrl: "/pro?from=cta" });
// after
createCheckout({ productId, returnUrl: `${location.origin}/pro?from=cta` }); Defensive patterns
Strategy: validation
Validate before calling
function absoluteUrl(u: string): string {
const parsed = new URL(u); // throws on non-absolute input — same rule as the server
return parsed.toString();
}
const safe = returnUrl ? absoluteUrl(returnUrl) : undefined; Type guard
function isValidAbsoluteReturnUrl(u: string): boolean {
try { new URL(u); return true; } catch { return false; }
} Try / catch
try {
await createCheckout({ productId, returnUrl });
} catch (e) {
if (e instanceof ConvexError && String(e.message).includes("valid absolute URL")) {
return createCheckout({ productId }); // retry with the SITE_URL default
}
throw e;
} Prevention
- Always build returnUrl from location.origin plus a route, never a bare route
- Run URL.canParse (or a try/new URL) on user- or config-supplied return targets before the action call
- Prefer omitting returnUrl over guessing — the server default (SITE_URL) is always valid
When it happens
Trigger: Calling createCheckout with returnUrl: "/pro?from=cta" (route path instead of full origin), "pro", "worldmonitor.app/pro" (missing scheme), or any string with embedded whitespace/control characters that new URL() refuses.
Common situations: Frontend passes a router path where another library accepted it; config/env typos dropping the https:// scheme; template strings that begin with ? or #; porting code that previously concatenated base+path manually.
Understand the failure class
Background: "Invalid URL" errors: why new URL(), URI.parse, and reqwest::Url reject your string — missing scheme, whitespace, and bad path format — this error's family across 39 libraries.
Related errors
- Invalid returnUrl: must use a trusted worldmonitor.app…
- Checkout timed out. Please try again.
- INVALID_CHECKOUT_PRODUCT
- INVALID_ORIGIN
- userId is required
AI-assisted analysis of koala73/worldmonitor@e586b8b4b8 (2026-08-21).
Data as JSON: /api/errors/c27d60057fd785d1.
Report an issue: GitHub.
Appendix: source
Thrown at convex/payments/checkout.ts:286
async function _createCheckoutSession(
ctx: ActionCtx,
args: CheckoutArgs,
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;
View on GitHub (pinned to e586b8b4b8)