Eazip
Eazip.jsPricingSign inStart free
Guides

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. GET costs $0.0004 per 1,000 and LIST $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 InvalidObjectState on GET. Check StorageClass while 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 the GetObjectCommand — the presigner (you) is the payer.
  • Archives cap at 50 GB each. A bigger export still works: set max_zip_size_bytes and 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.