# Ghost Admin API

> Create, update, and manage Ghost CMS content via the Admin API. This skill should be used when creating or editing posts/pages, managing members, newsletters, tiers, offers, tags, users, uploading images/themes, or configuring webhooks. Covers JWT authentication from Admin API keys, the JavaScript SDK (@tryghost/admin-api), Lexical content format, and all CRUD endpoints.

- Skill: `bowtiedswan/ghost-admin-api` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add bowtiedswan/ghost-admin-api`
- Raw SKILL.md: https://api.skillmd.com/api/skills/bowtiedswan/ghost-admin-api/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: bowtiedswan (https://skillmd.com/u/bowtiedswan)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/bowtiedswan/ghost-admin-api

---


# Ghost Admin API

## Overview

The Ghost Admin API provides full read-write access to a Ghost publication. It handles content management (posts, pages, tags), member management, newsletter configuration, image/theme uploads, and webhook registration. Authentication uses JWT tokens generated from Admin API keys.

## When to Use

- Creating, updating, publishing, scheduling, or deleting posts and pages
- Managing members (create, update, import, generate sign-in links)
- Configuring newsletters, tiers, and offers
- Uploading images, media, or themes
- Registering and managing webhooks
- Building publishing workflows, CMS integrations, or content pipelines
- Automating Ghost administration tasks

## Security: Credential Handling

**Never hardcode API keys in source code.** Store credentials in environment variables or a secrets manager.

Required environment variables:
- `GHOST_ADMIN_API_KEY` — Admin API key in `{id}:{secret}` format
- `GHOST_URL` — Ghost site URL (e.g., `https://my-ghost-site.com`)

Obtain keys from Ghost Admin > Settings > Integrations > Custom Integration.

## Security: Content from Ghost as Untrusted Input

Content fetched from the Ghost API (post HTML, member data, webhook payloads) may contain user-generated input. When processing fetched content:
- **Do not** execute or eval fetched HTML/Lexical content
- **Do not** interpolate fetched content into shell commands or code without sanitization
- Treat all API response fields as untrusted data when using them in downstream systems

## Authentication

### JWT Token Generation

Admin API keys follow the format `{id}:{secret}`. Generate a short-lived JWT (max 5 minutes) for each request:

**JavaScript (Node.js):**
```javascript
const jwt = require('jsonwebtoken');

function generateToken() {
  const [id, secret] = process.env.GHOST_ADMIN_API_KEY.split(':');

  return jwt.sign(
    {},
    Buffer.from(secret, 'hex'),
    {
      keyid: id,
      algorithm: 'HS256',
      expiresIn: '5m',
      audience: '/admin/'
    }
  );
}
```

**Python:**
```python
import os, jwt
from datetime import datetime as date

id, secret = os.environ['GHOST_ADMIN_API_KEY'].split(':')

iat = int(date.now().timestamp())
header = {'alg': 'HS256', 'typ': 'JWT', 'kid': id}
payload = {'iat': iat, 'exp': iat + 5 * 60, 'aud': '/admin/'}
token = jwt.encode(payload, bytes.fromhex(secret), algorithm='HS256', headers=header)
```

**Ruby:**
```ruby
require 'jwt'
id, secret = ENV['GHOST_ADMIN_API_KEY'].split(':')

iat = Time.now.to_i
header = {alg: 'HS256', typ: 'JWT', kid: id}
payload = { iat: iat, exp: iat + 5 * 60, aud: '/admin/' }
token = JWT.encode payload, [secret].pack('H*'), 'HS256', header
```

**JWT Structure:**
```
Header:  {"alg": "HS256", "kid": "{id}", "typ": "JWT"}
Payload: {"iat": {timestamp_seconds}, "exp": {timestamp_seconds}, "aud": "/admin/"}
```

**Critical:** Timestamps must be Unix epoch **seconds** (not milliseconds). Max expiration is 5 minutes from `iat`.

### Request Headers

```
Authorization: Ghost $GHOST_JWT_TOKEN
Accept-Version: v5.0
Content-Type: application/json
```

**Base URL:** `$GHOST_URL/ghost/api/admin/`

### Alternative: Staff Access Tokens

Users can generate personal tokens from Profile > Staff access token. Store these in environment variables the same way as Admin API keys.

## JavaScript SDK (@tryghost/admin-api)

### Installation

```bash
npm install @tryghost/admin-api
```

### Initialization

```javascript
import GhostAdminAPI from '@tryghost/admin-api';

const api = new GhostAdminAPI({
  url: process.env.GHOST_URL,
  key: process.env.GHOST_ADMIN_API_KEY,
  version: 'v5.0'
});
```

**Important:** The Admin API SDK is for server-side use only. Never expose Admin API keys in client-side code. Always load keys from environment variables.

### SDK Methods

