# How to Integrate Retool with Ghost

- Tool: Retool
- Difficulty: Beginner
- Time required: 15 minutes
- Last updated: April 2026

## TL;DR

Connect Retool to Ghost by creating a REST API Resource using the Ghost Admin API key (generated in Ghost Admin → Settings → Integrations). Build content management panels that create and update posts, manage tags and members, track newsletter subscription data, and combine Ghost membership metrics with payment information from Stripe or your billing system.

## Build a Ghost Content and Membership Management Panel in Retool

Ghost's native admin interface is excellent for individual writers and editors, but publishing operations teams need views it doesn't provide: all drafts across all authors with estimated publish dates, membership churn analysis combining Ghost member data with Stripe payment history, a bulk post-tagger for reorganizing content taxonomies after a site rebrand, or a subscriber health dashboard tracking which newsletter segments have declining open rates. Retool lets you build these operational views directly against the Ghost Admin API.

Ghost Admin API v3 provides full access to posts, pages, tags, authors, members, tiers (membership plans), and email newsletter analytics. Authentication uses a JWT token derived from the Admin API key — Ghost splits the key into an ID and secret, and you use them to sign a short-lived JWT for each request. For simpler setups, Ghost also supports a static API key passed as a URL parameter for the Content API (read-only public data).

The most powerful Retool use case for Ghost is a membership management dashboard that joins Ghost members with Stripe payment data. Publishers can see each member's subscription status, payment history, last login date, and email engagement score in one view — identifying which members are at risk of churning (paid, but haven't logged in or opened newsletters in 30 days) so the retention team can intervene before renewal.

## Before you start

- A Ghost installation (Ghost Pro or self-hosted Ghost 4.0+) with Admin API access
- A Ghost Admin API key (generated from Ghost Admin → Settings → Integrations → Add custom integration)
- For self-hosted Ghost: your Ghost site URL (the domain where Ghost is running)
- A Retool account with permission to create Resources
- Basic familiarity with Retool's query editor and Table component

## Step-by-step guide

### 1. Generate a Ghost Admin API key and prepare JWT authentication

In Ghost Admin, navigate to Settings (gear icon in the left sidebar). Click Integrations under the Advanced section. Click Add custom integration. Give it a name (e.g., 'Retool Integration') and click Create. Ghost generates an integration entry with two values: Admin API Key and Content API Key.

Click the Admin API Key to copy it. The Admin API key format is: ADMIN_KEY_ID:ADMIN_KEY_SECRET, separated by a colon. Split this value — everything before the colon is the key ID, everything after is the secret. For example, if the key is '64abc:def123456789', the ID is '64abc' and the secret is 'def123456789'.

Ghost Admin API authentication uses JWT tokens. You must generate a JWT signed with the key secret for each API session. The JWT payload must include: { sub: 'user_email_or_id', iat: current_timestamp_seconds, exp: timestamp_5_minutes_from_now, aud: '/v3/admin/' }. Sign with HS256 using the hex-decoded key secret.

For simplicity in Retool, you can generate a long-lived JWT token externally (valid for hours or days) and store it in Retool Configuration Variables. Alternatively, use a Retool JavaScript query to generate a fresh JWT token before each API request using the jose or jsonwebtoken library patterns.

