Create a ZIP from URLs with One API Call
POST a list of file URLs to the Eazip API and get back an expiring download link. No ZIP code, no bucket CORS setup, and no archive bytes through your server.
This guide turns a list of file URLs into a downloadable ZIP from your
backend: one POST, then a download link you can return to your app, put in
an email, or hand to a webhook consumer. Eazip fetches the files
server-to-server and builds the archive, so you write no ZIP code, configure
no bucket CORS, and never proxy archive bytes through your own server.
The whole flow has four steps:
- Collect the file URLs (public URLs, or presigned URLs from your bucket).
POST /jobswith the list.- Get the download link — by webhook, or by polling the job.
- Hand the link to whoever needs the ZIP.
Building a button the user clicks in your web app instead? Then build the ZIP in the browser — see Download an S3 Bucket as a ZIP in the Browser and the integration decision guide.
Create the job
Authentication is a secret API key in the X-API-Key header — created in
the dashboard, kept on the server, never in browser
code (browser flows use public keys
instead).
const response = await fetch('https://api.eazip.io/jobs', {
method: 'POST',
headers: {
'X-API-Key': process.env.EAZIP_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
files: [
{ url: 'https://cdn.example.com/reports/q1.pdf', filename: 'q1.pdf' },
{ url: 'https://cdn.example.com/reports/q2.pdf', filename: 'q2.pdf' },
],
zip_filename: 'reports.zip',
expires_in: 172800,
}),
});
const { job_id } = await response.json();curl -X POST https://api.eazip.io/jobs \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"files": [
{ "url": "https://cdn.example.com/reports/q1.pdf", "filename": "q1.pdf" },
{ "url": "https://cdn.example.com/reports/q2.pdf", "filename": "q2.pdf" }
],
"zip_filename": "reports.zip",
"expires_in": 172800
}'The response is 201 Created with a job_id. The
Jobs API reference documents every option; the ones
worth knowing on day one:
expires_in— seconds until the ZIP is deleted (default 24 hours; plan maximums reach 90 days).fail_on_url_error: false— skip unreachable URLs and record them inerrorsinstead of failing the whole job.mode—stored(default) prepares the archive once and serves it repeatedly;streambuilds it at download time. See Stream or stored.max_zip_size_bytes— cap the size per archive and Eazip auto-splits the job into numbered ZIPs.
Get the download link
Webhook (recommended). Register an HTTPS endpoint in the dashboard and
Eazip POSTs the result when the job reaches a terminal state — no polling:
{
"event": "job.completed",
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"download_url": "https://api.eazip.io/download/eyJ...",
"metadata": { "order_id": "ord_123" }
}Use metadata on the create call to carry your own IDs through to the
webhook. Payloads are signed; see Webhooks for
verification and the job.failed event.
Polling. For scripts and simple backends, fetch the job until status
is completed:
curl https://api.eazip.io/jobs/550e8400-e29b-41d4-a716-446655440000 \
-H "X-API-Key: YOUR_API_KEY"{
"job": {
"status": "completed",
"download_url": "https://api.eazip.io/download/eyJ...",
"expires_at": "2026-08-31T10:00:00.000Z"
}
}For auto-split jobs, the top-level download_url is null and each entry
in zips[] carries its own link.
The link itself is a signed URL: it works in an email or a chat message,
needs no Eazip account to open, supports Range/resume, and dies at
expires_at.
Where the URLs come from
Eazip fetches each url server-to-server, so anything reachable over HTTPS
works — public CDN URLs as-is, private storage via short-lived presigned
URLs. No CORS configuration is involved anywhere. For private buckets:
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ region: 'us-east-1' });
const url = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: 'your-bucket', Key: 'reports/q1.pdf' }),
{ expiresIn: 3600 },
);The full S3 walkthrough — egress costs, presign lifetimes, Glacier — is in Zip S3 Files into a Download Link via API.
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const r2 = new S3Client({
region: 'auto',
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
},
});
const url = await getSignedUrl(
r2,
new GetObjectCommand({ Bucket: 'your-bucket', Key: 'reports/q1.pdf' }),
{ expiresIn: 3600 },
);The full R2 walkthrough — zero-egress economics, API tokens, Infrequent Access — is in Zip R2 Files into a Download Link via API.
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY);
const { data } = await supabase.storage
.from('reports')
.createSignedUrls(['q1.pdf', 'q2.pdf'], 3600);
const files = data.map((entry) => ({
url: entry.signedUrl,
filename: entry.path,
}));The full Supabase walkthrough — egress quotas, service role vs. RLS, listing pagination — is in Zip Supabase Storage Files into a Download Link via API.
Make the presigned lifetime long enough for the job to start and read every object. Stored mode (the default) fetches each source once, while the archive is prepared; after that, repeat downloads are served from zero-egress storage — no re-fetch from your bucket and no bandwidth fee from Eazip.
When to zip in the browser instead
Creating jobs from your server is the right shape when no user is waiting in a tab — nightly exports, "email me my invoices", report bundles — or when the job is too large to trust to a browser. If you are actually building a download button in a web app and the files are small enough to zip client-side, Eazip.js does that with no account at all; start with the browser guide and the decision guide.
FAQ
Can I call this API from the browser?
Not with an API key — it is a secret credential. Browser integrations use a publishable key scoped by a Public App (allowed origins and source hosts), or a backend-created session when the URL list should stay on your server.
How many files and how much data per job?
Plan-dependent: 100 files and 5 GB of output per job on the free tier, up to 20,000 files and 500 GB on the largest plan. The Jobs API reference has the full table.
What if some URLs are unreachable?
By default the job fails so you notice. Set fail_on_url_error: false to
skip failed URLs; the job completes with the rest and lists the failures in
errors — useful when one expired link should not block a 500-file export.
How long can the download link live?
You choose with expires_in: minimum 5 minutes, default 24 hours, maximum
by plan (24 hours on free, up to 90 days on the largest plan). After
expires_at, the archive is deleted and the link stops working.
Does the user downloading five times cost me five times?
No. In stored mode the archive is prepared once; your bucket pays egress for that single preparation fetch, Eazip adds no outbound-bandwidth fee, and repeat downloads do not touch your bucket again. See Zero-egress exports.
Backend-created sessions
Keep source URLs and server authorization off the client while the SDK handles polling and downloads.
Zip S3 Files into a Download Link via API
Presign any set of S3 objects — a prefix, a customer's folder, or a hand-picked list — POST it to Eazip, and hand out an expiring ZIP download link. With the real S3 numbers on egress cost, presign lifetimes, and Glacier gotchas.