Patu API Reference
Every request is authenticated with an X-Api-Key header. The base URL is https://patu.dev. Send the raw asset bytes as the request body. Patu optimizes images, SVG, JavaScript, CSS and fonts; non-image assets are routed by the request Content-Type (see Non-image assets). EPUB books, CBZ/CBR comic archives, Office documents and PDFs are optimized as a whole document (see EPUB optimization, CBZ/CBR optimization, Office optimization, PDF optimization).
POST /v1/compress
Compress one image. Returns the bytes (inline, default) or a JSON manifest (stored).
| Query | Default | Meaning |
|---|---|---|
mode | inline | inline returns the chosen variant's bytes; stored persists every variant and returns a manifest. |
formats | avif,webp,fallback | Comma list. fallback resolves to JPEG for photos, lossless WebP/PNG for graphics or transparency. |
widths | original | Comma list of pixel widths. Never upscaled past the source width. |
target | per key | The ssimulacra2 score to aim for (higher = closer to the original, larger file). Typical 75–85. |
src | — | A public http(s) URL to fetch as the input instead of a request body (send an empty body). Works with both modes. Private and internal addresses are refused; the fetch obeys your key's size limit exactly like an upload. Errors: 400 unfetchable URL, 413 too large, 502 unreachable. |
For inline mode the returned format also honours the Accept header when formats is not set.
Inline response
The image bytes, plus headers describing the result:
X-Patu-Format | chosen wire format (avif/webp/jpeg, or js/css/woff2/svg) |
X-Patu-Original-Bytes / X-Patu-Output-Bytes | input and output sizes |
X-Patu-Encoded-Bytes | for JS/CSS, the size after the edge compresses the response (Brotli/gzip) — what actually crosses the wire |
X-Patu-Latency-Ms | server-side processing time for this request, in milliseconds |
X-Patu-Integrity | Subresource Integrity token (sha384-…) of the bytes served, ready for <script>/<link> integrity="…" |
X-Patu-Score | ssimulacra2 score (measured, or expected on the fast path) |
X-Patu-Verified | true if the score was measured this request, false if predicted by the learner |
X-Patu-Cache | hit or miss on the in-process dedup cache |
X-Patu-Passthrough | true when no variant beat the input, so the original is returned unchanged |
Stored response (manifest)
{
"id": "<64-hex content hash>",
"url": "https://cdn.patu.dev/a/<id>",
"original": { "width": 1024, "height": 683, "bytes": 240133, "format": "jpeg" },
"variants": [
{ "format": "avif", "width": 1024, "height": 683, "bytes": 30215,
"score": 81.5, "url": "https://cdn.patu.dev/a/<id>/1024.avif",
"integrity": "sha384-…" }
],
"created_at": "2026-06-29T12:00:00Z"
}
The root url is one content-negotiated URL: the edge returns the best format the request's Accept allows. Each variant url is an explicit, immutable, content-addressed URL for one exact format and width. URLs are absolute on cdn.patu.dev when the CDN is enabled (the default), or pod-relative /v1/asset/<id>/… when it is off. Re-posting the same image with new widths extends the manifest with only the new widths; an identical request is de-duplicated and returns the existing manifest unchanged. Each variant carries its integrity token, and the edge serves it as X-Patu-Integrity.
Treat variant URLs as opaque — read them from the manifest rather than constructing them. When Patu's encoders improve, a re-posted asset is transparently re-encoded: the fresh manifest points at new variant URLs (a .vN name suffix), it reflects the formats and widths of that request, and every previously issued URL keeps serving its original bytes forever, so existing pages, caches and integrity pins are never broken.
Non-image assets: JS, CSS, SVG and fonts
The same /v1/compress endpoint (inline or mode=stored) optimizes text and fonts. They have no magic bytes, so route them with the request Content-Type.
| Content-Type | What Patu does |
|---|---|
text/javascript, text/css | Minifies with esbuild (whitespace and safe syntax only; identifiers are never renamed, so exported and global names survive). The edge compresses it (Brotli/gzip) on delivery. |
font/ttf, font/otf, font/woff, font/collection | Converts to WOFF2. Optional glyph subsetting (below). |
font/woff2 | Already the target format; served unchanged. |
image/svg+xml | Sanitized (scripts, event handlers and external refs removed, hardened CSP on delivery), then path- and structure-optimized with silk. |
Output never exceeds the input. X-Patu-Format is js, css, woff2 or svg, and X-Patu-Score does not apply (-1). Stored code and fonts are served from the edge like images, with the same integrity.
For JS and CSS, compare X-Patu-Encoded-Bytes, not X-Patu-Output-Bytes. Minification alone (Output-Bytes) is only a few percent; the real win is minify plus the edge's Brotli, which is what Encoded-Bytes reports (the size that crosses the wire): on a real corpus that is roughly -90% on JS and -93% on CSS versus the raw source.
| Query | Applies to | Meaning |
|---|---|---|
subset-unicodes | fonts | Subset to code-point ranges, e.g. 0-FF,20AC,2000-206F. Drops unused glyphs, the biggest font win. Hex, optional U+ prefix. |
subset-text | fonts | Subset to the glyphs covering this string (unioned with subset-unicodes). Good for a known, small character set; for a whole site compute the code points at build time and pass subset-unicodes. |
# minify a stylesheet curl -X POST "https://patu.dev/v1/compress" \ -H "X-Api-Key: $PATU_KEY" -H "Content-Type: text/css" \ --data-binary @app.css -o app.min.css # convert + subset a font to a Latin WOFF2 curl -X POST "https://patu.dev/v1/compress?subset-unicodes=0-FF,20AC" \ -H "X-Api-Key: $PATU_KEY" -H "Content-Type: font/ttf" \ --data-binary @Inter.ttf -o Inter.woff2
EPUB optimization
POST /v1/epub optimizes a whole EPUB container: embedded images go through the same quality search as /v1/compress, and TTF/OTF fonts are subset to the glyphs the book actually uses. Send the raw .epub bytes as the request body.
| Query | Default | Meaning |
|---|---|---|
profile | compat | Optimization profile. compat is the only profile P1 accepts; any other value is a 400. |
Quota is one unit per image processed plus one per font subsetted, counted from the manifest before any encode runs. A request rejected for quota, size or DRM consumes nothing.
Sync vs. async
Bodies at or under 10 MB (10,485,760 bytes) are optimized inline and returned directly in the response. Larger bodies are queued and answered with 202:
{ "id": "a1b2c3d4e5f67890", "status_url": "/v1/epub/a1b2c3d4e5f67890" }
Poll GET /v1/epub/{id}: 202 while the job is still running, 200 with the optimized .epub once it finishes. Async results expire 24 hours after completion. A pod restart drops any job still pending in memory; its GET then answers 404 — re-submit the original request.
Response headers
X-Patu-Original-Bytes / X-Patu-Output-Bytes | input and output container sizes |
X-Patu-Images-Processed | number of images actually re-encoded |
X-Patu-Fonts-Subset | number of fonts actually subset |
X-Patu-Quota-Used | the quota this request actually consumed (images processed + fonts subset) |
X-Patu-Passthrough | true when re-zipping the optimized items made no gain, so the original bytes are returned unchanged |
Max upload size by tier
| Tier | Max upload |
|---|---|
| Free | 25 MB |
| Basic | 25 MB |
| Pro | 50 MB |
| Business | 150 MB |
Errors
400 | not a valid epub container, or an unknown profile |
413 | upload over the tier's max size (above). Does not count against quota. |
422 | the book uses DRM encryption and cannot be optimized |
429 | monthly quota would be exceeded. Does not count against quota. |
# optimize a book (sync if <= 10 MB) curl -X POST "https://patu.dev/v1/epub" \ -H "X-Api-Key: $PATU_KEY" \ --data-binary @book.epub -o book.optimized.epub # a larger book: submit, then poll the async result curl -X POST "https://patu.dev/v1/epub" -H "X-Api-Key: $PATU_KEY" \ --data-binary @big-book.epub curl "https://patu.dev/v1/epub/a1b2c3d4e5f67890" -H "X-Api-Key: $PATU_KEY" \ -o big-book.optimized.epub
CBZ/CBR optimization
POST /v1/cbz optimizes a whole comic archive: every page goes through the same same-format quality search as /v1/compress (a JPEG page stays a JPEG, a PNG page stays a PNG), everything else in the archive - ComicInfo.xml, any other sidecar file - passes through untouched, and page order and names never change. Send the raw .cbz or .cbr bytes as the request body; a CBR (RAR) archive is transparently converted to CBZ.
| Query | Default | Meaning |
|---|---|---|
profile | compat | Optimization profile. compat is the only profile accepted; any other value is a 400. |
max_width | native | Resize every page down to at most this width before the search/re-encode (never upscaled). Integer, 0–8192; out of range is a 400. |
Quota is one unit per page processed, counted from the archive before any encode runs. A request rejected for quota or size consumes nothing.
Always async
Every /v1/cbz request is queued and answered with 202 - there is no synchronous path, since even a small comic archive's full-page quality search can take a while:
{ "id": "a1b2c3d4e5f67890", "status_url": "/v1/cbz/a1b2c3d4e5f67890" }
Poll GET /v1/cbz/{id}: 202 while the job is still running, 200 with the optimized .cbz once it finishes. Async results expire 24 hours after completion. A pod restart drops any job still pending in memory; its GET then answers 404 - re-submit the original request.
Response headers (on GET)
Content-Type | always application/vnd.comicbook+zip, whether the input was CBZ or CBR |
X-Patu-Container-Converted | cbz, present only when the input was a CBR archive converted to CBZ |
Errors
400 | not a valid cbz/cbr container, or an unknown profile/max_width |
413 | upload over the tier's max size, or the archive exceeds the decompressed-size budget. Does not count against quota. |
429 | monthly quota would be exceeded. Does not count against quota. |
# submit a comic archive, then poll the async result
curl -X POST "https://patu.dev/v1/cbz" -H "X-Api-Key: $PATU_KEY" \
--data-binary @issue-01.cbz
curl "https://patu.dev/v1/cbz/a1b2c3d4e5f67890" -H "X-Api-Key: $PATU_KEY" \
-o issue-01.optimized.cbz
Office optimization
POST /v1/office optimizes a whole Office document (.docx, .pptx, .odt, .odp, .ods): embedded images go through the same same-format quality search as /v1/compress (a JPEG image stays a JPEG, a PNG stays a PNG), everything else - markup, styles, relationships, embedded objects - passes through untouched. The document's format never changes: there is no conversion, no resize (no max_width) and no profile choice. Send the raw document bytes as the request body. .xlsx (OOXML spreadsheets) is refused by name - see Refusals below - .ods is accepted.
Quota is one unit per image processed, counted from the document before any encode runs. A request rejected for quota, size or one of the refusals below consumes nothing.
Sync vs. async
Bodies at or under 10 MB (10,485,760 bytes) are optimized inline and returned directly in the response. Larger bodies are queued and answered with 202:
{ "id": "a1b2c3d4e5f67890", "status_url": "/v1/office/a1b2c3d4e5f67890" }
Poll GET /v1/office/{id}: 202 while the job is still running, 200 with the optimized document once it finishes. Async results expire 24 hours after completion. A pod restart drops any job still pending in memory; its GET then answers 404 - re-submit the original request.
Refusals
A document is refused whole, before any encode, when it matches one of these - each gets its own distinct 400 message:
| Reason | Detail |
|---|---|
| Legacy OLE format | a pre-2007 binary .doc/.xls/.ppt - detected by its compound-file magic, not extension, so it is refused whether or not the file is misnamed |
| Digitally signed | the package contains a signature part (OOXML's _xmlsignatures/ or ODF's META-INF/documentsignatures.xml) - recompressing any entry would invalidate the signature |
| .xlsx spreadsheet | refused by file name, not content: spreadsheets carry effectively no embedded images worth optimizing. This check needs the client's original file name, which POST /v1/office has no way to learn from the request alone - it never fires over HTTP. It does fire from the patu CLI, which knows the real path. |
Errors
400 | not a valid office document, or one of the refusals above |
413 | upload over the tier's max size, or the document exceeds the decompressed-size budget. Does not count against quota. |
429 | monthly quota would be exceeded. Does not count against quota. |
# optimize a document (sync if <= 10 MB)
curl -X POST "https://patu.dev/v1/office" \
-H "X-Api-Key: $PATU_KEY" \
--data-binary @report.docx -o report.optimized.docx
PDF optimization
POST /v1/pdf optimizes a whole PDF and returns a PDF. Embedded JPEG images are re-encoded through the same quality search as /v1/compress (a JPEG stays a JPEG, a grayscale image stays grayscale), and wastefully encoded streams (ASCII85, RunLength, LZW, weak Flate) are recompressed losslessly. Text, fonts, vector drawings, links and forms are not touched; JPEG 2000, JBIG2 and CCITT images (typical of scans) are left as they are. Before the result ships, every page is rendered and compared with the original; if a page diverges, or if nothing got smaller, the original document comes back unchanged with X-Patu-Passthrough: true. target (ssimulacra2, default 85) sets the image quality goal. A PDF sent to POST /v1/compress goes through the same pipeline inline, up to 10 MB, and also comes back as a PDF.
Quota is one unit per embedded JPEG actually re-encoded; the lossless stream pass is free. A request rejected for quota, size or one of the refusals below consumes nothing.
Sync vs. async
Bodies at or under 10 MB (10,485,760 bytes) are optimized inline and returned directly in the response. Larger bodies are queued and answered with 202:
{ "id": "a1b2c3d4e5f67890", "status_url": "/v1/pdf/a1b2c3d4e5f67890" }
Poll GET /v1/pdf/{id}: 202 while the job is still running, 200 with the optimized PDF once it finishes. Async results expire 24 hours after completion. A pod restart drops any job still pending in memory; its GET then answers 404 - re-submit the original request.
Refusals
A document is refused whole, before any encode, when it matches one of these - each gets its own distinct 400 message:
| Reason | Detail |
|---|---|
| Encrypted | rewriting would drop or break the encryption |
| Digitally signed | any change to the bytes invalidates the signature |
| PDF/A | archival conformance would not survive the rewrite unchecked |
Errors
400 | not a readable PDF, a structure Patu cannot rewrite safely, or one of the refusals above |
413 | upload over the tier's max size; on /v1/compress, a PDF over 10 MB (use /v1/pdf). Does not count against quota. |
429 | monthly quota would be exceeded. Does not count against quota. |
# optimize a PDF (sync if <= 10 MB)
curl -X POST "https://patu.dev/v1/pdf" \
-H "X-Api-Key: $PATU_KEY" \
--data-binary @invoice.pdf -o invoice.optimized.pdf
POST /v1/compress/batch
Compress many images in one request (stored mode only). Send multipart/form-data with one file part per image; widths, formats, target and async apply to all parts.
{
"results": [
{ "id": "…", "original": {…}, "variants": [{…}], "created_at": "…" },
{ "error": "image exceeds the pixel limit", "status": 413 }
]
}
One slot per input image, in order. A bad image fails only its slot, not the batch. With async=1 the response is 202 and each slot is a pending manifest (its URLs answer 202 until encoded). Each image counts as one monthly-quota unit; the whole batch is one rate-limit unit. Limits: at most PATU_BATCH_MAX (20) parts and a bounded total payload size per request.
GET /v1/asset/{id}
Fetch a stored asset by content negotiation. The response carries ETag, Vary: Accept and a cacheable Cache-Control.
| Form | Returns |
|---|---|
GET /v1/asset/{id} | The largest width, in the best format the Accept header allows (preference AVIF > WebP > JPEG). |
GET /v1/asset/{id}?w=768 | The smallest stored width ≥ 768 (or the largest available if none qualifies), negotiated by format. |
GET /v1/asset/{id}/{width}.{ext} | One exact variant, e.g. /v1/asset/{id}/768.avif. Immutable, cached hard. |
Negotiation follows the Accept header (RFC 9110, including q=0 exclusions): Accept: image/jpeg returns JPEG, image/avif;q=0 skips AVIF, and */* or no header returns the best (AVIF). For deterministic format control regardless of the client, use the explicit {width}.{ext} URLs (e.g. in <source type> tags).
DELETE /v1/asset/{id}
Remove a stored asset you own (requires X-Api-Key). Returns 204 on success, 404 when the asset does not exist or is not yours. Assets are content-addressed and de-duplicated across accounts: deleting removes your reference, and the underlying files are physically deleted only when no other account still references the same content. Deletion is origin-side lifecycle cleanup — already-cached copies at the CDN edge drain on their own TTL.
Other endpoints
GET /v1/stats | Authenticated. The learner's current per-format quality seeds, expected scores and sample counts. |
GET /healthz, GET /readyz | Liveness and readiness. No auth. |
GET /metrics | Prometheus metrics (request counts by class/format/status, latency histogram, bytes saved). |
Limits & backpressure
Per-key limits keep one tenant from saturating the service. The values below are the Free tier; paid tiers raise them. Honour Retry-After and you will pace yourself optimally.
| Limit | Free tier | On exceed |
|---|---|---|
| Rate | 60 req/min per key | 429 + Retry-After |
| Daily quota | 25 req/day per key | 429 + Retry-After (seconds until 00:00 UTC) |
| Monthly quota | 750 req/month per key | 429 + Retry-After (seconds until the 1st, 00:00 UTC) |
| Concurrent encodes | 4 in flight | 503 + Retry-After: 2, retry, do not treat as failure |
Both quota windows reset at 00:00 UTC (daily every night, monthly on the 1st). A request rejected with 429 or 413 does not count against your quota. A 503 means the encoders are momentarily full, not that the request was bad: back off for Retry-After seconds and resend. Cache hits and re-posts of already-stored images are never rate-limited by the encoder. widths are never upscaled past the source width, so a request for a width larger than the original returns the original width instead.
Errors
401 | missing or invalid X-Api-Key |
400 | unsupported or corrupt image |
413 | payload over 25 MB (25,000,000 bytes), image over 25,000,000 pixels, or an animated clip past the frame/frame-pixel limit. Does not count against quota. |
429 | rate, daily, or monthly quota exceeded, see Retry-After. Does not count against quota. |
503 | encoders momentarily full, retry after Retry-After |
501 | stored mode requested but storage is not configured |
Error bodies are JSON: {"error": "..."}.