Stirling-Tools/Stirling-PDF · error · StripeFunctionError

create-customer-portal-session failed

Error message

create-customer-portal-session failed

What it means

Fallback thrown by createPortalSession when the create-customer-portal-session edge function returns success === false OR omits url. The Stripe customer portal must return a hosted URL to redirect to; an absent url is a hard failure even if success was true. The comment notes a typical cause is 404 team_not_subscribed for free teams.

Source

Thrown at frontend/editor/src/portal/billing/stripe.ts:520

  }
  return stripePromise;
}

/**
 * Mint a Stripe Customer Portal session. The admin can manage their card,
 * view invoices, and cancel from Stripe's hosted UI. The edge function returns
 * 404 with {@code team_not_subscribed} if called for a free team — surfaced
 * here as a StripeFunctionError the caller can toast.
 */
export async function createPortalSession(
  req: PortalSessionRequest,
): Promise<string> {
  const res = await invoke<PortalResponse>("create-customer-portal-session", {
    team_id: req.teamId,
    return_url: req.returnUrl,
  });
  if (!res.success || !res.url) {
    throw new StripeFunctionError(
      res.error ?? "create-customer-portal-session failed",
    );
  }
  return res.url;
}

View on GitHub (pinned to 9ef20dcab8)

Solutions

  1. Only show the portal/billing-manage control when the team is subscribed (hide for free teams to avoid team_not_subscribed).
  2. Read res.error / the thrown message and toast specifically for team_not_subscribed vs a real Stripe error.
  3. Verify returnUrl is supplied and is an allowed domain in the Stripe portal config.
  4. Ensure the function returns url on success and a stable error code on failure.

Example fix

// before
<Button onClick={() => createPortalSession({ teamId, returnUrl })} />

// after — gate on subscription, toast on expected failures
if (!team.isSubscribed) {
  notifications.show({ message: "This team has no subscription to manage.", color: "orange" });
  return;
}
try {
  const url = await createPortalSession({ teamId, returnUrl });
  window.location.href = url;
} catch (e) {
  if (e instanceof StripeFunctionError && /team_not_subscribed/i.test(e.message)) {
    notifications.show({ message: "No subscription found.", color: "orange" });
  } else throw e;
}
Defensive patterns

Strategy: try-catch

Validate before calling

function teamHasSubscription(team: { isSubscribed?: boolean }): boolean {
  return team.isSubscribed === true;
}

if (!teamHasSubscription(team)) {
  notifications.show({ message: "This team has no subscription to manage.", color: "orange" });
  return;
}

Type guard

function isPortalResponseOk(res: PortalResponse | null): res is PortalResponse & { success: true; url: string } {
  return res != null && res.success === true && typeof res.url === "string" && res.url.length > 0;
}

Try / catch

try {
  const url = await createPortalSession({ teamId, returnUrl });
  window.location.href = url;
} catch (e) {
  if (e instanceof StripeFunctionError && /team_not_subscribed/i.test(e.message)) {
    notifications.show({ message: "No subscription found for this team.", color: "orange" });
  } else throw e;
}

Prevention

When it happens

Trigger: create-customer-portal-session responds success:false (with res.error, or this fallback if omitted), or success:true without url. Most common for a team with no active subscription (the function returns team_not_subscribed) or when Stripe portal-session creation fails.

Common situations: Clicking "manage subscription" / "view invoices" for a free team that has no Stripe customer; Stripe-side failure creating the portal session; missing return_url; function deploy dropped the url field.

Related errors


AI-assisted analysis of Stirling-Tools/Stirling-PDF@9ef20dcab8 (2026-08-13). Data as JSON: /api/errors/427fcbbfc89f8d63. Report an issue: GitHub.