To compress an image from Python with the Blinkimg API you make four HTTP calls with requests: ask for an upload slot, PUT the bytes, create a job, then read the result link. The client below is 40 lines with no SDK. In my run it turned a 576.8 KB JPEG into a 93.9 KB AVIF with targetBytes: 100000.
What does the API flow look like?
The API has three endpoints you need, under https://api.blinkimg.com/v1:
-
POST /v1/uploadswithfilenameandcontentLength. It answers with anuploadIdand a signedurl. You PUT the file to thaturl. -
POST /v1/jobswith theuploadIdand anoptionsobject:format,qualityortargetBytes,resize,stripMetadata. -
GET /v1/jobs/{id}untilstatusleavesqueuedandprocessing, thenGET /v1/jobs/{id}/resultfor a signed link to the file.
Every call carries Authorization: Bearer <key>. You create the key from your account; the API docs list the endpoints and the OpenAPI description.
The whole client
import os, sys, time, uuid
from pathlib import Path
import requests
API = os.environ.get("BLINKIMG_API", "https://api.blinkimg.com/v1")
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['BLINKIMG_KEY']}"
class BlinkimgError(Exception):
pass
def call(method, path, **kw):
r = session.request(method, f"{API}{path}", timeout=30, **kw)
if r.status_code >= 400:
err = r.json().get("error", {})
raise BlinkimgError(f"{r.status_code} {err.get('code')}: {err.get('message')}")
return r.json()
def compress(src: Path, dest_dir: Path, **options) -> dict:
data = src.read_bytes()
# 1. an upload slot, then the bytes straight to storage (no auth header there)
upload = call("POST", "/uploads",
json={"filename": src.name, "contentLength": len(data)})
requests.put(upload["url"], data=data, timeout=60).raise_for_status()
# 2. the job; the Idempotency-Key makes a retried POST return the same job
job = call("POST", "/jobs",
json={"uploadId": upload["uploadId"], "options": options},
headers={"Idempotency-Key": str(uuid.uuid4())})
# 3. poll until the job leaves queued/processing
while job["status"] in ("queued", "processing"):
time.sleep(0.5)
job = call("GET", f"/jobs/{job['id']}")
if job["status"] != "succeeded":
raise BlinkimgError(f"job {job['status']}: {job.get('error')}")
link = call("GET", f"/jobs/{job['id']}/result")
out = dest_dir / link["filename"]
out.write_bytes(requests.get(link["url"], timeout=60).content)
return {"file": out.name, **job["result"]}
if __name__ == "__main__":
fmt, cap = sys.argv[1], int(sys.argv[2])
out_dir = Path("out"); out_dir.mkdir(exist_ok=True)
for name in sys.argv[3:]:
r = compress(Path(name), out_dir, format=fmt, targetBytes=cap)
print(f"{name:15} {r['inputBytes']:>9,} -> {r['outputBytes']:>7,} B "
f"{r['outputFormat']:5} {r['width']}x{r['height']} "
f"saved {r['savedPercent']}% {r['file']}")
Three choices in it are deliberate.
-
One
Session. The requests docs say a session reuses the underlying TCP connection to the same host (requests: Session objects). The polling loop makes several calls per file, so that saves a handshake each time. - A timeout on every call. By default requests do not time out unless you set one (requests: Timeouts). A script that runs overnight should never hang on one socket.
-
An
Idempotency-Keyon the job. The API accepts this header onPOST /v1/jobs. If you add a retry around that call and keep the same key, a repeated POST returns the first job instead of creating (and charging for) a second one.
What did it print?
export BLINKIMG_KEY=bk_...
python blinkimg_compress.py avif 100000 photo.jpg photo.png screenshot.png
python blinkimg_compress.py jpeg 50000 photo.jpg
photo.jpg 576,828 -> 93,922 B avif 1920x1280 saved 83.7% photo.avif
photo.png 4,096,344 -> 93,922 B avif 1920x1280 saved 97.7% photo.avif
screenshot.png 1,121,126 -> 99,028 B avif 2880x1800 saved 91.2% screenshot.avif
photo.jpg 576,828 -> 48,734 B jpeg 1920x1280 saved 91.6% photo.jpg
| Input | Bytes in | Job options | Bytes out |
|---|---|---|---|
| photo.jpg, 1920 × 1280 | 576.8 KB | avif, targetBytes 100000 | 93.9 KB |
| photo.png, same photo | 4,096.3 KB | avif, targetBytes 100000 | 93.9 KB |
| screenshot.png, 2880 × 1800 | 1,121.1 KB | avif, targetBytes 100000 | 99.0 KB |
| photo.jpg | 576.8 KB | jpeg, targetBytes 50000 | 48.7 KB |
Every output landed under its cap. The JPEG and the PNG of the same photo gave AVIFs of the same size, 93,922 bytes: the encoder sees the same pixels. And the 50 KB JPEG run matches the 48.7 KB the site publishes for that photo; how the site measures explains where its numbers come from.
Note: The upload
urlpoints at object storage, not at the API. That is why the client PUTs with plainrequests.putand not with the session: the session carries your Bearer key, and the key has no business going to the storage host. Send only the bytes there.
What does an error look like?
Every API error has the same body: {"error": {"code", "message", "requestId"}}. The call() helper turns it into one exception. With a wrong key (the message text came from my local stand-in; the code is the API's):
__main__.BlinkimgError: 401 UNAUTHORIZED: bad key
The codes you will meet most in a batch script are UNAUTHORIZED, FILE_TOO_LARGE, INSUFFICIENT_CREDITS and RATE_LIMITED. A 429 also carries a Retry-After header; sleep that many seconds and try again. Keyed requests are shaped at 120 writes a minute.
What does it cost?
Compressing an image costs 1 credit; converting it to another format costs 2. Every account gets 500 free credits a month. The four runs above were three conversions and one compress, so 7 credits on the real service.
How I measured
- Machine: Apple M1 Pro (8 cores), macOS, 2026-10-17.
- Python 3.9.6, requests 2.32.5 in a fresh venv.
- Files:
photo.jpg,photo.pngandscreenshot.pngfrom the site's reference set (apps/web/assets/reference/). - The API calls ran against a local stand-in of
/v1that I wrote from the API's published request and response contract (packages/shared/src/api/uploads.tsandjobs.ts), with the Blinkimg engine (processImage, the code the workers run) doing every encode. The byte counts are the engine's; the error body shape and codes come from the API's own schema.
What I did not test
I did not call the production api.blinkimg.com with a live key for this article, so I report no network timings. I did not test batches, /zip or retries under a real 429.
I build Blinkimg, an image compressor and converter with a developer API. I wrote this article with help from an AI assistant (Claude), which ran the code on my machine; every number in it comes from those runs.

Top comments (0)