🐛 Fixed member edited webhook missing tiers#27236
Conversation
The member.edited webhook payload is missing subscriptions when a comp subscription is added via the Admin API. The API response correctly includes both tiers and subscriptions, but the webhook only includes tiers. Also fixed stripe-mocker to return type field on price objects, matching real Stripe API behavior.
The webhook serializer was using the raw model from the member.edited event, which only had labels loaded. The API response was correct because it does a fresh read() with all relations, but the webhook payload was missing subscriptions, tiers, and other relation data. Now loads the full set of member relations before serializing the webhook payload. closes https://linear.app/ghost/issue/ENG-000
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdded a Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes 🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Only expose subscription id, stripe_price_id, and status in the member webhook payload rather than full Stripe customer/payment details. Tiers are still included from the products relation.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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/test/e2e-webhooks/members.test.js`:
- Around line 168-169: The test redundantly re-initializes webhookMockReceiver
here; remove the two lines that call mockManager.mockWebhookRequests() and await
webhookMockReceiver.mock(webhookURL) so the test uses the webhookMockReceiver
set up in beforeEach; ensure any required mocking is handled in the existing
beforeEach setup (referencing webhookMockReceiver and
mockManager.mockWebhookRequests()) and that this test only uses
webhookMockReceiver without reassigning or re-mocking.
🪄 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: bee51def-2a70-47a4-ab41-c9c38ad94616
⛔ Files ignored due to path filters (1)
ghost/core/test/e2e-webhooks/__snapshots__/members.test.js.snapis excluded by!**/*.snap
📒 Files selected for processing (3)
ghost/core/core/server/services/webhooks/serialize.jsghost/core/test/e2e-webhooks/members.test.jsghost/core/test/utils/stripe-mocker.js
Subscription objects now contain the Ghost tier id and stripe_product_id rather than the stripe_price_id, making it easier for webhook consumers to identify which tier a subscription belongs to.
The webhook was missing tiers because the products relation wasn't loaded on the model at event time. Now loads products, labels, and newsletters — no Stripe subscription data is exposed.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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/webhooks/serialize.js`:
- Around line 54-63: The minimal subscription payload created in
result.subscriptions (inside stripeSubscriptions.map) omits the price
identifier; update the mapping to include stripe_price_id (the price the member
is actually subscribed to) alongside the tier info so consumers can distinguish
monthly vs yearly plans. Locate the map callback that constructs the object with
id, status and tier (uses stripePrice/stripeProduct via
sub.related('stripePrice').related('stripeProduct')) and add a stripe_price_id
field (sourced from the related stripePrice model, e.g.
sub.related('stripePrice').get('stripe_price_id')) into the returned
subscription shape while keeping the existing tier.{id,stripe_product_id}
values.
🪄 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: 9c37a6ba-46cb-4520-89c3-e4463378bc0e
📒 Files selected for processing (2)
ghost/core/core/server/services/webhooks/serialize.jsghost/core/test/e2e-webhooks/members.test.js
Without Stripe relations loaded, subscriptions was always an empty array which falsely implies the member has no subscriptions. Removed it entirely — tiers is the source of truth for member access.
EvanHahn
left a comment
There was a problem hiding this comment.
Several non-blocking comments. The subscriptions removal is my biggest question.
- Restored subscriptions field in webhook payload (breaking change concern) - Used switch statement for docName checks in serialize.js - Used .example domain in test webhook URL (RFC 2606) - Added tier shape assertions (id, name, slug) - Combined nested conditional in stripe-mocker price handling
The tiers array now appears in member webhook payloads, so the click-tracking test needs matchers for the dynamic fields (ids, timestamps) in the tier objects.
|



ref https://linear.app/ghost/issue/NY-1216/
Summary
The
member.editedwebhook payload was missingtiersbecause the webhook serializer used the raw Bookshelf model from the event, which only hadlabelsloaded. The Admin API response was correct (it does a freshread()with all relations), but the webhook was not.The webhook serializer now loads
products,labels, andnewsletterson the member model before serializing, sotiersis included in the payload.Also fixed the stripe-mocker to return the
typefield on price objects, matching real Stripe API behavior (needed for the comp flow to work in tests).Root cause
The
member.editedevent fires with the Bookshelf model frommemberRepository.update(), which only haslabelsin itswithRelated. TheserializeMemberfunction only setstierswhenjson.productsexists on the model — and it didn't, becauseproductswas never loaded.Payload: before → after
Before (comped member):
{ "member": { "current": { "id": "...", "uuid": "...", "email": "comped-test@example.com", "name": "Comped Test Member", "status": "comped", "comped": true, "subscribed": true, "subscriptions": [], "labels": [], "avatar_image": null, "email_count": 0, "email_opened_count": 0, "email_open_rate": null, "last_seen_at": null, "created_at": "...", "updated_at": "..." }, "previous": { "status": "free", "updated_at": "..." } } }After (comped member):
{ "member": { "current": { "id": "...", "uuid": "...", "email": "comped-test@example.com", "name": "Comped Test Member", "status": "comped", "comped": true, "subscribed": true, "subscriptions": [], "tiers": [ { "id": "...", "name": "Default Product", "slug": "default-product", "active": true, "type": "paid", "currency": "usd", "monthly_price": 500, "yearly_price": 5000 } ], "labels": [], "newsletters": [...], "avatar_image": null, "email_count": 0, "email_opened_count": 0, "email_open_rate": null, "last_seen_at": null, "created_at": "...", "updated_at": "..." }, "previous": { "status": "free", "updated_at": "..." } } }Changes:
tiers— the Ghost products/tiers the member has access to (source of truth for member access)newsletters— the newsletters the member is subscribed tosubscriptionsremains[](Stripe relations not loaded; documenting/fixing this separately)Test plan
tierswith correct shape (id, name, slug)