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.

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.
| Item | What the page states |
|---|---|
| Method and path | PUT /playlists/{playlist_id}/images |
| Body | Base64 encoded JPEG image data |
| Maximum payload | 256 KB |
| Scopes | ugc-image-upload, playlist-modify-public, playlist-modify-private |
| Success response | 202 |
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
nup 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.costfield 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
- Spring AI MCP client request-timeout 20s vs Sume jobs_wait
Spring AI's MCP client defaults request-timeout to 20s, shorter than a 50s Sume jobs_wait. Raise it with a customizer or keep waits short and re-issue them.
- Store the Idempotency-Key before you submit: a SQLite intent table
Write key and body to SQLite, submit with that key, then store the job id. A crash between steps replays the same key and returns the original Sume job.
- SSML in text to speech: Sume takes a plain transcript, no ssml field
Does Sume's text to speech accept SSML? The tts_create body has a plain transcript and rejects unknown keys. What to use for speed, volume, emotion and pauses.
- Stippled AI graphics turn to gray mush when resized: a downscale test
A dotted AI graphic can lose most of its contrast when downscaled. A Pillow test of nearest, bilinear and Lanczos on a stipple, and what to request instead.
Written by Sume