Optimize API
Request
POST /v1/optimize
POST /v1/optimize?async=true
multipart/form-data with two fields:
| Field | Required | Description |
|---|---|---|
image | yes | The image file. |
data | no | A JSON string with the options below. |
curl -X POST https://api.helloimg.io/v1/optimize \
-H "Authorization: Bearer <your API key>" \
-F "[email protected]" \
-F 'data={"level":"ultra","convert":"avif","resize":{"width":1600}}'
data fields
| Field | Type | Default | Description |
|---|---|---|---|
level | string | normal | lossless | normal | aggressive | ultra — an alias for the boolean flags below. |
normal / aggressive / ultra | boolean | normal: true | The same levels as booleans, if you'd rather not use level. |
lossless | boolean | false | Encode without lossy compression. For a JPEG target this produces a PNG instead — JPEG has no lossless mode. |
convert | string | null | null | Target format: jpeg, jpg, png, webp, avif, or gif. Omit to keep the source format. |
keep_exif | boolean | true | Keep EXIF metadata (camera, GPS, etc.) in the output. |
resize | object | — | See below. |
level and the boolean flags both work — level just sets the
corresponding flag for you. If both are sent, the explicit boolean wins.
resize
| Field | Type | Description |
|---|---|---|
width | number | Target width in pixels. |
height | number | Target height in pixels. |
percent | number | Resize by percentage instead of absolute dimensions. |
smartcrop | boolean | Subject-aware crop to an exact width x height instead of a proportional resize — see Smart crop. Requires both width and height. |
As a shortcut, you can send max_width / max_height at the top level of
data instead of a resize object — it fits the image inside that box
without upscaling, and is equivalent to resize: { width, height }.
Response
No async flag (default): the request blocks until the job finishes
and returns the result directly — 200 on success, 422 if processing
failed, 408 if it didn't finish within the timeout (poll /v1/jobs/{job_id}
yourself instead, using the job_id and poll_url in the timeout body).
?async=true: returns immediately with 202 and a job to poll:
{
"job_id": "job_5f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c",
"status": "processing",
"poll_url": "/v1/jobs/job_5f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c"
}
Either way, if the exact same image and options were already optimized
recently, you get the cached result back immediately with 200 instead
of a job — see Job polling for the result shape.
Multiple formats in one call
POST /v1/optimize/multi takes the same image field, but data carries
a formats array (up to 5) instead of a single convert/level:
curl -X POST "https://api.helloimg.io/v1/optimize/multi?async=true" \
-H "Authorization: Bearer <your API key>" \
-F "[email protected]" \
-F 'data={"formats":[{"format":"jpeg","level":"normal"},{"format":"webp","level":"aggressive"},{"format":"avif","level":"aggressive"}]}'
Each entry in formats is { "format": "jpeg" | "png" | "webp" | "avif", "level"?: string }.
This produces one job per format in parallel; poll the returned job_id
the same way as a single optimize call — see Job polling
for the multi-format response shape.
resize (and its max_width / max_height alias, including smartcrop)
work the same way here as on a single-format call — set it at the top
level of data, alongside formats, and it applies to every format in
the batch.
level field, an unrecognized level string inside
one of the formats[] entries is not rejected — it's silently treated
as normal. Double-check spelling ("ultra", not "utlra") rather than
relying on a 400 to catch it.Multi-format errors
/v1/optimize/multi validates its formats array before the shared
sizing/format checks below run:
| Status | Body | Cause |
|---|---|---|
400 | { "error": "Missing 'data' field with formats array" } | No data field, or it has no formats array. |
400 | { "error": "At least one format required" } | formats is an empty array. |
400 | { "error": "Maximum 5 formats allowed" } | formats has more than 5 entries. |
400 | { "error": "Invalid format: ..." } | An entry's format isn't jpeg, jpg, png, webp, or avif. Unlike single-format /v1/optimize, gif isn't accepted here. |
Errors
| Status | Body | Cause |
|---|---|---|
400 | { "error": "Missing 'image' field" } | No image file in the request. |
400 | { "error": "Unsupported convert target: ...", "code": "invalid_request", "supported": [...] } | convert isn't one of the supported targets. |
400 | { "error": "...", "code": "invalid_resize" } | resize.smartcrop was sent without both width and height, or combined with percent. |
400 | { "error": "Invalid level: ...", "code": "invalid_level" } | level isn't one of the four accepted values. |
413 | { "error": "File size exceeds 10MB limit", "code": "file_too_large", "max_size": 10485760 } | Image is larger than 10MB. |
401 | { "error": "invalid api key" } | Missing or malformed Authorization header. Applies to every /v1/* route — one shared check in front of all of them. |
429 | { "error": "monthly image limit reached", "code": "limit_reached" } | Free plan, 1,000 images already processed this calendar month — see Plans & limits. |