Skip to content

Added webhook handler for gift subscription purchases#27169

Merged
mike182uk merged 1 commit intomainfrom
BER-3484-handle-stripe-webhook
Apr 7, 2026
Merged

Added webhook handler for gift subscription purchases#27169
mike182uk merged 1 commit intomainfrom
BER-3484-handle-stripe-webhook

Conversation

@mike182uk
Copy link
Copy Markdown
Member

ref https://linear.app/ghost/issue/BER-3484

When a gift checkout completes on Stripe, the webhook now persists a gift record to the database with full purchase details, buyer member resolution, and idempotency protection via checkout session ID

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 6, 2026

Walkthrough

Introduces end-to-end gift-purchase support: a Gift domain model and expiry constant, a Bookshelf Gift model with destroy protection, a Bookshelf repository, a GiftService with wrapper and singleton entrypoint, and unit tests for Gift.fromPurchase and GiftService.recordPurchase. Boot now initializes the gifts service. Stripe integration is updated to inject the gift service and route checkout.session webhook events containing gift metadata to a new handleGiftEvent that calls recordPurchase.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The PR title directly describes the main change: adding a webhook handler for gift subscription purchases, which is the primary feature introduced across all modified files.
Description check ✅ Passed The PR description clearly relates to the changeset, explaining the webhook handler implementation for gift checkouts, database persistence, buyer resolution, and idempotency protection via checkout session ID.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch BER-3484-handle-stripe-webhook

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (5)
ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts (2)

3-4: Consider simplifying redundant type aliases.

Per static analysis, BookshelfModelInstance and BookshelfOptions are just aliases for unknown. While they add semantic intent, they could be inlined for simplicity.

