# Export Datasets to SFTP, FTP, Dropbox & Google Drive (`charlesmarkette/dataset-delivery-export-to-sftp-ftp-dropbox-google-drive`) Actor

Deliver any scraper's dataset as a CSV, JSON, JSONL or Excel file (optionally gzipped) to your own SFTP or FTPS server, Dropbox or Google Drive after every run. Atomic uploads, SFTP host-key pinning, retries, and one destination failing never blocks the others. $0.0045 per file delivered.

- **URL**: https://apify.com/charlesmarkette/dataset-delivery-export-to-sftp-ftp-dropbox-google-drive.md
- **Developed by:** [Charles Markette](https://apify.com/charlesmarkette) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $4.50 / 1,000 file delivereds

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

**Dataset Delivery** exports any Apify dataset as a **CSV, JSON, JSONL or Excel (XLSX)** file, optionally gzipped, and uploads it to **your own storage**: an **SFTP** server, an **FTP/FTPS** server, **Dropbox** or **Google Drive**. Connect it as the last step after any scraper, and every run drops a fresh file where your client, your BI tool (Power BI, Tableau, Looker), your data warehouse loader or your colleagues pick it up. It's for agencies and freelancers delivering scraped data to clients, data teams with an SFTP drop folder, and anyone who wants scraper results as files in Dropbox or Google Drive without writing upload code.

- **SFTP**, with password or private-key login and host-key pinning: the way most enterprise data handoffs work.
- **FTP and FTPS** (explicit TLS), in passive mode.
- **Dropbox**, through your own Dropbox app (a long-lived refresh token), including files far above Dropbox's 150 MiB single-upload limit.
- **Google Drive**, through a service account, into a folder you share with it (best in a shared drive).

### Why use Dataset Delivery?

- **Atomic uploads.** On SFTP and FTP the file is written as `<name>.part` and renamed into place only when it's complete, so a job watching the folder never picks up half a file. Overwriting uses an atomic POSIX rename where the server supports it, and never deletes the old file before the new one is in place. For drop folders that react to any new file (or forbid renames), choose **Upload mode: direct**.
- **Safe with SFTP.** Pin the server's host-key fingerprint and the Actor refuses any other server before it sends the password or key. Password login without a pin is refused by default (the error tells you the server's fingerprint to pin), because without it a man-in-the-middle could capture the password and the file.
- **Independent destinations.** Fill in any combination; each one is delivered and retried on its own, so a broken FTP login never stops the SFTP and Dropbox deliveries. The run fails only when every destination failed.
- **Retries the right things.** SFTP and FTP connections get 3 attempts with backoff; Dropbox and Google Drive requests are retried on rate limits (HTTP 429) and server errors (5xx), honouring `Retry-After`. Big Dropbox uploads use upload sessions, big Drive uploads are resumable and continue from where the connection dropped. Wrong passwords, a host-key mismatch, a folder not shared with the service account or a Dropbox path outside an App-folder app fail at once, with the fix in the message.
- **Streams big datasets.** The dataset is read in pages into a temp file on disk, never held in memory, and Excel files are written in openpyxl's write-only mode.
- **Spreadsheet-safe.** CSV values starting with `=`, `+`, `-` or `@` get a leading `'` so Excel and Google Sheets don't run scraped text as a formula; Excel cells are always stored as text, never formulas. Nested fields are flattened into columns like `seller/name`, as in Apify's own CSV export.
- **Pay only for what arrived.** Empty datasets, dry runs, skipped and failed destinations aren't charged as deliveries. A restarted run skips destinations it already recorded as delivered and isn't charged for them again (see Limitations for the small gap).
- **Your secrets stay secret.** Hosts, user names, passwords, keys and tokens are stored encrypted. Hosts, passwords, keys and tokens are masked in every log line, error message and output (including passwords in `user:password@` URLs, Dropbox and Google access tokens, and signed JWTs); user names are masked wherever they appear as a separate word.

### Use cases

- **Client data delivery:** a daily CSV of scraped prices, leads or listings on the client's SFTP server, named by date.
- **BI and data warehouse loads:** drop gzipped CSV or JSONL files into a folder that Power BI, Tableau, Looker or a warehouse loader picks up.
- **Shared reports:** an Excel file in a Google Drive shared drive or a Dropbox folder that colleagues open directly; with `overwrite`, the Drive link stays the same.
- **Archiving:** keep a dated JSONL copy of every crawl in Dropbox or on your own server.
- **Several destinations at once:** the same file to SFTP and Google Drive in one run, each delivered and retried on its own.

