Patu

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).

QueryDefaultMeaning
modeinlineinline returns the chosen variant's bytes; stored persists every variant and returns a manifest.
formatsavif,webp,fallbackComma list. fallback resolves to JPEG for photos, lossless WebP/PNG for graphics or transparency.
widthsoriginalComma list of pixel widths. Never upscaled past the source width.
targetper keyThe 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-Formatchosen wire format (avif/webp/jpeg, or js/css/woff2/svg)
X-Patu-Original-Bytes / X-Patu-Output-Bytesinput and output sizes
X-Patu-Encoded-Bytesfor JS/CSS, the size after the edge compresses the response (Brotli/gzip) — what actually crosses the wire
X-Patu-Latency-Msserver-side processing time for this request, in milliseconds
X-Patu-IntegritySubresource Integrity token (sha384-…) of the bytes served, ready for <script>/<link> integrity="…"
X-Patu-Scoressimulacra2 score (measured, or expected on the fast path)
X-Patu-Verifiedtrue if the score was measured this request, false if predicted by the learner
X-Patu-Cachehit or miss on the in-process dedup cache
X-Patu-Passthroughtrue 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-TypeWhat Patu does
text/javascript, text/cssMinifies 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/collectionConverts to WOFF2. Optional glyph subsetting (below).
font/woff2Already the target format; served unchanged.
image/svg+xmlSanitized (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.

QueryApplies toMeaning
subset-unicodesfontsSubset to code-point ranges, e.g. 0-FF,20AC,2000-206F. Drops unused glyphs, the biggest font win. Hex, optional U+ prefix.
subset-textfontsSubset 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.

QueryDefaultMeaning
profilecompatOptimization 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-Bytesinput and output container sizes
X-Patu-Images-Processednumber of images actually re-encoded
X-Patu-Fonts-Subsetnumber of fonts actually subset
X-Patu-Quota-Usedthe quota this request actually consumed (images processed + fonts subset)
X-Patu-Passthroughtrue when re-zipping the optimized items made no gain, so the original bytes are returned unchanged

Max upload size by tier

TierMax upload
Free25 MB
Basic25 MB
Pro50 MB
Business150 MB

Errors

400not a valid epub container, or an unknown profile
413upload over the tier's max size (above). Does not count against quota.
422the book uses DRM encryption and cannot be optimized
429monthly 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.

QueryDefaultMeaning
profilecompatOptimization profile. compat is the only profile accepted; any other value is a 400.
max_widthnativeResize 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-Typealways application/vnd.comicbook+zip, whether the input was CBZ or CBR
X-Patu-Container-Convertedcbz, present only when the input was a CBR archive converted to CBZ

Errors

400not a valid cbz/cbr container, or an unknown profile/max_width
413upload over the tier's max size, or the archive exceeds the decompressed-size budget. Does not count against quota.
429monthly 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:

ReasonDetail
Legacy OLE formata 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 signedthe package contains a signature part (OOXML's _xmlsignatures/ or ODF's META-INF/documentsignatures.xml) - recompressing any entry would invalidate the signature
.xlsx spreadsheetrefused 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

400not a valid office document, or one of the refusals above
413upload over the tier's max size, or the document exceeds the decompressed-size budget. Does not count against quota.
429monthly 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:

ReasonDetail
Encryptedrewriting would drop or break the encryption
Digitally signedany change to the bytes invalidates the signature
PDF/Aarchival conformance would not survive the rewrite unchecked

Errors

400not a readable PDF, a structure Patu cannot rewrite safely, or one of the refusals above
413upload over the tier's max size; on /v1/compress, a PDF over 10 MB (use /v1/pdf). Does not count against quota.
429monthly 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.

FormReturns
GET /v1/asset/{id}The largest width, in the best format the Accept header allows (preference AVIF > WebP > JPEG).
GET /v1/asset/{id}?w=768The 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/statsAuthenticated. The learner's current per-format quality seeds, expected scores and sample counts.
GET /healthz, GET /readyzLiveness and readiness. No auth.
GET /metricsPrometheus 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.

LimitFree tierOn exceed
Rate60 req/min per key429 + Retry-After
Daily quota25 req/day per key429 + Retry-After (seconds until 00:00 UTC)
Monthly quota750 req/month per key429 + Retry-After (seconds until the 1st, 00:00 UTC)
Concurrent encodes4 in flight503 + 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

401missing or invalid X-Api-Key
400unsupported or corrupt image
413payload 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.
429rate, daily, or monthly quota exceeded, see Retry-After. Does not count against quota.
503encoders momentarily full, retry after Retry-After
501stored mode requested but storage is not configured

Error bodies are JSON: {"error": "..."}.