Proposed simplification
-type BookshelfModelInstance = unknown;
-type BookshelfOptions = unknown;
-type BookshelfModel<T extends BookshelfModelInstance> = {
-    add(data: Partial<T>, unfilteredOptions?: BookshelfOptions): Promise<T>;
-    findOne(data: Record<string, unknown>, unfilteredOptions?: BookshelfOptions): Promise<T | null>;
+type BookshelfModel<T> = {
+    add(data: Partial<T>, unfilteredOptions?: unknown): Promise<T>;
+    findOne(data: Record<string, unknown>, unfilteredOptions?: unknown): Promise<T | null>;
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts` around
lines 3 - 4, Remove the two redundant type aliases BookshelfModelInstance and
BookshelfOptions (both aliased to unknown) and inline unknown where they are
used, or replace their usages with the concrete/appropriate type if available;
update the file's type annotations referencing BookshelfModelInstance and
BookshelfOptions (e.g., function signatures, variable declarations) to use
unknown (or the real type) and delete the type alias declarations to simplify
the code.

12-17: Mark #Model as readonly.

The private field is never reassigned after construction.

Proposed fix
 export class GiftBookshelfRepository {
-    `#Model`: GiftBookshelfModel;
+    readonly `#Model`: GiftBookshelfModel;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts` around
lines 12 - 17, The private field `#Model` in class GiftBookshelfRepository is
never reassigned after construction; mark it readonly to reflect immutability.
Update the field declaration in GiftBookshelfRepository to be readonly (e.g.,
readonly `#Model`: GiftBookshelfModel) and ensure the constructor still assigns
this.#Model = GiftModel and that no other code reassigns `#Model`. This change
targets the private field named `#Model` and the constructor parameter GiftModel
in the GiftBookshelfRepository class.
ghost/core/core/server/services/gifts/gift.ts (1)

3-4: Consider exporting the type aliases for external use.

GiftStatus and GiftCadence types are defined but not exported. If other modules need to reference these types (e.g., for type-safe status checks or API contracts), consider adding the export keyword.

♻️ Optional: Export type aliases
-type GiftStatus = 'purchased' | 'redeemed' | 'consumed' | 'expired' | 'refunded';
-type GiftCadence = 'month' | 'year';
+export type GiftStatus = 'purchased' | 'redeemed' | 'consumed' | 'expired' | 'refunded';
+export type GiftCadence = 'month' | 'year';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/gifts/gift.ts` around lines 3 - 4, The type
aliases GiftStatus and GiftCadence are currently module-private; export them so
other modules can import and use these types. Update the declarations for the
identifiers GiftStatus and GiftCadence to be exported (add the export keyword)
so they are available for external type-safe checks and API contracts, and
ensure any existing imports are updated to reference the exported types.
ghost/core/core/server/services/gifts/gift-service.ts (2)

22-29: Mark private fields as readonly.

Both #giftRepository and #memberRepository are only assigned in the constructor and never reassigned. Marking them as readonly improves type safety and documents intent.

♻️ Proposed fix
 export class GiftService {
-    `#giftRepository`: GiftBookshelfRepository;
-    `#memberRepository`: MemberRepository;
+    readonly `#giftRepository`: GiftBookshelfRepository;
+    readonly `#memberRepository`: MemberRepository;
 
     constructor({giftRepository, memberRepository}: {giftRepository: GiftBookshelfRepository; memberRepository: MemberRepository}) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/gifts/gift-service.ts` around lines 22 - 29,
GiftService’s private fields `#giftRepository` and `#memberRepository` are only set
in the constructor and should be marked readonly to express immutability; update
the class field declarations for `#giftRepository` and `#memberRepository` to
include readonly (while keeping their types GiftBookshelfRepository and
MemberRepository) so the constructor remains the sole assignment point
(constructor({giftRepository, memberRepository}) assigns them as before).

31-36: Use Number.parseInt and Number.isNaN for modern best practices.

The global parseInt and isNaN functions work here, but the Number.* equivalents are preferred: Number.parseInt is explicit about namespace, and Number.isNaN avoids type coercion pitfalls.

♻️ Proposed fix
     async recordPurchase(data: GiftPurchaseData): Promise<boolean> {
-        const duration = parseInt(data.duration);
+        const duration = Number.parseInt(data.duration, 10);
 
-        if (isNaN(duration)) {
+        if (Number.isNaN(duration)) {
             throw new errors.ValidationError({message: `Invalid gift duration: ${data.duration}`});
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/gifts/gift-service.ts` around lines 31 - 36,
In recordPurchase, replace the global calls with the Number namespace: use
Number.parseInt(data.duration, 10) to parse the duration (explicit radix) and
check the result with Number.isNaN(duration) instead of isNaN; update the error
branch to use the new checks so the ValidationError is thrown when
Number.isNaN(duration) is true.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ghost/core/core/boot.js`:
- Line 378: giftService.init() is synchronous but used alongside async
initializers; update GiftServiceWrapper.init to return a Promise (e.g., make it
async or return Promise.resolve()) so it consistently returns a Promise when
called from Promise.all in boot.js; change the implementation in the
GiftServiceWrapper class (method: init) to return a resolved Promise to
eliminate the SonarCloud warning and keep init semantics consistent with other
services.

---

Nitpick comments:
In `@ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts`:
- Around line 3-4: Remove the two redundant type aliases BookshelfModelInstance
and BookshelfOptions (both aliased to unknown) and inline unknown where they are
used, or replace their usages with the concrete/appropriate type if available;
update the file's type annotations referencing BookshelfModelInstance and
BookshelfOptions (e.g., function signatures, variable declarations) to use
unknown (or the real type) and delete the type alias declarations to simplify
the code.
- Around line 12-17: The private field `#Model` in class GiftBookshelfRepository
is never reassigned after construction; mark it readonly to reflect
immutability. Update the field declaration in GiftBookshelfRepository to be
readonly (e.g., readonly `#Model`: GiftBookshelfModel) and ensure the constructor
still assigns this.#Model = GiftModel and that no other code reassigns `#Model`.
This change targets the private field named `#Model` and the constructor parameter
GiftModel in the GiftBookshelfRepository class.

In `@ghost/core/core/server/services/gifts/gift-service.ts`:
- Around line 22-29: GiftService’s private fields `#giftRepository` and
`#memberRepository` are only set in the constructor and should be marked readonly
to express immutability; update the class field declarations for `#giftRepository`
and `#memberRepository` to include readonly (while keeping their types
GiftBookshelfRepository and MemberRepository) so the constructor remains the
sole assignment point (constructor({giftRepository, memberRepository}) assigns
them as before).
- Around line 31-36: In recordPurchase, replace the global calls with the Number
namespace: use Number.parseInt(data.duration, 10) to parse the duration
(explicit radix) and check the result with Number.isNaN(duration) instead of
isNaN; update the error branch to use the new checks so the ValidationError is
thrown when Number.isNaN(duration) is true.

In `@ghost/core/core/server/services/gifts/gift.ts`:
- Around line 3-4: The type aliases GiftStatus and GiftCadence are currently
module-private; export them so other modules can import and use these types.
Update the declarations for the identifiers GiftStatus and GiftCadence to be
exported (add the export keyword) so they are available for external type-safe
checks and API contracts, and ensure any existing imports are updated to
reference the exported types.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f5b8a4ab-e9e4-42ed-9881-628d7fee9f71

📥 Commits

Reviewing files that changed from the base of the PR and between 0f7df8e and 95bd649.

📒 Files selected for processing (14)
  • ghost/core/core/boot.js
  • ghost/core/core/server/models/gift.js
  • ghost/core/core/server/services/gifts/constants.ts
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
  • ghost/core/core/server/services/gifts/gift-service-wrapper.js
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/services/gifts/gift.ts
  • ghost/core/core/server/services/gifts/index.js
  • ghost/core/core/server/services/stripe/service.js
  • ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js
  • ghost/core/core/server/services/stripe/stripe-service.js
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
  • ghost/core/test/unit/server/services/gifts/gift.test.ts
  • ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js

@mike182uk mike182uk force-pushed the BER-3484-handle-stripe-webhook branch from 95bd649 to 68bf2ac Compare April 6, 2026 12:19
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
ghost/core/core/server/services/gifts/gift-service.ts (1)

32-32: Specify radix for parseInt to avoid ambiguity.

Number.parseInt(data.duration) without a radix can behave unexpectedly with certain string formats (e.g., leading zeros in some engines historically treated as octal). While modern JavaScript defaults to base 10, explicitly specifying the radix is a defensive best practice.

Suggested fix
-        const duration = Number.parseInt(data.duration);
+        const duration = Number.parseInt(data.duration, 10);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/gifts/gift-service.ts` at line 32, The
parseInt call in gift-service.ts uses Number.parseInt(data.duration) without a
radix; update the parsing of the duration (the const duration assignment) to
explicitly specify the base (e.g., use radix 10) so the conversion is
unambiguous (replace Number.parseInt(data.duration) with an explicit-radix
parseInt call and keep any existing null/undefined handling around the duration
variable).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@ghost/core/core/server/services/gifts/gift-service.ts`:
- Line 32: The parseInt call in gift-service.ts uses
Number.parseInt(data.duration) without a radix; update the parsing of the
duration (the const duration assignment) to explicitly specify the base (e.g.,
use radix 10) so the conversion is unambiguous (replace
Number.parseInt(data.duration) with an explicit-radix parseInt call and keep any
existing null/undefined handling around the duration variable).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a8f34645-ed5a-457e-9497-80c2f36c28df

📥 Commits

Reviewing files that changed from the base of the PR and between 95bd649 and 68bf2ac.

📒 Files selected for processing (14)
  • ghost/core/core/boot.js
  • ghost/core/core/server/models/gift.js
  • ghost/core/core/server/services/gifts/constants.ts
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
  • ghost/core/core/server/services/gifts/gift-service-wrapper.js
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/services/gifts/gift.ts
  • ghost/core/core/server/services/gifts/index.js
  • ghost/core/core/server/services/stripe/service.js
  • ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js
  • ghost/core/core/server/services/stripe/stripe-service.js
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
  • ghost/core/test/unit/server/services/gifts/gift.test.ts
  • ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js
✅ Files skipped from review due to trivial changes (4)
  • ghost/core/core/server/services/gifts/constants.ts
  • ghost/core/core/server/services/gifts/index.js
  • ghost/core/test/unit/server/services/gifts/gift.test.ts
  • ghost/core/core/server/services/stripe/service.js
🚧 Files skipped from review as they are similar to previous changes (4)
  • ghost/core/core/server/models/gift.js
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
  • ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js
  • ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js


const member = data.stripeCustomerId
? await this.#memberRepository.get({customer_id: data.stripeCustomerId})
: null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a neat trick. I wonder if we should extend that to use the buyer email as source, instead of the stripe customer?

Example: buyer is "mike@ghost.org" → There is a free member with mike@ghost.org → attribute gift to that member?

ref https://linear.app/ghost/issue/BER-3484

When a gift checkout completes on Stripe, the webhook now persists a
gift record to the database with full purchase details, buyer member
resolution, and idempotency protection via checkout session ID
@mike182uk mike182uk force-pushed the BER-3484-handle-stripe-webhook branch from 68bf2ac to 237f477 Compare April 7, 2026 08:35
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud bot commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
ghost/core/core/server/services/gifts/gift-service.ts (1)

31-40: Specify radix for parseInt to avoid ambiguity.

Number.parseInt without a radix can lead to unexpected behavior with leading zeros (e.g., '08' in older environments). Explicitly pass 10 as the radix for decimal parsing.

Proposed fix
-        const duration = Number.parseInt(data.duration);
+        const duration = Number.parseInt(data.duration, 10);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/gifts/gift-service.ts` around lines 31 - 40,
In recordPurchase (gift-service.ts) the call to Number.parseInt(data.duration)
omits a radix; update it to parse the duration as base 10 by calling
Number.parseInt(data.duration, 10) (or otherwise parse as decimal) so inputs
like "08" are handled consistently; ensure this change is applied inside the
recordPurchase method before the NaN check that throws the ValidationError.
ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts (1)

25-47: Return type mismatch: declared Promise<unknown> but returns Promise<void>.

The method awaits this.#Model.add(...) but doesn't return its result, so it actually returns Promise<void>. The declared return type should match.

Proposed fix
-    async create(gift: Gift) {
+    async create(gift: Gift): Promise<void> {
         await this.#Model.add({
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts` around
lines 25 - 47, The create method declares it returns Promise<unknown> but
currently awaits this.#Model.add(...) and does not return its result (so it
returns Promise<void>); update the method to either return the result of
this.#Model.add(...) (i.e., add a return before this.#Model.add(...) in the
create function) or change the declared return type to Promise<void> to match
behavior; locate the create method in gift-bookshelf-repository (method name:
create) and make the return-type and actual return consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ghost/core/core/server/services/stripe/stripe-service.js`:
- Around line 115-117: The getter giftService should defensively handle the case
where giftService.service is not yet initialized to avoid runtime failures in
webhook handling; update the giftService getter to check that the module-level
giftService and giftService.service are defined and, if not, either return a
safe no-op stub (implementing the methods used by recordPurchase) or throw a
clear error so callers can bail gracefully; reference the giftService getter,
giftService.init, stripe.init and the recordPurchase path that handles
checkout.session.completed webhooks when adding this guard.

---

Nitpick comments:
In `@ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts`:
- Around line 25-47: The create method declares it returns Promise<unknown> but
currently awaits this.#Model.add(...) and does not return its result (so it
returns Promise<void>); update the method to either return the result of
this.#Model.add(...) (i.e., add a return before this.#Model.add(...) in the
create function) or change the declared return type to Promise<void> to match
behavior; locate the create method in gift-bookshelf-repository (method name:
create) and make the return-type and actual return consistent.

In `@ghost/core/core/server/services/gifts/gift-service.ts`:
- Around line 31-40: In recordPurchase (gift-service.ts) the call to
Number.parseInt(data.duration) omits a radix; update it to parse the duration as
base 10 by calling Number.parseInt(data.duration, 10) (or otherwise parse as
decimal) so inputs like "08" are handled consistently; ensure this change is
applied inside the recordPurchase method before the NaN check that throws the
ValidationError.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 68d42b00-38df-4ea3-9220-11db0b8a470e

📥 Commits

Reviewing files that changed from the base of the PR and between 68bf2ac and 237f477.

📒 Files selected for processing (14)
  • ghost/core/core/boot.js
  • ghost/core/core/server/models/gift.js
  • ghost/core/core/server/services/gifts/constants.ts
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
  • ghost/core/core/server/services/gifts/gift-service-wrapper.js
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/services/gifts/gift.ts
  • ghost/core/core/server/services/gifts/index.js
  • ghost/core/core/server/services/stripe/service.js
  • ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js
  • ghost/core/core/server/services/stripe/stripe-service.js
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
  • ghost/core/test/unit/server/services/gifts/gift.test.ts
  • ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js
✅ Files skipped from review due to trivial changes (7)
  • ghost/core/core/server/services/gifts/index.js
  • ghost/core/core/server/models/gift.js
  • ghost/core/core/server/services/gifts/constants.ts
  • ghost/core/core/server/services/gifts/gift-service-wrapper.js
  • ghost/core/test/unit/server/services/gifts/gift.test.ts
  • ghost/core/core/server/services/gifts/gift.ts
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • ghost/core/core/boot.js
  • ghost/core/core/server/services/stripe/service.js
  • ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js
  • ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js

Comment on lines +115 to +117
get giftService(){
return giftService.service;
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Verify boot/service init ordering for gifts vs stripe
fd -i "boot.js" ghost/core/core
rg -n -C4 "services|init|gifts|stripe" ghost/core/core/boot.js

# 2) Verify gifts export/init shape
fd -i "index.js" ghost/core/core/server/services/gifts
rg -n -C4 "module\\.exports|GiftServiceWrapper|init\\(" ghost/core/core/server/services/gifts/index.js
rg -n -C4 "class GiftServiceWrapper|service;|async init\\(" ghost/core/core/server/services/gifts/gift-service-wrapper.js

# 3) Verify call site assumes concrete service methods
rg -n -C4 "giftService|recordPurchase" ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js

Repository: TryGhost/Ghost

Length of output: 19001


🏁 Script executed:

# Check when stripe service registers webhooks
rg -n -C5 "webhook|registerWebhook|handleEvent" ghost/core/core/server/services/stripe/stripe-service.js | head -60

# Check if webhooks are registered during init() or later
rg -n -C3 "registerWebhook|listen" ghost/core/core/server/services/stripe/index.js

# Verify the actual webhook event flow - when is the getter accessed?
rg -n -B5 -A5 "giftService.*recordPurchase|deps\.giftService" ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js

Repository: TryGhost/Ghost

Length of output: 3907


🏁 Script executed:

# Check when webhooks are actually registered/started in stripe init
rg -n -B10 -A10 "webhookManager\|WebhookManager\|webhooks.listen" ghost/core/core/server/services/stripe/index.js

# Check if there's any middleware or guard before webhook handling
rg -n "webhookController" ghost/core/core/server/services/stripe/stripe-service.js

# Check if giftService is checked for null/undefined before use
rg -n -B2 -A2 "giftService\." ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js

# Check the webhook event dispatcher - does it await initialization?
fd -i "webhook-controller.js" ghost/core/core/server/services/stripe
rg -n -B5 -A10 "class WebhookController\|async.*handle\|handle.*event" ghost/core/core/server/services/stripe/webhook-controller.js | head -80

Repository: TryGhost/Ghost

Length of output: 504


🏁 Script executed:

# Get the full webhook controller file to understand webhook handling
cat -n ghost/core/core/server/services/stripe/webhook-controller.js | head -150

# Check how stripe service initializes and when webhooks start listening
cat -n ghost/core/core/server/services/stripe/index.js | head -100

Repository: TryGhost/Ghost

Length of output: 6143


🏁 Script executed:

# Get the stripe service init logic
cat -n ghost/core/core/server/services/stripe/service.js | head -200

# Also check when the webhook route is registered - look for router/app setup
rg -n "webhook\|stripe.*route" ghost/core/core/server/web -A3 -B3 | head -80

Repository: TryGhost/Ghost

Length of output: 3211


🏁 Script executed:

# Check how CheckoutSessionEventService is instantiated and when giftService is passed to it
rg -n -B5 -A5 "CheckoutSessionEventService" ghost/core/core/server/services/stripe/stripe-service.js

# Check the full checkout-session-event-service to see how it uses giftService
cat -n ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js

Repository: TryGhost/Ghost

Length of output: 15712


🏁 Script executed:

# Get lines 101-120 from stripe-service.js to see the full CheckoutSessionEventService instantiation
sed -n '101,120p' ghost/core/core/server/services/stripe/stripe-service.js

# Also get the complete constructor of StripeService to see when giftService is stored
sed -n '50,120p' ghost/core/core/server/services/stripe/stripe-service.js

Repository: TryGhost/Ghost

Length of output: 2795


Add a guard to ensure giftService is initialized before accessing its methods during webhook handling.

The race condition is real: stripe.init() completes and awaits on line 347 of boot.js, but giftService.init() runs concurrently in a Promise.all without awaiting. Stripe webhooks can arrive immediately after initialization, before giftService.service is populated. If a checkout.session.completed webhook with gift metadata arrives before giftService.init() finishes, the getter at lines 115-117 returns undefined, causing recordPurchase() to fail at runtime.

Either ensure giftService.init() is awaited before stripe webhooks become active, or add a defensive check in the getter to guard against uninitialized state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/stripe/stripe-service.js` around lines 115 -
117, The getter giftService should defensively handle the case where
giftService.service is not yet initialized to avoid runtime failures in webhook
handling; update the giftService getter to check that the module-level
giftService and giftService.service are defined and, if not, either return a
safe no-op stub (implementing the methods used by recordPurchase) or throw a
clear error so callers can bail gracefully; reference the giftService getter,
giftService.init, stripe.init and the recordPurchase path that handles
checkout.session.completed webhooks when adding this guard.

@mike182uk mike182uk merged commit 0c339fa into main Apr 7, 2026
39 checks passed
@mike182uk mike182uk deleted the BER-3484-handle-stripe-webhook branch April 7, 2026 09:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants