tinyhumansai/openhuman · error · Error

Failed to send magic link

Error message

Failed to send magic link (${response.status})

What it means

Thrown by the magic-link request in authApi.ts when the POST to `${backendUrl}/auth/email/send-link` returns a non-ok HTTP status AND the response body is either unparseable or has no `error` field to use instead. The numeric status is embedded as the fallback message. This is a backend HTTP error surfaced to the login UI — the fetch itself succeeded at the transport level.

Solutions

  1. Read the status code from the message: 400 → validate email and frontendRedirectUri, 429 → wait and retry, 5xx → backend/provider outage, check backend health.
  2. Verify backendUrl (resolveBackendUrl / BACKEND_API_URL config) points at the intended environment.
  3. Confirm frontendRedirectUri is an allow-listed redirect for the backend's email-link flow.
  4. If it persists, curl the endpoint directly with the same payload to see the backend's raw response.

Example fix

// before
if (!response.ok) {
  const body = (await response.json().catch(() => ({}))) as { error?: string };
  throw new Error(body.error ?? `Failed to send magic link (${response.status})`);
}

// after — map status to actionable user guidance
if (!response.ok) {
  const body = (await response.json().catch(() => ({}))) as { error?: string; message?: string };
  const hint = response.status === 429 ? 'Too many attempts — try again shortly.'
    : response.status >= 500 ? 'Server error — please retry later.'
    : undefined;
  throw new Error(body.error ?? hint ?? `Failed to send magic link (${response.status})`);
}
Defensive patterns

Strategy: try-catch

Validate before calling

const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
const redirectOk = /^https?:\/\//.test(frontendRedirectUri ?? '');
if (!emailOk || !redirectOk) {
  // fix inputs before calling sendMagicLink — avoids the most common 400s
}

Try / catch

try {
  await sendMagicLink(email, frontendRedirectUri);
} catch (e) {
  if (e instanceof Error && /Failed to send magic link \((\d+)\)/.test(e.message)) {
    const status = Number(e.message.match(/\((\d+)\)/)?.[1]);
    if (status === 429) notify('Too many attempts — wait a moment.');
    else if (status >= 500) notify('Server issue — try again later.');
    else notify('Could not send the link — check the email address.');
  } else throw e;
}

Prevention

When it happens

Trigger: Backend returns 400 (malformed email/redirect URI), 429 (rate limited), 5xx, or a proxy/gateway error page with an empty JSON body; backendUrl points to an environment whose /auth/email/send-link route is disabled or misconfigured; frontendRedirectUri not allow-listed by the backend.

Common situations: Wrong BACKEND_API_URL (staging vs prod) in .env; the email-sending provider behind the backend is down so the backend 500s with an empty body; corporate proxy intercepting and returning HTML (json() fails → {}); rate-limiting on repeated login attempts.

Understand the failure class

Background: 'Something went wrong' / 'Request failed (500)' / 'HTTP error! status: 404' — what failed HTTP requests actually mean and how to find the real cause — this error's family across 28 libraries.

Related errors


AI-assisted analysis of tinyhumansai/openhuman@7491200858 (2026-08-17). Data as JSON: /api/errors/8dec4d3ccdab5aec. Report an issue: GitHub.

Appendix: source

Thrown at app/src/services/api/authApi.ts:33

  email: string,
  frontendRedirectUri: string,
  timeoutMs = EMAIL_MAGIC_LINK_TIMEOUT_MS
): Promise<void> {
  const backendUrl = await getBackendUrl();
  const controller = new AbortController();
  const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);

  try {
    const versionHeaders = await getClientVersionHeaders();
    const response = await fetch(`${backendUrl}/auth/email/send-link`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', ...versionHeaders },
      body: JSON.stringify({ email, frontendRedirectUri }),
      signal: controller.signal,
    });
    if (!response.ok) {
      const body = (await response.json().catch(() => ({}))) as { error?: string };
      throw new Error(body.error ?? `Failed to send magic link (${response.status})`);
    }
  } catch (error) {
    if (
      (error instanceof DOMException && error.name === 'AbortError') ||
      (error instanceof Error && error.name === 'AbortError')
    ) {
      throw new Error('Request timed out. Please try again.');
    }
    throw error;
  } finally {
    window.clearTimeout(timeoutId);
  }
}

/**
 * Consume a verified login token and return the JWT.
 * Works for both Telegram and OAuth login tokens.
 * POST /telegram/login-tokens/:token/consume (no auth required)

View on GitHub (pinned to 7491200858)