How to upload a members CSV file using Node

I needed Stripe customer IDs in Ghost and this is the only method that works for me.

A very confused ghost. In a tie.

I've been working this week on a Netlify cloud function that creates Ghost users upon receiving their email addresses and names. Yeah, I know, that's built-in Ghost functionality. What's not built in is that the client is actually collecting sign-ups and creating Stripe subscriptions on a WordPress site, and then those users need to become native Ghost users. Including their subscriptions.

I know how to create users with the API, so my plan was to create the users and then log them in automagically. What I could not for the life of me figure out is how to get the API to actually accept the user's Stripe customer ID. I'm sure that once I post this, someone will come along and tell me what I'm missing, but it eluded me. (And the members API is genuinely undocumented, unlike last week's little failed-to-read-the-documentation-about-recommendations fiasco.) Free members or complimentary members = no problem. But getting the real subscriptions in? Wasn't happening.

So, eventually, I decided that I'd just create them as free users, and then immediately upload a CSV file containing their subscriber ID. I knew that worked, because it worked in the Ghost admin portal, and I could see the call to the API happening, so I knew what the payload needed to look like. Easy, right?

Yeah, well, I hate multipart form data. And fussy capitalization. And dealing with Netlify. [I should really always run everything with 'netlify dev' first so that I can see all my error messages, but I keep thinking that jobs are easy enough to just deploy while testing. And I'm usually wrong.]

Here's what finally actually worked:

// Top of the file //
const FormData = require('form-data'); 
// There are several packages that provide FormData but this is the one that actually worked for me.

// Excerpted from deep in the midst of an async function // 

  let filecontents = `id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,labels
,${useremail},${fullname},added by Stripe,,FALSE,${customerID},stripe integration`

    const mybody = new FormData();

    mybody.append("mapping[email]", "email");
    mybody.append("mapping[complimentary_plan]", "complimentary_plan");
    mybody.append("mapping[stripe_customer_id]", "stripe_customer_id");
    mybody.append("mapping[created_at]", "created_at");
    mybody.append("mapping[labels]", "labels");
    mybody.append('membersfile', Buffer.from(filecontents), { filename: 'data.csv' });

    let result = await fetch( GHOST_URL + '/ghost/api/admin/members/upload/', {
      method: 'POST',
      headers: {
        cookie: ADMINcookie,
      body: mybody

In hindsight, I'm not sure if I needed cookie authentication here or not. An API key might have been OK. I've got to do cookie authentication later in this process (to get a magic link) anyway, so I just used it here rather than trying the API key first.

What happens after we import the users?

If a user has a subscription set up by Ghost and your Ghost is linked to that same Stripe account, then when you import their customer ID via the CSV upload, it'll show up as the right subscription in Ghost.

If you upload a customer ID from Stripe (still matching the same Stripe account that Ghost is linked to) but the subscription is not created by Ghost, Ghost will assign them to your first paid subscription tier. It gets the user's pricing, end date, and everything else right, but it seems to ignore what's in the 'tiers' column if your user doesn't actually have a subscription that Ghost thinks corresponds to that tier. It appears that you can work around this by using the Stripe API to put the user in a subscription plan that Ghost manages (using the Stripe subscription update to assign a different price), and then you can delete and reimport the member, which will trigger Ghost to get the new subscription setting. [So better yet, if this is a new Ghost user: Fix the Stripe price via Stripe API, then import the user into Ghost.]

You can't upload a Stripe customer ID that isn't associated with the Stripe account you have Ghost linked to (and 'test' vs 'live' matters for Stripe) - it'll throw an error. Mismatched Stripe keys look like this (request made with a live key on a test user):

Could not find Stripe customer, but a live mode key was used to make this request.

You also can't upload a Stripe customer ID if you've already uploaded a the same customer ID, either for that user, or for a different user.

Duplicate entry 'cus_XXXXXX' for key 'members_stripe_customers.members_stripe_customers_customer_id_unique'
Tip/Trick/Gotcha (I'm honestly not sure which) - if you have a user with a bunch of cancelled subscriptions that are showing up in Ghost (which can mess up some tricky subscription-dependent theme logic), exporting them, deleting them**, and then re-importing them results in cancelled subscriptions disappearing from Ghost. I fixed a problem today for a customer related to one of her members having cancelled and resubscribed three times. Her custom theme checks for subscription tier, but was confused by extra cancellations.

But... Importing a user with a cancelled subscription but time remaining on it didn't work correctly the last time I checked, so use with caution if at all.

**Impacts on comments not tested. Test before you do it!

As an FYI, using the CSV import generates an email to the user you're running as, but doesn't seem to result in any member-created webhooks. Plan accordingly.

Hey, before you go... If your finances allow you to keep this tea-drinking ghost and the freelancer behind her supplied with our hot beverage of choice, we'd both appreciate it!