Eazip
Eazip.jsPricingSign inStart free
Guides

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.

This guide turns any set of Cloudflare R2 objects — a prefix, a customer's folder, or a hand-picked list — into a ZIP download link from your backend. Presign the objects, POST them as one job, and give the link to whoever needs it. No bucket CORS configuration, no archive bytes through your server, no browser tab involved.

R2 has one property that changes the economics of this entire feature: zero egress fees. With S3, every export pays ~$0.09/GB to leave the bucket. With R2, Eazip reads the objects for free, and Eazip adds no bandwidth fee of its own — the whole pipeline from bucket to the user's download has no per-gigabyte bandwidth cost at all.

Building a button the user clicks in your web app instead? See the integration decision guide. Still comparing approaches? Start with 3 Ways to Download Cloudflare R2 Files as a ZIP.

Sign the objects

R2 speaks the S3 API, so the AWS SDK presigns against your account endpoint with an R2 API token (create one in the Cloudflare dashboard under R2 → Manage API Tokens):

import {
  S3Client,
  ListObjectsV2Command,
  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,
  },
});

export async function listPrefixAsSignedUrls(bucket: string, prefix: string) {
  const sources: { url: string; filename: string }[] = [];
  let continuationToken: string | undefined;

  do {
    const page = await r2.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(
        r2,
        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;
}

Listing a prefix is the common shape; files is just an array, so keys from your own database work the same way. If the bucket is public (an r2.dev subdomain or a custom domain), you can skip presigning entirely and pass the public URLs.

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: 'export.zip',
    expires_in: 172800,
  }),
});

const { job_id } = await response.json();

A webhook or a GET /jobs/:id poll returns the expiring download link when the job completes. The full flow, delivery options, and webhook payloads live in Create a ZIP from URLs with One API Call; this page stays on what is specific to R2.

What an export costs you on R2

This is where R2 stands apart:

  • Egress: $0. R2 charges nothing for data leaving the bucket, regardless of destination. A 100 GB export costs the same $0 in bandwidth as a 1 GB one. (The same export from S3 would cost about $9 in egress.)
  • Reads are Class B operations at $0.36 per million (after the 10-million monthly free allowance) — a 10,000-object export is about $0.004. Listing is Class A ($4.50/million after 1 million free), still noise at export scale.
  • Repeat downloads stay free. In Eazip's default stored mode the archive is prepared once and served from zero-egress storage; Eazip adds no outbound-bandwidth fee. End to end, bucket → archive → five user downloads involves no per-GB bandwidth charge anywhere.

R2-specific limits and gotchas

  • Presigned URLs cap at 7 days (SigV4, same as S3). Sign shortly before creating the job and keep lifetimes to what the job needs.
  • Infrequent Access storage class objects can be read directly — no Glacier-style restore step exists on R2 — but each read bills a retrieval fee ($0.01/GB), so a large export of IA objects has a real per-GB cost where standard-class R2 has none.
  • Workers are not required. A common misconception is that reading R2 from outside Cloudflare needs a Worker; the S3-compatible endpoint works from any backend with an R2 API token. (If you do want to build ZIPs inside a Worker instead, mind the Worker memory limit of 128 MB — that path is compared honestly in the R2 roundup.)
  • Jurisdiction-restricted buckets (EU, FedRAMP) use a jurisdiction endpoint ({account}.eu.r2.cloudflarestorage.com); presigned URLs made against the wrong endpoint fail.
  • Archives cap at 50 GB each; set max_zip_size_bytes to auto-split larger exports, up to 500 GB of total output per job on the largest plan.

FAQ

Does the export flow through my server?

No. Your server sends only the signed URL list. Eazip fetches the objects from R2 directly and serves the download itself.

Do I really pay no bandwidth for a 100 GB export?

For the bucket-to-archive leg, yes — R2 charges no egress and Eazip adds no bandwidth fee. What you do pay: R2 read operations (cents per million), Eazip's plan quota (stored GB-days or streamed GB), and the one-time archive preparation.

Can I use my bucket's public URLs instead of presigning?

Yes. If the bucket is exposed via r2.dev or a custom domain, pass those URLs directly in files. Presigning is only needed for private buckets.

My export is bigger than 50 GB — can this still work?

Yes. 50 GB is the per-archive cap. Set max_zip_size_bytes and the job splits into numbered ZIPs, each with its own download link.

Why not just zip inside a Cloudflare Worker?

Workers cap at 128 MB of memory and are billed on CPU time, which makes large in-Worker archives impractical — that's exactly the gap this API path fills. For small archives a Worker can work; the roundup compares both honestly.