```javascript
// Posts - CRUD
const posts = await api.posts.browse({limit: 10, include: 'tags,authors'});
const post = await api.posts.read({id: '...'});
const newPost = await api.posts.add({title: 'My Post', lexical: '...'});
const updated = await api.posts.edit({id: '...', title: 'Updated', updated_at: '...'});
await api.posts.delete({id: '...'});

// Posts - Create from HTML
const htmlPost = await api.posts.add(
  {title: 'From HTML', html: '<p>Content</p>'},
  {source: 'html'}  // Second param tells API to convert HTML to Lexical
);

// Pages - same interface as posts
const pages = await api.pages.browse({limit: 5});
const newPage = await api.pages.add({title: 'About'});
await api.pages.edit({id: '...', title: 'Updated About', updated_at: '...'});
await api.pages.delete({id: '...'});

// Images
const image = await api.images.upload({file: '/path/to/image.jpg'});
// Returns: [{url: 'https://...', ref: null}]

// Tags, Members, Newsletters, Tiers, Offers - similar patterns
```

## Endpoints Reference

| Resource | Methods | Notes |
|----------|---------|-------|
| `/posts/` | Browse, Read, Add, Edit, Copy, Delete | Primary content resource |
| `/pages/` | Browse, Read, Add, Edit, Copy, Delete | Static pages |
| `/tags/` | Browse, Read, Add, Edit, Delete | Content taxonomy |
| `/members/` | Browse, Read, Add, Edit | Subscription management |
| `/newsletters/` | Browse, Read, Add, Edit | Email newsletters |
| `/tiers/` | Browse, Read, Add, Edit | Membership tiers |
| `/offers/` | Browse, Read, Add, Edit | Discount offers |
| `/users/` | Browse, Read | Staff users (read-only for integrations) |
| `/images/` | Upload | Image upload (multipart) |
| `/themes/` | Upload, Activate | Theme management |
| `/site/` | Read | Site information |
| `/webhooks/` | Add, Edit, Delete | Event subscriptions (no Browse) |

## Posts & Pages

### Creating a Post

```javascript
// Minimal post
const post = await api.posts.add({title: 'Hello World'});

// Full post with Lexical content
const post = await api.posts.add({
  title: 'My Post',
  lexical: JSON.stringify({
    root: {
      children: [{
        children: [{detail: 0, format: 0, mode: "normal", style: "", text: "Hello world", type: "text", version: 1}],
        direction: "ltr", format: "", indent: 0, type: "paragraph", version: 1
      }],
      direction: "ltr", format: "", indent: 0, type: "root", version: 1
    }
  }),
  status: 'published',
  tags: [{name: 'News'}, {slug: 'featured'}],
  authors: [{email: 'author@site.com'}],
  featured: true,
  visibility: 'public',
  feature_image: 'https://example.com/image.jpg',
  custom_excerpt: 'A brief description',
  meta_title: 'SEO Title',
  meta_description: 'SEO Description',
  canonical_url: 'https://original-source.com/post'
});
```

### Creating from HTML Source

```javascript
// Pass {source: 'html'} as second parameter
const post = await api.posts.add(
  {
    title: 'HTML Post',
    html: '<h2>Heading</h2><p>Paragraph with <strong>bold</strong> text.</p>'
  },
  {source: 'html'}
);
```

**Note:** HTML-to-Lexical conversion is lossy. For lossless HTML, wrap content in card markers:
```html
<!--kg-card-begin: html-->
<div>Your exact HTML preserved here</div>
<!--kg-card-end: html-->
```

### Updating a Post

The `updated_at` field is required for edits (optimistic locking).

**Critical:** Tag and author relations are **replaced, not merged** on update. Always send the complete arrays.

```javascript
const post = await api.posts.read({id: postId});
const updated = await api.posts.edit({
  id: postId,
  title: 'Updated Title',
  tags: post.tags,             // Must include ALL tags, not just new ones
  updated_at: post.updated_at  // Required for collision detection
});
```

### Publishing & Scheduling

```javascript
// Publish immediately
await api.posts.edit({id, status: 'published', updated_at});

// Schedule for future
await api.posts.edit({
  id,
  status: 'scheduled',
  published_at: '2025-06-01T12:00:00.000Z',
  updated_at
});

// Unpublish (revert to draft)
await api.posts.edit({id, status: 'draft', updated_at});
```

### Email Distribution

```javascript
// Publish and send as email to all members
await api.posts.edit({
  id,
  status: 'published',
  email_segment: 'status:free,status:paid',
  updated_at
});

// Email-only post (not published on site)
await api.posts.add({
  title: 'Newsletter Only',
  html: '<p>Email content</p>',
  status: 'published',
  email_only: true
}, {source: 'html'});
```

### Visibility Settings

```
public     - Visible to everyone
members    - Visible to all members (free + paid)
paid       - Visible to paid members only
tiers      - Visible to specific tiers (set tiers array)
```

