Skip to main content
SocialCutter

CMS and websites

Contentful: when to pre-generate images and how to publish

The Contentful Images API already crops on the fly: see when pre-generating files with SocialCutter pays off and how to publish the asset via the CMA.

  • Contentful
  • Images API
  • Content Management API
  • assets
  • uploadFrom
  • social media images
  • pre-generate sizes

What Contentful already does on its own

Contentful has its own Images API: a read-only API served from images.ctfassets.net to which you append query parameters on the asset file URL (fields.file.url) to transform the image on the fly. Nothing new is uploaded and no files are generated: you change the URL and the CDN returns the transformed version.

ParameterWhat it doesValues
w / hWidth and height in pixels4000 px maximum
fitResizing behaviourpad, fill, scale, crop, thumb
fFocus of the frame when using pad, fill, crop or thumbthe default is center
fmOutput formatjpg, png, webp, gif, avif, tiff; the original by default
qQualityan integer from 1 to 100
bgBackground colour for pad and rounded cornersRGB values, e.g. rgb:9090ff
rRounded corners or a circular croppixels, or max
flSpecific variantsprogressive for JPEG, png8 for 8-bit PNG

An example output URL:

https://images.ctfassets.net/SPACE_ID/ASSET_ID/TOKEN/name.jpg?w=1080&h=1080&fit=fill&fm=webp&q=85

Published assets need no authentication on the Images API, so anyone can request those transformations from your site. With fit=fill and the default focus (center) you get a centered crop equivalent to SocialCutter’s. And if the original image is over 100 MB, Contentful treats it as a plain asset and applies no transformations.

Honest conclusion: for your website and blog you almost never need to pre-generate anything. One well-formed call from the template solves it. Pre-generating matters in a different scenario.

Where it falls short for social media

The Images API returns a transformed URL. It does not return a downloadable file ready to upload. That is not a flaw: it solves a different problem.

When you publish to Instagram, TikTok, YouTube or LinkedIn, the network does not fetch your URL: you upload a file to it. And publishing schedulers, campaign tools, partners and client dossiers almost always ask for a file with its own name and weight. No query parameter helps there.

When pre-generating with SocialCutter pays off

CaseWhyUsual destination
Pieces for networks that do not go through the Contentful CDNThe network needs a file at the exact size, not a URLinstagram post, instagram story, tiktok cover
Publishing schedulers and campaign toolsThey only accept a file uploadfacebook post, linkedin post, twitter post
Campaigns outside Contentful (paid, email, partners)The asset leaves the CMS and is handed overyoutube thumbnail, twitter header
Exports and client deliveriesYou need a pack of files with readable names and controlled weightAll of the campaign
One master feeding several channelsOne design, one output per channel, no template changeslinkedin cover, facebook cover
Partners that cannot use URLs with parametersTheir system does not build the transformationAny

The flow is always the same: one master goes in, SocialCutter returns every size, and the file gets published or delivered. The cover crop (the default mode) is centered: it scales and trims the excess evenly on both sides, with no analysis of the image. Leave some air around the edges of the master.

When you do not need to pre-generate

CaseWhat to use instead
The image is only served on your website or blogThe Images API parameters (w, h, fit, fm, q); costs no uses
The theme or template already applies its ratioNothing: do not duplicate assets
You only want different resolutions for different screensThe same asset with w+fit and a srcset
Brand archiveKeep the uncropped master and generate at publish time
You need retouching, a transparent background or composed textA photo editor: that is not what SocialCutter does

One warning that prevents an expensive mistake: do not replace the Contentful master with a cropped output. If the campaign’s main asset becomes a 1080x1080, the 2560x1440 YouTube banner will come out upscaled and blurry. The master stays the master; sizes are generated when you publish.

Generating a campaign’s sizes

curl -s -X POST "https://api.socialcutter.theboomer.dev/api/v1/images/process" \
  -H "X-API-Key: sc_your_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: contentful-campaign-2026-09" \
  -d '{
    "source": { "type": "url", "value": "https://images.ctfassets.net/SPACE_ID/ASSET_ID/TOKEN/master.jpg" },
    "destinations": [
      { "platform": "instagram", "format": "post" },
      { "platform": "instagram", "format": "story" },
      { "platform": "linkedin", "format": "post" },
      { "platform": "youtube", "format": "thumbnail" }
    ],
    "options": { "fit_mode": "cover", "format": "jpg", "quality": 85 }
  }' > sc.json

jq -r '.outputs[] | "\(.platform)/\(.format) \(.width)x\(.height) \(.size_bytes) bytes \(.url)"' sc.json

That request costs 4 uses and returns four outputs. Note the useful detail: you can pass the Contentful CDN URL itself as the source, so the master keeps living in one place. Every output is public, which is exactly what you need to publish it to a network or upload it as an asset.

Publishing the asset with the Content Management API

The Content Management API (CMA) uses the base https://api.contentful.com and Authorization: Bearer <token>. Every call carries Content-Type: application/vnd.contentful.management.v1+json. Creating an asset takes three steps: create, process and publish.

export CF="https://api.contentful.com/spaces/SPACE_ID/environments/ENV_ID"
export CF_TOKEN="CFPAT-..."
export LOCALE="en-US"

1. Upload the binary to the Upload API

If the file is already at a public URL (a SocialCutter output, for instance), you can skip this step and pass the URL directly in the upload field when creating the asset. If the file is local, upload it first:

curl -s -X POST "https://upload.contentful.com/spaces/SPACE_ID/environments/ENV_ID/uploads" \
  -H "Authorization: Bearer $CF_TOKEN" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @instagram-post.jpg > upload.json

UPLOAD_ID=$(jq -r '.sys.id' upload.json)

The response carries the upload sys.id. Watch its expiry: if you do not associate it with an asset and process it within 24 hours, the file and its metadata are deleted. Clients with EU data residency use upload.eu.contentful.com.

2. Create the asset pointing at the upload

curl -s -X POST "$CF/assets" \
  -H "Authorization: Bearer $CF_TOKEN" \
  -H "Content-Type: application/vnd.contentful.management.v1+json" \
  -d '{
    "fields": {
      "title": { "en-US": "September campaign · Instagram post 1080x1080" },
      "file": {
        "en-US": {
          "contentType": "image/jpeg",
          "fileName": "september-campaign-instagram-post-1080x1080.jpg",
          "uploadFrom": {
            "sys": { "type": "Link", "linkType": "Upload", "id": "'"$UPLOAD_ID"'" }
          }
        }
      }
    }
  }' > asset.json

ASSET_ID=$(jq -r '.sys.id' asset.json)
VERSION=$(jq -r '.sys.version' asset.json)

To choose the ID yourself, use a PUT to /spaces/SPACE_ID/environments/ENV_ID/assets/ASSET_ID: it creates the asset with that ID or updates the existing one. On update, Contentful does not merge changes: you send the whole resource and must pass the current version in the X-Contentful-Version header (optimistic locking).

File names have rules: letters, digits, dots, hyphens and underscores only; any other character is replaced by an underscore. Do not rely on accents or symbols.

3. Process and publish

# Process: mandatory before publishing
curl -s -o /dev/null -w "%{http_code}\n" -X PUT \
  "$CF/assets/$ASSET_ID/files/$LOCALE/process" \
  -H "Authorization: Bearer $CF_TOKEN" \
  -H "Content-Type: application/vnd.contentful.management.v1+json" \
  -H "X-Contentful-Version: $VERSION"

# Publish
curl -s -X PUT "$CF/assets/$ASSET_ID/published" \
  -H "Authorization: Bearer $CF_TOKEN" \
  -H "Content-Type: application/vnd.contentful.management.v1+json" \
  -H "X-Contentful-Version: $VERSION" | jq '{id: .sys.id, publishedVersion: .sys.publishedVersion}'

Processing is the step that brings the file into Contentful’s system and fills fields.file.url; the call may return before processing finishes. Without processing you cannot publish the asset or preview it in the Media tab. Once published, the asset is available on the Content Delivery API and, if it is an image, on images.ctfassets.net with the transformation parameters.

With a CMA token the default limit is 7 requests per second; exceed it and the API answers 429 and tells you how long to wait in X-Contentful-RateLimit-Reset.

The same flow in Python

import requests

CF = "https://api.contentful.com/spaces/SPACE_ID/environments/ENV_ID"
HDR = {"Authorization": "Bearer CFPAT-...",
       "Content-Type": "application/vnd.contentful.management.v1+json"}
LOCALE = "en-US"

outputs = requests.post(
    "https://api.socialcutter.theboomer.dev/api/v1/images/process",
    headers={"X-API-Key": "sc_...", "Content-Type": "application/json"},
    json={"source": {"type": "url", "value": "https://images.ctfassets.net/.../master.jpg"},
          "destinations": [{"platform": "instagram", "format": "post"},
                           {"platform": "youtube", "format": "thumbnail"}]},
    timeout=60,
).json()["outputs"]

for out in outputs:
    binary = requests.get(out["url"], timeout=60).content
    upload = requests.post(
        "https://upload.contentful.com/spaces/SPACE_ID/environments/ENV_ID/uploads",
        headers={"Authorization": HDR["Authorization"],
                 "Content-Type": "application/octet-stream"},
        data=binary, timeout=120,
    ).json()["sys"]["id"]

    name = f"campaign-{out['platform']}-{out['format']}-{out['width']}x{out['height']}.jpg"
    asset = requests.post(f"{CF}/assets", headers=HDR, json={"fields": {
        "title": {LOCALE: f"Campaign {out['platform']} {out['format']}"},
        "file": {LOCALE: {"contentType": "image/jpeg", "fileName": name,
                          "uploadFrom": {"sys": {"type": "Link", "linkType": "Upload", "id": upload}}}}},
        }, timeout=60).json()

    ver = asset["sys"]["version"]
    requests.put(f"{CF}/assets/{asset['sys']['id']}/files/{LOCALE}/process",
                 headers={**HDR, "X-Contentful-Version": str(ver)}, timeout=60).raise_for_status()
    requests.put(f"{CF}/assets/{asset['sys']['id']}/published",
                 headers={**HDR, "X-Contentful-Version": str(ver)}, timeout=60).raise_for_status()

print("Published", len(outputs), "assets")

Cost

  • 1 use per destination (platform and format) per request; duplicates are not charged twice.
  • Contentful’s Images API transformations consume no uses: they are Contentful’s.
  • Failed processing jobs are refunded.
  • Every plan includes API and MCP: Free 3 uses/day, Basic 10, Pro 30, Agency 100, from 0 / 3 / 9 / 29 EUR per month.

Typical errors

SymptomCauseWhat to do
401 on the CMAToken missing, expired or without access to the environmentCheck the personal access token and its environment access
409 / version conflictStale X-Contentful-VersionRe-read the asset and retry with its current version
“Cannot publish until processing”Publishing was attempted before processingProcess the locale file first
The asset stays draft with no URLThe upload expired before processingUpload the binary again: an upload expires in 24 hours
422 with odd characters in the namefileName with accents or symbolsUse letters, digits, dots, hyphens and underscores only
429 on the CMAMore than 7 requests per secondWait for what X-Contentful-RateLimit-Reset says
Content-Type comes back as an errorThe API version header is missingSend application/vnd.contentful.management.v1+json on every call
Blurry YouTube bannerA cropped output was stored as the masterKeep the master and generate the banner from it
413 from SocialCutterThe master is over 5 MBShrink the master or use the CDN URL as source

Next steps

Frequently asked questions

If Contentful already resizes in the URL, why would I want SocialCutter?

For everything that does not go through its CDN. The Images API returns a transformed URL, not a file: when the social network, the publishing scheduler, the partner or the client delivery demands a file at the exact size, that file has to be generated elsewhere.

Should I replace the Contentful master with the cropped version?

No, that is a mistake. If you store a 1080x1080 asset as the main one, you cannot produce the 2560x1440 YouTube banner from it without upscaling. Keep the uncropped master and generate the sizes when you publish.

How many calls does it take to publish an asset through the API?

Three: create the asset, process it and publish it. If the file is not at a reachable URL there is a fourth call first: upload the binary to the Upload API to get an upload_id. Processing is mandatory: nothing can be published before it.

What goes in the Content-Type header of the Management API?

application/vnd.contentful.management.v1+json on every CMA call, with Authorization: Bearer <token>. That Content-Type is what pins the API version, so it is best to always send it explicitly instead of leaving it to the default.

What does it cost to prepare a campaign with SocialCutter?

1 use per destination, meaning per platform and format pair. Duplicate destinations in the same request are not charged twice and failed processing is refunded. Contentful's Images API transformations do not consume uses.

Can I publish the asset with an ID I choose?

Yes. Besides the POST that generates the ID automatically, the Management API lets you create or update an asset with your own ID through a PUT to /spaces/{space_id}/environments/{environment_id}/assets/{asset_id}. Handy for matching the campaign ID.