{"record":{"id":"ee44da4c5774da44","repo":"TryGhost/Ghost","slug":"failed-to-apply-offer","errorCode":null,"errorMessage":"Failed to apply offer","messagePattern":"Failed to apply offer","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"apps/portal/src/utils/api.js","lineNumber":873,"sourceCode":"        async applyOffer({offerId, subscriptionId}) {\n            const identity = await api.member.identity();\n            const url = endpointFor({type: 'members', resource: `subscriptions/${subscriptionId}/apply-offer`});\n\n            const res = await makeRequest({\n                url,\n                method: 'POST',\n                headers: {\n                    'Content-Type': 'application/json'\n                },\n                body: JSON.stringify({\n                    identity,\n                    offer_id: offerId\n                })\n            });\n\n            if (!res.ok) {\n                const errorText = await res.text();\n                throw new Error(errorText || 'Failed to apply offer');\n            }\n\n            return true;\n        }\n    };\n\n    api.init = async () => {\n        let [member] = await Promise.all([\n            api.member.sessionData()\n        ]);\n        let site = {};\n        let newsletters = [];\n        let tiers = [];\n        let settings = {};\n        let offers = [];\n\n        try {\n            [{settings}, {tiers}, {newsletters}] = await Promise.all([","sourceCodeStart":855,"sourceCodeEnd":891,"githubUrl":"https://github.com/TryGhost/Ghost/blob/47d8b0e2ad2fd4757d3bc45f46c3ac165ff8a1fe/apps/portal/src/utils/api.js#L855-L891","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before: raw backend text (may be HTML or unhelpful) used directly as the message\nconst errorText = await res.text();\nthrow new Error(errorText || 'Failed to apply offer');\n\n// after: try structured JSON first, fall back to text\nlet detail = '';\ntry {\n    const errData = await res.json();\n    detail = errData?.errors?.[0]?.message || '';\n} catch { detail = await res.text().catch(() => ''); }\nthrow new Error(detail || 'Failed to apply offer');","handlingStrategy":"try-catch","validationCode":"// Validate the offer id and member eligibility client-side before applying\nfunction validOfferId(id) {\n    return typeof id === 'string' && id.length > 0;\n}\nasync function memberIsEligible(api) {\n    try {\n        const member = await api.member.sessionData();\n        // Offers typically target free/non-paid members\n        return !member?.subscriptions?.some(s => s.status === 'active');\n    } catch {\n        return false;\n    }\n}","typeGuard":null,"tryCatchPattern":"try {\n    await api.member.applyOffer(offerId);\n} catch (err) {\n    // err.message is the backend's raw text — usually specific\n    showToast(err.message);\n}","preventionTips":["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."],"tags":["portal","offers","members-api","redemption","network"],"backgroundTag":null,"analyzedSha":"47d8b0e2ad2fd4757d3bc45f46c3ac165ff8a1fe","analyzedAt":"2026-08-13T01:25:26.651Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}