Store in Retool Configuration Variables: GHOST_ADMIN_KEY_ID, GHOST_ADMIN_KEY_SECRET (marked as secret), and GHOST_SITE_URL (e.g., https://yourblog.ghost.io).

```
// JavaScript query to generate Ghost Admin API JWT
// Run this before making API calls, refresh as needed
const keyId = retoolContext.configVars.GHOST_ADMIN_KEY_ID;
const keySecret = retoolContext.configVars.GHOST_ADMIN_KEY_SECRET;

// Convert hex secret to bytes
const secretBytes = new Uint8Array(keySecret.match(/.{1,2}/g).map(b => parseInt(b, 16)));

// Build JWT manually (simplified — use a proper JWT library in production)
const now = Math.floor(Date.now() / 1000);
const header = btoa(JSON.stringify({ alg: 'HS256', kid: keyId, typ: 'JWT' })).replace(/=/g,'').replace(/\+/g,'-').replace(/\//g,'_');
const payload = btoa(JSON.stringify({ iat: now, exp: now + 300, aud: '/v3/admin/' })).replace(/=/g,'').replace(/\+/g,'-').replace(/\//g,'_');

// Note: Full HMAC signing requires crypto API — use a pre-generated token for initial setup
return `${header}.${payload}.SIGNATURE_PLACEHOLDER`;
```

**Expected result:** You have a Ghost Admin API JWT token stored in Retool Configuration Variables. Your Ghost site URL, key ID, and key secret are also stored as configuration variables.

### 2. Create the Ghost Admin API REST Resource in Retool

In Retool, navigate to the Resources tab and click Add Resource. Select REST API from the connector list.

Name: Ghost Admin API

Base URL: {{ retoolContext.configVars.GHOST_SITE_URL }}/ghost/api/v3/admin

For Ghost Pro sites, the URL is typically https://yoursite.ghost.io/ghost/api/v3/admin. For self-hosted Ghost, use your domain: https://yourdomain.com/ghost/api/v3/admin.

Authentication: Select Bearer Token. In the Token field, enter {{ retoolContext.configVars.GHOST_ADMIN_TOKEN }}. This references your pre-generated JWT token.

Add a default header: Accept-Version: v5.0 (optional, but specifies the Ghost API version you're targeting. Ghost Admin API is backwards compatible but specifying the version prevents unexpected behavior from API updates).

Click Create Resource. Test the connection with a GET query to /posts/?limit=5&fields=id,title,status. A successful response returns a JSON object with a posts array. If you get 401, the JWT token is expired or incorrectly formed — regenerate it.

Note: Ghost also has a Content API (read-only, public data) at /ghost/api/content/ authenticated with just a content key as a URL parameter: ?key=YOUR_CONTENT_KEY. For dashboards that only need to read published content without accessing member data, the Content API is simpler to set up.

**Expected result:** The Ghost Admin API Resource is created. GET /posts/?limit=5&fields=id,title,status returns the 5 most recent posts with their IDs, titles, and publication status.

### 3. Query posts and pages with filtering and pagination

Create a getPosts query: GET /posts/ with the following URL parameters:
- limit: 25 (Ghost uses 'limit' not 'per_page')
- page: {{ Math.floor(table_posts.pagination.offset / 25) + 1 }}
- filter: {{ buildFilter.data }} (Ghost uses a structured filter syntax)
- fields: id,title,slug,status,published_at,updated_at,featured,excerpt,tags,authors,primary_author
- include: tags,authors (embeds tag and author objects in the response)
- order: published_at desc

Ghost's filter parameter supports a powerful query syntax. For example:
- status:published — only published posts
- author:john-doe — posts by a specific author slug
- tag:newsletter — posts with a specific tag
- published_at:>2024-01-01 — posts published after a date
- status:[draft,scheduled] — multiple status values

Combine with + for AND: status:published+featured:true for published featured posts.

Bind filter options to form components: a Status Select with options [All, Draft, Published, Scheduled], an Author Select (populated from GET /authors/), and a Tag Select (populated from GET /tags/). Build the filter string dynamically in a JavaScript query based on selected filter values.

```
// Transformer for Ghost posts
const posts = data?.posts || [];
return posts.map(post => ({
  id: post.id,
  title: post.title || '(No Title)',
  slug: post.slug,
  status: post.status,
  author: post.primary_author?.name || post.authors?.[0]?.name || 'Unknown',
  tags: (post.tags || []).map(t => t.name).join(', '),
  published_at: post.published_at ? new Date(post.published_at).toLocaleDateString() : 'Unpublished',
  updated_at: post.updated_at ? new Date(post.updated_at).toLocaleDateString() : '',
  featured: post.featured ? 'Yes' : 'No',
  excerpt: (post.excerpt || '').substring(0, 150),
  edit_url: `${retoolContext.configVars.GHOST_SITE_URL}/ghost/#/editor/post/${post.id}`
}));
```

**Expected result:** The posts Table shows all Ghost posts with author, tags, status, and publish date. Filters by status and author work correctly. Clicking a post shows the edit link in Ghost.

### 4. Fetch members and build the membership management panel

Create a getMembers query: GET /members/ with parameters:
- limit: 25
- page: {{ Math.floor(table_members.pagination.offset / 25) + 1 }}
- filter: {{ select_tierFilter.value === 'paid' ? 'status:paid' : select_tierFilter.value === 'free' ? 'status:free' : '' }}
- include: tiers,newsletters
- order: created_at desc
- search: {{ input_memberSearch.value || '' }}

Ghost members include: id, name, email, status (free, paid, comped), created_at, last_seen_at (last login), tiers (subscription plan objects with name and billing_period), newsletters (subscribed newsletter lists), email_count, email_opened_count, and email_open_rate.

The email_open_rate field is Ghost's computed engagement metric — the ratio of opened emails to total sent, expressed as a percentage. Use this to identify disengaged members.

Create a getMemberDetail query: GET /members/{{ table_members.selectedRow.data.id }} to fetch a single member's full profile when clicking a table row.

For membership management actions, create updateMember: PUT /members/{{ selected_member_id }} with body containing fields to update. Common updates: changing tiers, updating newsletter subscriptions, or adding a note. Create deleteMember for removing members (use cautiously — deletion is permanent).

```
// Transformer for Ghost members
const members = data?.members || [];
const today = new Date();

