Skip to main content
SocialCutter

CMS and websites

Upload images to Ghost and publish the right post

Ghost Admin API flow: sign the JWT, upload images to /ghost/api/admin/images/upload and create or update a post with feature_image.

  • Ghost
  • Admin API
  • JWT
  • images upload
  • feature_image
  • SocialCutter
  • Node

The problem: one cover, many networks

A Ghost post carries a feature_image that gets reused when sharing on social networks and in the theme’s cards. If that image is not the right size, each network crops it its own way. The same problem shows up with images inside the post body.

This guide’s flow processes one master with SocialCutter and uploads the correct output to Ghost, so the cover and the post images come out at the right size.

Requirements and integration

You need a Ghost custom integration (Settings → Integrations → Add custom integration). That gives you the Admin API key, with the form id:secret. The Admin API lives under /ghost/api/admin/ on the same install as your blog.

1. Sign the JWT with the Admin API key

Ghost does not use the key directly: it signs an HS256 JWT per request. The id goes in the kid header, the secret (hex-decoded) is the signing key, and the token expires in 5 minutes at most. Node snippet:

import jwt from 'jsonwebtoken'

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

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

console.log(token)

To use it from the terminal, generate the token with node and store it in a variable:

export GHOST_URL="https://your-blog.com"
export GHOST_ADMIN_API_KEY="id:secret"

TOKEN=$(node -e "const jwt=require('jsonwebtoken');const [id,secret]=process.env.GHOST_ADMIN_API_KEY.split(':');console.log(jwt.sign({},Buffer.from(secret,'hex'),{keyid:id,algorithm:'HS256',expiresIn:'5m',audience:'/admin/'}))")

2. API version note

The Admin API is versioned by header. Send Accept-Version: v6.0 (or v5.0 depending on your install):

curl -s "$GHOST_URL/ghost/api/admin/site/" \
  -H "Authorization: Ghost $TOKEN" \
  -H "Accept-Version: v6.0" | jq

The number follows Ghost’s major version. If you send a version that does not correspond, the shape of some responses changes. Pin the version your install returns and check the official docs at https://ghost.org/docs/admin-api/ when you upgrade Ghost.

3. Process the master with SocialCutter

Before uploading anything, process the master to get the cover size. For a social feature_image a 1.91:1 usually works:

curl -s -X POST "https://api.socialcutter.theboomer.dev/api/v1/images/process" \
  -H "X-API-Key: *** \
  -H "Content-Type: application/json" \
  -d '{
    "source": { "type": "url", "value": "https://your-cdn.com/master.jpg" },
    "destinations": [
      { "platform": "linkedin", "format": "post" },
      { "platform": "facebook", "format": "link" }
    ]
  }' > sc.json

jq '.image_id, (.outputs[] | {url, platform, format, width, height})' sc.json

Download the output you want to upload:

curl -s -o cover.jpg "$(jq -r '.outputs[0].url' sc.json)"

4. Upload the image to Ghost

Upload the file to /ghost/api/admin/images/upload/ as multipart, with Content-Type: multipart/form-data. Ghost’s documentation defines three fields on that form:

  • file (required): the image data, as a Blob or File. Images are uploaded one at a time.
  • purpose (optional, defaults to image): the intended use, which changes the validations performed. Accepts image, profile_image and icon. The supported formats for all three are WEBP, JPEG, GIF, PNG and SVG; profile_image must be square, and icon must be square too and additionally accepts ICO.
  • ref (optional): a reference, for example the original file path. Ghost returns it as-is, which makes it useful for replacing local paths with the uploaded URLs.

It goes with the same JWT from step 1:

curl -s -X POST "$GHOST_URL/ghost/api/admin/images/upload/" \
  -H "Authorization: Ghost $TOKEN" \
  -H "Accept-Version: v6.0" \
  -F "file=@./cover.jpg" \
  -F "purpose=image" \
  -F "ref=cover.jpg" > img.json

jq '.images[0] | {url, ref}' img.json

The response carries an images list, each with a url (the address it can be fetched from) and a ref. Use that url as feature_image. With the default storage adapter, Ghost stores the file in /content/images/ with no changes other than sanitising the filename.

5. Create the post with feature_image

FEATURE=$(jq -r '.images[0].url' img.json)

curl -s -X POST "$GHOST_URL/ghost/api/admin/posts/?source=html" \
  -H "Authorization: Ghost $TOKEN" \
  -H "Accept-Version: v6.0" \
  -H "Content-Type: application/json" \
  -d "{
    \"posts\": [{
      \"title\": \"Sample post\",
      \"html\": \"<p>Post content.</p>\",
      \"feature_image\": \"$FEATURE\",
      \"status\": \"draft\"
    }]
  }" | jq '.posts[0] | {id, updated_at, feature_image}'

