A toolkit for migrating paid subscribers from platform-controlled Stripe accounts to your own Stripe account (e.g., Beehiiv → Ghost/Magic Pages).
Both Beehiiv and Ghost use Stripe Connect, but with different levels of control:
- Beehiiv connects with
controller.type = "application", meaning the platform controls the account. This prevents other platforms from connecting with the same level of access. - Ghost connects with
controller.type = "account", meaning you retain control of your Stripe account.
Because Beehiiv claims platform control, you cannot connect the same Stripe account to Ghost. A new Stripe account is required, and subscriptions must be recreated with the new account's Product/Price IDs.
This toolkit handles the migration by:
- Exporting subscription data from the source account
- Copying customer payment methods via Stripe's PAN copy tool
- Creating new subscriptions with
trial_endset to the old renewal date - Generating a Ghost import CSV with new customer IDs
- Cancelling old subscriptions at period end
Result: Subscribers transition seamlessly without double-charging.
npm install
cp .env.example .env
# Edit .env with your Stripe API keysEdit .env with your Stripe API keys:
STRIPE_SOURCE_SECRET_KEY=sk_live_xxx # Beehiiv-connected account
STRIPE_DEST_SECRET_KEY=sk_live_xxx # Ghost-connected accountFind your API keys in each Stripe Dashboard under Developers → API keys.
npm run exportThis exports all active and trialing subscriptions from the source account.
Output:
output/subscriptions-export.json- Full subscription dataoutput/subscriptions-export.csv- CSV for reference
Copy customers (with payment methods) from source to destination using Stripe Dashboard:
- Go to source Stripe Dashboard → Customers
- Click Copy (top right) → Copy all customers
- Enter destination account ID (find it in destination Dashboard under Settings → Business → Account details, starts with
acct_) - Go to destination Stripe Dashboard → accept the copy request
- Wait for copy to complete (usually minutes, up to 3 days for large datasets)
npm run create-customer-mappingThis matches customers by email between accounts.
Output: output/customer-mapping.json
The summary shows all customers matched. If any are unmatched, ensure the customer copy (step 3) completed.
In Ghost Admin, create your membership tiers:
- Go to Settings → Membership → Tiers
- Create tiers matching your Beehiiv (or any other platform that follows similar patterns) subscription offerings
- Set the prices (monthly/yearly) to match
This creates products and prices in your destination Stripe account.
Create config/price-mapping.json to map old prices to new Ghost prices:
- Open
output/subscriptions-export.jsonand note the uniquepriceIdvalues - Go to destination Stripe Dashboard → Products → find the Ghost-created products
- Click each product to find its price IDs (under Pricing)
- Create the mapping file:
[
{
"oldPriceId": "price_xxx",
"newPriceId": "price_yyy",
"description": "Monthly"
},
{
"oldPriceId": "price_aaa",
"newPriceId": "price_bbb",
"description": "Yearly"
}
]Add one entry for each unique price in your export.
npm run create-subscriptionsThis runs in dry-run mode by default. Review the output to verify:
- All customers are mapped correctly
- All prices are mapped correctly
- Trial end dates align with old subscription renewal dates
When ready, run with --live to create the subscriptions:
npm run create-subscriptions -- --liveOutput: output/migration-results.json
npm run generate-ghost-csvOutput: output/ghost-import.csv
Import this file into Ghost:
- Go to Ghost Admin → Members. Click on the gear icon on the top right.
- Click Import Members and upload the
ghost-import.csv - Verify members appear with correct tiers
npm run cancel-oldThis runs in dry-run mode by default. Review the output to verify which subscriptions will be cancelled.
When ready, run with --live to cancel subscriptions at period end:
npm run cancel-old -- --liveOutput: output/cancel-results.json
npm run verifyThis checks that old subscription end dates align with new subscription trial end dates.
Output: output/verification-results.json
All subscriptions should show as "aligned". If any show issues, review the specific subscription in both Stripe accounts.
To test this toolkit you can use test Stripe accounts (sk_test_xxx) before running on production.
npm run test:setupThis creates test products, customers, and subscriptions.
Stripe Test Clocks let you simulate time passing without waiting. This verifies that:
- Old subscriptions cancel at period end
- New subscriptions transition from trialing to active billing
Customers must be attached to a test clock when created. Time only advances for customers on that clock.
# List existing test clocks
npm run test:advance-clock -- --list
# Create a new test clock (frozen at current time)
npm run test:advance-clock -- --create
# Advance clock by 30 days (triggers subscription renewals/cancellations)
npm run test:advance-clock -- --clock clock_xxx --days 30
# Check subscription statuses after advancing
npm run test:advance-clock -- --clock clock_xxx --status| Command | Description |
|---|---|
npm run export |
Export subscriptions from source account |
npm run create-subscriptions |
Create new subscriptions in destination |
npm run cancel-old |
Cancel old subscriptions at period end |
npm run generate-ghost-csv |
Generate Ghost import CSV |
npm run verify |
Verify migration alignment |
npm run test:setup |
Set up test data (test accounts only) |
npm run test:advance-clock |
Advance test clock |
The key is the trial_end parameter:
- Old subscription renews on Jan 15
- New subscription created with
trial_end: Jan 15 - Old subscription cancelled at period end (Jan 15)
- New subscription trial ends Jan 15, billing begins
Timeline:
Jan 1: New subscription created (trialing)
Jan 1-14: Old subscription still active, new in trial
Jan 15: Old subscription cancels, new subscription activates
Ghost matches the stripe_customer_id in the CSV to subscriptions in the connected Stripe account. Since we created subscriptions with the correct price IDs, Ghost assigns the correct tier.
Customer wasn't copied to destination account. Use Stripe PAN copy tool.
Add the missing price ID to config/price-mapping.json.
The stripe_customer_id in the CSV doesn't exist in the Stripe account connected to Ghost. Ensure:
- Customer was copied to the correct account
- Ghost is connected to the destination account
- Using the NEW customer ID, not the old one
- Stripe: Copy PAN data
- Stripe: Billing Migration Toolkit
- Ghost: Import members
- Ghost: Beehiiv migration
MIT License - see LICENSE for details.