Publish to Ghost with the Admin API: keys, JWT and a working request
Create a custom integration for the Admin API key, sign a 5-minute HS256 JWT with audience /admin/, and POST HTML to /ghost/api/admin/posts/?source=html.
LLaunchScaler·Published ·8 min read
The Ghost Admin API lets a server-side script create, edit and publish posts at https://{admin_domain}/ghost/api/admin/, authenticated with a short-lived JWT signed from an Admin API key. You get the key by creating a custom integration in Ghost Admin, sign an HS256 token with audience /admin/ that expires within 5 minutes, and send it as Authorization: Ghost <token>.
Below: where the key comes from, how the token is built, a Node example with no dependencies that creates a draft from HTML, and the follow-up calls for images, publishing and scheduling.
How do you get a Ghost Admin API key?
Create a custom integration. In Ghost Admin, open Settings and go to the Integrations screen (Ghost's docs suggest searching "integrations" in settings to jump there), then click Add custom integration and give it a name. The integration's page shows a Content API key, an Admin API key and the API URL to use.
The Admin API key has two parts separated by a colon: an id and a secret. Both are used to build the token, separately, so keep the full string and split it in code. The Content API key is a different thing: read-only and safe for browsers. The Admin API key can write to your site, so Ghost's docs say it "must be kept private," and token authentication "is not suitable for browsers or other insecure environments."
A few rules for handling it:
Store it in an environment variable or a secret manager, never in a repository.
Create one integration per tool, so you can regenerate one key without breaking the others.
Regenerate it from the same screen if it leaks. Ghost says you can regenerate the key any time, and every script using the old one will need the new value.
There is also a per-user option. Ghost calls it staff access token authentication: a key from a user's own profile page that authenticates as that person, with that person's role and permissions. An integration token has a fixed set of permissions designed for publishing workflows. Use the integration unless you need requests to act as a specific staff user.
Questions, answered
What people ask about this
01
Where do I find the Ghost Admin API key?
In Ghost Admin, open Settings, go to the Integrations screen (search 'integrations' in settings) and click Add custom integration. The new integration shows a Content API key, an Admin API key in the form id:secret, and the API URL.
All Admin API requests start with https://{admin_domain}/ghost/api/admin/, followed by a resource such as posts/, tags/ or images/upload/. Your admin domain can be different from the domain readers visit and can include a subdirectory, so copy the API URL shown on the integration's page instead of guessing.
Ghost's docs say every Ghost(Pro) site has a *.ghost.io admin domain and requires HTTPS. On a self-hosted install, the admin URL is whatever you configured, often the same as the site URL.
Each request also takes an Accept-Version header in the form v{major}.{minor}, such as v5.0. It tells Ghost the minimum API version your code expects. Ghost answers with a Content-Version header naming the version that responded, and if it cannot process a request because of a version mismatch, it emails the site's owner and administrators with details. Ghost's versioning FAQ says major versions ship every 8 to 12 months and code written against the API "will be stable for a minimum of 2 years."
How do you sign the JWT for the Admin API?
Split the key at the colon, hex-decode the secret into bytes, and sign a JWT with HS256. The header carries kid set to the key's id. The payload carries iat (now), exp (at most 5 minutes after now) and aud set to /admin/. Send the result as Authorization: Ghost <token>.
Part
Field
Value
Header
alg
HS256
Header
typ
JWT
Header
kid
The id, the part of the key before the colon
Payload
iat
Current Unix time in seconds
Payload
exp
No more than 5 minutes after iat
Payload
aud
/admin/
Signature
key
The secret, hex-decoded to bytes (not the hex string itself)
Two mistakes cause most failures. The first is signing with the secret as a text string; Ghost's own examples decode it first (Buffer.from(secret, 'hex') in Node, bytes.fromhex(secret) in Python). The second is a long expiry: the 5-minute maximum means you create a fresh token for each batch of requests instead of caching one for hours. Ghost's docs say the API returns specific error messages when a required value is missing or wrong, so read the response body when you get a 401.
The docs list libraries on jwt.io that all accept these values. You do not need one. HS256 is an HMAC-SHA256 over the encoded header and payload, which Node's crypto module computes directly, as the example below does.
How do you create a Ghost draft from HTML in Node?
This script runs on Node 18 or later with no packages installed. It signs the token with node:crypto, then posts HTML to the posts endpoint with ?source=html, so Ghost converts it into its own editor format. It creates a draft and prints the draft's id and URL.
The request body follows Ghost's standard shape: a top-level key named after the resource (posts) holding an array, even for a single post. Only title is required. The creating-a-post docs say every other field "can be empty or have a default that is applied automatically."
Ghost's official JavaScript client, @tryghost/admin-api, does the token signing for you: pass it the site URL, the key and a version, then call api.posts.add(). Use it if you would rather not maintain the signing code; the raw version above shows exactly what it sends.
How does the HTML conversion work, and when is it lossy?
With ?source=html, Ghost converts your HTML into Lexical, its editor format. Ghost's docs call the conversion lossy: it produces "the best available Lexical representation," so the HTML Ghost renders may differ from what you sent. Well-formed HTML that uses block and inline elements correctly converts best.
When you need the markup kept exactly, wrap it in a single HTML card using the comments Ghost documents:
The trade-off is editing. Converted content opens as normal paragraphs, headings and lists that anyone can edit in Ghost's editor. A wrapped HTML card opens as one block of raw HTML. A sensible split is to send prose unwrapped and wrap only the parts that do not survive conversion, such as tables or custom embeds.
How do you add tags, authors and a feature image?
Tags and authors go in the same request. The short form identifies tags by name and authors by email address. Ghost creates any tag it cannot match. With token authentication, a post with no matched author falls back to the staff user with the Owner role, so pass the author's email when the byline matters.
{
"posts": [{
"title": "Publishing to Ghost from a script",
"html": "<p>...</p>",
"tags": ["Guides", { "name": "API", "description": "Posts about our API" }],
"authors": ["writer@example.com"],
"feature_image": "https://example.ghost.io/content/images/2026/09/cover.jpg",
"feature_image_alt": "Diagram of the publishing flow",
"status": "draft"
}]
}
The second tag uses Ghost's long form: an object with at least one identifying key, which also lets you set fields such as description on a tag Ghost creates. Short and long forms can be mixed in one array.
For the feature image, upload the file first. Send a multipart/form-data POST to /ghost/api/admin/images/upload/ with the image in a file field and, optionally, a ref field (Ghost echoes ref back unchanged, which helps map local paths to uploaded URLs). Ghost accepts WEBP, JPEG, GIF, PNG and SVG for the default image purpose. Take the URL from the response and put it in feature_image on the post, with feature_image_alt describing the image.
How do you publish or schedule the draft?
Publishing is an edit: send PUT /ghost/api/admin/posts/{id}/ with "status": "published" and the post's current updated_at. Scheduling is the same edit with "status": "scheduled" and a future published_at. Ghost requires updated_at on every edit to detect collisions, so fetch the post first.
Ghost's docs say that at the published_at time a scheduled post is published, email newsletters are sent where applicable, and the status changes to published. That last point matters if your Ghost site mails posts to members: publishing from a script can reach inboxes, so test on a draft first. Add ?save_revision=true to an edit if you want Ghost to keep a revision of the change.
You can also create a post as "status": "published" in one step. Creating drafts and publishing them in a separate call is safer while you are testing, because a draft never reaches readers or newsletters by accident.
The request above is the easy part; a month of articles worth sending is the slow part. LaunchScaler's content engine plans a month of articles from your own Search Console queries (the searches you already rank for just off page one), writes one a day from what your product does, in your voice, and puts every draft in your workspace to rewrite, trim or scrap. By default, every draft waits for your review before it publishes; once you approve it, it goes to Ghost, or to WordPress, Webflow, Shopify, Notion, Medium or a signed webhook, under your byline.
It costs $99/mo per website, with 3 days free before the first charge. Start the engine and the first article is in your workspace on day one.
Every Admin API request starts with https://{admin_domain}/ghost/api/admin/. The admin domain can differ from your public domain, and on Ghost(Pro) it is your *.ghost.io domain.
03
How do I authenticate with the Ghost Admin API?
Split the Admin API key at the colon, sign a JWT with the hex-decoded secret using HS256, the id as kid, audience /admin/ and an expiry no more than 5 minutes out, then send it in the Authorization header as the word Ghost, a space and the token.
04
Can I create a Ghost post from HTML through the API?
Yes. POST to /ghost/api/admin/posts/?source=html with an html field, and Ghost converts it to its Lexical format. The conversion is lossy; wrap the HTML in kg-card-begin: html and kg-card-end: html comments for an exact copy.
05
Why does updating a Ghost post through the API fail?
Edits require the post's current updated_at value, which Ghost uses to detect collisions. Fetch the post first and send its updated_at back with your changes.
Google has no posting-frequency ranking factor. Publish as often as you can review: one to three posts a week for most small sites, daily only with a plan.