Batch form uploads
Post a batch of images to the HTTP API from curl, Python, and Ruby, reading X-Result-Bytes and handling target-unreachable.
The same POST /v1/compress call from three languages, compressing a directory of images and
reporting what happened to each one — including the one honest failure this endpoint can hand
back, target-unreachable.
Every sample globs in/*.jpg. Widen that to PNG and WebP freely — but not to HEIC or AVIF: this
endpoint accepts JPEG, PNG, and WebP only and refuses the other two with
415 unsupported-format, so a directory straight off a phone needs converting before any of this
runs. See Limits for why.
curl
#!/usr/bin/env bash
set -euo pipefail
API_KEY="${IKB_API_KEY:?set IKB_API_KEY}"
TARGET="50kb"
mkdir -p out
for file in in/*.jpg; do
out="out/$(basename "$file")"
response=$(curl -sS -w '\n%{http_code}' \
"https://api.imagekbreducer.com/v1/compress" \
-H "Authorization: Bearer $API_KEY" \
-F "file=@$file" \
-F "target=$TARGET" \
-D - \
-o "$out")
status=$(tail -n1 <<<"$response")
if [ "$status" = "200" ]; then
result_bytes=$(grep -i '^x-result-bytes:' <<<"$response" | tr -d '\r' | cut -d' ' -f2)
echo "$file -> $out ($result_bytes bytes)"
else
echo "$file: failed with status $status" >&2
rm -f "$out"
fi
done
-D - prints response headers to stdout alongside the body’s status line, which is how this
extracts X-Result-Bytes without a second request. For the error body’s JSON shape on a non-200
(400/401/413/415/422/…), pipe the same response through jq instead of writing straight to a file
— see the Python example below for that shape read directly.
Python (requests)
import os
import sys
from pathlib import Path
import requests
API_KEY = os.environ["IKB_API_KEY"]
TARGET = "50kb"
API_URL = "https://api.imagekbreducer.com/v1/compress"
def compress_one(path: Path, out_dir: Path) -> None:
with path.open("rb") as f:
response = requests.post(
API_URL,
headers={"Authorization": f"Bearer {API_KEY}"},
files={"file": (path.name, f)},
data={"target": TARGET},
timeout=90, # see /api/limits -- WALL_CLOCK_MS is not a reliable server-side bound
)
if response.status_code == 200:
out_path = out_dir / path.name
out_path.write_bytes(response.content)
print(f"{path} -> {out_path} ({response.headers['X-Result-Bytes']} bytes, "
f"{response.headers['X-Attempts']} attempts)")
return
body = response.json()
if body.get("error") == "target-unreachable":
# An answer, not a failure: body["closest"] is the smallest size the
# search actually reached, and body["suggestions"] names the levers
# that would make `target` reachable. See /api/errors.
print(f"{path}: could not reach {TARGET} -- closest was {body['closest']} bytes "
f"({body['reason']})", file=sys.stderr)
for s in body.get("suggestions", []):
print(f" try: {s}", file=sys.stderr)
else:
print(f"{path}: {response.status_code} {body.get('error')} -- {body.get('message')}",
file=sys.stderr)
def main() -> None:
in_dir, out_dir = Path("in"), Path("out")
out_dir.mkdir(exist_ok=True)
for path in sorted(in_dir.glob("*.jpg")):
compress_one(path, out_dir)
if __name__ == "__main__":
main()
Ruby (Net::HTTP)
require "net/http"
require "uri"
require "json"
API_KEY = ENV.fetch("IKB_API_KEY")
TARGET = "50kb"
API_URL = URI("https://api.imagekbreducer.com/v1/compress")
def compress_one(path, out_dir)
request = Net::HTTP::Post.new(API_URL)
request["Authorization"] = "Bearer #{API_KEY}"
file = File.open(path)
begin
request.set_form([["file", file, { filename: File.basename(path) }], ["target", TARGET]], "multipart/form-data")
# read_timeout defaults to 60s, and /api/limits records a successful
# response that took 79 -- WALL_CLOCK_MS is not a reliable server-side bound.
response = Net::HTTP.start(API_URL.host, API_URL.port, use_ssl: true, read_timeout: 90) do |http|
http.request(request)
end
ensure
file.close
end
case response
when Net::HTTPOK
out_path = File.join(out_dir, File.basename(path))
File.binwrite(out_path, response.body)
puts "#{path} -> #{out_path} (#{response["X-Result-Bytes"]} bytes, " \
"#{response["X-Attempts"]} attempts)"
else
body = JSON.parse(response.body)
if body["error"] == "target-unreachable"
# An answer, not a failure -- see /api/errors.
warn "#{path}: could not reach #{TARGET} -- closest was #{body["closest"]} bytes " \
"(#{body["reason"]})"
body.fetch("suggestions", []).each { |s| warn " try: #{s}" }
else
warn "#{path}: #{response.code} #{body["error"]} -- #{body["message"]}"
end
end
end
Dir.mkdir("out") unless Dir.exist?("out")
Dir.glob("in/*.jpg").sort.each { |path| compress_one(path, "out") }
Handling target-unreachable
All three examples above treat 422 target-unreachable as a distinct case from every other
failure, because it is one: the response carries closest (the smallest size the search actually
reached) and suggestions (the specific levers — switch format, raise the target, allow
downscaling — that would make target reachable). A batch job that just logs every non-200 as
“failed” throws that information away. See Errors for the full shape and
Give-up codes for what each reason means.
Concurrency
Nothing above sends requests concurrently — each loop is sequential, one file at a time. If you parallelise this yourself, keep each key’s request rate under the 60-per-60-seconds limit (see Authentication) and read Limits before assuming a slow request will time out on its own; it may not.