Spotify playlist cover upload: base64 JPEG under 256 KB from Sume

Spotify's playlist cover endpoint takes base64 JPEG data, 256 KB max. Make a 1:1 image on Sume, shrink it in Pillow, check the encoded size, then PUT it.

5 min readSume
All posts

Spotify's custom playlist cover endpoint takes base64-encoded JPEG data with a maximum payload of 256 KB, so the job is: get a square JPEG from Sume, shrink it until the base64 string is under the cap, and send it with a PUT. Sume returns a hosted image URL; Spotify wants raw base64 in the request body, so the two never meet without a few lines of glue code.

What Spotify's reference page says

The reference page for the upload call is short, and the useful facts are these. The page does not state pixel dimensions for the cover, so this post does not either; the 640 x 640 below is a choice, not a Spotify rule.

Spotify playlist cover upload (read 2026-10-03)
ItemWhat the page states
Method and pathPUT /playlists/{playlist_id}/images
BodyBase64 encoded JPEG image data
Maximum payload256 KB
Scopesugc-image-upload, playlist-modify-public, playlist-modify-private
Success response202

What to ask Sume for

Ask for output_format: "jpeg" so the file is already a JPEG. Most catalog models list png, jpeg and webp, but not all of them: some models pick the format themselves, and output_format is catalog-gated, so read supported_parameters on GET /v1/images/models before pinning it. The Image API docs list Seedream 4.5 with a 1:1 ratio and png, jpeg and webp output, which is why the script below uses it.

POST /v1/images blocks for up to 30 seconds and answers 200 with data[].url. If the generation is still running at that point it answers 202 with a job envelope instead, so the script checks the status code rather than assuming the body shape. See the 200 or 202 timeout post for a retry-safe version.

Shrink until the base64 string fits

Base64 inflates binary data by about a third, so test the encoded length, not the file size. The loop below lowers JPEG quality in steps and stops when the encoded string is under 256,000 bytes, which is safely under the cap whether Spotify counts KB as 1,000 or 1,024 bytes. Sume does not serve output_compression in v1 (an unsupported parameter returns 400), so compression has to happen on your side; the compression post covers why.

import os, requests
r = requests.post(
    "https://api.sume.com/v1/images",
    headers={"Authorization": f"Bearer {os.environ['SUME_API_KEY']}"},
    json={"model": "bytedance-seed/seedream-4.5", "aspect_ratio": "1:1",
          "output_format": "jpeg", "prompt": "flat illustration of a tape cassette, warm colors, no text"},
    timeout=60,
)
r.raise_for_status()
if r.status_code != 200:
    raise SystemExit("202: slow job, fetch GET /v1/jobs/{id}/result instead")
url = r.json()["data"][0]["url"]

import base64, io
from PIL import Image
img = Image.open(io.BytesIO(requests.get(url, timeout=60).content)).convert("RGB").resize((640, 640), Image.LANCZOS)
for q in (90, 80, 70, 60, 50):
    buf = io.BytesIO()
    img.save(buf, "JPEG", quality=q)
    b64 = base64.b64encode(buf.getvalue())
    if len(b64) < 256_000:
        break
else:
    raise SystemExit("still over 256 KB")
requests.put(
    f"https://api.spotify.com/v1/playlists/{os.environ['PLAYLIST_ID']}/images",
    headers={"Authorization": f"Bearer {os.environ['SPOTIFY_TOKEN']}",
             "Content-Type": "image/jpeg"},
    data=b64, timeout=60,
).raise_for_status()

Where the limit applies

The loop measures the base64 text length, because that is what the request body contains, and base64 is larger than the raw bytes by about a third. Checking the raw JPEG size alone would pass files that the endpoint rejects.

Checks before you ship it

  • Keep text out of the prompt. A playlist cover is shown small, and image models still misspell short words; put any lettering on in Pillow.
  • Request one image per call. The docs allow n up to 10 per request, but per-model ceilings are lower, and a cover needs one.
  • A 202 from Spotify means the upload was accepted for processing, not that the cover is visible yet.
  • Cost is the usage.cost field on the 200 response, which is the amount billed to your wallet. A failed or cancelled generation is not billed.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume