Eazip
Eazip.jsPricingSign inStart free
Guides

Bundle Generated Reports and Invoices into a ZIP

Turn a month of generated PDFs — invoices, statements, reports — into one ZIP download link on a schedule, with no browser involved and no ZIP code in your cron job.

Somewhere in your product, documents are generated on a schedule — invoices at month-end, statements per account, compliance reports per quarter. And somewhere in your inbox is the request: "can we get them all in one file?" This guide wires that up as a scheduled, fully server-side flow: generate, bundle, deliver a link.

What makes the reports case its own pattern:

  • There is no user waiting. A cron job runs at 2 a.m.; the deliverable is a link in tomorrow's email or on the billing page. Browser-based approaches don't even apply.
  • It recurs. Whatever you build runs every month, for every account — reliability and idempotency matter more than interactivity.
  • The bundles are per-recipient. One customer's invoices must never land in another customer's ZIP, so grouping happens in your code, where the ownership data lives.

The flow

Your scheduler already generates the documents into storage. Add one step per recipient: sign that recipient's batch and create a job.

monthly-invoice-bundles.ts
export async function bundleMonthlyInvoices(period: string) {
  for (const account of await accountsWithInvoices(period)) {
    const files = await listPrefixAsSignedUrls(
      'billing',
      `invoices/${account.id}/${period}/`,
    );
    if (files.length === 0) continue;

    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: `invoices-${period}.zip`,
        expires_in: 30 * 86400,
        metadata: { account_id: account.id, period },
      }),
    });
  }
}

Handle the rest in your job.completed webhook: metadata identifies the account and period, download_url goes to the billing page or the month-end email. One webhook handler serves every scheduled bundle you ever add. Flow details and payloads: Create a ZIP from URLs with One API Call.

Decisions that matter for scheduled bundles

  • Idempotency lives in metadata. Before creating a job, check your own records for (account_id, period) — a re-run of the cron after a crash should find the existing job, not mint a duplicate archive.
  • Match expires_in to the access pattern. A 30-day link covers "grab last month's invoices"; when someone needs March a year later, recreate the bundle from the originals — sources stay in your bucket, and a new job costs one API call.
  • Batch per recipient, not per document. One job per account per period keeps filenames clean (invoices-2026-08.zip) and makes the webhook → billing-page mapping trivial.
  • Failures should degrade per-account. With fail_on_url_error: false, one corrupted PDF yields a bundle plus an errors entry for that account — the other 400 accounts' bundles are unaffected either way, since each is its own job.

When you don't need this

A single small report per period doesn't need bundling — link the PDF directly. And if users assemble ad-hoc selections interactively ("these five reports, now"), that is the client portal pattern rather than a schedule. This page is for the recurring, multi-document, nobody-watching case.

FAQ

Can the same flow run from a queue worker instead of cron?

Yes — the trigger is irrelevant. Anything that can make two HTTPS calls (sign, create job) can run this: cron, queue consumer, workflow engine.

Send the email from your job.completed webhook handler: it receives the download_url and your metadata, which is everything the template needs.

What if a month has thousands of documents for one account?

Files per job are plan-bound (up to 20,000 on the largest plan), and max_zip_size_bytes splits oversized bundles into parts. Past that, split by sub-period — invoices-2026-08-part2.zip is still one API call.

Do old bundles pile up and cost storage?

No — each archive deletes itself at expires_at. Long-term retention stays where it belongs, with the source documents in your bucket.