Managing your posts, tags, and users with the Ghost Blog API

The Ghost API provides access to your blog's posts, tags, and users. You can get items, as well as create them. Before accessing anything though, you'll need several pieces of data, depending on your blog settings and whether you're trying to get data or create it. Let's see what we can do with it.

Managing your posts, tags, and users with the Ghost Blog API

Since writing this, the v0.1 API has been deprecated in favor of v2.0. You can read more about API versioning on the Ghost docs site, how I implemented the v2.0 API in C#, or my GhostSharp project that brings the (currently v3.0) API to C#.

The Ghost blogging platform is one that I'm really enjoying, since installing it about a year ago. I was a longtime user of WordPress, and still use it for a few sites I maintain for others, but in general it's a resource-hog. No matter how much I tried to streamline things, I always ended up with a couple dozen plugins installed to get it to do what I needed it to do.

It's relatively easy to customize too. I've made it so that all images link to themselves, and the presence of headers will automatically generate a table of contents. Here's a few other hacks too.

If you're using Ghost as well, did you know it has its own API? (I'm working on a C# wrapper for it called GhostSharp.) Let's see how to use the API - but first, two things before we get started:

  • If you're unfamiliar with APIs, you might want to read this first to familiarize yourself with them.
  • You may want to install Postman, which allows you to access API endpoints without having to write an app, as well as save the calls you make and sync them online between your computers.

What does it provide?

The Ghost API provides access to your Ghost blog's posts, tags, and users. You can get items, as well as create them. Before accessing anything though, you'll need several pieces of data - which ones exactly depends on your blog settings and whether you're trying to use the public API or private API. I'll elaborate on the differences below.


Authenticating

There are two APIs - one is public and can be used to retrieve data:

The 'Public' API essentially reflects the behaviour of a blog - it provides read access to any data that a user/reader of a blog would be able to see.

The other is referred to as private, and involves modifying content, not just reading it:

The 'Private' API provides access to blog data in accordance with the permissions of the user making the request. This includes all write access, and read access for any private data, for example draft posts and inactive users.

You can make the public API (or GETs) private by setting an option in the admin area (more below).

Access the public API, if you've allowed public access to it

To allow anyone to access the public API - querying data from your blog - the first step is to enable the public API from the admin area:

public api setting in admin

Gather the client id and client secret

Then you'll need your site's client id and client secret. These aren't available in the GUI anywhere, but they are available if you view the source code for any post on your blog. Just open up an existing post, view the page source, and look for something like this, between <script> tags. Client "secret" is a bit of a misnomer.

<script type="text/javascript">
ghost.init({
	clientId: "ghost-frontend",
	clientSecret: "53f1f0e99723"
});
</script>

Now if you (or anyone for that matter) makes a request like this one:

POST https://your-site.com/ghost/api/v0.1/posts?limit=2&client_id=ghost-frontend&client_secret=53f1f0e99723

They'll see something like this. There's nothing really personal in here, just meta data about two posts (since I set limit=2). I removed most of the "html" field since there's no reason to re-post the contents of two of my other posts in here.

