Using the Ghost API to create snippets

How to use the Ghost Admin API to create snippets the hard way.

A ghost in a wizard hat, typing on a keyboard.
🧰
If you aren't already familiar with snippets, you should totally read about them, because they're seriously awesome and can speed up your creation of somewhat repetitive content.

Normal people make snippets in the Ghost editor. I'm not always normal.

This week's project involved being able to add snippets to Ghost. I know you can do that in the editor, but I wanted to do that programmatically with the API, for a variety of good reasons to be detailed elsewhere. (Hint: it involves building contact forms for Ghost...)

I've been wondering for a while how exactly snippets get created, so I turned to my very favorite tool for figuring out how the not-always-well-documented bits of the Ghost API actually work. I turned on dev tools and watched network calls while I did what I wanted to do.

🔮
One of the really interesting things (to me anyway) about Ghost is that the admin panel (what you're interacting with at /ghost/) is actually just an app that makes API calls. Anything the admin panel can do, you can do as an API call. My solution to undocumented API features is to log into /ghost/ and watch for the browser's call in my network tools.

So, back to snippets. When you highlight some content in the editor and click the snippet button, Ghost makes a call to the "snippets" endpoint in the admin API. The payload looks like this:

{
    "snippets": [
        {
            "name": "teenysnippet",
            "mobiledoc": "{}",
            "lexical": "{\"namespace\":\"KoenigEditor\",\"nodes\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"Hello, I'm a snippet!\",\"type\":\"text\",\"version\":1}],\"direction\":\"ltr\",\"format\":\"\",\"indent\":0,\"type\":\"link\",\"version\":1,\"rel\":\"noreferrer\",\"target\":null,\"title\":null,\"url\":\"teenysnippet\"}]}"
        }
    ]
}

(As you can see, I'm using the new beta editor. Otherwise I'd have a null lexical and a mobiledoc string instead.)

Why is the lexical formatting such a mess? It's JSON.stringify-ed. Yep, you're going to make a post with stringified JSON nested inside a stringified JSON body. Messy.

The endpoint you want is:

/ghost/api/admin/snippets/?formats=mobiledoc%2Clexical

There's just one additional complication - since this is the Admin API, we have to authenticate. I started out trying to use the JavaScript SDK (because hey, it might have worked). Nope. It doesn't know about the snippets endpoint, as best I can tell. OK, fine. So then I tried using the admin API key and generating a JWT. If you haven't done JWT generation for talking to the admin API before, it's a good idea to hit an endpoint you know works first, for example, by following this example of creating a post with JWT authentication.

Why hit an endpoint you know works? Well, because there are some endpoints that don't work with JWT authentication, and the error can be pretty cryptic.

In the case of the snippets endpoint, using a JWT resulted in this error:

'The server does not support the functionality required to fulfill the request.', 'NotImplementedError'

Yeah, thanks undocumented snippets endpoint. I know you work because I can see you working in the Ghost admin panel, so what gives?

"What gives" is that the snippets endpoint only does cookie authentication. It doesn't like tokens. I don't know why it doesn't like tokens, but it doesn't. I'm guessing tokens🔑 don't taste as good as cookies🍪🥠? (It isn't the only endpoint like this - the signin_urls endpoint is the same way.)

So first you get a cookie (below), and THEN you POST to the endpoint with it (pass it in the headers as 'cookie').

async function getGhostAdminCookieWithCredentials ( email, password, GHOST_URL ) {
  let result = await fetch( GHOST_URL + '/ghost/api/admin/session', {
    method: 'POST',
    headers: {
      'Origin': GHOST_URL,
      "Content-Type": "application/json",
    },
    body: JSON.stringify( {
      "username": email,
      "password": password
    } )
  } )
  let headers = result.headers
  let cookiestring = headers.get( 'set-cookie' )
  let cookie = cookiestring.split( ';' )[ 0 ]
  return cookie;
}