### How to use Dataset Delivery

#### Setup per destination

Fill in only the destinations you want. To check a setup, run the Actor once **without a dataset**: it delivers a small test file with 3 sample items (charged like a normal delivery). Or turn on **Dry run** (free) to build the file and see its name, size and row count without uploading.

##### SFTP (about 3 minutes)

1. Ask for (or create) an SFTP user with write access to a folder. Fill in **SFTP host**, **port** (usually 22), **username**, and the **password** or a **private key**.
2. **Key login (recommended):** make a key pair with `ssh-keygen -t ed25519 -f apify_delivery` ([ssh-keygen manual](https://man.openbsd.org/ssh-keygen)). Add the public key (`apify_delivery.pub`) to the user's `~/.ssh/authorized_keys` on the server ([sshd: AUTHORIZED_KEYS FILE FORMAT](https://man.openbsd.org/sshd#AUTHORIZED_KEYS_FILE_FORMAT)), and paste the whole private key file (`apify_delivery`, starting with `-----BEGIN OPENSSH PRIVATE KEY-----`) into **SFTP private key**. If the key has a passphrase, put it in **Key passphrase**. Ed25519, ECDSA and RSA keys work, in OpenSSH or PEM format. PuTTY `.ppk` keys need converting first (PuTTYgen → Conversions → Export OpenSSH key).
3. **Pin the host key:** run once without a fingerprint. With a password, the run stops before logging in and its error shows `This server's host key is SHA256:… (ssh-ed25519)`; with a key, it delivers and logs a warning with the same fingerprint. Paste that `SHA256:…` value into **Host key fingerprint**. Better still, compare it with what the server's administrator gets from `ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the server (the format is the one `ssh-keygen -l` prints, SHA-256 by default). From then on, any other server is refused before logging in. A server has one key per type (Ed25519, ECDSA, RSA); you can paste several fingerprints separated by commas.

   **Without a pin, the server's identity isn't checked: a man-in-the-middle can capture the password and the file.** So password login without a pin is refused unless you turn on **Allow an unpinned host key with a password** (at your own risk). Key login without a pin is allowed (the key itself can't be captured, but the file can), with a warning in every run.
4. Set **SFTP remote folder** (absolute like `/data/outbound`, or relative to the login folder). It's created if missing.

##### FTP / FTPS

Fill in **FTP host**, **port** (usually 21), **username** and **password**. **FTP security** defaults to explicit TLS (FTPS, `AUTH TLS`); choose `none` only for a trusted network, because plain FTP sends the password and the file unencrypted. For a server with a self-signed certificate you trust, turn off **Verify the TLS certificate**; the run then logs a warning, because without verification a man-in-the-middle could capture the password and the file. Implicit FTPS (port 990) isn't supported. Passive mode is on by default; data connections reuse the TLS session, which servers like vsftpd (`require_ssl_reuse`) insist on.

##### Dropbox (about 3 minutes, once)

1. In the [Dropbox App Console](https://www.dropbox.com/developers/apps), click **Create app**, choose **Scoped access** and either **App folder** (the app can only use its own folder, `/Apps/<app name>/`; safest) or **Full Dropbox**. ([Dropbox: OAuth guide](https://docs.dropboxapi.com/dropbox-api/docs/oauth))
2. On the app's **Permissions** tab, tick **files.content.write** and **files.metadata.read**, and click **Submit**.
3. On the **Settings** tab, copy the **App key** and **App secret** into **Dropbox app key** and **Dropbox app secret**.
4. Get a refresh token (long-lived, it doesn't expire on its own): open this URL in your browser with your app key, click **Allow** and copy the code it shows:
   `https://www.dropbox.com/oauth2/authorize?client_id=<APP KEY>&response_type=code&token_access_type=offline`
   Then exchange the code for tokens in a terminal:
   ```
   curl https://api.dropboxapi.com/oauth2/token -d code=<CODE> -d grant_type=authorization_code -d client_id=<APP KEY> -d client_secret=<APP SECRET>
   ```
   Copy `refresh_token` from the answer into **Dropbox refresh token**. ([Dropbox: authorization](https://docs.dropboxapi.com/dropbox-api/docs/get-started/authorization), [Using OAuth 2.0 with offline access](https://dropbox.tech/developers/using-oauth-2-0-with-offline-access))
5. Set **Dropbox folder**, like `/reports`. With an App folder app this is inside `/Apps/<app name>/`, so write `/reports`, not `/Apps/<app name>/reports`.

For a quick test you can instead paste a short-lived token from the App Console's **Generate** button into **Dropbox access token**; it expires after a short time, so scheduled runs need the refresh token.

##### Google Drive (about 5 minutes)

1. In the [Google Cloud console](https://console.cloud.google.com/), pick or create a project and enable the [Google Drive API](https://console.cloud.google.com/apis/library/drive.googleapis.com).
2. Under **IAM & Admin → Service accounts**, create a service account (no roles needed), open it, and under **Keys → Add key → Create new key → JSON** download its key ([Google: create service account keys](https://cloud.google.com/iam/docs/keys-create-delete)). Paste the whole file into **Service account key (JSON)**.
3. **Use a shared drive.** Service accounts have no storage quota of their own and can't own files, so they can only create files in a [shared drive](https://support.google.com/a/users/answer/12380484) (or upload on behalf of a person with OAuth, which this Actor doesn't do). In Google Drive, open the shared drive (or a folder in it) → **Manage members**, add the service account's `client_email` (`…@….iam.gserviceaccount.com`) with the **Contributor** or **Content manager** role. Google's own words: "Service accounts don't have storage quota and can't own any files", so they must "upload files and folders into shared drives" ([Drive API: resolve errors, storageQuotaExceeded](https://developers.google.com/workspace/drive/api/guides/handle-errors)). Shared drives are part of Google Workspace.
4. Open the folder and copy its link (`https://drive.google.com/drive/folders/…`) into **Folder**.

A folder in someone's **My Drive** shared with the service account as Editor is accepted, and the run warns you: creating a new file there fails with `storageQuotaExceeded` (the Actor then explains this). Every Drive call sets `supportsAllDrives=true`, which Google requires for apps that work with shared drives ([Drive API: shared drives support](https://developers.google.com/workspace/drive/api/guides/enable-shareddrives)).

#### Deliver a file after every scraper run (integration)

1. Open your scraper's Actor or saved task, go to the **Integrations** tab and click **Add integration**.
2. Search for **Dataset Delivery**, choose to start it when the run **succeeded**, and set the input. Apify fills in `{{resource.defaultDatasetId}}` with that run's dataset:

```json
{
  "datasetId": "{{resource.defaultDatasetId}}",
  "format": "csv",
  "gzip": true,
  "fields": ["title", "price", "url", "seller.name"],
  "fileNameTemplate": "exports/{date}/{actorName}-{datetime}.{ext}",
  "ifExists": "overwrite",
  "onlyIfItems": true,
  "sftpHost": "sftp.client.example.com",
  "sftpPort": 22,
  "sftpUsername": "apify",
  "sftpPrivateKey": "-----BEGIN OPENSSH PRIVATE KEY-----\n…\n-----END OPENSSH PRIVATE KEY-----",
  "sftpHostKeyFingerprint": "SHA256:…",
  "sftpRemoteDir": "/inbound/prices",
  "dropboxAppKey": "…",
  "dropboxAppSecret": "…",
  "dropboxRefreshToken": "…",
  "dropboxFolder": "/Prices"
}
```

3. Save, and test the integration on a past run.

The file name uses single braces (`{date}`), which this Actor fills in; Apify itself fills in double-brace variables in an integration's input, so keep `{{…}}` for Apify's own (like `{{resource.defaultDatasetId}}`). Each run then delivers, for example, `/inbound/prices/exports/2026-10-13/amazon-scraper-2026-10-13T08-30-00Z.csv.gz` to the SFTP server and the same file to Dropbox.

**Only new items:** put [Merge & Deduplicate Datasets](https://apify.com/charlesmarkette/merge-deduplicate-datasets-only-new-items) in "only new" mode between the scraper and this Actor (connect this Actor to the dedupe Actor's runs) to deliver only items that weren't in earlier files.

### Input example

A weekly Excel report into a Google Drive shared drive, replacing last week's file under the same link:

```json
{
  "datasetId": "aBcDeFgHiJkLmNoPq",
  "format": "xlsx",
  "fileNameTemplate": "weekly-leads.{ext}",
  "ifExists": "overwrite",
  "xlsxSheetName": "Leads",
  "googleServiceAccountKey": "{ \"type\": \"service_account\", … }",
  "googleDriveFolder": "https://drive.google.com/drive/folders/…"
}
```

| Field | Notes |
|---|---|
| Dataset | The dataset to deliver. In integrations use `{{resource.defaultDatasetId}}`. Empty = a test file with 3 sample items (delivered and charged like a normal file). |
| File format | `csv` (default), `json` (one array), `jsonl` (one item per line) or `xlsx`. CSV and XLSX flatten nested fields into columns like `address/city` and `images/0`. |
| Compress with gzip | CSV, JSON and JSONL: delivers `.csv.gz`, `.json.gz`, `.jsonl.gz`. Ignored for XLSX, which is already compressed. |
| Fields / Remove these fields | Pick fields (in that order, dots for nested ones, named as you wrote them; missing ones are empty) and/or drop fields (dots for nested). |
| File name | Default `{actorName}-{date}.{ext}`. Placeholders: `{date}` (`2026-10-13`, UTC), `{datetime}` (`2026-10-13T08-30-00Z`, UTC), `{runId}`, `{datasetId}`, `{actorName}`, `{ext}` (`csv`, `csv.gz`, `xlsx` …). `{{date}}` works too in manual runs, but in integrations use single braces. `/` makes sub-folders (created as needed). Placeholder values are reduced to letters, digits, `.`, `_` and `-`; a name with `..`, a leading `/`, a backslash, a control character, an empty folder name or a stray `{`/`}` is refused before the file is built. |
| Name for {actorName} | Default: the name of the Actor whose run made the dataset (looked up on the platform), else the dataset's name, else `dataset`. |
| If a file exists | `overwrite` (default; atomic on SFTP; on Google Drive the same file gets a new version, so its link stays the same), `skip` (that destination gets nothing and isn't charged), or `addSuffix`: `report-1.csv`, `report-2.csv` … (Dropbox names it `report (1).csv` itself). |
| Upload mode | SFTP and FTP: `atomic` (default: `<name>.part`, then rename) or `direct` (writes the final name, no `.part`, no rename; a watcher may see the file while it's still being written). |
| Only if the dataset has items | Default on: empty datasets deliver nothing and are free. Off: an empty dataset delivers a header-only CSV (from **Fields**), `[]`, an empty JSONL or an Excel sheet with just the header. |
| Dry run | Builds the file and reports its name, size and row count per destination, without uploading (free). Works with no destination configured too. |
| CSV delimiter / UTF-8 BOM / formula protection | Comma (default), semicolon, tab or pipe. BOM off by default (turn it on for Excel with accented characters). Formula protection on by default. |
| Excel sheet name / When rows don't fit one sheet | Default `Data`. A sheet holds 1,048,576 rows including the header: `split` (default) continues on `Data (2)`, `Data (3)` …; `fail` delivers nothing and says so. |
| SFTP host, port, username, password, private key, key passphrase | Password or key (or both: the key is tried first). |
| Host key fingerprint / Allow an unpinned host key | `SHA256:…` as `ssh-keygen -l` prints it (with or without `SHA256:` and the trailing `=`); several separated by commas. Empty: password login is refused (the error shows the fingerprint) unless **Allow an unpinned host key with a password** is on; key login works with a warning. |
| SFTP / FTP remote folder | Absolute or relative to the login folder; created if missing. Empty = the login folder. |
| FTP host, port, username, password, security, verify certificate, passive mode | Empty username = anonymous. Security `tls` (default) or `none`. |
| Dropbox app key, app secret, refresh token / access token, folder | Refresh token + key + secret, or a short-lived access token. |
| Service account key, Folder | The JSON key, and the folder's link or ID. |

Hosts, user names, passwords, private keys, passphrases, Dropbox keys and tokens, and the service account key are secret inputs, stored encrypted. Ports are plain numbers (Apify's secret inputs are text, objects and arrays only).

### Output example

The run's **dataset** has one row per configured destination:

```json
[
  {
    "destination": "sftp",
    "status": "delivered",
    "remotePath": "/inbound/prices/exports/2026-10-13/amazon-scraper-2026-10-13T08-30-00Z.csv.gz",
    "bytes": 184213,
    "rows": 2500,
    "format": "csv.gz",
    "datasetId": "aBcDeFgHiJkLmNoPq",
    "error": null,
    "message": "Uploaded 184,213 bytes (posix-rename).",
    "url": null,
    "target": "SFTP port 22, key login, host key pinned",
    "attempts": 1,
    "deliveredAt": "2026-10-13T08:30:12+00:00"
  }
]
```

- `destination` is `sftp`, `ftp`, `dropbox` or `googleDrive`; `status` is `delivered`, `skipped`, `failed` or `dry run`; `remotePath` is where the file landed (the full path on SFTP/FTP/Dropbox, `<folder name>/<file>` on Drive); `url` is the Drive link; `error` says why a destination failed, with secrets masked.
- The key-value store **`OUTPUT`** holds the file name, format, size, rows, columns, sheets, notes (for example cells cut to Excel's 32,767 characters), status (`done`, `partial`, `failed`, `skipped` or `dry run`) and the same per-destination rows.
- With nothing configured at all (no dataset, no destination: the Store's daily test with the default input), the run returns a single row with setup instructions and exits successfully. A run with a dataset but no destination fails with a clear message.
- The run fails (non-zero exit) only when every configured destination failed, or when the input itself is wrong (an unknown placeholder, a dataset that can't be read, XLSX over the row limit in `fail` mode).

### How much does it cost to deliver files?

Dataset Delivery uses pay-per-event pricing: **$0.0045 per file delivered**, plus Apify's $0.00005 run start fee. One charge per destination that got the file: delivering one dataset to SFTP and Dropbox is two files delivered. Test files (a run without a dataset) are real deliveries and are charged. Empty datasets that are skipped, dry runs, destinations skipped because the file exists, failed destinations, and the result rows in the dataset aren't charged as deliveries; only the start fee applies.

*Examples use the Free-plan rate. Where an example doesn't add it, each run also has its run-start charge, counted per GB of Actor memory (minimum one; one at the default memory); Apify Proxy (where used) is billed separately.*

**Worked example:** a scraper that runs every day and delivers its CSV to an SFTP server and to Google Drive delivers 2 files per run: 2 × $0.0045 + $0.00005 = **$0.00905 per run**, about **$0.27 per month**. The file's size or row count doesn't change the price.

If you set a maximum cost per run, the Actor checks it **before** building and uploading anything and delivers to only as many destinations as it allows (in the order SFTP, FTP, Dropbox, Google Drive), marking the rest as skipped.

### FAQ

#### SFTP refuses to log in with my password and shows a fingerprint.

Password login without a pinned host key is refused by default, because without the pin a man-in-the-middle could capture the password. Copy the `SHA256:…` value from the error into **Host key fingerprint** (ideally after checking it with the server's administrator) and run again.

#### Google Drive fails with "storageQuotaExceeded".

Service accounts have no storage of their own, so they can create files only in a **shared drive**. Add the service account to a shared drive (or a folder in one) as Contributor or Content manager, and use that folder.

#### My Dropbox token stopped working.

Short-lived access tokens from the App Console's **Generate** button expire. For scheduled runs use the app key, app secret and a **refresh token** (see Dropbox setup).

#### My FTPS server uses port 990.

That's implicit FTPS, which isn't supported. Use the server's explicit TLS port (usually 21).

#### The run ran out of disk space.

A run's disk is twice its memory. Give the run more memory, or use gzip, JSONL or fewer fields.

#### Can a job watching the folder pick up a half-written file?

Not in the default `atomic` mode on SFTP and FTP: the file is uploaded as `<name>.part` and renamed when complete. Use `direct` only if your drop folder forbids renames.

#### Can I deliver to Amazon S3, OneDrive or email?

Not with this Actor: it delivers to SFTP, FTP/FTPS, Dropbox and Google Drive. To email a CSV of the results, use [Send Email Report & Slack, Discord, Teams, Telegram Alerts](https://apify.com/charlesmarkette/send-email-report-slack-discord-teams-telegram-alerts) with an attachment.

### Limitations

- **Restarts:** after each successful delivery, the destination is recorded in the run's key-value store together with the file name, and a restarted run (for example after a platform migration) skips recorded destinations and reuses the same file name. The gap: if the run is restarted in the moment after a file arrived but before that record was saved, the restarted run uploads that destination again (with `overwrite` it replaces the first copy, with `addSuffix` a second copy appears) and may charge it again, because the charge's idempotency key protects only against a repeat within a few minutes.
- **SFTP:** 3 connection attempts with backoff (on refused or dropped connections, timeouts and failed handshakes; a wrong password, a refused key or a host-key mismatch fail at once). Timeouts: 20 seconds to connect, read the banner, negotiate keys and log in; 120 seconds for any single read or write. Overwrite renames with the `posix-rename@openssh.com` extension (atomic replace); if the server doesn't support it, with a plain rename; if that refuses because the file exists, the old file is renamed to `<name>.<random>.bak`, the new one renamed into place, and only then the `.bak` deleted (for a moment the name doesn't exist). If moving the old file aside fails, nothing is deleted and the destination fails; if the new file can't be renamed into place, the old one is put back. Permission denied on the rename is final. Once the file is in place, the delivery counts as done even if reading back its absolute path fails. `skip` and `addSuffix` never replace a file. DSA keys aren't supported.
- **FTP:** the same 3 attempts and timeouts. After the upload the size is checked with `SIZE` where the server supports it. Some servers refuse to rename over an existing file; then the old file is moved aside to a `.bak` name first, exactly as on SFTP, so it's never deleted before the new file is in place. A server that won't encrypt the data connection (`PROT P`) fails with a clear message. Existence checks use `SIZE` (or a folder listing on servers without it).
- **Dropbox:** files up to 150 MiB go up in one request ("Do not use this to upload a file larger than 150 MiB", [files/upload](https://docs.dropboxapi.com/dropbox-api/api-reference/user-endpoints/files/upload)); bigger ones through an upload session in 8 MiB chunks ([upload_session/start](https://docs.dropboxapi.com/dropbox-api/api-reference/user-endpoints/files/upload-session-start), [append_v2](https://docs.dropboxapi.com/dropbox-api/api-reference/user-endpoints/files/upload-session-append-v-2), [finish](https://docs.dropboxapi.com/dropbox-api/api-reference/user-endpoints/files/upload-session-finish)), up to Dropbox's per-file maximum of 2^41 − 2^22 bytes (about 2 TB). If an answer gets lost and Dropbox reports a different offset, the upload continues from Dropbox's `correct_offset`. Paths and names may use any characters: they're sent in the `Dropbox-API-Arg` header as JSON with `0x7F` and every non-ASCII character escaped as `\uXXXX`, as [Dropbox requires](https://docs.dropboxapi.com/dropbox-api/docs/technical-reference/json-encoding). An expired access token is refreshed once and the request repeated.
- **Google Drive:** files up to 5 MB go up in one multipart request (new files) or media request (new version of an existing file); bigger ones as a [resumable upload](https://developers.google.com/workspace/drive/api/guides/manage-uploads) in 8 MiB chunks (multiples of 256 KiB, as Google requires), resumed from the server's `Range` after an interruption, and restarted once if the session expired. Google's per-file maximum is 5,120 GB ([files.create](https://developers.google.com/workspace/drive/api/reference/rest/v3/files/create)). Overwrite uses [files.update](https://developers.google.com/workspace/drive/api/reference/rest/v3/files/update) on the existing file (same ID and link, a new revision); if several files have the same name, the most recently modified one is updated. The stored file's MD5 checksum is compared with the local one. A shared drive folder holds at most 500,000 items.
- **Retries over HTTP:** up to 5 attempts per request on HTTP 408, 429, 5xx (and Drive's 403 rate-limit errors) and network errors, waiting at most 120 seconds at a time and 5 minutes in total. A service asking for a longer wait counts as failed for this run.
- **Excel:** 1,048,576 rows and 16,384 columns per sheet, 32,767 characters per cell (longer values are cut, and the run says how many). Integers beyond 2^53 are written as text so no digits are lost. Control characters Excel can't store are removed.
- **Columns:** CSV and XLSX get one column per flattened field, in the order fields first appear (with **Fields**, in your order). More than 50,000 columns (usually long arrays) fails with a hint to pick fields or use JSON/JSONL.
- **Memory and disk:** the dataset is read in pages of about 16 MB of JSON (the first page is 100 items; then 10 to 10,000 items, depending on item size), so memory use depends on the page, not the dataset. The file is built once in a temp folder that's deleted at the end. A run's disk on Apify is [twice its memory](https://docs.apify.com/platform/actors/running/usage-and-resources) (2 GB at the default 1 GB). JSON and JSONL need disk for the output file only; CSV and XLSX first stage the flattened rows (about the size of the JSON), then write the output next to them, and delete the staged copy right after, so they need roughly the JSON size plus the output size. Running out of disk stops the run with a message to raise the run's memory (or use gzip, JSONL or fewer fields).
- Not affiliated with Dropbox or Google.

### Related Actors

Dataset Delivery is part of a small suite of Actors that work together after any scraper:

| Actor | Use it to |
|---|---|
| [CSV & Excel to Dataset](https://apify.com/charlesmarkette/csv-excel-to-dataset-xlsx-json-converter) | The other direction: turn CSV, Excel or Google Sheets files into a dataset. |
| [Merge & Deduplicate Datasets](https://apify.com/charlesmarkette/merge-deduplicate-datasets-only-new-items) | Deliver only items that weren't in earlier runs. |
| [Google Sheets Sync](https://apify.com/charlesmarkette/google-sheets-sync) | Keep the same data in a live Google Sheet instead of a file. |
| [Send Email Report & Slack, Discord, Teams, Telegram Alerts](https://apify.com/charlesmarkette/send-email-report-slack-discord-teams-telegram-alerts) | Tell your team in Slack or by email when a new file is ready. |
| [SEC Funding Rounds Tracker](https://apify.com/charlesmarkette/sec-form-d-funding-tracker) | Deliver a daily file of newly funded companies from SEC filings. |
| [Vector DB Sync](https://apify.com/charlesmarkette/vector-db-sync-pinecone-qdrant-rag-loader-delta-updates) | Load the same crawl into Pinecone or Qdrant for RAG. |
| [Phone Number Validator & Formatter](https://apify.com/charlesmarkette/phone-number-validator-formatter-bulk-e164) | Clean and format phone numbers before delivering a lead list. |
| [Dataset Filter & Transform](https://apify.com/charlesmarkette/dataset-filter-transform-rename-flatten-fields) | Drop, rename or flatten columns so the file matches what the receiver expects. |

# Actor input Schema

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

The dataset to deliver, usually the scraper run's output. In an integration (Integrations → Connect Actor or task), use {{resource.defaultDatasetId}}. Leave empty to deliver a small test file with 3 sample items.

## `format` (type: `string`):

CSV and XLSX flatten nested fields into columns like 'address/city' and 'images/0', as Apify's own CSV export does.

## `gzip` (type: `boolean`):

CSV, JSON and JSONL only: delivers a .gz file (ext becomes csv.gz). XLSX is already compressed.

## `fields` (type: `array`):

Optional: only these fields, in this order. Dots for nested fields ('seller.name'). Empty = all fields.

## `omitFields` (type: `array`):

Optional: fields to leave out (dots for nested fields).

## `fileNameTemplate` (type: `string`):

Placeholders: {date} (YYYY-MM-DD, UTC), {datetime} (YYYY-MM-DDTHH-MM-SSZ, UTC), {runId}, {datasetId}, {actorName}, {ext}. Use '/' for sub-folders, e.g. 'exports/{date}/{actorName}.{ext}'. (Single braces: Apify integrations fill in {{…}} themselves.)

## `actorName` (type: `string`):

Optional. Default: the name of the Actor that made the dataset (looked up on the platform), else 'dataset'.

## `ifExists` (type: `string`):

Overwrite replaces it atomically (on Google Drive: same file and link, new version). Add a number: report-1.csv, report-2.csv … (Dropbox names it 'report (1).csv' itself).

## `uploadMode` (type: `string`):

Atomic (default) means a job watching the folder never sees half a file. Direct writes the final name with no .part and no rename, for drop folders that react to any new file or don't allow renames.

## `onlyIfItems` (type: `boolean`):

Empty datasets deliver nothing and are free.

## `dryRun` (type: `boolean`):

Builds the file and reports its name, size and row count for every configured destination without uploading (free).

## `csvDelimiter` (type: `string`):

CSV only.

## `csvBom` (type: `boolean`):

CSV only: start the file with a byte order mark, so Excel opens accented characters correctly. Leave off for databases and BI tools.

## `csvFormulaGuard` (type: `boolean`):

CSV only: values starting with = + - @ (or a tab or CR) get a leading ' so spreadsheet apps don't run them as formulas. Plain numbers like -5 are left alone. XLSX cells are always stored as text, never formulas.

## `xlsxSheetName` (type: `string`):

XLSX only. At most 31 characters.

## `xlsxOverflow` (type: `string`):

An Excel sheet holds 1,048,576 rows including the header.

## `sftpHost` (type: `string`):

Host name or IP, like sftp.example.com. Stored encrypted.

## `sftpPort` (type: `integer`):

Usually 22.

## `sftpUsername` (type: `string`):

Stored encrypted.

## `sftpPassword` (type: `string`):

Or use a private key below. Stored encrypted.

## `sftpPrivateKey` (type: `string`):

The whole private key file (OpenSSH or PEM: Ed25519, ECDSA or RSA), starting with -----BEGIN … PRIVATE KEY-----. Stored encrypted.

## `sftpPrivateKeyPassphrase` (type: `string`):

Only if the private key is encrypted. Stored encrypted.

## `sftpHostKeyFingerprint` (type: `string`):

Recommended (required for password login unless you allow an unpinned key): the server's SHA256 fingerprint as `ssh-keygen -l` prints it (SHA256:…). Any other server is refused before logging in. If empty, the run logs the fingerprint so you can paste it here. Several can be separated by commas.

## `allowUnpinnedHostKey` (type: `boolean`):

Off (default): without a pinned fingerprint, password login is refused (the run says the server's fingerprint so you can pin it), because a man-in-the-middle could capture the password and the file. Key login without a pin works, with a warning. Turn on only at your own risk.

## `sftpRemoteDir` (type: `string`):

Where the file goes, absolute (/data/outbound) or relative to the login folder (outbound). Created if missing. Empty = the login folder.

## `ftpHost` (type: `string`):

Host name or IP, like ftp.example.com. Stored encrypted.

## `ftpPort` (type: `integer`):

Usually 21 (also for explicit FTPS).

## `ftpUsername` (type: `string`):

Empty = anonymous. Stored encrypted.

## `ftpPassword` (type: `string`):

Stored encrypted.

## `ftpSecurity` (type: `string`):

Implicit FTPS (port 990) isn't supported; most servers offer explicit TLS on port 21.

## `ftpVerifyCertificate` (type: `boolean`):

Turn off only for a server with a self-signed certificate that you trust.

## `ftpPassive` (type: `boolean`):

Leave on unless your server needs active mode (rare; active mode usually fails through firewalls).

## `ftpRemoteDir` (type: `string`):

Absolute (/outbound) or relative to the login folder. Created if missing.

## `dropboxAppKey` (type: `string`):

From the App Console (Settings tab). Stored encrypted.

## `dropboxAppSecret` (type: `string`):

From the App Console (Settings tab). Stored encrypted.

## `dropboxRefreshToken` (type: `string`):

Long-lived; see the README for the one-time steps to get it. Stored encrypted.

## `dropboxAccessToken` (type: `string`):

Instead of the three fields above, for a quick test: the 'Generate' button in the App Console. It expires after a short time. Stored encrypted.

## `dropboxFolder` (type: `string`):

Like /reports. With an 'App folder' app, this is inside /Apps/<your app>/ (write /reports, not /Apps/<your app>/reports). Empty = the top (or the app folder).

## `googleServiceAccountKey` (type: `string`):

The whole JSON key file of a Google Cloud service account. Stored encrypted.

## `googleDriveFolder` (type: `string`):

The folder's link (https://drive.google.com/drive/folders/…) or ID, shared with the service account's client_email as Editor (in a shared drive: a member with the Contributor or Content manager role).

## Actor input object example

```json
{
  "format": "csv",
  "gzip": false,
  "fileNameTemplate": "{actorName}-{date}.{ext}",
  "ifExists": "overwrite",
  "uploadMode": "atomic",
  "onlyIfItems": true,
  "dryRun": false,
  "csvDelimiter": "comma",
  "csvBom": false,
  "csvFormulaGuard": true,
  "xlsxSheetName": "Data",
  "xlsxOverflow": "split",
  "sftpPort": 22,
  "allowUnpinnedHostKey": false,
  "ftpPort": 21,
  "ftpSecurity": "tls",
  "ftpVerifyCertificate": true,
  "ftpPassive": true
}
```

# Actor output Schema

## `destinations` (type: `string`):

Destination, status, remote path, bytes, rows and error for each configured destination.

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

File name, format, size, rows, notes and per-destination results.

# 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 = {};

// Run the Actor and wait for it to finish
const run = await client.actor("charlesmarkette/dataset-delivery-export-to-sftp-ftp-dropbox-google-drive").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 = {}

# Run the Actor and wait for it to finish
run = client.actor("charlesmarkette/dataset-delivery-export-to-sftp-ftp-dropbox-google-drive").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 '{}' |
apify call charlesmarkette/dataset-delivery-export-to-sftp-ftp-dropbox-google-drive --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,charlesmarkette/dataset-delivery-export-to-sftp-ftp-dropbox-google-drive"
        }
    }
}
```

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/tt4rvu6tJYJlkYpBO/builds/Q3Ndbn7soZyYTHaYy/openapi.json