{
    "posts": [
        {
            "id": "5a78f5825a04b0124daa8f50",
            "uuid": "d733b99e-74d5-4bd5-ac11-f75fc0f987f0",
            "title": "What is the PasswordRandom API?",
            "slug": "passwordrandom-api",
            "html": "<div class=\"kg-card-markdown\"><p>I've decided ... \n</div>",
            "feature_image": "/content/images/2018/02/Unlocking-APIs---passwordrandom.jpg",
            "featured": false,
            "page": false,
            "status": "published",
            "locale": null,
            "visibility": "public",
            "meta_title": null,
            "meta_description": null,
            "created_at": "2018-02-06T00:23:30.000Z",
            "created_by": "1",
            "updated_at": "2018-02-06T12:36:57.000Z",
            "updated_by": "1",
            "published_at": "2018-02-06T12:36:34.000Z",
            "published_by": "1",
            "custom_excerpt": "The PasswordRandom API provides random values - and not just passwords as the name would seem to suggest. It also generates GUIDs, random numbers, characters, etc.",
            "codeinjection_head": "",
            "codeinjection_foot": "",
            "og_image": null,
            "og_title": null,
            "og_description": null,
            "twitter_image": null,
            "twitter_title": null,
            "twitter_description": null,
            "custom_template": null,
            "author": "1",
            "primary_tag": null,
            "url": "/passwordrandom-api/",
            "comment_id": "5a78f5825a04b0124daa8f50"
        },
        {
            "id": "5a7648c90cceb30a46ac8c3f",
            "uuid": "8de9df86-8072-4589-9ae5-2614e65e05c6",
            "title": "Weekend Review - Slow but steady",
            "slug": "weekend-review-slow-but-steady",
            "html": "<div class=\"kg-card-markdown\"><p>My TRX instructor ... \n</div>",
            "feature_image": "/content/images/2018/02/weekend-review.jpg",
            "featured": false,
            "page": false,
            "status": "published",
            "locale": null,
            "visibility": "public",
            "meta_title": null,
            "meta_description": null,
            "created_at": "2018-02-03T23:42:01.000Z",
            "created_by": "1",
            "updated_at": "2018-02-04T03:03:21.000Z",
            "updated_by": "1",
            "published_at": "2018-02-04T03:03:21.000Z",
            "published_by": "1",
            "custom_excerpt": null,
            "codeinjection_head": "",
            "codeinjection_foot": "",
            "og_image": null,
            "og_title": null,
            "og_description": null,
            "twitter_image": null,
            "twitter_title": null,
            "twitter_description": null,
            "custom_template": null,
            "author": "1",
            "primary_tag": null,
            "url": "/weekend-review-slow-but-steady/",
            "comment_id": "5a7648c90cceb30a46ac8c3f"
        }
    ],
    "meta": {
        "pagination": {
            "page": 1,
            "limit": 2,
            "pages": 91,
            "total": 181,
            "next": 2,
            "prev": null
        }
    }
}

If you don't want just anyone to be able to access the public API, then disable the option in the admin area that I showed above. Make the same API request, and you should get a different response now:

{
    "errors": [
        {
            "message": "Please Sign In",
            "errorType": "NoPermissionError"
        }
    ]
}

You can still use the public APIs, but it's a little more work, which I'll cover next.


Access the private API, and the public API when you've disallowed public access

If you need to access the private API, or you've disabled public access to the public API, you'll need to authenticate with Ghost. That means gathering a few more pieces of information and making a separate request.

Gather the client id and client secret

This is the same as earlier. Open any post and check out the page source (see above for an example).

Gather your username and password

You'll need the email address you use as your username, as well as the password. Since these are going to be part of a REST request, if you've got anything crazy in your password (like an ampersand), you'll need to encode it. You can use a url encoder service, or just check out the "hex" column of any ascii chart.

Request an auth token

Once you've got those 4 pieces of data, open up Postman and make an API call to get an authorization token, which you can use to make further requests:

  • Method: POST
  • Endpoint: https://your-site.com/ghost/api/v0.1/authentication/token
  • Headers: Content-Type -> application/x-www-form-urlencoded
  • Body: (you can choose x-www-form-urlencoded and enter each parameter separately, or just choose "raw" and enter everything at once like a giant querystring)grant_type=password&username=<your-email>&password=<your-password>&client_id=<your-client-id>&client_secret=<your-client-secret>

If all goes well, your hard work is rewarded with an auth token. You can find out more in the docs.