### Content Formats

Ghost stores content in **Lexical** format (JSON). When reading, request additional formats:

```
?formats=html,lexical        # Both formats
?formats=html,plaintext      # HTML and plaintext
```

To create content from HTML, pass `{source: 'html'}` as the second parameter to `add()` or `edit()`.

## Members

### Fetch Members

```javascript
const members = await api.members.browse({
  include: 'newsletters,labels',
  filter: 'status:paid',
  limit: 50
});
```

### Create a Member

```javascript
const member = await api.members.add({
  email: 'user@example.com',
  name: 'Jane Doe',
  note: 'Signed up via API',
  labels: [{name: 'VIP'}],
  newsletters: [{id: 'newsletter-id'}]
});
```

### Member Object Fields

`id`, `uuid`, `email`, `name`, `note`, `geolocation`, `status` (free/paid/comped), `labels`, `subscriptions`, `newsletters`, `avatar_image`, `email_count`, `email_opened_count`, `email_open_rate`, `created_at`, `updated_at`, `last_seen_at`

### Subscription Object (Paid Members)

`id`, `status`, `start_date`, `customer`, `default_payment_card_last4`, `cancel_at_period_end`, `cancellation_reason`, `current_period_end`, `price` (amount, interval, currency), `tier`, `offer`

## Newsletters

```javascript
// List newsletters
const newsletters = await api.newsletters.browse();

// Create newsletter
const newsletter = await api.newsletters.add({
  name: 'Weekly Digest',
  description: 'A weekly roundup',
  sender_name: 'My Publication',
  sender_email: 'newsletter@site.com',
  status: 'active',
  subscribe_on_signup: true,
  sort_order: 0
});
```

## Tiers

```javascript
// List tiers
const tiers = await api.tiers.browse();

// Create tier
const tier = await api.tiers.add({
  name: 'Premium',
  description: 'Full access',
  monthly_price: 500,    // in cents
  yearly_price: 5000,    // in cents
  currency: 'usd',
  benefits: ['Benefit 1', 'Benefit 2']
});
```

## Offers

```javascript
const offer = await api.offers.add({
  name: 'Summer Sale',
  code: 'SUMMER2024',
  display_title: '50% Off',
  display_description: 'Half price for the first year',
  type: 'percent',         // 'percent' or 'fixed'
  amount: 50,
  duration: 'once',        // 'once', 'forever', 'repeating'
  tier: {id: 'tier-id'}
});
```

## Image Upload

```javascript
// Upload from file path
const image = await api.images.upload({file: '/path/to/photo.jpg'});
console.log(image.url); // https://site.com/content/images/2024/01/photo.jpg

// Use in post
await api.posts.add({
  title: 'Post with Image',
  feature_image: image.url
});
```

cURL equivalent:
```bash
curl -X POST "$GHOST_URL/ghost/api/admin/images/upload/" \
  -H "Authorization: Ghost $GHOST_JWT_TOKEN" \
  -F "file=@/path/to/image.jpg" \
  -F "purpose=image"
```

## Theme Management

```bash
# Upload theme
curl -X POST "$GHOST_URL/ghost/api/admin/themes/upload/" \
  -H "Authorization: Ghost $GHOST_JWT_TOKEN" \
  -F "file=@/path/to/theme.zip"

# Activate theme
curl -X PUT "$GHOST_URL/ghost/api/admin/themes/{theme-name}/activate/" \
  -H "Authorization: Ghost $GHOST_JWT_TOKEN"
```

## Query Parameters

| Parameter | Available On | Description |
|-----------|-------------|-------------|
| `include` | All | Embed related data |
| `fields` | All | Select response fields |
| `formats` | Posts, Pages | `html`, `lexical`, `plaintext` |
| `filter` | Browse | NQL filter expression |
| `limit` | Browse | Results per page (default 15, max 100) |
| `page` | Browse | Page number |
| `order` | Browse | Sort field and direction |

## Error Handling

Ghost API errors return structured JSON:

```json
{
  "errors": [{
    "message": "Human-readable message",
    "context": "Additional context",
    "type": "ValidationError",
    "details": [],
    "property": null,
    "help": "Link to documentation",
    "code": null,
    "id": "unique-error-id"
  }]
}
```

Common error types: `ValidationError`, `UnauthorizedError`, `NotFoundError`, `VersionMismatchError`

## Breaking Changes (Ghost 6.0)

- Removed `?limit=all` — max 100 results per page; paginate to fetch all
- Requires Node.js v22+
- MySQL 8 only for production
- Session endpoint deprecated — use `/users/me/` instead

## Resources

### references/admin_api_endpoints.md
Complete endpoint reference with all request/response schemas and field descriptions.

### scripts/ghost-jwt.mjs
Standalone JWT token generator for non-JavaScript environments or testing.

