TryGhost/Ghost · error · Error
Failed to apply offer
Error message
Failed to apply offer
What it means
Thrown by applyOffer() when the POST to apply an offer (members 'offers' resource) returns non-2xx. Unlike the other handlers, this reads the body as raw text (res.text()) and uses it as the error message, only falling back to 'Failed to apply offer' when the body is empty. Offers are discounted pricing plans redeemable by eligible members.
Source
Thrown at apps/portal/src/utils/api.js:873
async applyOffer({offerId, subscriptionId}) {
const identity = await api.member.identity();
const url = endpointFor({type: 'members', resource: `subscriptions/${subscriptionId}/apply-offer`});
const res = await makeRequest({
url,
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
identity,
offer_id: offerId
})
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(errorText || 'Failed to apply offer');
}
return true;
}
};
api.init = async () => {
let [member] = await Promise.all([
api.member.sessionData()
]);
let site = {};
let newsletters = [];
let tiers = [];
let settings = {};
let offers = [];
try {
[{settings}, {tiers}, {newsletters}] = await Promise.all([View on GitHub (pinned to 47d8b0e2ad)
Solutions
- Read the raw text thrown — applyOffer surfaces the backend's plain-text reason, which is usually specific (e.g. 'Offer no longer available').
- Confirm the offer is still active and not archived in Ghost Admin → Offers.
- Verify the member is eligible (offers often target members who are not already paid).
- Re-authenticate the member and retry the offer link.
- Check that the offer_id passed matches a real, active offer.
Example fix
// before: raw backend text (may be HTML or unhelpful) used directly as the message
const errorText = await res.text();
throw new Error(errorText || 'Failed to apply offer');
// after: try structured JSON first, fall back to text
let detail = '';
try {
const errData = await res.json();
detail = errData?.errors?.[0]?.message || '';
} catch { detail = await res.text().catch(() => ''); }
throw new Error(detail || 'Failed to apply offer'); Defensive patterns
Strategy: try-catch
Validate before calling
// Validate the offer id and member eligibility client-side before applying
function validOfferId(id) {
return typeof id === 'string' && id.length > 0;
}
async function memberIsEligible(api) {
try {
const member = await api.member.sessionData();
// Offers typically target free/non-paid members
return !member?.subscriptions?.some(s => s.status === 'active');
} catch {
return false;
}
} Try / catch
try {
await api.member.applyOffer(offerId);
} catch (err) {
// err.message is the backend's raw text — usually specific
showToast(err.message);
} Prevention
- Confirm the offer is still active in Ghost Admin before sharing the link.
- Don't offer redemption to already-paid members unless the offer targets them.
- Patch applyOffer to parse JSON error envelope first, then fall back to text.
When it happens
Trigger: Member clicks an offer redemption link; the backend rejects — the offer is expired/archived, the member is ineligible (already paid, or the offer targets a different segment), the offer_id is invalid, or the member's identity doesn't match the offer's constraints.
Common situations: Offer was archived in Ghost Admin after the link was generated; member already on a paid subscription (offer only targets free/eligible members); offer redemption limit reached; identity token expired; the offer_id in the URL is malformed. Per Portal's CONTEXT.md, ineligible visitors have the link silently ignored — this error only fires for visitors who reached the apply step.
Related errors
- fallbackMessage
- Failed to fetch site data
- Failed to send email address verification email
- Failed to update newsletter
- Failed to update member
AI-assisted analysis of TryGhost/Ghost@47d8b0e2ad (2026-08-13).
Data as JSON: /api/errors/ee44da4c5774da44.
Report an issue: GitHub.