{
    "access_token": "kiuKEImI1VGPr43XF63uCtsQvEmmP2Gk5AdM4PlSifmm7Py9RrJWp7HvLTUm5bllbaAvOD0mTJjoNvLVaCYeuEYN7xJxyOTaSLsPYwBA2ckmF80CNmHj3FE0pXEFxMYyQkJSC1Jc8JtuKnCnOwQJvt2r05V2Raf5fCR3OeHSKkkm9KCSdIqTw",
    "refresh_token": "3gjirgzUg0WNi7o2CyaFV35QKAFQVBq8AVgow0xTpy05F3mKLbxlPBMEagMt1f3cP3LRvfZWF5PLc6b8hJVAekTmDIjEa1sPmbVhG5JISOdOOFP01BlJ4mvP1n0qjl5gr3b1ctREn0tu2JOvtLAZ22b2hKnDL7nDokMwzAQ1yl2CNOCFtdPsv",
    "expires_in": 2628000,
    "token_type": "Bearer"
}

You can read more about how OAuth works. The above returned fields basically mean:

  • The "expires_in" field tells you many seconds until the "access_token" expires. (2628000 = a month)
  • The "refresh_token" is what you can use to reset the expiration time back to 2628000 on the "access_token". If you ever need to do that, it's a separate call:  Method: POST Endpoint: https://your-site.com/ghost/api/v0.1/authentication/token Header: Authorization -> Bearer <the-original-access-token> Header: Content-Type -> application/x-www-form-urlencoded Body: grant_type=refresh_token&client_id=<your-client-id>&refresh_token=<refresh-token>  

Since you have to provide the access token in the request to refresh it, I'm not sure if that means you can't refresh the token after it's expired, only before it does..?

Also, you can get a bearer token in a much more manual way, by accessing any section of your admin page, then opening the developer tools and clicking on "Network" and "Headers". Scroll down until you see a request (on the left) that's accessing some protected data - anything that starts with a ?. Select it and check near the bottom of the headers for an "authorization" field. Details about this method are available here.

ghost-bearer-token

So now that you've got the access/auth token, disable the "public api" setting in the admin area, and try making a call to get posts. It should return the same 2 posts as before, even though you've disallowed just anyone to access the public API.

  • Method: POST
  • Endpoint: https://your-site.com/ghost/api/v0.1/posts?limit=2&client_id=ghost-frontend&client_secret=53f1f0e99723
  • Header: Authorization -> Bearer <your-access-token>

You can also try making a request to something that would never be publicly accessible, like the ability to create a new post:

  • Method: POST
  • Endpoint: https://your-site.com/ghost/api/v0.1/posts
  • Header: Authorization -> Bearer <some-auth-token>
  • Header: Content-Type -> application/json
  • Body:{     "posts":     [         {             "title": "A pretend post",             "markdown": "<h2>HEADER!!</h2> Some text"         }     ] }

And you should get back the newly created post:

{
    "posts": [
        {
            "id": "5a7ba3985a04b0124daa8f8e",
            "uuid": "0bfbbfdd-8dda-4db2-93c0-0dadc43a126a",
            "title": "A pretend post",
            "slug": "a-pretend-post",
            "html": "<h2>HEADER!!</h2> Some text",
            "feature_image": null,
            "featured": false,
            "page": false,
            "status": "draft",
            "locale": null,
            "visibility": "public",
            "meta_title": null,
            "meta_description": null,
            "created_at": "2018-02-08T01:10:48.000Z",
            "created_by": "1",
            "updated_at": "2018-02-08T01:10:48.000Z",
            "updated_by": "1",
            "published_at": null,
            "published_by": null,
            "custom_excerpt": null,
            "codeinjection_head": null,
            "codeinjection_foot": null,
            "og_image": null,
            "og_title": null,
            "og_description": null,
            "twitter_image": null,
            "twitter_title": null,
            "twitter_description": null,
            "custom_template": null,
            "author": "1",
            "primary_tag": null,
            "url": "/a-pretend-post/",
            "comment_id": "5a7ba3985a04b0124daa8f8e"
        }
    ]
}

And here it is in the admin area. What's weird though is that when I open up the post, the "html" value is not in the body of the post. Not sure what's up with that, but according to the docs the API is still considered beta. Actually, the field listed in the docs isn't "html", but the one they did list seems wrong.

create-post-from-rest-endpoint

