Eazip
Eazip.jsPricingSign inStart free
Guides

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:

  1. Collect the file URLs (public URLs, or presigned URLs from your bucket).
  2. POST /jobs with the list.
  3. Get the download link — by webhook, or by polling the job.
  4. 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 in errors instead of failing the whole job.
  • modestored (default) prepares the archive once and serves it repeatedly; stream builds 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.

Webhook (recommended). Register an HTTPS endpoint in the dashboard and Eazip POSTs the result when the job reaches a terminal state — no polling:

job.completed payload (excerpt)
{
  "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"
response (excerpt)
{
  "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.

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.