HEIC to JXL API
POST a HEIC to /v2/convert/heic/to/jxl and get a JPEG XL file encoded from the decoded, upright photo. ImageQuality sets the trade-off, 75 unless you send one: the sample at 90 is 21,428 bytes against 22,517 for the HEIC, and 100 keeps every pixel.
- Endpoint
- POST /v2/
convert/ heic/ to/ jxl - Accepts
- heic
- Sample timing
- 69 ms median of 5 runsHow we measure
- Request size
- Up to 10 MB on the Free plan or 100 MB on paid plans, all files together
Request and response
Every sample below ran against the API before this page was published. Set PDFCONVERSIONAPI_KEY to your key and run it as is.
curl -sS -X POST \
https://api.pdfconversionapi.com/v2/convert/heic/to/jxl \
-H "Authorization: Bearer $PDFCONVERSIONAPI_KEY" \
-F File=@harbor-dusk.heic \
--form-string ImageQuality=90 \
-o response.json
# FileData is base64. Save the first file in converted/,
# or print the JSON error.
if jq -e '.Files' response.json > /dev/null; then
mkdir -p converted
name=$(jq -r '.Files[0].FileName' response.json)
jq -r '.Files[0].FileData' response.json \
| base64 --decode > "converted/$name"
else
cat response.json
fiimport base64
import os
import requests
key = os.environ["PDFCONVERSIONAPI_KEY"]
response = requests.post(
"https://api.pdfconversionapi.com/v2/convert/heic/to/jxl",
headers={"Authorization": f"Bearer {key}"},
files=[("File", open("harbor-dusk.heic", "rb"))],
data={
"ImageQuality": "90",
},
timeout=180,
)
result = response.json()
if not response.ok:
raise SystemExit(f"{result['Code']}: {result['Message']}")
os.makedirs("converted", exist_ok=True)
for file in result["Files"]:
path = os.path.join("converted", file["FileName"])
with open(path, "wb") as out:
out.write(base64.b64decode(file["FileData"]))
print(path, file["FileSize"], "bytes")
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
const form = new FormData();
const file = "harbor-dusk.heic";
form.append("File", new Blob([await readFile(file)]), file);
form.append("ImageQuality", "90");
const url = "https://api.pdfconversionapi.com/v2/convert/heic/to/jxl";
const key = process.env.PDFCONVERSIONAPI_KEY;
const response = await fetch(url, {
method: "POST",
headers: { Authorization: `Bearer ${key}` },
body: form,
});
const result = await response.json();
if (!response.ok) {
throw new Error(`${result.Code}: ${result.Message}`);
}
await mkdir("converted", { recursive: true });
for (const file of result.Files) {
const path = join("converted", file.FileName);
await writeFile(path, Buffer.from(file.FileData, "base64"));
console.log(path, file.FileSize, "bytes");
}
{
"ConversionCost": 1,
"ConversionTime": 0.069,
"Files": [
{
"FileName": "harbor-dusk.jxl",
"FileExt": "jxl",
"FileSize": 21428,
"FileData": "/wpYuQEAE4gCAKzTJULt/YJN…"
}
]
}Response from our test run on 2026-11-03. ConversionTime shows the median of 5 runs. FileData is shortened here; the real response carries the whole file as base64.
The sample, converted
The same file the code sends. Download the input and the output and compare them yourself.
- Input
- harbor-dusk.heic (22 KB)
- Output
- harbor-dusk.jxl (21 KB)
- Source
- A harbor at dusk that we drew ourselves, saved as a 400 x 300 HEIC stored sideways with a rotation flag and made-up camera and GPS tags. (CC0-1.0). How it was made
- Made by
- 2026-11-04, API build
9eeeb0640aba
Parameters
Send these as form fields next to the file. Names are case-insensitive. If you set one of the documented options below that we don't support yet, the request fails with a 400 that names it instead of returning a file made without it.
| Parameter | What it does |
|---|---|
ImageQualityinteger, default 75 | The encoder's visual quality. For the sample: 30 gave 3,790 bytes, 75 gave 8,157 (36 dB peak signal-to-noise against the decoded HEIC), 90 gave 21,428 (40.5 dB), and 100 a lossless 75,734.Values: 1–100 |
ImageWidthinteger | Scales the photo before encoding: 200 gave 200 × 150 in 3,334 bytes at the default quality.Values: 1–20000 |
ImageHeightinteger | Target height, or with ImageWidth a box to fit the photo into.Values: 1–20000 |
ScaleProportionsboolean, default true | false uses both sizes as given, distorting the photo. |
ScaleIfLargerboolean, default false | true only ever shrinks. |
ScaleImageboolean, default true | false crops the top-left area at the requested size. |
ColorSpaceenum, default default | gray writes a grayscale JPEG XL (6,741 bytes at quality 75). monochrome thresholds to black and white before encoding (7,060 bytes at 75), but the lossy encode brings back gray: 7,326 of the 120,000 pixels decode to neither black nor white. At ImageQuality=100 it stays two-level, in 834 bytes.Values: default, rgb, gray, monochrome |
ImageResolutioninteger | Accepted for ConvertAPI compatibility; the file we write records no resolution, so 300 and 200 gave identical bytes.Values: 10–800 |
FileNamestring | Name of the .jxl file without its extension. |
StoreFileboolean, default false | true returns a link valid for 3 hours instead of base64 data. |
What carries over, and what doesn't
Kept
- At quality 100, every decoded pixel: our decode of the 75,734-byte file matched the HEIC to PNG output exactly
- At 90, close detail: average error under 2 levels per channel, 40.5 dB
- 8-bit sRGB color, stated in the file's header
- The upright view and the 400 × 300 size
Lost or not yet supported
- A generation of quality below 100: the encode is lossy on top of the HEIC's own compression; at 75 the worst pixel moved 59 levels.
- EXIF, XMP and GPS. The file is a bare JPEG XL codestream without metadata boxes.
- A preview on this site: our API doesn't read JPEG XL, so the sample offers a download only.
- Tones of HEIC files tagged with an embedded Display P3 profile, stretched by the decoder (gray 235 became 255).
Limits
- One HEIC per request, up to 100,000,000 decoded pixels.
- Requests up to 10 MB on the Free plan or 100 MB on paid plans; 120 seconds per conversion.
- There is no
heif/to/jxlroute (404). A .heif file can be sent here instead: our transparent HEIF sticker gave a JPEG XL with an alpha channel. - HEIC files saved at a desktop encoder's top quality setting returned 422 'malformed data'.
Where teams use it
Archiving phone photos in an open format
Opening a HEIC takes an HEVC decoder. JPEG XL at quality 100 keeps the decoded pixels exactly, so an archive can drop the HEIC without losing anything that was visible.
Smaller web images where JPEG XL is shown
At the default quality the sample is 8,157 bytes, about a third of the HEIC. Serve it where clients accept image/jxl and keep a JPEG fallback from heic/to/jpg for the rest.
Questions
Is the JPEG XL smaller than the HEIC?
At the default quality, yes: 8,157 bytes against 22,517 for the sample. At 90 they are about even (21,428 bytes), and lossless output is more than three times the HEIC.
Is quality 100 really lossless?
Yes, relative to the decoded HEIC. The header reports a possibly lossless 8-bit RGB image, and decoding it gave the same pixels as our HEIC to PNG output, all 120,000 of them.
Can I send a .heif file?
Not to a .heif route, which doesn't exist for JPEG XL. Post it to /v2/convert/heic/to/jxl: the decoder reads the content, not the name. A HEIF with an alpha plane came back as an RGB plus alpha JPEG XL.
What does `ImageQuality` mean here?
It is passed to the JPEG XL encoder as its quality setting, from 1 to 100, where 100 means lossless. 75 is our default and also ConvertAPI's documented default for this pair.
Convert your first HEIC file in a minute.