What else should we try?

Let's make a few calls just to see what else we can do. There's a lot more documented here.

Getting the first 3 tags

  • Method: GET
  • Endpoint: https://your-site.com/ghost/api/v0.1/tags?limit=3
  • Header: Authorization -> Bearer <some-auth-token>
{
    "tags": [
        {
            "id": "596713e0bdf3c9076373681c",
            "name": "Getting Started",
            "slug": "getting-started",
            "description": null,
            "feature_image": null,
            "visibility": "public",
            "meta_title": null,
            "meta_description": null,
            "created_at": "2017-07-13T06:32:00.000Z",
            "created_by": "1",
            "updated_at": "2017-07-13T06:32:00.000Z",
            "updated_by": "1",
            "parent": null
        },
        {
            "id": "5967634199d09e0ee05c3a95",
            "name": ".net",
            "slug": "net",
            "description": "",
            "feature_image": null,
            "visibility": "public",
            "meta_title": null,
            "meta_description": null,
            "created_at": "2017-03-16T03:34:14.000Z",
            "created_by": "1",
            "updated_at": "2017-03-16T03:34:14.000Z",
            "updated_by": "1",
            "parent": null
        },
        {
            "id": "5967634199d09e0ee05c3a96",
            "name": "52 weeks of pi",
            "slug": "52-weeks-of-pi",
            "description": "",
            "feature_image": null,
            "visibility": "public",
            "meta_title": null,
            "meta_description": null,
            "created_at": "2017-03-16T03:34:14.000Z",
            "created_by": "1",
            "updated_at": "2017-03-16T03:34:14.000Z",
            "updated_by": "1",
            "parent": null
        }
    ],
    "meta": {
        "pagination": {
            "page": 1,
            "limit": 3,
            "pages": 87,
            "total": 260,
            "next": 2,
            "prev": null
        }
    }
}

Getting pages (instead of posts)

You can also filter results using the filter query parameter and various fields. The fields are documented here.