return members.map(member => {
  const lastSeen = member.last_seen_at ? new Date(member.last_seen_at) : null;
  const daysSinceLogin = lastSeen ? Math.floor((today - lastSeen) / 86400000) : null;
  const isPaid = member.status === 'paid';
  const isAtRisk = isPaid && (daysSinceLogin > 30 || member.email_open_rate < 10);

  return {
    id: member.id,
    name: member.name || 'Anonymous',
    email: member.email,
    status: member.status,
    tier: member.tiers?.[0]?.name || 'Free',
    billing_period: member.tiers?.[0]?.billing_period || '',
    created_at: new Date(member.created_at).toLocaleDateString(),
    last_seen: lastSeen ? lastSeen.toLocaleDateString() : 'Never',
    days_since_login: daysSinceLogin,
    open_rate: `${Math.round(member.email_open_rate || 0)}%`,
    at_risk: isAtRisk,
    newsletter_count: (member.newsletters || []).length
  };
});
```

**Expected result:** The members Table shows all Ghost members with subscription tier, last login date, and email open rate. At-risk paid members (low open rate or long absence) are highlighted in the Table.

### 5. Build newsletter analytics and post creation workflows

Ghost tracks email performance per newsletter send. Create a getNewsletters query: GET /newsletters/ to fetch all newsletter channels. Each newsletter has: id, name, subscriber_count, open_rate, and feedback_enabled.

For post email analytics, fetch individual post email stats: GET /posts/{{ select_post.value }}?include=email. The email object on a post contains: status (sent, failed), recipients (total sent count), opened_count, open_rate, clicked_count, click_rate, and feedback (thumbs up/down counts if feedback is enabled).

For post creation from Retool, create a createPost query: POST /posts/ with body:
{ "posts": [{ "title": "{{ input_title.value }}", "lexical": null, "status": "draft", "tags": [{ "name": "{{ input_tag.value }}" }], "authors": [{ "id": "{{ select_author.value }}" }], "excerpt": "{{ input_excerpt.value }}" }] }

Note: Ghost posts require either mobiledoc or lexical content format. Creating with null lexical creates an empty post that editors can then open in Ghost's editor via the edit_url.

For bulk operations like adding a tag to multiple posts, create a JavaScript query that loops over selected Table rows and patches each post using PUT /posts/{id}. Add a version timestamp in each update payload: "updated_at": post.updated_at (Ghost uses optimistic locking and requires the current updated_at to prevent overwriting concurrent edits).

For publishers managing large Ghost sites combining membership analytics, newsletter performance, and cross-team content workflows, RapidDev's team can help architect the complete Retool management solution.

```
// createPost request body
{
  "posts": [{
    "title": "{{ input_postTitle.value }}",
    "status": "draft",
    "excerpt": "{{ input_excerpt.value }}",
    "featured": {{ toggle_featured.value || false }},
    "tags": {{ JSON.stringify(
      (input_tags.value || '').split(',').map(t => ({ name: t.trim() })).filter(t => t.name)
    ) }},
    "authors": [{ "id": "{{ select_author.value }}" }],
    "custom_excerpt": "{{ input_excerpt.value }}"
  }]
}
```

**Expected result:** Newsletter analytics show per-newsletter open rates and subscriber counts. Post creation form creates draft posts in Ghost that editors can open and complete in Ghost's editor.

## Best practices

- Implement automated Ghost Admin API JWT token refresh in a Retool Workflow — tokens are short-lived (5 minutes) and manual refresh is impractical for an active dashboard. Refresh every 4 minutes on a schedule.
- Store Ghost Admin API key ID and secret separately as Retool Configuration Variables — the full key string format (id:secret) should be split so the secret can be marked as a secret config var and excluded from the frontend context.
- Always use the include parameter (include: tags,authors for posts; include: tiers,newsletters for members) to embed related objects in a single request rather than making separate lookup calls.
- For POST/PUT operations on Ghost posts, always fetch the current post state first and include its updated_at timestamp in the update body — Ghost's optimistic locking rejects updates that omit this field.
- Use Ghost's filter syntax for server-side filtering rather than fetching all records and filtering client-side — Ghost's API is not paginated across the full dataset and filtering server-side ensures you see results from the full content library.
- For membership tier filtering, use Ghost's status field (free, paid, comped) in the filter parameter rather than filtering on tier names, which are variable per publication.
- Cache the GET /tags/ and GET /authors/ queries for 10 minutes — these reference datasets change rarely and are often used to populate dropdown filters, making caching very effective.
- When building bulk post operations (bulk tagging, bulk status update), include a confirmation Modal before executing — bulk writes to Ghost cannot be undone easily and may affect published content visible to readers.

## Use cases

### Build a Ghost content management and editorial dashboard

Create a Retool panel showing all Ghost posts (drafts, scheduled, and published) across all authors. Editors can filter by author, tag, and status, bulk-add tags to multiple posts, and update post metadata like excerpt and feature image URL — all faster than navigating Ghost's individual post editor.

Prompt example:

```
Build a Retool Ghost content management panel. Show all posts with: title, author, status (draft/published/scheduled), publish date, tags, and featured image thumbnail. Add filters by author and status. Select multiple posts and add a tag to all of them with a Bulk Tag button. When a post is selected, show its excerpt and a link to edit it in Ghost. Include a summary row showing post count by status.
```

### Build a membership retention and churn analysis dashboard

Build a Retool panel combining Ghost member data with engagement metrics to identify at-risk subscribers. Show members who are paid (Stripe billing active) but have not opened any newsletter emails in the past 30 days, have not logged into Ghost in 60 days, or whose free trial is ending in the next 7 days. Retention teams can reach out proactively.

Prompt example:

```
Build a Retool membership retention dashboard. Fetch Ghost members with their: email, tier (free/paid), subscription status, last login date, open rate, and created date. Filter to: active paid members who haven't opened an email in 30+ days. Sort by days since last open descending. Add a Send Re-engagement Email button for selected members. Show total at-risk member count and MRR at risk.
```

### Build a newsletter subscriber growth and engagement report

Create a Retool tool showing Ghost newsletter subscriber growth over time, segmented by tier (free vs. paid) and acquisition source. Include engagement metrics: average open rate, click rate, and churn rate. Marketing teams can track which acquisition channels produce the most engaged subscribers.

Prompt example:

```
Build a Retool Ghost newsletter analytics panel. Show monthly subscriber counts for the last 12 months broken down by free vs. paid. Add summary stats: total members, paid conversion rate (paid/total %), average open rate, and monthly churn rate. Include a Chart with two series: new subscribers and churned subscribers by month to show net growth trend.
```

## Troubleshooting

### 401 Unauthorized on all Ghost Admin API requests

Cause: The Ghost Admin API JWT token has expired (tokens are short-lived, typically 5 minutes) or was not formatted correctly. Ghost's Admin API requires a properly signed JWT — not just the API key string itself.

Solution: Regenerate the JWT token. For manual setup, use jwt.io with: algorithm HS256, header { alg: 'HS256', kid: 'YOUR_KEY_ID', typ: 'JWT' }, payload { iat: current_unix_timestamp, exp: current + 300, aud: '/v3/admin/' }, and your hex-decoded key secret as the signature secret. Store the fresh token in GHOST_ADMIN_TOKEN configuration variable. For production, implement automated token refresh in a Retool Workflow.

```
// Ghost JWT payload structure:
// {
//   "iat": Math.floor(Date.now() / 1000),
//   "exp": Math.floor(Date.now() / 1000) + 300,
//   "aud": "/v3/admin/"
// }
// Sign with HS256, key = hex.decode(GHOST_ADMIN_KEY_SECRET)
// Header: { alg: 'HS256', kid: 'GHOST_ADMIN_KEY_ID', typ: 'JWT' }
```

### PUT /posts/{id} returns 409 Conflict when updating a post

Cause: Ghost uses optimistic locking for posts. The PUT request must include the current updated_at timestamp of the post. If this field is missing or outdated (another edit happened since you fetched the post), Ghost rejects the update.

Solution: Always fetch the post immediately before updating it with GET /posts/{id}. Include the updated_at field from that fresh response in your PUT request body: { 'posts': [{ 'title': '...', 'updated_at': '2024-01-01T00:00:00.000Z' }] }. Build your update flow so the fetch-then-update pattern is atomic in your Retool query chain.

```
// PUT /posts/{id} body must include current updated_at:
{
  "posts": [{
    "title": "{{ input_title.value }}",
    "updated_at": "{{ getPostDetail.data.posts[0].updated_at }}"
  }]
}
```

### Members API returns 404 or empty results on Ghost Pro (hosted) instances

Cause: Ghost Pro's Admin API URL structure may differ from self-hosted Ghost. Also, Ghost Pro may require a specific API version in the URL path.

Solution: For Ghost Pro, verify your site URL is the Ghost.io subdomain: https://yoursite.ghost.io. The Admin API path for Ghost Pro is /ghost/api/admin/ (without version in path, using Accept-Version header instead). Try adding Accept-Version: v5.0 as a request header and updating the base URL to https://yoursite.ghost.io/ghost/api/admin.

### Ghost filter query with multiple conditions returns no results

Cause: Ghost filter syntax uses + for AND and , for OR — not standard URL query operators. Incorrect syntax silently returns empty results instead of an error.

Solution: Verify Ghost filter syntax: use + for AND (status:published+featured:true), use , for OR (status:[draft,scheduled]), and use - for NOT (status:-draft). Encode + as %2B in URL parameters to prevent it being interpreted as a space. Test filters incrementally — start with one condition and add more until you find which one breaks the filter.

```
// Ghost filter syntax examples:
// AND: status:published+tag:newsletter
// OR: status:[draft,scheduled]
// NOT: featured:-false
// Date: published_at:>2024-01-01
// Encoded in URL: ?filter=status:published%2Bfeatured:true
```

## Frequently asked questions

### Does Retool have a native Ghost connector?

No, Retool does not have a dedicated native connector for Ghost. You connect via a REST API Resource using Ghost's Admin API JWT authentication. The setup takes about 15 minutes for initial configuration, plus additional time to implement automated JWT token refresh. Once configured, you have access to all Ghost Admin API endpoints including posts, pages, members, tiers, tags, and newsletter analytics.

### What is the difference between Ghost's Admin API and Content API?

The Content API (public, read-only) provides access to published posts, pages, tags, and authors using a simple API key in a URL parameter — no JWT required. The Admin API (authenticated) provides full read/write access including drafts, unpublished content, member data, and all management operations. Use the Content API for public-facing Retool tools that only need to display published content. Use the Admin API for management panels that need to create, update, or access member data.

### Can I create posts in Ghost from Retool without the full Lexical editor content?

Yes. Creating a post via POST /posts/ with only title, status: 'draft', and basic metadata fields creates an empty draft post that editors can then open in Ghost's native editor to write the content. This is useful for editorial workflows where Retool handles metadata management (tags, authors, scheduling) and Ghost handles the actual writing experience.

### How do I access Ghost member email addresses from the API?

Ghost member email addresses are returned by the Admin API GET /members/ endpoint without any additional permissions — they are included by default in the member response object. Member data including email is only accessible via the Admin API (not the public Content API), so this requires JWT authentication with an Admin API key that has the appropriate permissions.

### Can Retool trigger Ghost email newsletter sends?

Yes, indirectly. Ghost sends newsletters automatically when a post is published with its newsletter setting enabled. To trigger a newsletter send from Retool, update a post's status from 'draft' to 'published' using PUT /posts/{id} and include 'newsletter': { 'id': 'your_newsletter_id' } in the update body. This publishes the post and queues the newsletter email. Preview email sends (to admins only) can be triggered via POST /posts/{id}/email/send-test.

---

Source: https://www.rapidevelopers.com/retool-integrations/ghost
© RapidDev — https://www.rapidevelopers.com/retool-integrations/ghost
