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.
This guide turns any set of S3 objects into a ZIP download link from your
backend — a whole prefix, one customer's folder, or an arbitrary list of
keys from your database. Presign the objects, POST them as one job, and
give the resulting link to whoever needs it. Eazip fetches the objects
directly from S3 — no bucket CORS configuration, no archive bytes through
your server, and the job doesn't depend on anyone's browser tab.
Building a download button where the user waits in your web app instead? That is the browser version: Download an S3 Bucket as a ZIP in the Browser. Still comparing approaches? Start with 4 Ways to Download an Entire S3 Bucket as a ZIP.
Sign the objects
Presign each object with a short-lived GetObject URL — AWS credentials
never leave your server. Listing a prefix is the common shape, so the
helper below does that, but files is just an array: keys pulled from your
own database work exactly the same way.
import {
S3Client,
ListObjectsV2Command,
GetObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ region: 'us-east-1' });
export async function listPrefixAsSignedUrls(bucket: string, prefix: string) {
const sources: { url: string; filename: string }[] = [];
let continuationToken: string | undefined;
do {
const page = await s3.send(
new ListObjectsV2Command({
Bucket: bucket,
Prefix: prefix,
ContinuationToken: continuationToken,
}),
);
for (const object of page.Contents ?? []) {
if (!object.Key || object.Key.endsWith('/')) continue;
const url = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: bucket, Key: object.Key }),
{ expiresIn: 3600 },
);
sources.push({ url, filename: object.Key.slice(prefix.length) });
}
continuationToken = page.NextContinuationToken;
} while (continuationToken);
return sources;
}ListObjectsV2 returns at most 1,000 keys per page, so keep the
ContinuationToken loop for real prefixes. Make expiresIn long enough for
the job to start and read every object — an hour is a comfortable default.
Create the job
const files = await listPrefixAsSignedUrls('your-bucket', 'exports/2026-08/');
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,
zip_filename: 'bucket-export.zip',
expires_in: 172800,
}),
});
const { job_id } = await response.json();When the job completes, a webhook or a GET /jobs/:id poll gives you the
expiring download link. The flow, delivery options, and webhook payloads are
documented once in
Create a ZIP from URLs with One API Call;
this page stays on what is specific to S3.
What an export costs you on S3
The part most guides skip. Numbers below are US regions, standard tier:
- Egress is the real cost. Objects leaving S3 for Eazip are data transfer out at $0.09/GB (after the 100 GB/month free allowance across your AWS account). A 20 GB export costs about $1.80 in S3 bandwidth — once. In the default stored mode Eazip fetches each object a single time while preparing the archive, and repeat downloads are served from zero-egress storage: the user downloading the ZIP five times does not touch S3 again, and Eazip adds no bandwidth fee of its own.
- Requests are noise until they aren't.
GETcosts $0.0004 per 1,000 andLIST$0.005 per 1,000. A 10,000-object export is fractions of a cent; a few million objects start to show up on the bill — filter the prefix rather than exporting blindly.
S3-specific limits and gotchas
- Presigned URLs die with temporary credentials. URLs signed with an
IAM role's session credentials (Lambda, ECS, STS) become invalid when the
session expires, regardless of
expiresIn. For links that must live hours, sign with long-lived IAM user credentials; SigV4's hard maximum is 7 days either way. - Archived objects can't be fetched. Objects in Glacier Flexible
Retrieval, Deep Archive, or Intelligent-Tiering's archive tiers return
InvalidObjectStateonGET. CheckStorageClasswhile listing and restore those objects first, or exclude them and let the job skip them (fail_on_url_error: false). - Requester Pays buckets need
RequestPayer: 'requester'on theGetObjectCommand— the presigner (you) is the payer. - Archives cap at 50 GB each. A bigger export still works: set
max_zip_size_bytesand Eazip auto-splits into numbered ZIPs, each with its own download link. Total output per job reaches 500 GB on the largest plan. - Files per job are plan-bound (100 on the free tier, up to 20,000). For a bucket beyond that, export per prefix or per manifest — one job per customer folder is usually the shape the product wanted anyway.
FAQ
Does the export flow through my server?
No. Your server sends only the signed URL list. Eazip fetches the objects from S3 directly, builds the archive, and serves the download — your server's bandwidth is not involved.
Can I zip objects stored in Glacier?
Not directly. Restore them to a retrievable tier first (or exclude them).
GET on an archived object fails with InvalidObjectState, and the job
either fails fast or records the failures in errors, depending on
fail_on_url_error.
Why did my presigned URLs stop working before they expired?
Almost always temporary credentials: a URL signed by a Lambda or ECS role is only valid while that session token lives. Sign with IAM user credentials when the job may start later than a few minutes after signing.
My export is bigger than 50 GB — can this still work?
Yes. 50 GB is the per-archive cap, not the job cap. Set
max_zip_size_bytes and the job splits into multiple ZIPs, up to 500 GB of
total output per job.
Is this cheaper than zipping on my own server?
Usually, for repeated downloads. Your own server pays S3 egress on every download it proxies; this path pays it once per export, at preparation time. For the one-time S3 egress itself there is no way around AWS pricing — that cost exists in every architecture.
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.
Zip R2 Files into a Download Link via API
Presign any set of Cloudflare R2 objects, POST the list to Eazip, and hand out an expiring ZIP download link. R2 charges no egress, which makes this the cheapest storage-to-ZIP setup available.