# Bulk Image Downloader to ZIP with SHA-256 Manifest (`mmdincer/bulk-file-downloader-zip`) Actor

Bulk image downloader and file downloader. Image and file URLs become ZIP parts with a SHA-256 manifest, the real file type from the bytes and per-URL status. HTML error pages are rejected and failed downloads are free. Not affiliated with Google, Dropbox, Shopify or YouTube.

- **URL**: https://apify.com/mmdincer/bulk-file-downloader-zip.md
- **Developed by:** [Muhammet Mustafa Dincer](https://apify.com/mmdincer) (community)
- **Categories:** Automation, Developer tools, AI
- **Security:** Limited permissions
- **Stats:** 1 total users, 1 monthly users, 80.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.40 / 1,000 downloaded files

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

![Bulk image downloader to ZIP: a list of image and file URLs becomes ZIP parts with a SHA-256 manifest, the real file type and a reason for every file that was not delivered](https://api.apify.com/v2/key-value-stores/vbQuJ5uhWVgUngr57/records/bulkdl-banner.png?signature=1yeulnLSAXLrJShwSzvEQ)

### What is Bulk Image Downloader to ZIP with SHA-256 Manifest?

**Bulk Image Downloader to ZIP with SHA-256 Manifest** is a bulk image downloader and a file downloader. It takes image and file URLs (up to 10,000 per run) and hands them back as **ZIP parts** you can unpack in one step. Every file is **checked while it downloads**: the Actor reads the real file type from the bytes, computes the **SHA-256**, measures image size, and refuses a web page that pretends to be a file. You get:

- **`FILES-001.zip`, `FILES-002.zip`, ...** in the key-value store: your files, plus a `manifest.json` (hash, type, size, source URL for every file) and a `SHA256SUMS` file that `sha256sum -c` understands;
- **one dataset row per URL, in input order**: `downloaded`, `skipped`, `failed`, `duplicate` or `unchanged`, with a plain `failureReason` such as `not_found`, `html_page` or `hash_mismatch`;
- a `RUN_SUMMARY` record with the totals and the first problems.

You pay for **delivered files only**. A 404, a login page saved as `report.pdf`, a blocked address or a file that fails its expected hash costs nothing. A run of 7 addresses (4 files, one web page, one missing file, one internal address) took 1 second on the platform and cost $0.0004 of platform usage.

### Quick answer: when should you (or an AI agent) pick this Actor?

**Pick this Actor when** you already have the file addresses (a dataset from another Actor, a CSV export, a list of image or PDF links) and you want them **in one archive you can trust**: a dataset of product images for training, the PDFs from a crawl, the attachments of a record export, an evidence set that must be verifiable later. It checks the content, not just the status code, so a run does not quietly fill your archive with HTML error pages.

**Pick something else when** you need to **find** the files first. This Actor does not crawl a page, parse a sitemap or follow links; it downloads exactly the addresses you give it. It does not log in, keep cookies, solve CAPTCHAs, resolve Google Drive or Dropbox share pages, or download streaming video from sites like YouTube. For a page of images, run a crawler first and pass its URLs here with `datasetId`.

**Defaults that work:** send `{"urls": ["https://example.com/a.pdf", "https://example.com/b.png"]}` and you get one ZIP. For a recurring job that should only fetch what changed, schedule `{"datasetId": "<id>", "onlyChanged": true}`. For an integrity check without keeping files, send `{"urls": [...], "output": "none"}`.

### At a glance

| | |
|---|---|
| **Does** | Downloads up to 10,000 public file URLs, checks each file, packs them into deterministic ZIP parts with `manifest.json` and `SHA256SUMS`, and reports every URL in a dataset |
| **Input** | `urls`, or `files` (URL + file name + expected SHA-256), or `datasetId` + `urlField`. Optional: `output`, `folders`, `allowedTypes`, size, time and count limits, `onlyChanged` |
| **Output** | ZIP parts (`FILES-001.zip`, ...) and `MANIFEST` in the key-value store, one `recordType: "file"` row per URL in the dataset, `RUN_SUMMARY` |
| **Verifies** | Real file type from the bytes, SHA-256, optional expected SHA-256, image width and height, HTML pages rejected, empty files rejected, duplicates by content |
| **Needs** | Nothing: no account, no API key, no proxy. Public files only |
| **Speed** | Measured on the platform: 4 files in 2 s, 100 images in about 10 s, a 410 MB run in 66 s |
| **Pricing** | Pay per event: **$0.002 per delivered file** and $0.0005 per run on the Free plan, plus $0.01 per full 100 MB delivered. Failures, skipped files, duplicates and unchanged files are free |
| **Does not** | Crawl pages, log in, use cookies, resolve share pages, download streaming video, or contact private and local network addresses |

### Key terms used in this README

- **Delivered file:** a file that was downloaded, passed every check and is in the ZIP (or stored as its own record). Only delivered files are charged.
- **Manifest:** `manifest.json` inside each ZIP part, and the `MANIFEST` record in the key-value store. It lists every delivered file with its path, part number, URL, final URL, size, SHA-256, real type and image size.
- **`SHA256SUMS`:** a text file inside each ZIP part with one line per file in the format `sha256sum -c` reads. Unzip and run `sha256sum -c SHA256SUMS` to verify the part.
- **Real file type:** the type read from the first bytes of the content (JPEG, PNG, PDF, ZIP, MP4 and dozens more), not what the server says in `Content-Type` or what the address ends with.
- **Deterministic ZIP:** the same files in the same order produce the same ZIP bytes, so the ZIP's own SHA-256 in `RUN_SUMMARY` can be compared between runs.
- **Resume:** when a transfer is cut mid-file and the server supports byte ranges, the download continues from where it stopped instead of starting again.
- **Conditional download:** with `onlyChanged`, the Actor asks the server "has this changed since I last saw it?" (ETag and Last-Modified, a `304 Not Modified` answer) and compares the SHA-256 of what it does download.
- **Part:** one ZIP of the run, about `zipPartSizeMb` large. Each part carries the manifest of the run and a checksum file for its own files.

### What can you use it for?

#### Image and document datasets

Feed the image URLs of a scraped catalog and get one ZIP per ~200 MB with the real type and size of each image. Set `allowedTypes: ["image"]` and anything that is not an image is skipped and not charged. Set `folders: "type"` or `"host"` to sort the archive.

#### Archiving the output of another Actor

Pass the ID of a dataset and the name of its URL field (`datasetId`, `urlField`). The field can hold a text or a list of texts. Run this Actor after your scraper to turn a column of links into files.

#### Evidence and compliance archives

Each file has its SHA-256, its final URL after redirects, its server date (`Last-Modified`), and the time it was downloaded. The ZIP contains a manifest and a checksum file, and the ZIP itself has a SHA-256 in `RUN_SUMMARY`. Months later, `sha256sum -c` proves the files are what was downloaded.

#### Known-good downloads

Give `files` with an expected SHA-256 for each file (for example from a vendor's release notes). A file that does not match is rejected with `hash_mismatch` and not charged.

#### Scheduled sync

Schedule the Actor with `onlyChanged: true` and a `stateName`. The first run delivers everything; each later run asks the servers whether anything changed and delivers only new or changed files. A run in which nothing changed costs the start fee only.

### How does it work?

1. **Check the address.** Only `http` and `https` are accepted. Addresses with embedded credentials, internal host names, private or reserved IP addresses and ports of non-HTTP services (SSH, SMTP, Redis, databases and similar) are rejected before any request is made.
2. **Download.** Up to `concurrency` files at a time and no more than `perHostConcurrency` per website. Redirects are followed by hand, up to 5, and **every hop is checked again**. The connection is made only to a resolved address that is public; if a name resolves to even one private address, the connection is refused (this closes DNS-rebinding tricks).
3. **Check while streaming.** The first bytes decide the real type. A web page where a file was expected is stopped immediately. The SHA-256 is computed as the bytes arrive; the size limits apply to the announced size and to the bytes actually received.
4. **Retry and resume.** Timeouts, network errors, HTTP 429 and 5xx are retried with backoff and the server's `Retry-After`. An interrupted transfer continues with a `Range` request when the server supports it. 401, 403 and 404 are never retried.
5. **Pack.** Files are named safely (no path tricks, no reserved characters), given the right extension when `fixExtensions` is on, and written into ZIP parts with a fixed timestamp so the result is reproducible. Already compressed types (JPEG, PNG, MP4, ZIP and so on) are stored, not compressed again.
6. **Report.** One dataset row per URL, a `MANIFEST` and a `RUN_SUMMARY`.

### How to download a list of files into a ZIP (step by step)

1. Open the Actor and paste your addresses into **File URLs**, one per line. (The example loads four public files: a JPEG, a PNG, a PDF and a text file.)
2. Leave the defaults, or set a **Largest file**, **Total size of the run** and **Time limit for the run**.
3. Click **Start**. The status line shows progress, for example `37 of 120 URL(s) processed, 54.2 MB`.
4. Open the **Output** tab: **Files** (one row per URL), **Status and reasons**, **ZIP (first part)**, the manifest and the summary.
5. Download `FILES-001.zip`, unzip it and run `sha256sum -c SHA256SUMS` if you want to verify.

### Input

| Field | What it does |
|---|---|
| `urls` | One address per line, up to 10,000. The same address twice is downloaded once. |
| `files` | List of `{url, fileName?, sha256?}`. Choose the file name or verify the download against an expected SHA-256. |
| `datasetId`, `urlField` | Read addresses from a dataset (default field `url`; a text or a list of texts). |
| `output` | `zip` (default), `files` (each file as its own record with its own link), `both`, or `none` (download, hash and report without keeping the files). |
| `folders` | `none` (flat), `host` (one folder per website) or `type` (image, pdf, office, audio, video, archive, text, data). |
| `fixExtensions` | On by default. Renames files whose extension is missing, is a script extension (`.php`), or contradicts the bytes. The original address stays in the manifest. |
| `allowedTypes` | Keep only these groups: image, pdf, office, audio, video, archive, text, data. Others are skipped and not charged. |
| `allowHtml` | Off by default. Turn on only if you want web pages saved as files. |
| `maxFileSizeMb` | Default 50, up to 500. Larger files are rejected as `too_large`. |
| `maxTotalSizeMb` | Default 500, up to 2,000. When reached, the rest is marked `total_limit_reached` and what was delivered is still zipped. |
| `maxFiles` | Default 10,000. |
| `fileTimeoutSecs` | Default 120, up to 900. A server that sends nothing for 30 seconds is cancelled sooner. |
| `maxRunMinutes` | Default 30, up to 120. At the limit, running downloads are cancelled and the delivered files are zipped. |
| `maxRetries` | Default 2, up to 5. |
| `concurrency`, `perHostConcurrency` | Parallel downloads (default 8, up to 20) and per website (default 3, up to 5). |
| `zipPartSizeMb` | Default 200, up to 300. |
| `onlyChanged`, `stateName` | Skip files unchanged since the last run with the same state name. |

Example with names and expected hashes:

```json
{
  "files": [
    { "url": "https://example.com/report.pdf", "fileName": "report-2026.pdf", "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" },
    { "url": "https://example.com/logo.png" }
  ],
  "output": "zip",
  "folders": "type"
}
```

### Output

#### The ZIP parts

`FILES-001.zip`, `FILES-002.zip`, ... are stored in the run's key-value store. Each contains the files, `manifest.json` and `SHA256SUMS`. The `output` tab links the first part; `RUN_SUMMARY.zipParts` lists every part with its key, file count, size and SHA-256.

![Sample output: one dataset row per URL with status, real type, size, image size and SHA-256, and the content of the ZIP verified with sha256sum](https://api.apify.com/v2/key-value-stores/vbQuJ5uhWVgUngr57/records/bulkdl-sample-output.png?signature=zDggHDUp9DSqbodo09M2)

#### File rows

One row per input URL, in input order (`recordType: "file"`).

| Field | Meaning |
|---|---|
| `index` | Position in your list, starting at 1 |
| `url`, `finalUrl` | The address you gave and the address after redirects |
| `status` | `downloaded`, `duplicate`, `unchanged`, `skipped`, `failed` or `not_attempted` |
| `failureReason`, `message`, `httpStatus` | Why a file was not delivered, in words, and the HTTP status if any |
| `fileName`, `zipFile`, `zipPath`, `zipUrl`, `fileUrl` | Where the file is: name, ZIP part, path inside it, download link of the part, link of the single file record (output `files` or `both`) |
| `sizeBytes`, `sha256` | Size and SHA-256 of the delivered bytes |
| `hashVerified` | `true` when you gave an expected SHA-256 and it matched; `null` when you gave none |
| `detectedType`, `typeSource` | Real type, and where it came from: `content` (the bytes), `extension` (plain text with no signature) |
| `declaredType`, `typeMismatch` | What the server claimed, and `true` when it contradicts the bytes |
| `width`, `height` | Image size in pixels (JPEG, PNG, GIF and WebP) |
| `change` | With `onlyChanged`: `new`, `changed` or `unchanged` |
| `duplicateOf` | `index` of the first row with identical content |
| `etag`, `lastModified` | The server's version markers |
| `attempts`, `resumed`, `durationMs` | How many tries, whether a cut transfer continued, how long |
| `downloadedAt` | Time of delivery |

#### Failure reasons

`invalid_url`, `unsupported_scheme`, `blocked_address`, `duplicate_url`, `same_content`, `dns_error`, `tls_error`, `network_error`, `timeout`, `access_denied` (401, 403), `not_found` (404), `http_error`, `too_many_redirects`, `too_large`, `empty_file`, `html_page`, `type_not_allowed`, `hash_mismatch`, `total_limit_reached`, `spending_limit`, `run_time_limit`, `file_limit`, `internal_error`.

#### Run summary

`RUN_SUMMARY` holds `urlsRequested`, `downloaded`, `duplicates`, `unchanged`, `skipped`, `failed`, `notAttempted`, `deliveredBytes`, `zipParts`, `reasons` (a count per failure reason), whether the run stopped at the time, size or spending limit, and the first 20 problems.

### How much does it cost?

You pay per event, never for failures.

| Event | Price (Free plan) | When |
|---|---|---|
| Actor Start | $0.0005 | Once per run |
| Downloaded file | $0.002 (Bronze $0.0018, Silver $0.0016, Gold and above $0.0014) | Each file delivered |
| Delivered data (100 MB) | $0.01 | Each *full* 100 MB (104,857,600 bytes) delivered in a run, rounded down |

The 100 MB event exists because a slow server holds the Actor running longer; a run under 100 MB pays nothing for data.

**Not charged:** `failed`, `skipped`, `duplicate` and `unchanged` rows, files rejected for `hash_mismatch`, `html_page`, `too_large` or `type_not_allowed`, and files not attempted.

Measured on the platform (the actual platform usage of the run, for comparison with the price):

| Run | Delivered | Time | Platform usage | You would pay (Free plan) |
|---|---|---|---|---|
| The example input (JPEG, PNG, PDF, text) | 4 files, 0.76 MB | 2 s | $0.0004 | $0.0085 |
| 100 image URLs (3 of them 404) | 97 files, 5.95 MB | about 10 s | $0.0016 | $0.1945 |
| 3 large files | 3 files, 210 MB | 59 s | $0.0021 | $0.0265 |
| 4 large files in 3 ZIP parts | 4 files, 410 MB | 66 s | $0.0029 | $0.0485 |

**Example prices:** 1,000 files averaging 200 KB (about 190 MB): 0.0005 + 1,000 × 0.002 + 1 × 0.01 = **$2.01**. 1,000 files averaging 2 MB (about 1.9 GB): 0.0005 + 2.00 + 19 × 0.01 = **$2.19**.

The **Maximum cost per run** setting is respected. The Actor works out the price of each file before it downloads it, and stops with `spending_limit` instead of going over. We tested a limit that allows one file: one file was delivered and the other three were marked `spending_limit`.

### How does it compare with other bulk downloaders?

Checked on the Apify Store on 2026-11-04. "Not documented" means the Actor's README does not mention it; it may still exist.

| | This Actor | chris_elder/bulk-file-downloader | automa-flow/parallel-bulk-file-downloader | nerolabs/bulk-file-downloader |
|---|---|---|---|---|
| Price | $0.002 per file + $0.01 per 100 MB | $0.002 per file | $0.004 per file + $0.003 per 10 MiB | $0.004 per file + $0.003 per 10 MB |
| ZIP output | Yes, parts with manifest | Yes | Yes | Yes |
| SHA-256 in a manifest | Yes, plus `SHA256SUMS` | Not documented | Yes | Not documented |
| Expected-hash verification | Yes | Not documented | Not documented | Not documented |
| Real type from bytes, HTML pages rejected | Yes | Not documented | Not documented | Partly |
| Mid-transfer resume (Range) | Yes | Not documented | Run-level resume | Not documented |
| Only new or changed files (ETag / 304 + hash) | Yes | Not documented | Not documented | Not documented |
| Reproducible ZIP | Yes | Not documented | Not documented | Not documented |
| Per-host limit | Yes | Not documented | Yes | Not documented |
| Private-network (SSRF) protection | Yes | Yes | Not documented | Not documented |
| Extracts file links from pages, Drive, Dropbox, Shopify | **No** | Yes | Not documented | Not documented |

**Where the others are better:** if you need to **find** files on a page, or to resolve Google Drive, Dropbox or Shopify links, chris_elder's Actor does inputs this one does not. If you only want the cheapest possible download of trusted URLs, it is also marginally cheaper for large files. **Where this one is better:** when the archive has to be right, and you want failures to be free and visible.

### Is it legal to download files with this Actor?

The Actor downloads **only the addresses you list**, once each, with a plain User-Agent that says what it is. It does not crawl, does not read `robots.txt` (it never discovers addresses), and does not log in or bypass anything: no cookies, no CAPTCHA solving, no proxy rotation. **You are responsible for having the right to download and keep the files you list.** Copyright, database rights, terms of service and personal-data law (GDPR and similar) apply to the files, not to the Actor. Do not use it on files you are not allowed to copy, and do not point it at sites whose terms forbid automated downloads.

Be polite to servers: the defaults use 3 connections per website and retry with backoff. Lower `perHostConcurrency` for small sites. The Actor stops retrying on 401, 403 and 404 and does not try to get around a block.

**Safety:** the Actor refuses private, local and reserved network addresses (including IPv6 forms of them), internal host names, non-HTTP service ports and embedded credentials, on every redirect and at the moment of connection. This protects you and others from using it as a way into internal networks.

If you are the owner of a file or a server and want it removed from this Actor's use, write to us through the Actor's Issues tab and we will answer. **Not affiliated with Google, Dropbox, Shopify, YouTube or any website whose files you download.**

### Using the API

#### MCP (AI agents)

The Actor works as a tool through the Apify MCP server at `https://mcp.apify.com`. An agent can call it with `urls` or `files`, then read the dataset (one row per URL) and fetch `RUN_SUMMARY` to see what was delivered and why files were not. Keep `output` at `zip` for an archive, or `none` to only verify that links work and get their hashes.

#### JavaScript

```js
import { ApifyClient } from 'apify-client';

const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('mmdincer/bulk-file-downloader-zip').call({
    urls: ['https://example.com/a.pdf', 'https://example.com/b.png'],
    allowedTypes: ['pdf', 'image'],
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
console.table(items.map(({ url, status, failureReason, sha256 }) => ({ url, status, failureReason, sha256 })));
const zip = await client.keyValueStore(run.defaultKeyValueStoreId).getRecord('FILES-001.zip', { buffer: true });
```

#### Python

```python
from apify_client import ApifyClient

client = ApifyClient("<APIFY_TOKEN>")
run = client.actor("mmdincer/bulk-file-downloader-zip").call(run_input={
    "urls": ["https://example.com/a.pdf", "https://example.com/b.png"],
    "folders": "type",
})
for row in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(row["index"], row["status"], row["failureReason"], row["sha256"])
```

#### curl

```bash
curl -X POST "https://api.apify.com/v2/acts/mmdincer~bulk-file-downloader-zip/runs?token=<APIFY_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"urls": ["https://example.com/a.pdf"], "output": "zip"}'
```

#### Verifying a ZIP

```bash
unzip -q FILES-001.zip -d out && cd out && sha256sum -c SHA256SUMS
```

### FAQ

#### Do I need an account, an API key or a proxy for the files?

No. The Actor downloads public addresses directly. It cannot download files behind a login.

#### Why was my URL marked `html_page`?

The server answered with a web page where you expected a file: a login wall, a "not found" page served with status 200, or a hotlink-protection page. It is not saved and not charged. If you really want pages saved, turn on **Allow HTML pages**.

#### Why does a file have a different extension than in my URL?

With **Fix wrong or missing extensions** on (the default), the extension follows the real type: a PNG saved as `photo.jpg` becomes `photo.png`. Your original address is kept in the manifest and in the row. Turn the option off to keep names as they are.

#### What happens when a download is cut halfway?

It is retried. If the server supports byte ranges and the file has not changed, the transfer continues from where it stopped (`resumed: true` in the row). Otherwise it restarts.

#### How does "Only new or changed files" work?

The Actor stores each file's ETag, Last-Modified and SHA-256 in a named key-value store (`stateName`). On the next run it asks the server whether the file changed; on a `304` the file is `unchanged` and not downloaded. If the server cannot answer, the file is downloaded and its SHA-256 is compared; an identical file is `unchanged` and not charged. The state keeps up to 50,000 files.

#### Can I get every file as its own link instead of a ZIP?

Yes: `output: "files"` stores each file as its own record and puts the link in `fileUrl`. `both` gives ZIP parts and single files.

#### What if the run reaches a limit?

Files not yet downloaded are marked `total_limit_reached`, `run_time_limit`, `file_limit` or `spending_limit`. Everything delivered so far is still zipped and charged only as delivered.

#### How large can a run be?

Up to 10,000 files, 2,000 MB in total (the limit follows the free disk of the run) and 500 MB per file. For more, split the list into several runs; ZIP parts are at most 300 MB each.

#### Is the ZIP reproducible?

Yes: files are written in input order with a fixed timestamp, so the same inputs give the same ZIP bytes. The ZIP's SHA-256 is in `RUN_SUMMARY.zipParts`.

#### Where can I report a problem or request a feature?

Open an issue in the Actor's **Issues** tab with the run ID. Include the row (`index`, `failureReason`, `message`) of the file that misbehaved.

# Changelog

This Actor's version history is a separate document: https://apify.com/mmdincer/bulk-file-downloader-zip/changelog.md

# Actor input Schema

## `urls` (type: `array`):

One address per line, <code>http://</code> or <code>https://</code>. Public files only: no login, and addresses that point to private or local networks are refused. Up to 10,000 per run. The same address twice is downloaded once.

## `files` (type: `array`):

Use this instead of, or together with, the URL list when you want to choose the file name or verify the download. A JSON list of objects: <code>url</code> (required), <code>fileName</code> (optional) and <code>sha256</code> (optional, 64 hex characters). When a hash is given and the download does not match, the file is rejected and not charged.

## `datasetId` (type: `string`):

ID or name of an Apify dataset, for example the output of another Actor. The Actor reads the address from the field named below in every item (a text field or a list of texts).

## `urlField` (type: `string`):

The dataset field that holds the file address. Used only with a dataset ID.

## `output` (type: `string`):

<b>ZIP</b> stores the files in ZIP parts with <code>manifest.json</code> and <code>SHA256SUMS</code> inside. <b>Each file</b> stores every file as its own record with its own link. <b>Both</b> does both. <b>Nothing (check only)</b> downloads, hashes and reports without keeping the files. The dataset always has one row per URL.

## `folders` (type: `string`):

<b>Flat</b> puts every file in the ZIP root. <b>By host</b> uses one folder per website. <b>By type</b> uses image, pdf, office, audio, video, archive, text and data folders.

## `fixExtensions` (type: `boolean`):

Rename a file when its extension is missing, a script extension such as <code>.php</code>, or contradicts what the bytes show (a PNG saved as <code>photo.jpg</code> becomes <code>photo.png</code>). The original address stays in the manifest.

## `allowedTypes` (type: `array`):

Keep only files whose <b>real</b> type (from the bytes, not the address) is in the list. Others are skipped and not charged. Empty means all types. Groups: image, pdf, office (Word, Excel, PowerPoint, OpenDocument, EPUB), audio, video, archive, text, data (JSON, CSV, XML, Parquet, SQLite).

## `allowHtml` (type: `boolean`):

By default an address that answers with a web page (a login wall, a 404 page served with status 200, a hotlink-protection page) is rejected as <code>html_page</code> instead of being saved as a fake file. Turn this on only if you want web pages saved as files.

## `maxFileSizeMb` (type: `integer`):

Files bigger than this are rejected as <code>too_large</code>, as soon as the server announces the size or the download passes it. Not charged.

## `maxTotalSizeMb` (type: `integer`):

When the files together reach this size, the remaining downloads stop and are marked <code>total_limit_reached</code>. Everything delivered so far is still zipped.

## `maxFiles` (type: `integer`):

Only the first this-many addresses are downloaded. The rest are marked <code>file_limit</code>.

## `fileTimeoutSecs` (type: `integer`):

One download, including retries of the same attempt, is cancelled after this long. A server that sends nothing for 30 seconds is cancelled sooner.

## `maxRunMinutes` (type: `integer`):

When this time is up, running downloads are cancelled, unstarted ones are marked <code>run_time_limit</code>, and the files delivered so far are zipped.

## `maxRetries` (type: `integer`):

How many more times a download is tried after a timeout, a network error, HTTP 429 or a 5xx answer. An interrupted transfer continues where it stopped when the server supports it. Refusals (401, 403, 404) are never retried.

## `concurrency` (type: `integer`):

How many files are downloaded at the same time.

## `perHostConcurrency` (type: `integer`):

No more than this many connections to one host at a time, so a list of files from a single server does not hammer it. Maximum 5.

## `zipPartSizeMb` (type: `integer`):

A new ZIP part (FILES-002.zip, ...) starts when the files of the current part reach about this size. A file bigger than this gets a part of its own.

## `onlyChanged` (type: `boolean`):

Skip files that are the same as in the last run with the same saved-state name. The Actor asks the server whether the file changed (ETag, Last-Modified) and compares the SHA-256, so unchanged files are not delivered and not charged. The first run delivers everything.

## `stateName` (type: `string`):

Name of the key-value store that remembers each file's fingerprint for Only new or changed files. Use a different name for each schedule. Letters, digits and hyphens.

## Actor input object example

```json
{
  "urls": [
    "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
  ],
  "files": [
    {
      "url": "https://example.com/report.pdf",
      "fileName": "report-2026.pdf",
      "sha256": "0000000000000000000000000000000000000000000000000000000000000000"
    }
  ],
  "urlField": "url",
  "output": "zip",
  "folders": "none",
  "fixExtensions": true,
  "allowHtml": false,
  "maxFileSizeMb": 50,
  "maxTotalSizeMb": 500,
  "maxFiles": 10000,
  "fileTimeoutSecs": 120,
  "maxRunMinutes": 30,
  "maxRetries": 2,
  "concurrency": 8,
  "perHostConcurrency": 3,
  "zipPartSizeMb": 200,
  "onlyChanged": false,
  "stateName": "bulk-file-downloader-zip-state"
}
```

# Actor output Schema

## `files` (type: `string`):

One row per address with status, size, SHA-256, real type and image size.

## `reasons` (type: `string`):

Status, failure reason and message for every address. Filter on status to see the ones that were not delivered.

## `zip` (type: `string`):

The first ZIP part. Further parts are FILES-002.zip and so on.

## `manifest` (type: `string`):

JSON manifest of every delivered file.

## `summary` (type: `string`):

Counts per status, failure reasons, ZIP parts and limits.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "urls": [
        "https://upload.wikimedia.org/wikipedia/commons/a/a7/Camponotus_flavomarginatus_ant.jpg",
        "https://www.w3.org/Graphics/PNG/alphatest.png",
        "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
        "https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("mmdincer/bulk-file-downloader-zip").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "urls": [
        "https://upload.wikimedia.org/wikipedia/commons/a/a7/Camponotus_flavomarginatus_ant.jpg",
        "https://www.w3.org/Graphics/PNG/alphatest.png",
        "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
        "https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("mmdincer/bulk-file-downloader-zip").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "urls": [
    "https://upload.wikimedia.org/wikipedia/commons/a/a7/Camponotus_flavomarginatus_ant.jpg",
    "https://www.w3.org/Graphics/PNG/alphatest.png",
    "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
    "https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore"
  ]
}' |
apify call mmdincer/bulk-file-downloader-zip --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,mmdincer/bulk-file-downloader-zip"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/f357DtuBDFJl2y85G/builds/gIgtqj5vaVKJPYPSo/openapi.json
