Admin API key doesn't have permission to add new member?

Here’s what I tried:

GOAL: add a subscription box to a specific newsletter on my ghost instance from a separate nextjs site

What I tried

I created a custom integration and got the Admin API key. I verified that the code is using the correct script.

And here are the relevant portions of the script:

const api = new GhostAdminAPI({
      url: ghostApiUrl,
      key: ghostAdminApiKey,
      version: 'v5.0'
    })

      api.members.add({
        email,
        labels: [`source:${source}`]
      }, {
        send_email: false
      })

What happened as a result

Got this error back:

Detailed Ghost API error: {
  message: 'Permission error, cannot save member.',
  context: 'You do not have permission to add members',
  type: 'NoPermissionError',
  details: null,
  stack: 'NoPermissionError: Permission error, cannot save member.\n'

Everywhere I look at says admin api key should be able to do this. And I can add members when I’m logged into my ghost instance with the user who created the custom integration (also an owner).

Any hints? Things I can try? Origin of this error?

Cheers, Zvonimir

What version of ghost?

Thanks for the reply, Cathy.

It’s version 5.101.1

Thanks. I can try it this evening. I would expect that to work. Possibly a regression?

It’s probably worth triple checking that you’ve copied the api key correctly. Do other endpoints work ok?

Meanwhile, some working examples from a couple months ago:

I’m not able to replicate this. Here’s my full replication, run with ‘node test.js’. Contents of test.js:

const adminApi = require('@tryghost/admin-api');

const ghostApiUrl = 'http://localhost:2368'
const ghostAdminApiKey = '673904359036b20f7a02ace3:fd16e44dade014ac540d8726ed467365d30a2f4b45e01048929304d132b4d19e'
const email='myactualemail@gmail.com';
const source='test';

const api = new adminApi({
    url: ghostApiUrl,
    key: ghostAdminApiKey,
    version: 'v5.0'
  })

  api.members.add({
    email,
    labels: [`source:${source}`]
  }, {
    send_email: false
  })

(Admin API key is a localhost key, no need for alarm. Not useful to anyone. I did anonymize my email.)

Things to consider:

  • Might need to update the APK if you installed a long time ago. I have version 1.13.12
  • might have the url wrong. Note the format of mine. No trailing slash, no /ghost, etc. If you have your back end on a different domain, it’s the back end url (where /ghost takes you).
  • copy-paste error on the API key, or you accidentally grabbed the (much shorter) content API key.

Thanks so much for testing this, Cathy.

I’m using "@tryghost/admin-api": "^1.13.12", so same as yours.

I logged out all the values and everything seems ok. It should work.

Putting the wrong key gives me a different error.

For completeness, here’s my full nextjs test route:

import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import GhostAdminAPI from '@tryghost/admin-api'

const subscribeRequestSchema = z.object({
  email: z.string().email(),
  source: z.string(),
})

export async function POST(request: NextRequest) {
  try {
    const body = await request.json()
    const { email, source } = subscribeRequestSchema.parse(body)

    const ghostApiUrl = process.env.GHOST_ADMIN_API_URL
    const ghostAdminApiKey = process.env.GHOST_ADMIN_API_KEY

    if (!ghostApiUrl || !ghostAdminApiKey) {
      throw new Error('Ghost API configuration missing')
    }

    console.log('Ghost API URL:', ghostApiUrl)
    console.log('Ghost Admin Key format check:', {
      hasColon: ghostAdminApiKey.includes(':'),
      length: ghostAdminApiKey.length,
      prefix: ghostAdminApiKey.substring(0, 6) + '...'
    })

    const api = new GhostAdminAPI({
      url: ghostApiUrl,
      key: ghostAdminApiKey,
      version: 'v5.0'
    })

    try {
      console.log('Attempting to add member:', { email, source })

      console.log("ghostApiUrl", ghostApiUrl)
      console.log("ghostAdminApiKey", ghostAdminApiKey)
      console.log("email", email)
      console.log("source", source)
      
      await api.members.add({
        email,
        labels: [`source:${source}`]
      }, {
        send_email: false // Don't send welcome email
      })
      
      return NextResponse.json({ success: true })
    } catch (error: any) {
      console.error('Detailed Ghost API error:', {
        message: error.message,
        context: error.context,
        type: error.type,
        details: error.details,
        stack: error.stack
      })

      if (error.message?.includes('Member already exists')) {
        return NextResponse.json({ success: true })
      }

      return NextResponse.json(
        { 
          message: 'Failed to subscribe',
          details: error.context || error.message
        },
        { status: 500 }
      )
    }
  } catch (error) {
    console.error('Subscription error:', error)
    
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { message: 'Invalid request data' },
        { status: 400 }
      )
    }

    return NextResponse.json(
      { message: 'Internal server error' },
      { status: 500 }
    )
  }
} 

This makes me think it might actually be a permissions problem on a lower level.
I’ve had this instance for years now and it’s gone through many version updates so far.

What do permission look like for you in the database? Maybe I need to fiddle with it.

Interesting. Of course, migration are /supposed/ to keep everything just right. But perhaps something has gone wrong somewhere.

You’d want to look for a Members Add permission in permissions, that (via permissions_roles and roles) maps to the admin integration.

And while we’re at it, can you confirm you created an integration with the integrations menu on the dashboard, and that’s where the api key is coming from?

Took me a minute but here it goes.

I ran this:

SELECT p.id AS permission_id, p.name AS permission_name,
         r.id AS role_id, r.name AS role_name
FROM permissions p
JOIN permissions_roles pr ON p.id = pr.permission_id
JOIN roles r ON pr.role_id = r.id
WHERE p.name LIKE '%member%';

Result:

only a Self-Serve Migration Integration role has Add Members permission

I created the integration from Settings → Advanced → Integrations → Add custom integration.

My guess is Admin Integration role also needs Add Members permission.
But I’m gonna give you a chance to stop me and point me to a proper way to do it before I start editing the database directly :upside_down_face:

I think you’re right about what’s needed.

You /could/ export all the content and then reimport it into a fresh Ghost install. (Copy images folder, import your export.json and your theme and your redirects and routes (if any), and your members. But I suspect that’s going to be a headache. You can import your members (after exporting them), but I don’t think comments will survive this process. (Happy to be wrong - haven’t tested.)

I don’t think editing the database is a great idea in general, but I don’t immediately see a simple alternative. Obviously, you should MAKE A BACKUP! I’m a fan of VPS snapshots for “oh, crud, let’s invent time travel real quick”.

That sounds a lot more work than an SQL query :slightly_smiling_face:

Could you tell me what you get as a result of that sql above? I just wanna know which roles should have the Add Member permission, while I’m at it.

Happy to. Big caveat here: I’m running in development, using sqlite3.
(I think this instance was upgraded from 5.94ish, now at 5.101.2.)


Apologies for the screenshot - it wasn’t going to copy paste well.

Agreed! :laughing:

… I’m a little surprised you don’t have Administrator having the ‘Add members’ permission also. But maybe you don’t create members manually. (Or maybe the permission isn’t correctly checked…)

Just to confirm that it works now.

Thanks so much for your help, Cathy! :pray:

1 Like