The ?source=html parameter says html is already rendered HTML. Keep the id and updated_at the response returns: you need them to update.

feature_image, og_image and twitter_image

A Ghost post object exposes three different image fields, and they are not synonyms:

FieldWhat it is for
feature_imageThe post cover: the one used by the theme and the feed cards. It comes with feature_image_alt and feature_image_caption.
og_imageThe Open Graph card image. Ghost documents the site-level one as the image used “when shared on Facebook and across the web”.
twitter_imageThe X card image.

Each also has its own title and description: og_title, og_description, twitter_title and twitter_description. Because they are independent fields, you can give the cover the crop your theme wants and the cards the 1.91:1 that networks usually ask for. Upload each SocialCutter output with step 4 and split the URLs:

curl -s -X POST "$GHOST_URL/ghost/api/admin/posts/" \
  -H "Authorization: Ghost $TOKEN" \
  -H "Accept-Version: v6.0" \
  -H "Content-Type: application/json" \
  -d "{\"posts\":[{\"title\":\"Sample post\",\"feature_image\":\"$FEATURE\",\"og_image\":\"$OG_IMAGE\",\"twitter_image\":\"$TW_IMAGE\"}]}" \
  | jq '.posts[0] | {id, feature_image, og_image, twitter_image}'

6. Update an existing post

To change the feature_image of a post already created, use PUT with the current updated_at. Ghost requires it to detect collisions:

curl -s -X PUT "$GHOST_URL/ghost/api/admin/posts/$POST_ID/" \
  -H "Authorization: Ghost $TOKEN" \
  -H "Accept-Version: v6.0" \
  -H "Content-Type: application/json" \
  -d "{
    \"posts\": [{
      \"updated_at\": \"$UPDATED_AT\",
      \"feature_image\": \"$FEATURE\"
    }]
  }"

If updated_at does not match the server’s, Ghost returns 409 and you have to re-read the post before retrying.

7. Images inside the body

For body images, repeat step 4 with each SocialCutter output and place the returned url in the post HTML:

<figure>
  <img src="https://your-blog.com/content/images/2026/09/output-instagram.jpg" alt="Processed output">
</figure>

Each image uploaded once is then served by Ghost at its already-resolved size.

Cost

  • 1 use per destination (platform and format combination) per SocialCutter request.
  • Repeated destinations in the same request are not charged twice.
  • Failed processing runs are refunded.

Common errors

SituationLikely cause
401 from GhostJWT expired (over 5 min), badly signed or wrong kid
403 from GhostThe integration lacks permission for that route
409 updatingStale updated_at; re-read the post before retrying
Image not uploadedMissing file field or invalid purpose
Odd response shapeAccept-Version does not match your Ghost
413 from SocialCutterMaster file exceeds 5 MB
429 from SocialCutterWallet quota exhausted

Next steps

Frequently asked questions

Where do the id and secret of the Admin API key come from?

From a custom integration in Ghost: Settings → Integrations → Add custom integration. The key has the form id:secret; the id goes in the JWT kid header and the secret (hex encoded) signs the token.

How long does the JWT last?

Ghost documents a maximum of 5 minutes. Sign a new one for each batch of requests; if it expires the Admin API returns 401.

Which version header should I send?

Accept-Version, for example v6.0. The number follows the major of your Ghost install; if it does not match, some responses change. Check the version with GET /ghost/api/admin/site/.

How do I update an existing post?

With PUT /ghost/api/admin/posts/{id}/. Ghost requires the post's updated_at to detect collisions; if it does not match, it returns 409.

What role does SocialCutter play?

It processes a single master and returns the correct sizes per network. You upload the output you need to Ghost and set it as feature_image or as an image in the body. It costs 1 use per destination.

Which formats does the upload accept and how many images per request?

WEBP, JPEG, GIF, PNG and SVG. Images are uploaded one at a time. The purpose field accepts image, profile_image and icon; the last two must be square and the icon additionally accepts ICO.

What is the difference between feature_image, og_image and twitter_image?

They are three independent image fields on the post object. feature_image is the cover used by the theme and the feed cards. og_image is the Open Graph card image, documented by Ghost as the one used when sharing on Facebook and across the web. twitter_image is the X card image. Each has its own title and description: og_title, og_description, twitter_title and twitter_description.