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

  1. Pass a fully-qualified absolute URL such as `${location.origin}/pro` — or omit returnUrl entirely, in which case it defaults to SITE_URL
  2. Validate client-side first with URL.canParse(returnUrl) (or a try/catch around new URL(...)) before invoking the action
  3. 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

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


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)