> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tornadoapi.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Google Drive Delivery

> Deliver completed downloads into a Google Drive folder using a Google service account

## Read this first

Google Drive delivery works, but two current limitations decide *how* you must configure it.

<Warning>
  **1. The dashboard cannot save Drive as an organisation default today.**

  The dashboard writes org-level storage through `POST /settings/org-storage`, which is
  gated by the `ORG_STORAGE_PROVIDERS` setting. That setting currently defaults to
  `blob` (Azure Blob only), so a Drive request is rejected before any credential is read:

  ```json theme={null}
  {
    "detail": "Org-level default is not yet available for provider 'gdrive' (Rust rollout in progress). Available: ['blob']"
  }
  ```

  This is deliberate, not a misconfiguration. The Tornado API's `POST /user/gdrive`
  handler does not accept the `apply_to_all_keys` flag that the org path depends on, so
  adding `gdrive` to `ORG_STORAGE_PROVIDERS` would not produce an org default. It would
  silently write a **per-key** config on whichever key the dashboard picked to
  authenticate its internal call, then fail the response check with
  `The Tornado API did not confirm org-level scope`.

  Until that ships, configure Drive with the direct API, `POST /user/gdrive`, on each
  API key you send jobs with. See [Configure Tornado](#5-configure-tornado).
</Warning>

<Warning>
  **2. Do not configure Drive during onboarding. Do it from Settings afterwards.**

  The onboarding wizard runs its steps in this order:
  `destination` → `credentials` → `payment` → `key` → `request` → `delivered`.
  The storage step therefore runs **two steps before** the API key is created.

  The Drive tile posts to `POST /settings/org-storage`, and that route resolves an
  existing active key from your organisation to authenticate its internal call. A brand
  new organisation has none, so the wizard returns:

  ```json theme={null}
  {
    "detail": "No usable API key in this organization — create one first (the org default is set through the Tornado API)."
  }
  ```

  Finish onboarding (pick **Tornado bucket** at the destination step, or any provider you
  already have credentials for), then configure Drive against your real API key.
</Warning>

***

## What Drive delivery is, and is not

Tornado authenticates to Drive as a **Google service account** using a signed JWT, the same
credential type as Google Cloud Storage. There is no OAuth consent screen and no
"sign in with Google" redirect. You create the service account, you share a folder with it,
you paste its JSON key.

The backend requests exactly one scope:

```
https://www.googleapis.com/auth/drive.file
```

Google lists `drive.file` under **non-sensitive scopes** and marks it Recommended. It grants
the ability to "Create new Drive files, or modify existing files, that you open with an app
or that the user shares with an app while using the Google Picker API or the app's file
picker". In practice, for a server-side service account with no file picker, that means
**Tornado sees only the files Tornado itself created**.

<Note>
  If you point Tornado at a folder that already contains 500 files, Tornado cannot list,
  read, overwrite or delete any of them. It can only add new files alongside them, and
  manage those. This is a deliberate least-privilege choice, and it is also why Tornado
  can never be used to exfiltrate the rest of your Drive.
</Note>

Other behaviours worth knowing before you commit:

| Behaviour                        | Drive                                                                                            | Other providers                 |
| -------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------- |
| Download URL                     | Permanent `https://drive.google.com/file/d/{id}/view` link, access governed by Drive permissions | Presigned URL, 24 hour validity |
| `folder_prefix` / `base_folder`  | Flattened into the **file name**, no nested folders are created                                  | Real object key path            |
| Inline `storage` in `POST /jobs` | Not supported                                                                                    | S3, Blob, GCS, OSS supported    |
| Upload throughput                | Single sequential resumable stream                                                               | S3 multipart, parallel          |

<Warning>
  `base_folder` and `folder_prefix` do **not** build a folder tree in Drive. A job with
  `base_folder: "premier"` and `folder_prefix: "alex/test/"` produces one file in your
  destination folder literally named `premier/alex/test/my-video.mp4`. The generic
  [Folder Prefix](/features/custom-storage#folder-prefix) diagrams in the Custom Storage
  page describe the object-storage providers, not Drive.
</Warning>

***

## The step everyone misses: share the folder with the service account

A service account is a Google identity with its own email address. It is not you, it
cannot see your Drive, and it is not a member of your organisation unless you add it.

**Sharing the destination folder with that email address is not optional. Skip it and you
land in one of two failure modes:**

* You set a `folder_id` the service account cannot see. Drive does not distinguish
  "not shared with you" from "does not exist", so every upload fails with a 404 on a
  folder that is plainly sitting in your browser.
* You left `folder_id` empty, so Tornado targets the service account's own root Drive.
  That Drive belongs to a robot identity with no web UI you can log into, and it has no
  storage quota, so uploads fail with `storageQuotaExceeded` against a destination you
  cannot inspect.

### Finding the service account's email address

It is the `client_email` field inside the JSON key file you downloaded. It always ends in
`.iam.gserviceaccount.com`:

```bash theme={null}
jq -r .client_email service-account.json
# tornado-drive@my-project.iam.gserviceaccount.com
```

<Tip>
  In the dashboard, paste the JSON into the **Service account key (JSON)** box first. The
  UI parses `client_email` out of it and shows a **Share your folder with:** row with a
  copy button, so you never have to open the file by hand.
</Tip>

Share the folder with that address as **Editor**, not Viewer or Commenter. Editor is the
level that lets a collaborator "add files to the folder".

***

## Minimum and recommended access

Drive has no policy document. What you grant is one OAuth scope plus one sharing level, and
Tornado picks the scope for you, so the only decision you make is the sharing level.

### Minimum

| What                                   | Value                                                                                                                                                                                                                                             |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OAuth scope                            | `https://www.googleapis.com/auth/drive.file`, and only that. Tornado requests it and nothing else. You do not configure it; it is here so you can check the consent record and see that `drive`, `drive.readonly` and `drive.metadata` are absent |
| Cloud IAM roles on the service account | **None.** Cloud IAM does not grant Drive access. Assign nothing at project level                                                                                                                                                                  |
| Drive API enablement                   | **Google Drive API** enabled on the project that owns the service account                                                                                                                                                                         |
| Sharing, on a Shared Drive             | The service account's `client_email` added as a member with the **Content manager** role, on the drive or on a folder inside it                                                                                                                   |
| Sharing, on a My Drive folder          | **Editor** on the folder. This is the minimum that permits upload, but see [Storage quota](#storage-quota-use-a-shared-drive-not-my-drive): it fails anyway, for a different reason                                                               |

Those cover everything Tornado calls: `files.create` (the resumable upload and its chunks),
`files.list` (finding a delivered file by name, and the one-item validation call), and
`files.delete`.

<Warning>
  **Contributor is not enough on a Shared Drive.** Google's own guidance is explicit that if
  you want members who cannot remove content, "give them **Contributor**, **Commenter**, or
  **Viewer** access only. These members can't delete files." Tornado deletes, so Contributor
  breaks the cleanup path. Content manager is the lowest role that both uploads and deletes.
</Warning>

### Recommended

**The minimum sharing level is the recommendation.** Content manager is already the lowest
role that works, and **Manager** adds only member management and the ability to delete the
shared drive itself, neither of which Tornado touches. One thing about placement is worth a
line:

| Added                                                                                        | Why                                                                                                                                                                                                                                                                     |
| -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A Shared Drive created **for Tornado deliveries**, rather than membership on an existing one | `drive.file` already stops Tornado from seeing pre-existing files, but pooled-storage consumption and the 750 GB daily upload cap are shared with everything else on the drive. A dedicated drive keeps the quota attributable and makes revocation a one-click removal |

***

## Storage quota: use a Shared Drive, not My Drive

This is the single most important decision on this page, and the answer differs between
My Drive and Shared Drives.

Google's Drive API documentation states it plainly:

> Service accounts don't have storage quota and can't own any files. Instead, they must
> upload files and folders into shared drives, or use OAuth 2.0 to upload items on behalf
> of a human user.

Combine that with the ordinary Drive storage rule, "Only the files you own count toward
your Google storage", and the consequence follows:

<AccordionGroup>
  <Accordion title="My Drive folder shared with the service account: expect failure">
    When the service account uploads into a folder in someone's My Drive, the service
    account becomes the **owner** of the new file. Storage is billed to the owner, and a
    service account has no quota to bill. Google rejects the upload with a 403:

    ```json theme={null}
    { "error": { "code": 403, "errors": [{ "reason": "storageQuotaExceeded" }] } }
    ```

    The folder owner's free space is irrelevant here. Their quota is not consumed because
    they do not own the file. Sharing the folder as Editor is necessary but not
    sufficient.
  </Accordion>

  <Accordion title="Shared Drive: the supported configuration">
    In a Shared Drive, the drive owns the files rather than any individual, so no personal
    quota is involved. Consumption counts against your Google Workspace organisation's
    pooled storage. Tornado sends `supportsAllDrives=true` on every Drive upload, list and
    delete call, so Shared Drive targets work without any extra configuration on your side.

    Shared Drives require a work or school account. They are not available on personal
    `@gmail.com` accounts, and your Workspace edition and administrator must permit them.
  </Accordion>
</AccordionGroup>

<Warning>
  Tornado's JWT contains no `sub` claim, which means it does **not** use domain-wide
  delegation to impersonate a human user. The second option in Google's guidance,
  "use OAuth 2.0 to upload items on behalf of a human user", is therefore not available.
  A Shared Drive is the only configuration with a supported quota story.
</Warning>

<Note>
  Google Workspace caps uploads at 750 GB per day per user across My Drive and all shared
  drives. High-volume delivery can hit this. Nothing on the Tornado side raises it.
</Note>

***

## Setup

### 1. Create a Google Cloud project and enable the Drive API

<Steps>
  <Step title="Create the project">
    Go to [console.cloud.google.com/projectcreate](https://console.cloud.google.com/projectcreate),
    or in the Cloud console open **Menu** > **IAM & Admin** > **Create a Project**.

    Enter a project name, optionally edit the project ID (it cannot be changed later), pick
    a location, then click **Create**.

    An existing project is fine. The project only carries the service account and the API
    enablement, it has nothing to do with where the files land.
  </Step>

  <Step title="Enable the Drive API">
    In the Cloud console go to **Menu** > **APIs & Services** > **Library** > **Google Workspace**,
    click **Google Drive API**, then click **Enable**.

    Direct link: [console.cloud.google.com/apis/library/drive.googleapis.com](https://console.cloud.google.com/apis/library/drive.googleapis.com)

    Or with the CLI:

    ```bash theme={null}
    gcloud services enable drive.googleapis.com --project=YOUR_PROJECT_ID
    ```

    Skipping this step produces a 403 with `accessNotConfigured` on the first upload, not
    at configuration time.
  </Step>
</Steps>

### 2. Create a service account and download its JSON key

<Steps>
  <Step title="Create the service account">
    Go to [console.cloud.google.com/iam-admin/serviceaccounts](https://console.cloud.google.com/iam-admin/serviceaccounts),
    select your project, click **Create service account**, enter a name such as
    `tornado-drive`, then click **Done**.

    Assign no IAM roles. Cloud IAM roles do not grant Drive access. Drive permissions come
    exclusively from Drive sharing, which is step 3.
  </Step>

  <Step title="Create a JSON key">
    Click the service account's email address, open the **Keys** tab, then
    **Add key** > **Create new key**, select **JSON**, and click **Create**. The file
    downloads automatically.

    Google notes that "This file is the only copy of this key. After you download the key
    file, you cannot download it again."
  </Step>

  <Step title="Note the client_email">
    ```bash theme={null}
    jq -r .client_email service-account.json
    ```

    You need this address in the next step.
  </Step>
</Steps>

<Warning>
  The JSON key is a long-lived private credential with no expiry. Store it in a secret
  manager, not in your repository. Tornado stores it in Azure Key Vault and never returns
  it, but the copy on your disk is your responsibility. Rotate it from the same **Keys**
  tab.
</Warning>

### 3. Share the destination folder with the service account

<Tabs>
  <Tab title="Shared Drive (recommended)">
    1. Open [drive.google.com](https://drive.google.com) and click **Shared drives** in the
       left sidebar.
    2. Click **New**, name the drive, then click **Create**. Skip to step 3 if you are
       reusing an existing one.
    3. Open the shared drive and click **Manage members**.
    4. Paste the service account's `client_email` address.
    5. Set the role to **Content manager** or higher. Content manager can upload, edit,
       move and delete files, which matches what Tornado does on delivery and cleanup.
       **Contributor** is not enough, because it cannot delete.
    6. Click **Send**.

    You can target the shared drive's root, or a folder inside it. Either works.
  </Tab>

  <Tab title="My Drive folder">
    Read [Storage quota](#storage-quota-use-a-shared-drive-not-my-drive) first. This path
    is expected to fail at upload time with `storageQuotaExceeded`.

    1. Open [drive.google.com](https://drive.google.com) and select the folder.
    2. Click **Share**.
    3. Enter the service account's `client_email` address.
    4. Set the access level to **Editor**.
    5. Click **Send** or **Share**.

    Editor permissions cascade to everything inside the folder.
  </Tab>
</Tabs>

### 4. Find the folder ID

Open the folder in Drive and read it out of the address bar. The ID is everything after
`/folders/`:

```
https://drive.google.com/drive/folders/1AbCdEfGhIjKlMnOpQrStUvWxYz
                                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^
                                       this is the folder_id
```

For the root of a Shared Drive, the ID in the URL after `/drive/folders/` is the shared
drive's own ID, which is a valid `parents` value.

<Warning>
  `folder_id` is optional in the API and defaults to `""`, which means "the service
  account's own root Drive". Treat it as required. The dashboard does: its **Connect & verify**
  button stays disabled until you fill it in, precisely because an empty value delivers into
  an invisible Drive.
</Warning>

### 5. Configure Tornado

Storage is resolved **per API key**. A configuration set on one key does not apply to your
other keys. Run this for every key you send jobs with.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://api.tornadoapi.io/user/gdrive" \
    -H "x-api-key: sk_your_api_key" \
    -H "Content-Type: application/json" \
    -d '{
      "folder_id": "1AbCdEfGhIjKlMnOpQrStUvWxYz",
      "service_account_json": "{\"type\":\"service_account\",\"project_id\":\"my-project\",\"private_key\":\"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n\",\"client_email\":\"tornado-drive@my-project.iam.gserviceaccount.com\"}"
    }'
  ```

  ```python Python theme={null}
  import json
  import requests

  with open("service-account.json") as f:
      sa_json = json.dumps(json.load(f))

  response = requests.post(
      "https://api.tornadoapi.io/user/gdrive",
      headers={"x-api-key": "sk_your_api_key"},
      json={
          "folder_id": "1AbCdEfGhIjKlMnOpQrStUvWxYz",
          "service_account_json": sa_json,
      },
  )
  print(response.json())
  ```

  ```json Response theme={null}
  {
    "message": "Google Drive configured successfully",
    "provider": "gdrive",
    "folder_id": "1AbCdEfGhIjKlMnOpQrStUvWxYz",
    "folder_prefix": null
  }
  ```
</CodeGroup>

#### Field reference

| Field                  | Required                     | Notes                                                                                                                                                                                 |
| ---------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `service_account_json` | Yes                          | The whole JSON key file as a single string. Must contain `type: "service_account"`, `project_id`, `private_key` and `client_email`.                                                   |
| `folder_id`            | No, but treat it as required | Destination folder ID. Defaults to `""`, meaning the service account's root Drive.                                                                                                    |
| `folder_prefix`        | No                           | Flattened into the file name, see the warning above. Rejected if it starts with `/`, contains `//`, exceeds 512 characters, or contains any of the characters listed below the table. |
| `base_folder`          | No                           | Defaults to `videos`. Also flattened into the file name.                                                                                                                              |

Characters rejected in `folder_prefix`:

```
<  >  "  |  ?  *  \
```

<Warning>
  **The Drive field names differ from the GCS field names.** Drive takes `folder_id`. GCS
  takes `project_id` and `bucket`. Drive accepts neither `project_id` nor `bucket`, and
  passing them changes nothing, because unknown fields are dropped silently rather than
  rejected. If you copied a working GCS payload and only swapped the URL, you have almost
  certainly configured the service account root Drive with an empty `folder_id`.
</Warning>

#### Through the dashboard

The dashboard's Drive tile is blocked by [limitation 1](#read-this-first) above, so this
is recorded for completeness rather than as a working path. The wire format nests the
config under the provider key:

```json theme={null}
{
  "provider": "gdrive",
  "gdrive": {
    "service_account_json": "{\"type\":\"service_account\", ... }",
    "folder_id": "1AbCdEfGhIjKlMnOpQrStUvWxYz"
  }
}
```

The dashboard labels map to the API fields as **Service account key (JSON)** to
`service_account_json` and **Destination folder ID** to `folder_id`.
`service_account_json` is capped at 10,000 characters on this route. A standard Google key
file is roughly 2.3 KB, so this only bites if you paste something other than the key.

***

## What "verified" actually proves

`POST /user/gdrive` validates the credentials live before saving, and the dashboard button
says **Connect & verify**. Be precise about what that check covers.

The validation mints an OAuth token from the JSON key, then issues a one-item
`files.list`. That proves the service account exists, the private key parses and signs,
the Drive API is enabled on the project, and the `drive.file` scope is granted.

It does **not** touch `folder_id`. The list call carries no folder filter, and under
`drive.file` a fresh service account has created nothing, so it returns an empty list and
the check passes.

Nothing is written. The dashboard's S3 probe was extended to write, read back, open and
abort a multipart upload, and delete; Drive validation is a single read-only `files.list`
and was not changed. If you have read the [AWS S3](/storage/aws-s3) page, do not carry over
what it says about the probe.

<Warning>
  A green "verified" result does not prove that the folder ID is correct, that the folder
  exists, that it is shared with the service account, or that the target has usable
  storage quota. All four fail for the first time on your first real delivery. Always
  follow configuration with a test job.
</Warning>

```bash theme={null}
curl -X POST "https://api.tornadoapi.io/jobs" \
  -H "x-api-key: sk_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "max_resolution": "360"
  }'
```

Then open the folder in Drive and confirm the file is there.

***

## Troubleshooting

### The job says `Completed` but nothing appears in my folder

Work through these in order.

<Steps>
  <Step title="Check which API key sent the job">
    Storage is per key. If the job went out on a key with no Drive config, the file went to
    Tornado's managed storage and the job still reports `Completed`. This is the most
    common cause. Re-run `POST /user/gdrive` with the `x-api-key` you actually send jobs
    with.
  </Step>

  <Step title="Check that folder_id was not empty">
    An empty or omitted `folder_id` sends everything to the service account's own root
    Drive, which no human can browse. There is no read-back endpoint for Drive
    configuration, so if you are unsure, simply re-run `POST /user/gdrive` with the correct
    `folder_id`. It overwrites the previous config for that key.
  </Step>

  <Step title="Check the file name, not the folder tree">
    Drive does not create nested folders. If you set `base_folder` or `folder_prefix`, look
    for a flat file literally named `videos/my-video.mp4` inside the destination folder,
    not for a `videos` subfolder.
  </Step>

  <Step title="Check that you are looking at the right Drive">
    A shared drive and a My Drive folder can have the same name. Confirm the ID in the URL
    matches the `folder_id` you configured.
  </Step>
</Steps>

### Permission and lookup errors

| Symptom                                                                                 | Cause                                                                                                                                         | Fix                                                                                                                          |
| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `Drive resumable init failed: 404 ... File not found: {folder_id}`                      | The service account cannot see the folder, or the ID is wrong. A folder that is not shared is indistinguishable from one that does not exist. | Share the folder with the `client_email` as Editor, or Content manager on a Shared Drive. Re-check the ID after `/folders/`. |
| `403 insufficientFilePermissions`                                                       | Shared as Viewer or Commenter, or as Contributor on a Shared Drive.                                                                           | Raise to Editor, or Content manager on a Shared Drive. Contributor cannot delete, so cleanup fails.                          |
| `403 accessNotConfigured`                                                               | Drive API not enabled on the project that owns the service account.                                                                           | Enable **Google Drive API** on that specific project.                                                                        |
| `Credential validation failed: Failed to get Drive access token: 400 ... invalid_grant` | The key has been deleted or disabled in Cloud IAM, or the service account itself was removed.                                                 | Confirm the key is still listed under the service account's **Keys** tab, and create a new one if not.                       |
| `Invalid configuration: Service account JSON missing 'client_email' field`              | Wrong file. An OAuth client secret is not a service account key.                                                                              | Re-download from **Keys** > **Add key** > **Create new key** > **JSON**.                                                     |
| `Invalid configuration: Service account JSON 'type' must be 'service_account'`          | Same as above.                                                                                                                                | Same as above.                                                                                                               |
| `Google Drive storage configuration is temporarily unavailable` (503)                   | Tornado's credential vault is unreachable. Nothing to fix on your side.                                                                       | Retry, then contact support.                                                                                                 |

### Quota errors

| Symptom                                                                                         | Cause                                                                                                                             | Fix                                                                                                                                            |
| ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `403 storageQuotaExceeded` on every upload, to an empty folder, from an account with free space | The destination is a **My Drive** folder. The service account owns the uploaded file, and service accounts have no storage quota. | Move the destination to a Shared Drive and add the service account as **Content manager**. Nothing about the folder owner's plan changes this. |
| `403 storageQuotaExceeded` on a Shared Drive                                                    | Your Workspace organisation's pooled storage is full.                                                                             | Free up pooled storage, or increase the organisation's plan.                                                                                   |
| Uploads stop after roughly 750 GB in a day                                                      | Google's per-user daily upload cap.                                                                                               | Wait 24 hours, or spread delivery across more than one destination.                                                                            |

### Download link problems

The `s3_url` returned for a Drive delivery is a permanent
`https://drive.google.com/file/d/{id}/view` link, not a presigned URL. It does not expire,
and anyone opening it needs Drive permission on that file. Sharing the link with someone
outside the folder's access list will show them a request-access page.

Tornado resolves that link by looking the file up **by name** inside the folder. Two jobs
that produce identical file names will collide, and the lookup returns whichever one Drive
answers with first. The same applies to `DELETE`. If that is a risk for you, set a distinct
per-job `folder` value on `POST /jobs`, which is folded into the file name and keeps the
names unique.

***

## Removing the configuration

```bash theme={null}
curl -X DELETE "https://api.tornadoapi.io/user/gdrive" \
  -H "x-api-key: sk_your_api_key"
```

New downloads on that key revert to Tornado's managed storage. Files already delivered to
Drive are left in place. Revoking the service account's access in Drive, or deleting the
JSON key in Cloud IAM, does not remove the stored configuration. Jobs will keep trying and
failing until you run the `DELETE`.

***

## Related

<CardGroup cols={2}>
  <Card title="Custom Storage" icon="database" href="/features/custom-storage">
    S3, Azure Blob, GCS and Alibaba OSS configuration
  </Card>

  <Card title="POST /user/gdrive" icon="code" href="/api-reference/user/configure-gdrive">
    Full endpoint reference
  </Card>
</CardGroup>
