TryGhost/Ghost · error · Error
Failed to send email address verification email
Error message
Failed to send email address verification email
What it means
Thrown by Portal's updateEmailAddress() when the POST to the members 'member/email' endpoint returns a non-2xx status. The Portal API client tries to surface the backend's structured error (errors[0].message) and falls back to this generic string only when the backend sent no parseable error body. It is a client-side Error propagated to the Portal UI, which typically renders it as a toast/alert to the member.
Source
Thrown at apps/portal/src/utils/api.js:493
const body = {
email,
identity
};
return makeRequest({
url,
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
}).then(async function (res) {
if (res.ok) {
return 'Success';
} else {
const errData = await res.json();
const errMssg = errData?.errors?.[0]?.message || 'Failed to send email address verification email';
throw new Error(errMssg);
}
});
},
async checkoutPlan({plan, tierId, cadence, cancelUrl, successUrl, email: customerEmail, name, offerId, newsletters, metadata = {}} = {}) {
const siteUrlObj = new URL(siteUrl);
const identity = await api.member.identity();
const url = endpointFor({type: 'members', resource: 'create-stripe-checkout-session'});
if (!successUrl) {
const checkoutSuccessUrl = window.location.href.startsWith(siteUrlObj.href) ? new URL(window.location.href) : new URL(siteUrl);
checkoutSuccessUrl.searchParams.set('stripe', 'success');
successUrl = checkoutSuccessUrl.href;
}
if (!cancelUrl) {
const checkoutCancelUrl = window.location.href.startsWith(siteUrlObj.href) ? new URL(window.location.href) : new URL(siteUrl);
checkoutCancelUrl.searchParams.set('stripe', 'cancel');
cancelUrl = checkoutCancelUrl.href;View on GitHub (pinned to 47d8b0e2ad)
Solutions
- Inspect the actual thrown message — if it differs from the fallback, the backend's errors[0].message names the real cause; act on that.
- Confirm the member is still signed in: re-derive identity via api.member.identity() and retry; a stale identity token is the most frequent trigger.
- Verify the new email is well-formed and not already used by another member on the site.
- Check Ghost server logs (members API + email service) for the corresponding 4xx and the backend's reason.
- If using a proxy/CDN, ensure it isn't returning an HTML error page that defeats res.json() parsing.
Example fix
// before: stale identity cached long before the call
const identity = await api.member.identity();
await api.member.updateEmailAddress({email});
// after: refresh identity at call time and surface the real error
try {
await api.member.updateEmailAddress({email});
} catch (err) {
showToast(err.message || 'Could not update your email. Please refresh and try again.');
} Defensive patterns
Strategy: try-catch
Validate before calling
// Validate the email shape and identity before calling
function isValidEmail(email) {
return typeof email === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
async function canUpdateEmail(api) {
try {
await api.member.identity(); // throws if session is stale
return true;
} catch {
return false;
}
}
if (isValidEmail(email) && await canUpdateEmail(api)) {
await api.member.updateEmailAddress({email});
} Type guard
function isMembersApiErrorBody(x) {
return typeof x === 'object' && x !== null && Array.isArray(x.errors) && typeof x.errors[0]?.message === 'string';
} Try / catch
try {
await api.member.updateEmailAddress({email});
} catch (err) {
// err.message is the backend reason, or the generic fallback
showToast(err.message);
if (/session|identity|unauthor/i.test(err.message)) {
// re-authenticate and let the member retry
}
} Prevention
- Refresh the member identity at the point of action, not at page load.
- Pre-validate the email client-side before posting.
- Treat any updateEmailAddress rejection as user-facing and surface the backend message verbatim.
When it happens
Trigger: A signed-in member submits a new email address in Portal account settings; the Ghost backend rejects the change. Common backend rejections: email already registered to another member, malformed email, rate-limited verification sends, or the member's identity token is stale/expired.
Common situations: Members portal loaded with an expired session cookie so the identity claim fails; duplicate email across members; email validation regex mismatch; SMTP/transactional email service down so the backend refuses to queue the verification. Also seen in local dev when siteUrl/identity mismatch makes the endpoint 401.
Related errors
- Failed to fetch site data
- fallbackMessage
- Failed to apply offer
- Failed to update newsletter
- Failed to update member
AI-assisted analysis of TryGhost/Ghost@47d8b0e2ad (2026-08-13).
Data as JSON: /api/errors/f2089e1fc57a200c.
Report an issue: GitHub.