# Bulk Image and File Downloader (ZIP + SHA-256) (`steady-scrape/bulk-file-downloader`) Actor

Download public image, PDF and file URLs into one ZIP with SHA-256 checksums and a per-file status manifest. $0.004 per saved file; failed downloads are free.

- **URL**: https://apify.com/steady-scrape/bulk-file-downloader.md
- **Developed by:** [SteadyScrape](https://apify.com/steady-scrape) (community)
- **Categories:** Developer tools, Automation, E-commerce
- **Features:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $4.00 / 1,000 saved files

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

### What does Bulk Image and File Downloader do?

**Bulk Image and File Downloader** fetches a short list of **public direct file URLs** and packs the files that actually downloaded into one **ZIP**. Each URL gets a status row with the byte count, detected type, **SHA-256** checksum, and a sanitized source URL. The same manifest is stored as JSON and inside the ZIP.

It is built for people who already have a list of image, PDF, or data-file links: catalog and e-commerce teams, researchers, QA and compliance staff, and developers who need a checksummed file pack from an API call. **Price: $0.004 per file saved in the ZIP plus $0.003 once per run that saves at least one file.** Rejected, failed, and partial downloads are free, and a run that saves nothing costs nothing.

The Actor does not crawl, search, follow links, render pages, or log in. It does not read cookies, tokens, or custom headers. It does not bypass DRM or paywalls, unpack archives, or execute anything it downloads. Public access is not a copyright license. You need the right to fetch and reuse each file. This Actor does not scan for malware and does not clear copyright.

SteadyScrape publishes a separate Actor for turning a page into JSON: [AI Page to JSON](https://apify.com/steady-scrape/ai-page-to-json). That is a different job. This downloader only retrieves the file URLs you supply.

### Why use Bulk Image and File Downloader?

Use it when you already have the file addresses and you want one bounded, checksummed pack:

- Pull public product images you are licensed to reuse into a ZIP for a catalog import.
- Collect public PDF spec sheets and CSV or JSON data files for an offline review.
- Save public Office files and zip, gzip, 7z, or rar archives as-is, without opening them.
- Hand a teammate a manifest that shows which URLs failed, instead of a folder of unnamed partial files.
- Keep a dated, hashed copy of public files (terms pages, price lists, published reports) so you can later prove what the file contained.
- Feed image URLs found by another Actor, such as Google Images results, into one ZIP from a workflow or an AI agent.

There is no proxy setting, no browser, and no scheduler inside this Actor.

### What does the downloader return?

The dataset has one row per processed URL. The default key-value store has three fixed keys: `OUTPUT` (counts only), `MANIFEST` (the full JSON manifest), and `PACK` (the ZIP, when it fits). File bytes are not copied into the key-value store a second time. Keys are not built from the source URLs.

| Field               | Meaning                                                                               |
| ------------------- | ------------------------------------------------------------------------------------- |
| `status`            | Explicit outcome, such as `saved`, `saved_type_mismatch`, or `rejected_type_mismatch` |
| `saved`             | Whether the file is in the ZIP                                                        |
| `partial`           | Whether bytes were counted even though the file was not saved                         |
| `sourceUrl`         | Sanitized URL, or null when the URL contained credential material                     |
| `detectedMediaType` | Type read from the bytes, not from the filename alone                                 |
| `byteLength`        | Decoded bytes read, including partial failures                                        |
| `sha256`            | SHA-256 of those decoded bytes                                                        |
| `zipEntryName`      | Sanitized name inside the ZIP                                                         |
| `httpStatus`        | Last HTTP status, when one was received                                               |

A row is not labeled as a successful image when the body is an HTML challenge. That case is `rejected_type_mismatch` and the file is left out of the ZIP.

### How it compares

| Approach | What you get |
| --- | --- |
| Saving files one by one in a browser | No checksums, no record of which links failed, and HTML error pages saved as if they were images |
| A quick `wget` or `curl` script | Fast, but you have to handle redirects to private addresses, fake image bodies, size caps, and file names yourself |
| This Actor | One ZIP, a SHA-256 per file, an explicit status per URL, private-network and type checks, hard caps, and charges only for files that were saved |

### How to download public files

1. Open the input and paste up to 20 direct `http` or `https` file URLs. Use port 80 for http and port 443 for https.
2. Lower a cap if you want a smaller run. You cannot raise the caps.
3. Run the Actor.
4. Read the dataset for per-URL status. Download the `PACK` record for the ZIP and `MANIFEST` for the JSON copy.

Locally, from this project:

```bash
npm ci
npm run build
apify run
```

Put the input at `storage/key_value_stores/default/INPUT.json` before `apify run`. `npm start` is only the container entry point. Use `apify run` for a local run so storage is set up the same way. The offline fixture runner needs the Apify CLI on `PATH`, or the executable at `$HOME/.local/bin/apify`. Those checks used CLI 1.11.0. `apify info` still requires a login; schema checks and local runs do not.

### How much does the bulk file downloader cost?

This Actor uses pay-per-event pricing:

| Event | Price | When it is charged |
| --- | --- | --- |
| `saved-file` | $0.004 | Once per file stored in the ZIP |
| `run-with-saved-file` | $0.003 | Once per run, only when at least one file was saved |

With pay-per-event pricing these events are the whole Actor price; there is no separate start fee and no compute charge on top.

Worked examples:

- 1 saved file: $0.004 + $0.003 = **$0.007**.
- A full run of 20 saved files: 20 × $0.004 + $0.003 = **$0.083**.
- 100 files split over 5 runs of 20: 5 × $0.083 = **$0.415**.
- 20 URLs where 15 save and 5 fail: 15 × $0.004 + $0.003 = **$0.063**. The 5 failures are free.

Rejected, failed, and partial URLs are not charged. A run that saves nothing is not charged. There is no per-byte event. If the run's maximum charge cannot cover another saved file together with the run event, that file is left out of the ZIP and is not charged (`skipped_charge_limit`). A ZIP that fails to store is not charged. A restarted or resurrected process stops before new downloads and charges. It does not replace the ZIP or the manifest. If the earlier `OUTPUT` can be read, its delivery fields stay and `finishedReason` becomes `stopped_uncertain_prior_run`, with the earlier reason kept as `priorFinishedReason`. This is not an exactly-once resume. Start a new run to download again.

The dataset, manifest, and logs do not carry prices.

### Input

See the input tab for the fields. `urls` is required. Every numeric cap has a hard maximum and a default equal to that maximum.

There is no field for headers, cookies, proxy, a DNS resolver, localhost, or a fixture path. Unknown fields are ignored. A URL with userinfo, a nonempty credential query value (`token`, `signature`, `api_key`, `password`, and similar names), or a fragment that carries token material is rejected and is not written to the dataset, manifest, or log.

The prefilled example downloads three small public files (a W3C PNG icon, a W3C test PDF, and RFC 2606 as plain text), so a first run shows a real ZIP and manifest.

### Output

You can download the dataset as JSON, CSV, or the other formats Apify provides for a dataset. The ZIP is the `PACK` key. The example below is a **fixture shape**, not a live download:

```json
{
    "index": 0,
    "status": "saved",
    "saved": true,
    "partial": false,
    "sourceUrl": "https://cdn.example/files/report.pdf",
    "detectedMediaType": "pdf",
    "byteLength": 1024,
    "sha256": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
    "zipEntryName": "report.pdf",
    "httpStatus": 200
}
```

`OUTPUT` uses `billingStrategy` `pay-per-event` for the events this Actor is built to emit, and `platformPricing` `active` or `inactive` for whether this run's platform pricing is on. `inactive` means those events are not being billed. Neither field contains a price, a charge ceiling, or account usage. The run log is the normal run log. It is not a private owner channel. Byte and elapsed counts are not written to that log, the dataset, or the key-value store.

Downloaded files are not rendered in the dataset. The ZIP entry is `application/zip`. HTML, SVG, and JavaScript are rejected rather than previewed.

### Limits and security

Defaults are the hard maximums. Input can lower them and cannot raise them.

| Cap                                | Maximum          |
| ---------------------------------- | ---------------- |
| URLs processed                     | 20               |
| Decoded bytes per file             | 10 MB (10485760) |
| Decoded plus partial bytes per run | 25 MB (26214400) |
| ZIP size                           | 32 MB (33554432) |
| Concurrency                        | 2                |
| Runtime                            | 120 seconds      |
| Redirects                          | 5                |
| Connect and header timeout         | 10 seconds       |
| Read idle timeout                  | 10 seconds       |

`Content-Length` is not trusted and is not used to allocate memory. Chunked responses, missing lengths, and short or huge declared lengths are judged by the bytes actually read. One socket read can cross a cap by a single chunk (the stream high-water mark is 16 KB). The socket is then destroyed and those bytes stay on the run budget.

HTTP `Content-Encoding` of `gzip`, `deflate`, or `br` is decoded, and the decoded size counts toward the caps. Any other encoding, including two encodings at once, is rejected. A `.gz` or `.zip` file is stored whole. It is not extracted, so a compressed archive cannot expand inside the Actor. The 25 MB cap still applies to the bytes of that archive.

Only public `http` and `https` are allowed. Each target and every redirect is resolved. If any address is private, loopback, link-local, CGNAT, documentation, multicast, reserved, or otherwise not public unicast, the URL is rejected. Mixed public and private answers are rejected. IPv4-mapped IPv6 is unwrapped and checked. NAT64, 6to4, and Teredo addresses are rejected. Decimal, hex, octal, and other literal forms are normalized before the check. The connection uses the approved IP only, with the original hostname kept for the `Host` header and for TLS SNI. Certificate verification stays on. Proxy variables and user headers are not forwarded. There is no localhost switch.

Names inside the ZIP are sanitized. `..`, slashes, drive prefixes, NUL and control characters, and Windows device names cannot become entry paths. `Content-Disposition` is only a name hint. Collisions get a numeric suffix. `manifest.json` is reserved.

On a deadline, abort, or cap, open streams and temp files are closed and the scratch directory is removed. Rows that already finished keep their status. Bytes from a failed URL are not given back to the budget. A connection is retried once only when the failure is a connect failure and no bytes were received.

### File types, archives, and rights

Saved types are JPEG, PNG, GIF, WebP, BMP, TIFF, PDF, plain text, CSV, JSON, ZIP, gzip, 7z, RAR, DOCX, XLSX, PPTX, and legacy OLE Office files when the name or content type says so. HTML, SVG, JavaScript, scripts, WASM, and executables are rejected. An unknown binary is rejected. A declared type and a sniffed type that disagree are recorded. If either side is an active rejected type, the file is not saved.

You must be entitled to fetch and reuse the files. The Actor does not grant that right, does not check licenses, and does not claim the files are safe to open. Archives are not unpacked. Nothing that was downloaded is executed or treated as instructions.

### Retention

This Actor uses the run's default unnamed dataset and key-value store. It does not name them, does not set `expiresAt`, and does not delete the dataset, the ZIP, or the manifest. It does not create a cleanup task or a schedule.

How long the files remain is the retention period of the Apify plan on the account that runs the Actor. Checked against Apify's storage documentation: unnamed storages and runs beyond the 10 most recent are deleted automatically, and named storages stay until someone deletes them. On the Free plan, the 10 most recent runs are retained for 4 months. On a paid plan, all of that account's data, including the 10 most recent runs, follows the retention period configured in that account's billing settings. Unnamed storages beyond those 10 runs are deleted when that period ends. The runs documentation also says the ten most recent runs stay accessible, and older runs are deleted with their default storages after the plan period. This Actor does not invent a shorter TTL.

### Failure semantics

A URL that is rejected does not become a saved file. Common statuses:

- `saved` and `saved_type_mismatch` are the only statuses that put a file in the ZIP, and they are the only download rows that can be charged.
- `skipped_charge_limit` means the charge ceiling could not cover that saved file, so it was left out of the ZIP and was not charged.
- `rejected_credentials` omits the URL from the row and the log.
- `rejected_nonpublic_address`, `rejected_nonpublic_host`, `rejected_mixed_dns`, and `rejected_port` stop before a private or odd destination is used.
- `rejected_type_mismatch` and `rejected_disallowed_type` mean the body was not an allowed file. An HTML body with an image `Content-Type` is the mismatch case.
- `rejected_oversize`, `rejected_budget`, and `rejected_zip_limit` mean a cap was hit. Partial bytes remain on the run total.
- `rejected_length_mismatch` means the body ended cleanly at a different size than `Content-Length`.
- `rejected_timeout` means the connect or the read went idle.
- `partial_failure` means bytes arrived and then the transfer failed, including a connection that closed before a declared `Content-Length` was complete. The file is not saved. There is no second try after that.
- `rejected_http_status` covers responses other than 200.
- `aborted_deadline`, `skipped_deadline`, and `aborted_run` mean the run stopped. Completed rows are left as they were.
- `skipped` URLs past `maxUrls` are not fetched and are not copied into the dataset. `OUTPUT.omittedUrlCount` is the count.

If the ZIP cannot be stored, saved rows are marked `rejected_zip_limit` and `PACK` is absent. The manifest and dataset still describe the run. An all-failure run still writes a ZIP that contains only `manifest.json`.

### API, scheduling, and integrations

- **API:** start a run with the input JSON above through the Apify API or the Apify clients for JavaScript and Python, then read the dataset and the `OUTPUT`, `MANIFEST`, and `PACK` records.
- **Scheduling:** use an Apify schedule to download the same public files every day or week and keep dated, hashed copies.
- **Make, Zapier, and n8n:** use the Apify integrations to send a list of URLs and pick up the ZIP when the run finishes.
- **AI agents and MCP:** the Apify MCP server can call this Actor, so an agent can hand over a list of file links and get a checksummed pack back.

The Actor itself does not create schedules, webhooks, or a standby web server.

### FAQ and disclaimers

**Does it crawl a site or discover files?** No. You provide each direct file URL.

**Can it use a signed or expiring link?** Query names such as `token`, `signature`, `X-Amz-Signature`, and `X-Goog-Signature` are rejected so those secrets are not stored. Use a URL you are allowed to fetch that does not put a credential in the query or fragment.

**Will it open the ZIP it downloaded?** No. Archive bytes are stored. They are not extracted.

**Are the files checked for malware or copyright?** No.

**What if a download fails?** That row records the failure and any bytes already read. Other URLs keep the status they earned. A rejected, failed, or partial URL is not charged.

**How many URLs per run?** Up to 20. Start more runs for longer lists.

**Can I prove a file did not change?** Compare the `sha256` in the dataset with a later run. Any change in the bytes changes the hash.

Questions or a file that should have worked? Open an issue on this Actor's Issues tab.

### More from SteadyScrape

- [Google Images API](https://apify.com/steady-scrape/google-images-api): Google Images results as JSON, with real image sizes, source pages, site names and related searches.
- [PDF to Text Markdown Extractor](https://apify.com/steady-scrape/pdf-text-markdown): extract plain text and Markdown from digital PDFs you supply by URL.
- [Website Screenshot Evidence Packs](https://apify.com/steady-scrape/website-screenshot-evidence-packs): desktop and mobile PNG screenshots of public pages with SHA-256 hashes and HTTP status.
- [AI Page to JSON](https://apify.com/steady-scrape/ai-page-to-json): fetch a public page and extract structured JSON with your own schema or field list.

See every **SteadyScrape (steady-scrape)** Actor on [our Apify profile](https://apify.com/steady-scrape).

# Actor input Schema

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

Public http and https direct file URLs. Maximum 20. Credential query parameters, userinfo, and token fragments are rejected and not stored.

## `maxUrls` (type: `integer`):

How many leading URLs to process. Cannot exceed 20. Extra URLs are counted and not stored.

## `maxFileBytes` (type: `integer`):

Decoded byte cap for one file. Cannot exceed 10485760 (10 MB).

## `maxTotalBytes` (type: `integer`):

Run-wide cap for decoded and partial bytes, including failed responses. Cannot exceed 26214400 (25 MB). Redirects and retries do not reset it.

## `maxConcurrency` (type: `integer`):

How many files to fetch at once. Cannot exceed 2.

## `maxRuntimeSeconds` (type: `integer`):

Actor-enforced deadline. Cannot exceed 120. In-flight work is aborted and already saved files keep their status.

## `maxRedirects` (type: `integer`):

Redirects to follow. Each hop is checked again. Cannot exceed 5.

## `connectTimeoutSeconds` (type: `integer`):

Time allowed to open a connection and receive response headers. Cannot exceed 10.

## `readIdleTimeoutSeconds` (type: `integer`):

Maximum silence while reading a body. Cannot exceed 10.

## Actor input object example

```json
{
  "urls": [
    "https://www.w3.org/Icons/w3c_home.png",
    "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
    "https://www.rfc-editor.org/rfc/rfc2606.txt"
  ],
  "maxUrls": 20,
  "maxFileBytes": 10485760,
  "maxTotalBytes": 26214400,
  "maxConcurrency": 2,
  "maxRuntimeSeconds": 120,
  "maxRedirects": 5,
  "connectTimeoutSeconds": 10,
  "readIdleTimeoutSeconds": 10
}
```

# Actor output Schema

## `results` (type: `string`):

Dataset rows with status, byte count, media type, SHA-256, and sanitized source URL.

## `pack` (type: `string`):

ZIP archive of successfully saved files plus manifest.json. The key is the fixed name PACK.

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

JSON manifest stored under the fixed key MANIFEST. Source URLs are sanitized.

# 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://www.w3.org/Icons/w3c_home.png",
        "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
        "https://www.rfc-editor.org/rfc/rfc2606.txt"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("steady-scrape/bulk-file-downloader").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://www.w3.org/Icons/w3c_home.png",
        "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
        "https://www.rfc-editor.org/rfc/rfc2606.txt",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("steady-scrape/bulk-file-downloader").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://www.w3.org/Icons/w3c_home.png",
    "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
    "https://www.rfc-editor.org/rfc/rfc2606.txt"
  ]
}' |
apify call steady-scrape/bulk-file-downloader --silent --output-dataset

```

## MCP server setup

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

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/O8CkaMewqjWRxy6Vb/builds/IVwznLcYdhokOhnsR/openapi.json
