@tinify-dev/client
v0.1.3
Published
Zero-dependency TypeScript client for the Tinify.dev image API: compress, resize, crop, convert, batches, and usage.
Maintainers
Readme
@tinify-dev/client
Zero-dependency TypeScript client for the Tinify.dev image API: compress, resize, crop, convert, durable batches, and account usage.
- Zero runtime dependencies — built on the platform
fetch,FormData, andBlob. - Node.js >= 20, dual ESM + CJS, fully typed.
- Honest results — compression never returns more bytes than you sent; when the API cannot shrink a file it says so via
optimized: false. (Resize, crop, and conversion report byte changes plainly — conversion can legitimately grow a file.) - Safe by default — automatic idempotency keys, retries with
Retry-Aftersupport, typed error codes withrequest_idfor support.
Not affiliated with TinyPNG's
tinifypackage. This client talks to the Tinify.dev API.
Install
npm install @tinify-dev/clientQuickstart
import { TinifyClient } from "@tinify-dev/client";
const client = new TinifyClient({ apiKey: process.env.TINIFY_API_KEY });
const result = await client.compress("./photo.png", { quality_mode: "balanced" });
console.log(result.data.original_bytes, "->", result.data.result_bytes);
if (result.data.optimized === false) {
console.log("Already as small as it gets — the original bytes were returned.");
}
const blob = await client.download(result);
await fs.writeFile("./photo.min.png", new Uint8Array(await blob.arrayBuffer()));CJS works too:
const { TinifyClient } = require("@tinify-dev/client");Authentication
Every call needs an API key (tnf_live_* for production, tnf_test_* for development), created at tinify.dev/developers. Pass it as apiKey or set the TINIFY_API_KEY environment variable. Keys are sent as Authorization: Bearer <key>.
new TinifyClient({
apiKey: "tnf_live_...", // default: process.env.TINIFY_API_KEY
baseUrl: "https://api.tinify.dev", // default: process.env.TINIFY_BASE_URL or this value
maxRetries: 3, // retries after the first attempt (429/503/network)
timeoutMs: 60_000, // per-attempt timeout
fetch: customFetch, // injectable, e.g. for proxies or tests
});Inputs
All image methods accept a file path (Node.js only), Buffer, Uint8Array, ArrayBuffer, Blob, or File.
Methods
| Method | Endpoint | Notes |
| --- | --- | --- |
| compress(input, { quality_mode?, target_size_bytes? }) | POST /api/v1/images/compress | quality_mode: balanced (default), best_quality, lossless. target_size_bytes is beta (server rollout). |
| resize(input, { width?, height?, scale?, keep_aspect_ratio?, optimize? }) | POST /api/v1/images/resize | At least one of width/height/scale. |
| crop(input, { x, y, width, height }) | POST /api/v1/images/crop | |
| convert(input, { format, quality_mode? }) | POST /api/v1/images/convert | Beta (server rollout). format: avif, webp, jpeg, png. |
| usage() | GET /api/v1/usage | { plan, period_start, period_end, included, reserved, used, remaining }. |
| download(resultOrUrl) | result download_url | Returns a Blob. URLs are unauthenticated and expire after 2 hours. |
Every call resolves to { data, requestId, rateLimit }:
const { data, requestId, rateLimit } = await client.compress(input);
// rateLimit: { limit, remaining, reset } from the X-RateLimit-* headers (or null)
// requestId: quote this when contacting supportlossless is rejected for JPEG inputs (lossless_not_supported) because JPEG has no pixel-preserving lossless mode — the API refuses to pretend otherwise.
When compress runs with target_size_bytes, the result includes target_size_achieved: true when the output met the byte budget, false when it could not. The field is absent otherwise.
Batch lifecycle
Batches (Developer/Pro plans) process up to 200 files of up to 40 MB each (JPEG, PNG, WebP, AVIF) durably on the server. The operation is compress, resize, crop, or convert, and options mirror the synchronous endpoints — convert requires format (avif, webp, jpeg, png; files already in the target format fail per job with same_format_conversion), and compress accepts quality_mode plus target_size_bytes (skipped per job when it is not smaller than that job's input):
createBatch(manifest) 201 status: awaiting_upload
| returns uploads[]: { file_id, client_id, upload_url, headers }
v
uploadBatchFiles(session, files) PUT raw bytes to each presigned upload_url
| (exact headers passed through, NO Authorization header)
v
commitBatch(id) 202 status: queued
|
waitForBatch(id) polls getBatch(id): queued -> processing -> terminal
| terminal: succeeded | partially_succeeded | failed
| | canceled | expired
v
downloadBatchArchive(id) 200 ZIP of the successful resultsconst session = await client.createBatch({
operation: "compress",
options: { quality_mode: "balanced" },
files: [
{ client_id: "hero", filename: "hero.png", size_bytes: 812_331, content_type: "image/png" },
{ client_id: "logo", filename: "logo.jpg", size_bytes: 41_022, content_type: "image/jpeg" },
],
});
await client.uploadBatchFiles(session.data, {
hero: "./hero.png",
logo: "./logo.jpg",
}); // concurrency 4 by default
await client.commitBatch(session.data.id);
const finished = await client.waitForBatch(session.data.id); // 1s polls growing x1.5, cap 10s, 10 min budget
for (const file of finished.data.files) {
console.log(file.client_id, file.status, file.original_bytes, "->", file.result_bytes);
}
const zip = await client.downloadBatchArchive(session.data.id);Notes:
size_bytesandcontent_typein the manifest must exactly match the bytes you upload — mismatches fail the commit withuploads_incomplete(the offendingclient_ids are listed inerror.details.client_ids).- Each
client_idmust be unique (duplicate_client_id). - Batch download URLs and archives follow the same 2-hour expiry as synchronous results.
cancelBatchis idempotent; canceling a finished batch is a no-op.
Retries and idempotency
- Every mutating call automatically sends an
Idempotency-Keyheader (a fresh UUID per logical call, reused across retries of that call). Override it with{ idempotencyKey: "your-key" }(8–160 chars) to make your own retries safe across process restarts. - The client retries only 429, 503, and network failures — never any other 4xx/5xx.
Retry-After(seconds or HTTP-date) is honored when present; otherwise exponential backoff with full jitter:random(0, min(8s, 500ms * 2^attempt)).- Reusing an idempotency key with a different request body yields
idempotency_conflict(409).
Errors
All API failures throw TinifyApiError with status, code, message, requestId, details, and (on 429/503) retryAfter seconds. Transport failures throw TinifyNetworkError; per-attempt timeouts and exhausted waitForBatch budgets throw TinifyTimeoutError. All extend TinifyError.
import { TinifyApiError } from "@tinify-dev/client";
try {
await client.compress(input);
} catch (error) {
if (error instanceof TinifyApiError) {
console.error(error.code, error.status, error.requestId);
}
}Error codes
| Code | HTTP | Meaning |
| --- | --- | --- |
| missing_authorization | 401 | No Authorization: Bearer <token> header. |
| invalid_api_key | 401 | The API key is invalid or revoked. |
| missing_identity | 401 | The request is not authenticated. |
| insufficient_scope | 403 | The key lacks the required scope. |
| account_unavailable | 403 | The account is unavailable (e.g. on hold). |
| batch_plan_required | 403 | Batches require the Developer or Pro plan. |
| invalid_request | 400 | Malformed body or parameters. |
| invalid_idempotency_key | 400 | Missing or malformed Idempotency-Key (8–160 chars). |
| batch_not_found | 404 | Unknown batch id (or not yours). |
| idempotency_conflict | 409 | Key reused with a different request body. |
| batch_not_complete | 409 | Archive requested before the batch finished. |
| batch_has_no_results | 409 | The batch finished without any successful file. |
| file_too_large | 413 | Image exceeds the 40 MB limit. |
| unsupported_media_type | 415 | API v1 supports AVIF, WebP, JPEG, and PNG. |
| validation_failed | 422 | One or more request fields are invalid. |
| invalid_quality_mode | 422 | Quality mode must be balanced, best_quality, or lossless. |
| lossless_not_supported | 422 | JPEG has no pixel-preserving lossless mode. |
| invalid_resize | 422 | Resize needs a positive width, height, or scale. |
| invalid_crop | 422 | Crop needs non-negative x/y and positive width/height. |
| invalid_operation | 422 | Batch operation must be compress, resize, crop, or convert. |
| batch_too_large | 422 | More files than your plan's per-batch limit. |
| duplicate_client_id | 422 | Every batch file needs a unique client_id. |
| uploads_incomplete | 422 | Uploaded objects missing or mismatching the manifest. |
| invalid_target_format | 422 | Convert: unknown target format. (beta) |
| same_format_conversion | 422 | Convert: target equals the source format. (beta) |
| invalid_target_size | 422 | Compress: unusable target_size_bytes. (beta) |
| target_size_conflict | 422 | Compress: target_size_bytes conflicts with the quality mode. (beta) |
| quota_exhausted | 429 | Monthly quota used up; check usage() and Retry-After. |
| too_many_upload_sessions | 429 | Too many concurrent upload sessions. |
| upload_session_storage_limit | 429 | Upload session storage limit reached. |
| internal_error | 500 | Unexpected server failure — quote the requestId. |
The code type is an open union (KnownTinifyErrorCode | string), so new server codes never break your compile.
Limits
- 40 MB (41,943,040 bytes) and 50 MP per image.
- 200 files per batch (plan-dependent, lower on some plans).
- Result download URLs expire after 2 hours — download promptly or re-run.
- Rate-limit state is exposed on every response via
rateLimit(X-RateLimit-Limit/-Remaining/-Reset).
CLI
The package ships a tinify-dev binary:
export TINIFY_API_KEY=tnf_live_...
tinify-dev compress *.png # writes photo.min.png next to each input
tinify-dev compress --quality lossless --out dist/ img/*.png
tinify-dev resize --width 800 photo.jpg
tinify-dev crop --x 0 --y 0 --width 600 --height 400 photo.png
tinify-dev convert --format webp photo.png # beta
tinify-dev usagePrints a per-file saved-bytes table, warns and skips files over 40 MB, and exits 1 if any file fails. Outputs are written as <name>.min.<ext> beside the input (or into --out <dir>); the CLI never overwrites your originals.
Browser usage (read this first)
The API rejects untrusted browser origins; use it server-side. CORS is only allowed for trusted first-party origins, so calls from arbitrary web apps will fail with a 403 — proxy through your backend instead. (Keys in browser bundles are public anyway.)
TypeScript notes
- Ships
.d.ts(ESM) and.d.cts(CJS) — correct types under both"module": "NodeNext"and bundlers. TinifyResponse<T>,ImageResultData,Batch,Usage, and every option type are exported.- Response field names mirror the wire format (
snake_case); client-side concepts (requestId,rateLimit) are camelCase. convert()andtarget_size_bytesare marked@betain TSDoc until the server rollout completes.
License
MIT © Stian Larsen
