# 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.
- URL: https://socialcutter.theboomer.dev/en/guides/ghost/
- Idioma: en
- Familia: cms
- Actualizado: 2026-09-24
- Palabras clave: 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:

```js
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:

```bash
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):

```bash
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:

```bash
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:

```bash
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:

```bash
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

```bash
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:

| Field | What it is for |
|---|---|
| `feature_image` | The post cover: the one used by the theme and the feed cards. It comes with `feature_image_alt` and `feature_image_caption`. |
| `og_image` | The Open Graph card image. Ghost documents the site-level one as the image used "when shared on Facebook and across the web". |
| `twitter_image` | The 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:

```bash
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:

```bash
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:

```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

| Situation | Likely cause |
|---|---|
| 401 from Ghost | JWT expired (over 5 min), badly signed or wrong `kid` |
| 403 from Ghost | The integration lacks permission for that route |
| 409 updating | Stale `updated_at`; re-read the post before retrying |
| Image not uploaded | Missing `file` field or invalid `purpose` |
| Odd response shape | `Accept-Version` does not match your Ghost |
| 413 from SocialCutter | Master file exceeds 5 MB |
| 429 from SocialCutter | Wallet quota exhausted |

## Next steps

- WordPress guide: [Publish the correct sizes in WordPress](/en/guides/wordpress/)
- Shopify guide: [Product and blog images in Shopify](/en/guides/shopify/)
- Automation: [Orchestrate the flow with n8n](/en/guides/n8n/)
- API from the terminal: [Process images with curl](/en/guides/curl/)
- Ghost documentation: https://ghost.org/docs/admin-api/