Skip to content

Uploading a cover image

Every content item — article, video, event, series, track, guide, course — can carry a cover image. PlusPlus shows it on cards and detail pages, and falls back to a generated or default cover when none is set. The upload mirrors guides and transcripts: request a URL, PUT the image, poll until ready. If the image is already online, import it from its URL in one call instead.

Flow at a glance

sequenceDiagram
    participant Your app
    participant PlusPlus API
    participant Storage
    Your app->>PlusPlus API: POST /content-items/{public_id}/cover/uploads/ { content_type }
    PlusPlus API-->>Your app: 201 { upload_id, upload_url, expires_at }
    Your app->>Storage: PUT (image/png, image/jpeg, image/gif or image/webp)
    loop until ready or errored
        Your app->>PlusPlus API: GET /content-items/{public_id}/cover/uploads/{upload_id}/status/
        PlusPlus API-->>Your app: 200 { status, cover_url }
    end

1. Request an upload URL

Declare the image's MIME type. The presigned URL is signed for that type, so the PUT must carry it as its Content-Type header. Accepted types are image/png, image/jpeg, image/gif and image/webp; files up to 10 MiB are accepted.

curl -X POST https://acme.plusplus.app/api/v2/content-items/01HXYDBKW5V2H6F3M8R9N0Q2YP/cover/uploads/ \
  -H "Authorization: Bearer your_token_here" \
  -H "Content-Type: application/json" \
  -d '{ "content_type": "image/png" }'
{
  "upload_id": "3f2a9c1e5b7d4e8f9a0b1c2d3e4f5a6b",
  "upload_url": "https://plusplus-storage.s3.amazonaws.com/…",
  "upload_type": "s3_presigned_put",
  "content_item_id": "01HXYDBKW5V2H6F3M8R9N0Q2YP",
  "expires_at": "2026-04-29T13:00:00Z",
  "status": "awaiting_upload"
}

You need management access to the item (author, facilitator, channel owner, organizer or admin); the request returns 403 otherwise.

2. Upload the image

curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: image/png" \
  --data-binary @./onboarding-cover.png

The URL expires one hour after it was issued. Past that, request a new one.

3. Poll until ready

curl https://acme.plusplus.app/api/v2/content-items/01HXYDBKW5V2H6F3M8R9N0Q2YP/cover/uploads/3f2a9c1e5b7d4e8f9a0b1c2d3e4f5a6b/status/ \
  -H "Authorization: Bearer your_token_here"

Polling is what attaches the image: the first poll after your PUT completes reads the file, checks that it is a readable image of an accepted format, and makes it the item's cover. Only one poll at a time does this, so polling from several places is safe. Poll every 2–3 seconds after the upload finishes.

status Meaning
awaiting_upload The file has not landed in storage yet.
processing Another poll is attaching the file right now. Poll again in a moment.
ready The image is now the item's cover. cover_url points at it, and GET /content-items/{public_id}/ reports the same cover_url.
errored The file was refused: not a readable image, not an accepted format, or larger than 10 MiB. error_message says which. Request a new upload URL to try again.
{
  "upload_id": "3f2a9c1e5b7d4e8f9a0b1c2d3e4f5a6b",
  "content_item_id": "01HXYDBKW5V2H6F3M8R9N0Q2YP",
  "status": "ready",
  "message": "Cover image is ready.",
  "cover_url": "https://plusplus-storage.s3.amazonaws.com/…/covers/…_cover.png",
  "error_message": null
}

Alternative: import from a URL

When the image is already hosted somewhere public, skip the upload and let PlusPlus fetch it:

curl -X PUT https://acme.plusplus.app/api/v2/content-items/01HXYDBKW5V2H6F3M8R9N0Q2YP/cover/ \
  -H "Authorization: Bearer your_token_here" \
  -H "Content-Type: application/json" \
  -d '{ "source_url": "https://images.example.com/onboarding-cover.png" }'

The response is the updated content item, with cover_url pointing at the new cover. The URL must be http(s), point at a public host (private, local and internal addresses are refused, as are redirects to them; at most 3 redirects are followed) and answer with a PNG, JPEG, GIF or WebP image of at most 10 MiB. Anything else — an unreachable host, a non-200 answer, an HTML page, an oversized file — is a 400 with code invalid_cover_source and a message saying which.

What replacing a cover does

  • The new image replaces the item's current cover, whether it was uploaded by hand or generated by PlusPlus.
  • An item with an uploaded cover is never overwritten by automatic cover generation, and POST /content-items/{public_id}/regenerate-cover/ returns 422 for it. To go back to a generated cover, remove the uploaded one in the PlusPlus dashboard.
  • An event with its own cover no longer inherits the cover of its series.

Session lifetime

The upload session — the upload_id and its status — lives for one hour from the request. After that, the status endpoint returns 404, whether or not the file was attached; the item itself keeps its cover and stays reachable through GET /content-items/{public_id}/.