We can use this, for example, to get all posts that are of type "page". In my case, it returns the only 3 pages I have - a code license, cross-posting policy, and personal bio, respectively. I stripped out most of the html again.

  • Method: GET
  • Endpoint: https://your-site.com/ghost/api/v0.1/posts?filter=page:true
  • Header: Authorization -> Bearer <some-auth-token>
{
    "posts": [
        {
            "id": "5a763359af2ce009c238f606",
            "uuid": "3a025e6c-b2e8-4e2f-9dc0-051e6a00322f",
            "title": "License",
            "slug": "license",
            "html": "<div class=\"kg-card-markdown\"><p>For those interested, and unless I note otherwise ... \n</div>",
            "feature_image": null,
            "featured": false,
            "page": true,
            "status": "published",
            "locale": null,
            "visibility": "public",
            "meta_title": null,
            "meta_description": null,
            "created_at": "2018-02-03T22:10:33.000Z",
            "created_by": "1",
            "updated_at": "2018-02-03T22:36:24.000Z",
            "updated_by": "1",
            "published_at": "2018-02-03T22:23:02.000Z",
            "published_by": "1",
            "custom_excerpt": null,
            "codeinjection_head": "",
            "codeinjection_foot": "",
            "og_image": null,
            "og_title": null,
            "og_description": null,
            "twitter_image": null,
            "twitter_title": null,
            "twitter_description": null,
            "custom_template": null,
            "author": "1",
            "primary_tag": null,
            "url": "/license/",
            "comment_id": "5a763359af2ce009c238f606"
        },
        {
            "id": "5a650f58b64b0f0df589cc97",
            "uuid": "759dec4d-2433-4c79-b3b0-883a4bfb85e9",
            "title": "Cross-Posting Policy",
            "slug": "cross-posting-policy",
            "html": "<div class=\"kg-card-markdown\"><p>I've been asked a couple of times ... \n</div>",
            "feature_image": null,
            "featured": false,
            "page": true,
            "status": "published",
            "locale": null,
            "visibility": "public",
            "meta_title": null,
            "meta_description": null,
            "created_at": "2018-01-21T22:08:24.000Z",
            "created_by": "1",
            "updated_at": "2018-01-22T18:03:23.000Z",
            "updated_by": "1",
            "published_at": "2018-01-21T22:10:24.000Z",
            "published_by": "1",
            "custom_excerpt": null,
            "codeinjection_head": "",
            "codeinjection_foot": "",
            "og_image": null,
            "og_title": null,
            "og_description": null,
            "twitter_image": null,
            "twitter_title": null,
            "twitter_description": null,
            "custom_template": null,
            "author": "1",
            "primary_tag": null,
            "url": "/cross-posting-policy/",
            "comment_id": "5a650f58b64b0f0df589cc97"
        },
        {
            "id": "5967634499d09e0ee05c3b74",
            "uuid": "5e3bea4b-416e-46e1-89b9-1956fecadc17",
            "title": "Hi! I'm Grant.",
            "slug": "about",
            "html": "<div class=\"kg-card-markdown\"><p>Is there anything more satisfying than sharing ... \n</div>",
            "feature_image": "/content/images/2017/07/animalpetzoo-2-1.jpg",
            "featured": false,
            "page": true,
            "status": "published",
            "locale": null,
            "visibility": "public",
            "meta_title": null,
            "meta_description": null,
            "created_at": "2013-09-22T14:15:46.000Z",
            "created_by": "1",
            "updated_at": "2018-01-28T14:40:42.000Z",
            "updated_by": "1",
            "published_at": "2013-09-22T14:15:46.000Z",
            "published_by": "1",
            "custom_excerpt": null,
            "codeinjection_head": null,
            "codeinjection_foot": null,
            "og_image": null,
            "og_title": null,
            "og_description": null,
            "twitter_image": null,
            "twitter_title": null,
            "twitter_description": null,
            "custom_template": null,
            "author": "1",
            "primary_tag": null,
            "url": "/about/",
            "comment_id": "5967634499d09e0ee05c3b74"
        }
    ],
    "meta": {
        "pagination": {
            "page": 1,
            "limit": 15,
            "pages": 1,
            "total": 3,
            "next": null,
            "prev": null
        }
    }
}

Getting a tag by its slug

You can specify multiple values in brackets, like an array. Here's a request for two tags - one that's got a description assigned to it, and another that has a feature image.

  • Method: GET
  • Endpoint: https://your-site.com/ghost/api/v0.1/tags?filter=slug:[15-apis-in-15-days,raspberry-pi]
  • Header: Authorization -> Bearer <some-auth-token>
{
    "tags": [
        {
            "id": "5a32a221292eb46fca0fbe38",
            "name": "15 APIs in 15 Days",
            "slug": "15-apis-in-15-days",
            "description": "A personal challenge, to try out and write about 15 random APIs in 15 days.",
            "feature_image": null,
            "visibility": "public",
            "meta_title": null,
            "meta_description": null,
            "created_at": "2017-12-14T16:09:05.000Z",
            "created_by": "1",
            "updated_at": "2018-01-24T13:07:11.000Z",
            "updated_by": "1",
            "parent": null
        },
        {
            "id": "5967634299d09e0ee05c3b1a",
            "name": "raspberry pi",
            "slug": "raspberry-pi",
            "description": "",
            "feature_image": "/content/images/2016/09/joystick-color-wheel-setup-2.jpg",
            "visibility": "public",
            "meta_title": null,
            "meta_description": null,
            "created_at": "2017-03-16T03:34:14.000Z",
            "created_by": "1",
            "updated_at": "2017-03-26T23:49:30.000Z",
            "updated_by": "1",
            "parent": null
        }
    ],
    "meta": {
        "pagination": {
            "page": 1,
            "limit": 15,
            "pages": 1,
            "total": 2,
            "next": null,
            "prev": null
        }
    }
}

Other Resources

Here are some resources I found helpful, and used while writing this post: