Skip to main content
RapidDev - Software Development Agency
bubble-integrationsBubble API Connector

Ghost

Ghost connects to Bubble via two separate API Connector groups: a Content API group (your Content API key as a shared URL parameter — no Private flag needed, it is intentionally public) for reading published posts and tags, and an Admin API group (JWT Bearer token in a Private header) for member data and post creation. The critical challenge is that Admin API JWTs expire every 5 minutes and must be refreshed by a scheduled Backend Workflow — a paid Bubble plan feature.

What you'll learn

  • How to create a Ghost custom integration and obtain both the Content API key and the Admin API key
  • Why the Content API key goes in a URL parameter (not a Private header) and the Admin API key requires a JWT Bearer token
  • How to build two separate Bubble API Connector groups for the two Ghost APIs
  • How to implement the JWT refresh Backend Workflow that keeps the Admin API token alive past the 5-minute expiry
  • How to use Ghost's filter syntax (status:published, tag:newsletter) in Bubble URL parameters
  • How to display Ghost posts in a Bubble Repeating Group and link through to full post content
  • How to query Ghost member data (emails, subscription tiers) via the Admin API
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate22 min read4–6 hoursMedia & ContentLast updated July 2026RapidDev Engineering Team
TL;DR

Ghost connects to Bubble via two separate API Connector groups: a Content API group (your Content API key as a shared URL parameter — no Private flag needed, it is intentionally public) for reading published posts and tags, and an Admin API group (JWT Bearer token in a Private header) for member data and post creation. The critical challenge is that Admin API JWTs expire every 5 minutes and must be refreshed by a scheduled Backend Workflow — a paid Bubble plan feature.

Quick facts about this guide
FactValue
ToolGhost
CategoryMedia & Content
MethodBubble API Connector
DifficultyIntermediate
Time required4–6 hours
Last updatedJuly 2026

Ghost + Bubble: the dual-API pattern you need to understand first

Ghost's developer experience is deliberately split across two APIs, and understanding this split before touching Bubble's API Connector saves hours of confusion.

The **Content API** (`/ghost/api/content/`) is designed to be called from anywhere — including directly from a browser. It is authenticated with a simple `?key=YOUR_CONTENT_KEY` query parameter, not an Authorization header. This key is intentionally not secret: Ghost's documentation explicitly says it is safe to embed in front-end code. In Bubble, this means you add the key as a Shared URL Parameter on the Content API Connector group — no Private checkbox required. The Content API gives you: published posts (with title, HTML body, excerpt, feature image, tags, authors, published_at), pages, tags, and author profiles. What it does NOT give you: member email addresses, subscription tiers, newsletter analytics, or draft posts.

The **Admin API** (`/ghost/api/admin/`) is the privileged counterpart. It exposes member data, allows post creation and updates, and provides newsletter statistics. It uses JWT authentication: the Admin API key comes in `ID:SECRET` format, and you must split this at the colon, hex-decode the SECRET, and use it to sign a HS256 JWT with a 5-minute expiry. Bubble cannot sign JWTs natively — you build a Backend Workflow that constructs and stores the JWT, then refreshes it on a schedule every 4 minutes.

For most Bubble apps that surface a Ghost blog — showing the latest posts, linking to full articles, displaying author bios — the Content API group alone is sufficient and works on Bubble's Free plan. Add the Admin API group only when you need member management or content writing.

A secondary Ghost-specific gotcha for Bubble: the API base path changed between versions. Ghost 4.x used `/ghost/api/v3/admin/`; Ghost 5.x uses `/ghost/api/admin/` with no version number. Check your Ghost version in Ghost Admin → Settings → About before setting your base URL, or you will get 404 errors that look like auth failures.

Integration method

Bubble API Connector

Two API Connector groups: Content API (key as shared URL parameter) for reading posts, and Admin API (Private JWT Bearer header) for member data and writes.

Prerequisites

  • A Ghost publication — either Ghost Pro (ghost.org/pricing) or a self-hosted Ghost 5.x instance
  • Ghost Admin access to create a custom integration (Admin → Settings → Integrations → Add custom integration)
  • Both the Content API Key and Admin API Key copied from the custom integration settings page
  • The API Connector plugin installed in your Bubble app (Plugins tab → Add plugins → search 'API Connector' by Bubble)
  • A Bubble paid plan (Starter or higher) if you need the scheduled JWT refresh Backend Workflow for Admin API use in production
  • Your Ghost version confirmed in Ghost Admin → Settings → About (determines the correct Admin API base path: /ghost/api/admin/ for Ghost 5.x)

Step-by-step guide

1

Create a Ghost custom integration and copy both API keys

Log into your Ghost Admin panel at `https://yourblog.ghost.io/ghost` (replace with your actual Ghost URL). In the left sidebar, click **Settings** → scroll down to the **Integrations** section and click it. At the bottom of the Integrations page, click **Add custom integration**. Give it a descriptive name — for example, 'Bubble Integration' — and click **Create**. Ghost creates the integration and displays two keys: - **Content API Key**: a long alphanumeric string. This is intentionally public — Ghost allows it in browser requests. Copy and save it. - **Admin API Key**: displayed in the format `ID:SECRET` (for example, `64a5b3c9e2f1a72a91b4c8d5:3f7a9e1b2c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f`). Copy the entire string including the colon. This key is sensitive and must stay server-side. Also note your Ghost base URL. For Ghost Pro it is `https://yourblog.ghost.io`. For self-hosted Ghost, it is your custom domain. Finally, confirm your Ghost version: Ghost Admin → Settings → scroll to the bottom → click **About**. Ghost 5.x (current) uses Admin API path `/ghost/api/admin/`. Ghost 4.x uses `/ghost/api/v3/admin/`. Using the wrong path results in 404 errors during the Initialize step.

Pro tip: The Content API Key and Admin API Key serve completely different APIs and cannot be swapped. Do not attempt to use the Content API Key on Admin endpoints or vice versa — the error messages Ghost returns do not always make this clear.

Expected result: You have both API keys saved securely. You know your Ghost base URL and have confirmed whether you are on Ghost 4.x or 5.x.

2

Add the Content API Connector group for reading published posts

In your Bubble editor, go to the **Plugins tab** (the puzzle piece icon in the left sidebar). Click **Add plugins**, search for **API Connector**, and install the plugin made by Bubble — it is the first result and is free. Once installed, click **API Connector** in your Plugins list to open its configuration panel. Click **Add another API** and name it `Ghost Content API`. Set the **Authentication** dropdown to **No authentication** (the Content API uses URL parameters, not headers). Under **Shared parameters**, click **Add a shared parameter**: - Parameter name: `key` - Value: paste your Content API Key - Leave the **Private** checkbox unchecked — the Content API key is intentionally public and Ghost's documentation states it is safe to embed Now click **Add a call** to create your first API call: - **Name**: `Get Posts` - **Use as**: **Data** (this call returns a list of items to display in a Repeating Group) - **Method**: GET - **URL**: `https://yourblog.ghost.io/ghost/api/content/posts/` (replace with your actual Ghost URL) - Under **URL parameters**, add: - `include` = `tags,authors` (Ghost does not include related data by default) - `limit` = `10` (or `all` for small blogs) - `filter` = leave blank for now (you will make this dynamic in Step 6) Your API Connector configuration for this call looks like: ```json { "method": "GET", "url": "https://yourblog.ghost.io/ghost/api/content/posts/", "shared_params": { "key": "<your_content_api_key>" }, "call_params": { "include": "tags,authors", "limit": "10" } } ``` Click **Initialize call**. Ghost returns a JSON object with a `posts` array. Bubble detects all fields: `id`, `title`, `slug`, `html`, `feature_image`, `excerpt`, `published_at`, `tags`, `authors`, and more. Set the **Data path** field to `posts` so that the call returns a list of post objects directly (not the wrapper object with the `posts` key). Click **Save** on the call, then **Save** on the API group.

ghost-content-api-connector.json
1{
2 "method": "GET",
3 "url": "https://yourblog.ghost.io/ghost/api/content/posts/",
4 "shared_params": {
5 "key": "YOUR_CONTENT_API_KEY"
6 },
7 "call_params": {
8 "include": "tags,authors",
9 "limit": "10",
10 "page": "<dynamic:page_number>",
11 "filter": "<dynamic:filter_string>"
12 }
13}

Pro tip: After clicking Initialize call, if Ghost returns a 404, double-check your base URL format. The correct path for Ghost 5.x ends with /ghost/api/content/posts/ — including the trailing slash. Missing the trailing slash can cause 404 on some Ghost installations.

Expected result: The Initialize call succeeds. Bubble auto-detects the posts array fields including title, slug, feature_image, excerpt, published_at, html, and the nested tags and authors. The Data path is set to 'posts'.

3

Add the Admin API Connector group and configure the Private JWT header

The Admin API uses a different authentication mechanism: a short-lived JWT token that you generate from the Admin API key and inject as an `Authorization: Ghost [JWT]` header. In the API Connector panel, click **Add another API** to create a second group named `Ghost Admin API`. Set **Authentication** to **No authentication** (you will handle auth via a custom header). Under **Shared headers**, click **Add a shared header**: - Name: `Authorization` - Value: `Ghost ` followed by the JWT token value — but since the token is dynamic (stored in your database), you will bind it dynamically. For now, enter a placeholder like `Ghost placeholder_jwt_here` - **Tick the Private checkbox** — this marks the header as server-side only, ensuring the JWT never appears in the browser's network tab Also add: `Content-Type` = `application/json` (needed for POST/PATCH calls). Now add your first Admin API call: - **Name**: `Get Members` - **Use as**: **Data** - **Method**: GET - **URL**: `https://yourblog.ghost.io/ghost/api/admin/members/` - **URL parameters**: `limit=20`, `page=<dynamic>`, `filter=<dynamic>` (for subscription tier filtering) ```json { "method": "GET", "url": "https://yourblog.ghost.io/ghost/api/admin/members/", "shared_headers": { "Authorization": "Ghost <private: jwt_token>", "Content-Type": "application/json" }, "call_params": { "limit": "20", "page": "<dynamic:page>" } } ``` Do NOT try to Initialize this call yet — you need a real JWT first, which you generate in the next step. Initialize calls with a placeholder JWT will return 401 Unauthorized from Ghost. **Ghost 4.x vs 5.x reminder**: if you are on Ghost 4.x, change the Admin API URL to `https://yourblog.ghost.io/ghost/api/v3/admin/members/`. Confirm in Ghost Admin → Settings → About.

ghost-admin-api-connector.json
1{
2 "method": "GET",
3 "url": "https://yourblog.ghost.io/ghost/api/admin/members/",
4 "shared_headers": {
5 "Authorization": "Ghost <private: jwt_token_from_database>",
6 "Content-Type": "application/json"
7 },
8 "call_params": {
9 "limit": "20",
10 "page": "<dynamic:page_number>",
11 "filter": "<dynamic:filter_string>"
12 }
13}

Pro tip: Keep the Content API group and Admin API group completely separate in the API Connector — different group names, different base configurations. Mixing the two (trying to use one group for both APIs) causes authentication errors that are hard to debug because Ghost's error messages for wrong endpoints are generic.

Expected result: The Ghost Admin API group exists in your API Connector with a Private Authorization: Ghost [JWT] shared header. The Get Members call is configured but not yet initialized — that happens after you create the JWT refresh workflow.

4

Build the JWT generation and storage Backend Workflow

Ghost's Admin API JWT has three parts: a Base64URL-encoded header, a Base64URL-encoded payload, and a HS256 signature computed from the hex-decoded SECRET portion of your Admin API key. Bubble cannot sign JWTs natively. The practical solution for most Bubble builders: use a lightweight external service or a pre-signed JWT for prototyping, then implement a Backend Workflow that calls a signing helper. However, the most self-contained Bubble-native approach uses the **Toolbox plugin** (a free Bubble plugin) and its **Run JavaScript** action to construct and sign the JWT entirely in a Backend Workflow. First, in your Bubble **Data tab**, create a new data type called `App Settings` with a text field called `ghost_jwt` and a date field called `ghost_jwt_expires_at`. This is where the current JWT lives. **Install the Toolbox plugin**: Plugins tab → Add plugins → search **Toolbox** → Install (free). In your Bubble editor, go to **Backend Workflows** (in the left menu, under the database icon). Click **Add new API workflow** and name it `Refresh Ghost JWT`. In this Backend Workflow, add a **Run JavaScript** action (from Toolbox plugin). The JavaScript uses the CryptoJS library pattern to construct the JWT: ```javascript // Ghost Admin API JWT generation // Admin API key format: ID:SECRET var adminKey = "YOUR_ADMIN_API_KEY_ID:SECRET_HERE"; var parts = adminKey.split(":"); var id = parts[0]; var secret = parts[1]; // Convert hex secret to byte array function hexToBytes(hex) { var bytes = []; for (var i = 0; i < hex.length; i += 2) { bytes.push(parseInt(hex.substr(i, 2), 16)); } return bytes; } var now = Math.floor(Date.now() / 1000); var header = btoa(JSON.stringify({"alg": "HS256", "kid": id, "typ": "JWT"})).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); var payload = btoa(JSON.stringify({"iat": now, "exp": now + 300, "aud": "/admin/"})).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); // Note: btoa only Base64-encodes; HS256 signing requires CryptoJS or SubtleCrypto // This example shows the structure — use SubtleCrypto for production signing: async function signJWT() { var encoder = new TextEncoder(); var secretBytes = new Uint8Array(hexToBytes(secret)); var cryptoKey = await crypto.subtle.importKey("raw", secretBytes, {name: "HMAC", hash: "SHA-256"}, false, ["sign"]); var data = encoder.encode(header + "." + payload); var sig = await crypto.subtle.sign("HMAC", cryptoKey, data); var sigB64 = btoa(String.fromCharCode(...new Uint8Array(sig))).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); bubble_fn_result(header + "." + payload + "." + sigB64); } signJWT(); ``` After the Run JavaScript action, add a **Create a new Thing** or **Make changes to a Thing** action to save the returned JWT string to your `App Settings` record's `ghost_jwt` field, and set `ghost_jwt_expires_at` to `Current date/time + 4.5 minutes`. Schedule this Backend Workflow to run every 4 minutes using Bubble's **Schedule API Workflow** recursive pattern (a paid plan feature — the Backend Workflow calls itself with a delay of 4 minutes at the end). For builds on a paid plan, RapidDev's team has wired this exact JWT refresh loop in dozens of Ghost-connected Bubble apps — if you want this production-tested, book a free scoping call at rapidevelopers.com/contact.

ghost-jwt-generator.js
1// Ghost Admin JWT — SubtleCrypto signing (runs inside Bubble Toolbox 'Run JavaScript' action)
2var adminKeyId = "YOUR_KEY_ID"; // the part before the colon in your Admin API key
3var adminKeySecret = "YOUR_KEY_SECRET"; // the part after the colon (hex string)
4
5function hexToUint8Array(hex) {
6 var arr = new Uint8Array(hex.length / 2);
7 for (var i = 0; i < hex.length; i += 2) {
8 arr[i / 2] = parseInt(hex.substr(i, 2), 16);
9 }
10 return arr;
11}
12
13function b64url(str) {
14 return btoa(str).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
15}
16
17async function makeGhostJWT() {
18 var now = Math.floor(Date.now() / 1000);
19 var header = b64url(JSON.stringify({ alg: "HS256", kid: adminKeyId, typ: "JWT" }));
20 var payload = b64url(JSON.stringify({ iat: now, exp: now + 300, aud: "/admin/" }));
21 var signingInput = header + "." + payload;
22 var keyBytes = hexToUint8Array(adminKeySecret);
23 var cryptoKey = await crypto.subtle.importKey(
24 "raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
25 );
26 var sig = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(signingInput));
27 var sigB64 = b64url(String.fromCharCode(...new Uint8Array(sig)));
28 bubble_fn_result(signingInput + "." + sigB64);
29}
30makeGhostJWT();

Pro tip: The Admin API key SECRET is a hexadecimal string — it must be hex-decoded into raw bytes before being used as the HMAC-SHA256 signing key. If you use the raw hex string directly (without hex-decoding), the JWT will be malformed and Ghost will return 401 Unauthorized with a 'Invalid token' message.

Expected result: The Refresh Ghost JWT Backend Workflow runs successfully, generates a valid JWT, and saves it to the App Settings data type. The JWT expires at now+300 seconds. The workflow is scheduled to repeat every 4 minutes.

5

Initialize the Admin API call and bind the dynamic JWT from App Settings

Now that you have a valid JWT stored in your database, return to the API Connector to complete the Admin API group setup. In the **Ghost Admin API** group, find the `Get Members` call you created in Step 3. Click **Edit** on the Authorization shared header. Change the value from the placeholder to a dynamic expression: click the value field, delete the placeholder text, then use Bubble's **Insert dynamic data** to bind the value to `App Settings' ghost_jwt` (the App Settings record's ghost_jwt field). The header value should read: `Ghost [App Settings ghost_jwt]` — where the second part is the dynamic Bubble expression pulling the live JWT from your database. Now click **Initialize call** on the Get Members call. Bubble sends the request to Ghost with the current JWT. Ghost returns a response with a `members` array. Set the **Data path** to `members`. Bubble auto-detects member fields: `id`, `uuid`, `email`, `name`, `status`, `subscribed`, `created_at`, `updated_at`, and the nested `tiers` array. Repeat for any additional Admin API calls you need — for example, `GET /admin/posts/?status=draft` for draft post access, or `GET /admin/newsletters/` for newsletter stats. **Ghost filter syntax note**: Ghost's filter parameter uses `+` for AND and `,` for OR — for example `filter=status:published+tag:newsletter`. If you add these as Bubble URL parameters, Bubble may URL-encode the `+` as `%2B`. Test with a literal filter string first to confirm Ghost interprets it correctly. If `%2B` causes zero results, use `%2B` explicitly in a fixed value, or pass the filter through a dynamic URL parameter with the value pre-encoded.

ghost-admin-members-call.json
1{
2 "method": "GET",
3 "url": "https://yourblog.ghost.io/ghost/api/admin/members/",
4 "shared_headers": {
5 "Authorization": "Ghost <dynamic:App Settings ghost_jwt>",
6 "Content-Type": "application/json"
7 },
8 "call_params": {
9 "limit": "20",
10 "page": "<dynamic:current_page>",
11 "filter": "status:paid"
12 }
13}

Pro tip: If the Initialize call returns 401 after you bind the dynamic JWT, check that the App Settings record exists and that the ghost_jwt field is not empty. Create a dummy App Settings record manually in Bubble's Data tab and paste a freshly generated JWT into the ghost_jwt field to verify the API Connector binding before testing the Backend Workflow.

Expected result: The Initialize call for Get Members succeeds with the dynamic JWT. Bubble detects member fields including email, name, status, subscribed, and tiers. The Data path is set to 'members'.

6

Build the Ghost posts display and members admin pages in Bubble

With both API groups configured and initialized, you can now bind Ghost data to Bubble UI elements. **Posts page (Content API):** Create a new Bubble page called `blog`. Add a **Repeating Group** element. Set its **Data source** to the `Ghost Content API - Get Posts` call (this is a live API call, not a Bubble database query). Set **Type of content** to `Ghost Content API - Get Posts` (the API response type Bubble auto-creates during Initialize). Inside the Repeating Group's first cell, add: - An **Image** element → Source: `Current cell's Ghost Content API - Get Posts's feature_image` - A **Text** element → `Current cell's feature_image's title` (or `title` directly) - A **Text** element → `excerpt` - A **Text** element → `published_at` formatted as date - A **Button** → "Read more" → on click, open a popup or navigate to a separate page passing `current_cell's slug` as a URL parameter For post filtering by tag, add a **Dropdown** element above the Repeating Group. Set its choices to a static list of your Ghost tag names (or query `GET /content/tags/` to populate dynamically). When the dropdown value changes, use a Workflow to reset the Repeating Group with a new API call including `filter=tag:[SelectedTag]`. **Members admin page (Admin API):** Create a page called `ghost-admin` with restricted access (Bubble's page visibility conditions based on user role). Add a Repeating Group bound to `Ghost Admin API - Get Members`. Show email, name, status, and subscribed date. Add a filter dropdown for `status:free` vs `status:paid` using the filter URL parameter. **Pagination**: both APIs support `page` and `limit` parameters. Add Previous/Next buttons on each page that increment/decrement a Bubble Custom State (type Number, starting value 1) representing the current page number. Bind the `page` API parameter to this Custom State.

Pro tip: Ghost's published_at date is in ISO 8601 format (e.g., '2024-03-15T09:00:00.000Z'). In Bubble, bind it to a Text element and use the :formatted as date operator to display it in a human-readable format like 'March 15, 2024'. Bubble handles ISO 8601 parsing automatically.

Expected result: The blog page shows Ghost posts in a Repeating Group with feature images, titles, excerpts, and publication dates. The ghost-admin page shows members with their subscription status. Both pages paginate correctly using the page Custom State.

Common use cases

Ghost blog post feed in a Bubble app

A creator surfaces their Ghost publication's latest posts inside a Bubble community app. Using the Content API, a Repeating Group shows post cards with feature image, title, excerpt, and publication date. Clicking a card opens a popup displaying the full post HTML (rendered via an HTML element). Tags from Ghost drive a Bubble filter dropdown so readers can browse by topic without leaving the Bubble app.

Bubble Prompt

Build a Bubble page that displays the 10 most recent posts from a Ghost blog using the Ghost Content API. Show each post's feature_image, title, excerpt, published_at date, and tags. Add a dropdown filter by tag that re-queries the API with the selected tag name.

Copy this prompt to try it in Bubble

Ghost membership dashboard in Bubble

A newsletter publisher builds a Bubble admin panel to manage Ghost members without logging into Ghost Admin. The Bubble app queries the Ghost Admin API for member list (email, name, subscribed status, tier), allows filtering by subscription tier, and shows free-vs-paid member counts. A Repeating Group displays member rows with an action to update the member's tier via a PATCH call to the Admin API.

Bubble Prompt

Create a Bubble admin page that fetches all Ghost members from the Admin API. Display each member's email, name, subscription status, and tier label. Add a filter dropdown for tier. Include a button on each row that sends a PATCH request to update the member's tier between free and paid.

Copy this prompt to try it in Bubble

Ghost-to-Bubble content aggregator with post scheduling view

A media team uses Bubble to see all Ghost posts (published and scheduled drafts) in a shared editorial calendar view. The Admin API exposes draft posts with their scheduled_at timestamps. The Bubble app displays a month-view calendar grid where each day's cell shows post titles for that date. Editors can see at a glance what is live, what is scheduled, and where gaps exist — without giving every team member Ghost Admin access.

Bubble Prompt

Build a Bubble editorial calendar page using the Ghost Admin API. Fetch all posts with status of published or scheduled. Display them on a month calendar grid using scheduled_at or published_at as the date. Color-code published posts green and scheduled drafts yellow.

Copy this prompt to try it in Bubble

Troubleshooting

Initialize call on the Admin API returns 401 Unauthorized with message 'Invalid token'

Cause: The JWT in the Authorization header is either expired (JWTs are valid only 5 minutes), malformed (incorrect Base64URL encoding or missing HS256 signature), or the Admin API key's SECRET was not hex-decoded before signing.

Solution: First, verify the JWT format: it must be three Base64URL-encoded segments separated by dots (header.payload.signature). Check that the SECRET was hex-decoded (converted from hex characters to raw bytes) before signing — using the raw hex string directly produces a wrong signature. Regenerate a fresh JWT, paste it manually into the ghost_jwt App Settings field, then re-initialize within 4 minutes.

Content API returns an empty posts array or zero results with filter applied

Cause: Ghost's filter syntax uses + for AND and , for OR, but Bubble URL-encodes the + as %2B when passing it as a URL parameter, which Ghost may interpret differently depending on version.

Solution: Test your filter string directly in a browser first by appending it to the API URL: https://yourblog.ghost.io/ghost/api/content/posts/?key=YOUR_KEY&filter=tag:newsletter. If that works, configure the exact filter string as a fixed (non-dynamic) URL parameter in the Bubble API Connector call and re-initialize. For dynamic filters, use Bubble's 'URL encoded' text operator on the filter value.

Admin API call returns 404 Not Found on all endpoints

Cause: The Admin API base URL path is wrong for your Ghost version. Ghost 4.x uses /ghost/api/v3/admin/ while Ghost 5.x uses /ghost/api/admin/ with no version prefix.

Solution: Check your Ghost version in Ghost Admin → Settings → About. Update the Admin API Connector group's URL to match the correct path for your version. After correcting the URL, click Initialize call again to refresh the detected response shape.

Bubble shows 'There was an issue setting up your call' when running Initialize on the Content API

Cause: The Initialize call failed because the API Connector requires a real, successful HTTP response to detect the JSON schema. If the Content API key is missing from the URL parameter, or the Ghost URL is incorrect, the call returns an error instead of a valid posts JSON object.

Solution: Verify the request works outside Bubble first: open https://yourblog.ghost.io/ghost/api/content/posts/?key=YOUR_CONTENT_KEY in a browser. You should see a JSON object with a 'posts' array. If you see a 404 or error JSON, fix the URL or key before returning to Bubble. Also ensure your Ghost blog has at least one published post — an empty posts array causes Initialize to detect no fields.

Scheduled JWT refresh Backend Workflow is not available (option greyed out or missing)

Cause: Scheduled Backend Workflows (API Workflows that trigger on a schedule or recursively) are a paid Bubble plan feature. They are not available on the Free plan.

Solution: On the Free plan, the Admin API JWT must be refreshed manually — create a workflow triggered on page load that calls the Refresh Ghost JWT Backend Workflow when the stored ghost_jwt_expires_at date is in the past. This requires the user to re-open a page to refresh the token. For production use, upgrade to a Bubble paid plan to enable scheduled API Workflows that refresh the token every 4 minutes automatically.

Best practices

  • Always create two separate API Connector groups — one for the Content API and one for the Admin API. Never mix the two in a single group. The authentication mechanisms are different, and mixing them produces confusing 401 errors.
  • Mark the Admin API's Authorization header as Private in the API Connector. The Content API key can remain non-private (it is designed to be public), but the Admin API JWT must never reach the browser.
  • Store the current Ghost JWT and its expiry timestamp in a dedicated App Settings data type with a single row. Always check ghost_jwt_expires_at before making Admin API calls — if it is in the past, trigger the refresh workflow before proceeding.
  • Set privacy rules on any Bubble data type that stores Ghost member data (emails, subscription tiers). In Bubble's Data tab → Privacy, restrict access to admin users only. Ghost member emails are personally identifiable information — unrestricted Bubble privacy rules expose them to all app users.
  • Use Ghost's filter syntax to request only the data you need. Adding &limit=all to a Content API call on a large blog can return thousands of posts in a single call — this is expensive in Workload Units and slows the Bubble page. Use limit=10 with pagination for production content feeds.
  • Add the include=tags,authors parameter to all Content API posts calls. Ghost strips related data by default and returns only the post's own fields — without this parameter, tag filtering and author attribution are unavailable in your Bubble Repeating Group.
  • Test Ghost's filter syntax (e.g., status:published+tag:newsletter) in a browser URL bar before adding it to the API Connector — URL encoding differences between environments can cause zero results that are hard to debug inside Bubble's API Connector UI.
  • When building a PUT or PATCH workflow to update Ghost posts, always fetch the current post first to read its updated_at timestamp, then include that timestamp in the update request body. Ghost uses optimistic locking — a missing or stale updated_at causes a 409 Conflict error on every update attempt.

Alternatives

Frequently asked questions

Do I need a paid Bubble plan to connect to Ghost at all?

No — the Content API integration (reading published posts, tags, and authors) works on Bubble's Free plan. You only need a paid Bubble plan for the Admin API, because keeping the JWT fresh in production requires scheduled Backend Workflows, which are a paid-plan feature. For prototyping Admin API calls, you can manually refresh the JWT by running the Backend Workflow on demand.

Can Bubble write new posts to Ghost via the Admin API?

Yes, but with an important limitation. Ghost's POST /admin/posts/ endpoint creates a post, but the post body must be in Ghost's Lexical or mobiledoc JSON format — not plain HTML or plain text. Bubble's text inputs produce plain strings that Ghost will not accept as valid Lexical content. The realistic Bubble use-case is creating minimal draft posts (title + status only) via the Admin API, then editing the body in Ghost's native editor. Rich content creation via Bubble is impractical.

My Ghost blog has 500+ posts. Will the Content API call time out in Bubble?

A single call with limit=all on a 500-post blog may be slow but usually does not time out. However, it is expensive in Bubble Workload Units and returns a very large JSON payload. The better approach: use limit=10 to 20 with a page URL parameter, and let users paginate through posts with Previous/Next buttons. Ghost's Content API returns a meta.pagination object with total and pages counts to help you build pagination controls.

Can Ghost member data (emails, paid tier) be accessed without the Admin API JWT?

No. Member data is exclusively in the Admin API and is never exposed via the Content API. The Content API is intentionally public-facing and contains only published content metadata. If you need to access member emails, subscription tiers, or newsletter statistics, the Admin API with a valid JWT is required.

What happens if the JWT expires while a user is on a Bubble page using the Admin API?

Admin API calls made with an expired JWT return 401 Unauthorized from Ghost. Your Bubble workflow will fail silently or show an error to the user. The production fix is to use a scheduled Backend Workflow that refreshes the JWT every 4 minutes (paid plan). On the Free plan, add a condition on every Admin API workflow action that checks ghost_jwt_expires_at — if it is in the past, run the JWT refresh workflow first before making the API call.

Does Ghost self-hosted work the same way as Ghost Pro for this integration?

Yes, the API structure is identical between Ghost Pro and self-hosted Ghost 5.x. The only differences are your base URL (your custom domain instead of yourblog.ghost.io) and rate limits (self-hosted rate limits depend on your server configuration, not Ghost Pro's managed limits). Confirm your Ghost version and base URL in Ghost Admin → Settings → About before configuring the Bubble API Connector.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Integrations are where projects stall

We wire Bubble integrations like this one every week — auth, webhooks, edge cases included. Fixed-price builds from $13K, delivered in 6–10 weeks.

Talk to an integration engineer

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.