> ## 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.

# Cloudflare R2

> Deliver Tornado downloads into an R2 bucket: account ID, a bucket-scoped API token, and the four dashboard fields

## Overview

R2 is **not a separate provider** in the Tornado API. It is configured as
`provider: "s3"`, pointed at R2's S3-compatible endpoint, with the region set to
`auto`. Every field, error string and behaviour on this page is the S3 path with
R2-specific values.

The onboarding wizard hides that: the **Cloudflare R2** tile asks for an
**Account ID** and builds
`https://<account-id>.r2.cloudflarestorage.com` for you. Everywhere else, and
through the API, you supply that URL yourself as `endpoint`.

Tornado writes each completed file into your bucket, then hands your customers a
**presigned GET URL** for it (the `s3_url` / `download_url` field in job status
and in the `job.completed` webhook, valid 24 hours).

<Note>
  **The connection test now checks the read half too.** It used to write a probe
  object and delete it, and nothing else, so a token that could not read passed
  with a green tick and then 403'd every download. The probe now reads the object
  back and exercises the multipart create-and-abort lifecycle as well, so a token
  scoped too narrowly fails the test by name. See
  [what it proves](#what-test-connection-proves).

  Why reading matters: a presigned URL carries no permissions of its own. It is
  signed with the R2 API token you configured, and R2 evaluates the download as
  *that* identity.

  Choose **Object Read & Write** when you create the token. Cloudflare defines it
  as "Allows the ability to read, write, and list objects in specific buckets",
  which is the one level that covers the write, the read, the multipart abort and
  the cleanup.
</Note>

<Note>
  The hint under the credential fields in the wizard reads "Create an R2 API
  token with Object Write on this bucket." That is not one of Cloudflare's four
  permission levels. Pick **Object Read & Write**.
</Note>

***

## Before you start

<Info>
  You need:

  * A Cloudflare account **with an R2 subscription**. Cloudflare gates token
    creation on it: "You must purchase R2 before you can generate an API token."
    If you have not subscribed, open **Storage & databases** then **R2** in the
    dashboard and complete the checkout flow.
  * Your **account ID**, a 32-character hexadecimal string.
  * An **R2 API token** scoped to the bucket, with read and write on objects.
  * Admin or Owner role in your Tornado organisation. `POST /settings/storage` is
    gated on that; ordinary members can read the configuration but not change it.
</Info>

***

## 1. Create the bucket

<Steps>
  <Step title="Open R2 in the Cloudflare dashboard">
    Go to the **R2 object storage** page and its **Overview**. Cloudflare has
    moved this entry point more than once (it currently sits under
    **Storage & databases**), so navigate by product name rather than by a fixed
    menu path.
  </Step>

  <Step title="Create bucket">
    Select **Create bucket** and enter a name. Cloudflare's rules: lowercase
    letters, numbers and hyphens only, 3 to 63 characters, no leading or trailing
    hyphen. Dedicate the bucket to Tornado deliveries; a bucket-scoped token is
    pointless if the bucket also holds unrelated data.
  </Step>

  <Step title="Leave Location on the default">
    Under **Location**, leaving *None* selected lets R2 place the bucket in the
    closest available region to the request. A Location Hint is best effort, not
    a guarantee.

    Do not touch **Specify jurisdiction** unless you have a data residency
    requirement. It changes the hostname you must use, and the wizard cannot
    express it. See [Jurisdiction-restricted buckets](#jurisdiction-restricted-buckets-eu-fedramp).
  </Step>

  <Step title="Create bucket">
    Confirm with **Create bucket**. The bucket stays private; Tornado delivers
    through presigned URLs and never needs a public bucket or an `r2.dev` domain.
  </Step>
</Steps>

***

## 2. Account ID and region

### Account ID

Your account ID is a 32-character hex string such as
`b54f07a6c269ecca2fa60f1ae4920c99`. Three reliable ways to get it, in order of
resilience to dashboard redesigns:

1. **From the URL.** It is the first path segment of any account-scoped
   dashboard URL: `https://dash.cloudflare.com/<account-id>/r2/overview`.
2. **From the account menu.** On **Account home**, open the menu next to your
   account name and choose **Copy account ID**.
3. **From the Account Details panel** shown on the R2 and Workers pages.

The same value is the subdomain of your S3 endpoint:
`https://<account-id>.r2.cloudflarestorage.com`.

### Region is always `auto`

Cloudflare states it plainly: "When using the S3 API, the region for an R2 bucket
is `auto`." An empty value and `us-east-1` alias to `auto`, but there is no
reason to rely on that.

The R2 tile sets `region: "auto"` for you. Through the API, or through the
generic S3 form in Settings, you must type it yourself.

<Warning>
  Leave **Account ID** empty in the wizard and the derived endpoint is empty too.
  Tornado then falls back to AWS, signing a request to
  `https://<bucket>.s3.auto.amazonaws.com`, and the test fails with
  `Could not connect to the endpoint URL: …`. An `amazonaws.com` host in an R2
  error message always means the account ID never reached the configuration.
</Warning>

***

## 3. Create a bucket-scoped API token

<Steps>
  <Step title="Open the R2 API token manager">
    On the **R2 object storage** page, under the **Account Details** section,
    select **Manage** next to **API Tokens**.
  </Step>

  <Step title="Choose the token type">
    **Create Account API token** is tied to the account and stays valid until
    revoked. **Create User API token** is tied to your personal Cloudflare user
    and dies with your access to the account.

    For a server integration that must keep delivering after you leave, use an
    **Account API token**. Only Super Administrators can create or view them.
  </Step>

  <Step title="Pick Object Read & Write">
    Under **Permissions**, Cloudflare offers four levels:

    | Permission              | Cloudflare's description                                                                                                            | Use for Tornado                            |
    | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
    | Admin Read & Write      | Create, list and delete buckets, edit bucket configuration, read, write and list objects, and read and write to data catalog tables | Too broad. Account-wide, not bucket-scoped |
    | Admin Read only         | List buckets and view bucket configuration, read and list objects, and read from the data catalog tables                            | Cannot write. Test fails at step 1         |
    | **Object Read & Write** | **Read, write and list objects in specific buckets**                                                                                | **This one**                               |
    | Object Read only        | Read and list objects in specific buckets                                                                                           | Cannot write. Test fails at step 1         |

    Only the two Object levels can be scoped to specific buckets, which is the
    second reason to pick **Object Read & Write**.
  </Step>

  <Step title="Scope it to the one bucket">
    With an Object-level permission selected, restrict the token to the bucket
    you created in step 1. A token scoped to the wrong bucket fails the test with
    an access error, not with a message naming the bucket, so get this right
    while you can still see both names on screen.
  </Step>

  <Step title="Copy both values">
    After creation Cloudflare shows an **Access Key ID** and a
    **Secret Access Key**.

    <Warning>
      The Secret Access Key is shown once. Cloudflare cannot show it again; if
      you lose it, create a new token and delete the old one.
    </Warning>
  </Step>
</Steps>

<Note>
  If you build the token through the Cloudflare API instead of the dashboard, the
  bucket-scoped permission group you want is **Workers R2 Storage Bucket Item
  Write**. The read-only counterpart, **Workers R2 Storage Bucket Item Read**,
  fails the write probe immediately.
</Note>

### Minimum and recommended permissions

R2 has no per-action IAM. There is no policy document to write, and no way to
express `s3:PutObject` and friends separately: you pick one of Cloudflare's four
permission levels and a bucket scope.

**Minimum: Object Read & Write, scoped to the one delivery bucket.**

That single level is exactly what Tornado exercises, no more and no less:

| S3 operation Tornado calls                                       | Why the level covers it                                                                                                                                                                                                                                                                 |
| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PutObject`                                                      | Delivery, and the probe write. The "Write" half                                                                                                                                                                                                                                         |
| `GetObject`                                                      | The presigned download links, and the probe read-back. The "Read" half                                                                                                                                                                                                                  |
| `CreateMultipartUpload`, `UploadPart`, `CompleteMultipartUpload` | Every delivery above 50 MB. R2 implements all three, and they sit on the same write capability                                                                                                                                                                                          |
| `AbortMultipartUpload`                                           | Stopping a failed upload so its parts do not sit in the bucket. Cloudflare classifies the abort itself as a free operation, and every bucket has a default lifecycle rule that expires unfinished uploads after seven days, so this is a bounded exposure rather than an open-ended one |
| `DeleteObject`                                                   | The probe cleanup, and `DELETE /jobs/{id}/file`                                                                                                                                                                                                                                         |

**Recommended: the same level.** R2 offers nothing finer that is worth having, so
the minimum is the recommendation. Two things about *how* you issue it are worth
one line each:

| Choice                                         | Why                                                                                                             |
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| **Account API token**, not User API token      | A User token dies with your access to the account, which takes deliveries down when you change jobs             |
| Bucket scope set to the single delivery bucket | Only the two Object levels can be bucket-scoped at all, so this is the entire blast-radius control R2 gives you |

<Warning>
  Do not narrow **Object Read & Write** afterwards through a custom Access
  Policy. Anything that drops read, delete or the multipart abort now fails the
  connection test rather than passing quietly, which is an improvement, but a
  policy that carves out only the delivery prefix still passes the test and then
  breaks deliveries: the probe writes to `tornado-connection-test/` at the bucket
  root, not under `videos/`.
</Warning>

***

## 4. Fill in the four fields

The fields Tornado stores for R2 are exactly `bucket`, `region`,
`access_key_id`, `secret_access_key` and `endpoint`. The wizard collects four of
them and derives the fifth.

<Steps>
  <Step title="Onboarding wizard">
    On the **Destination** step, choose the **Cloudflare R2** tile. The
    **Credentials** step shows four inputs:

    | Label on screen       | Field               | Value                                    |
    | --------------------- | ------------------- | ---------------------------------------- |
    | **Account ID**        | derives `endpoint`  | The 32-character hex account ID          |
    | **Bucket name**       | `bucket`            | The bucket name only, no URL, no `r2://` |
    | **Access key ID**     | `access_key_id`     | From the R2 API token                    |
    | **Secret access key** | `secret_access_key` | From the R2 API token                    |

    `region` is set to `auto` for you and is not shown.

    Then press **Test connection**. On success the configuration is saved in the
    same click; a failed probe saves nothing.
  </Step>

  <Step title="Or Settings, after onboarding">
    Dashboard **Settings**, section **Default storage destination**. There is no
    R2 tile there. Choose provider **Amazon S3 / S3-Compatible** and fill the
    generic S3 form yourself:

    | Label on screen         | Value for R2                                    |
    | ----------------------- | ----------------------------------------------- |
    | **Bucket**              | The bucket name                                 |
    | **Region**              | `auto`                                          |
    | **Access Key ID**       | From the R2 API token                           |
    | **Secret Access Key**   | From the R2 API token                           |
    | **Endpoint (optional)** | `https://<account-id>.r2.cloudflarestorage.com` |

    <Warning>
      Settings has a **Save destination** button and no "Test connection"
      button. Saving there does not probe your bucket. Verify with a real job, or
      call [`POST /settings/storage/test`](#what-test-connection-proves)
      yourself.
    </Warning>
  </Step>
</Steps>

### What the Account ID field accepts

The wizard normalises whatever you paste before building the endpoint. Knowing
the exact rule saves a round of guessing:

| You paste                                                                  | Result                                                          |
| -------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `b54f07a6c269ecca2fa60f1ae4920c99`                                         | Accepted                                                        |
| The same in uppercase                                                      | Accepted, lowercased                                            |
| `https://b54f…c99.r2.cloudflarestorage.com`                                | Accepted, the ID is extracted                                   |
| The same without the scheme, or with a trailing slash                      | Accepted                                                        |
| Surrounding whitespace                                                     | Trimmed                                                         |
| `https://b54f…c99.eu.r2.cloudflarestorage.com`                             | **The `eu` is silently dropped.** You get the standard endpoint |
| A dashboard URL such as `https://dash.cloudflare.com/b54f…c99/r2/overview` | **Not recognised.** Pasted verbatim into the hostname           |
| Anything with a label, for example `Account ID: b54f…c99`                  | **Not recognised.** Pasted verbatim into the hostname           |
| A near-miss ID, for example 31 characters                                  | **Not recognised.** Pasted verbatim into the hostname           |

The last three produce a broken endpoint, and the resulting error message is
unhelpful. See
[`internal error during storage test`](#internal-error-during-storage-test) in
troubleshooting.

<Info>
  Credentials submitted through the dashboard are written to Azure Key Vault, not
  to the application database. Only the provider name and a vault reference are
  stored alongside your organisation. Reading the configuration back returns the
  secret masked. If Key Vault is unreachable, the save is refused with a 503
  rather than falling back to plaintext storage.
</Info>

***

## 5. Configuring through the API

Same body as the wizard, without the Account ID helper: you build the endpoint
yourself. The route is on the dashboard API, authenticated with your session
token, and requires Admin or Owner.

<CodeGroup>
  ```bash Test first theme={null}
  curl -X POST "https://api-dash.tornadoapi.io/api/settings/storage/test" \
    -H "Authorization: Bearer $TORNADO_SESSION_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "provider": "s3",
      "s3": {
        "bucket": "my-tornado-downloads",
        "region": "auto",
        "access_key_id": "YOUR_R2_ACCESS_KEY_ID",
        "secret_access_key": "YOUR_R2_SECRET_ACCESS_KEY",
        "endpoint": "https://b54f07a6c269ecca2fa60f1ae4920c99.r2.cloudflarestorage.com"
      }
    }'
  ```

  ```bash Then save theme={null}
  curl -X POST "https://api-dash.tornadoapi.io/api/settings/storage" \
    -H "Authorization: Bearer $TORNADO_SESSION_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "provider": "s3",
      "s3": {
        "bucket": "my-tornado-downloads",
        "region": "auto",
        "access_key_id": "YOUR_R2_ACCESS_KEY_ID",
        "secret_access_key": "YOUR_R2_SECRET_ACCESS_KEY",
        "endpoint": "https://b54f07a6c269ecca2fa60f1ae4920c99.r2.cloudflarestorage.com"
      }
    }'
  ```

  ```bash Read it back theme={null}
  curl "https://api-dash.tornadoapi.io/api/settings/storage" \
    -H "Authorization: Bearer $TORNADO_SESSION_TOKEN"
  ```
</CodeGroup>

The test route persists nothing. `POST /settings/storage` returns
`{"status": "saved", "vault": true}`. The read-back returns the configuration
with `secret_access_key` masked.

### The `s3` block

| Field               | Required                   | Value for R2                                      |
| ------------------- | -------------------------- | ------------------------------------------------- |
| `bucket`            | yes                        | Bucket name, max 255 characters                   |
| `region`            | yes                        | `auto`                                            |
| `access_key_id`     | yes                        | R2 Access Key ID, max 128 characters              |
| `secret_access_key` | yes                        | R2 Secret Access Key, max 256 characters          |
| `endpoint`          | no for AWS, **yes for R2** | `https://<account-id>.r2.cloudflarestorage.com`   |
| `path_style`        | no                         | Leave unset. R2 works with the default addressing |

The endpoint is normalised before use: whitespace trimmed, an accidental doubled
scheme (`https://https://…`) collapsed, `https://` prepended when absent,
trailing slash removed.

<Note>
  This page covers the **dashboard** configuration, which is per organisation.
  The public job API has a separate, older per-API-key route,
  [`POST /user/s3`](/api-reference/user/configure-s3), on
  `api.tornadoapi.io`. It names two of these fields differently, `access_key` and
  `secret_key`, and it is flat rather than nested under an `s3` key. See
  [Custom Storage](/features/custom-storage) for that surface, including its
  per-key gotcha.
</Note>

***

## What "Test connection" proves

Pressing **Test connection** calls `POST /settings/storage/test`, which runs a
five-step probe against your real bucket and reports the measured latency.

| Step | Call                                                             | What it proves about the token                                    |
| ---- | ---------------------------------------------------------------- | ----------------------------------------------------------------- |
| 1    | `PutObject` to `tornado-connection-test/<random>.txt` (23 bytes) | It can write                                                      |
| 2    | `GetObject` on the object just written                           | It can read, as the identity your download links are signed with  |
| 3    | `CreateMultipartUpload` on `<same key>.multipart`                | It can start the path every delivery above 50 MB takes            |
| 4    | `AbortMultipartUpload` on that upload, with no part uploaded     | It can stop a failed upload before its parts settle in the bucket |
| 5    | `DeleteObject` on the probe object                               | It can clean up after itself                                      |

No part is ever uploaded, so the whole probe still moves 23 bytes. Each step
names itself in the error string, so a failure says which capability is missing
rather than just `AccessDenied`.

**It proves:**

* The Access Key ID and Secret Access Key are valid and active.
* The account ID resolved to a real R2 endpoint that answered.
* The bucket exists and the token's scope includes it.
* The token can write, **read**, abort a multipart upload and delete.
* The whole round trip completes inside the 20 second budget (5 s connect, 10 s
  read, one attempt, no retries).

**It does not prove:**

* That a real delivery will fit. The probe moves 23 bytes and uploads no parts.
* That your token covers the **delivery prefix**. It writes to
  `tornado-connection-test/`, so a custom Access Policy scoped to `videos/*`
  passes the test and fails the job.
* That the endpoint points at the jurisdiction you intended. A standard-endpoint
  bucket and an EU-jurisdiction bucket of the same name are different buckets.
* Anything about lifecycle rules or object lifecycle policies on the bucket.

### Checking a real delivered link

The probe covers the read, so this is no longer the gap it was. It remains the
only way to check the delivery prefix and the path your customers take. Run one
real job, take the `s3_url` from the job status or the `job.completed` webhook,
and fetch a single byte:

```bash theme={null}
# 200 or 206 = the link works end to end from outside Tornado.
# 403        = a prefix-scoped policy, an expired link, or a revoked token.
curl -s -o /dev/null -w '%{http_code}\n' -r 0-0 "$S3_URL"
```

***

## Troubleshooting

The dashboard shows the backend's own error string. Match on it exactly.

| Error string                                                                     | What actually happened                                                       | Fix                                                                                                                                                                                                                                              |
| -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `internal error during storage test`                                             | The endpoint could not be parsed as a URL, so the request never left Tornado | Almost always a mistyped account ID or endpoint. See below                                                                                                                                                                                       |
| `AccessDenied: s3:PutObject`                                                     | The probe write was refused                                                  | The token has an Object Read only or Admin Read only permission, or it is scoped to a different bucket                                                                                                                                           |
| `AccessDenied: s3:GetObject`                                                     | The write succeeded, reading the same object back did not                    | The token is write-only, which on R2 means a custom Access Policy rather than one of the four dashboard levels. Reissue it as **Object Read & Write**. Every presigned download link depends on this exact permission, so fix it before you ship |
| `AccessDenied: s3:CreateMultipartUpload`                                         | Write and read passed, starting a multipart upload did not                   | A custom Access Policy that allows `PutObject` but not the multipart APIs, or one scoped to a key pattern that excludes `<key>.multipart`. The four dashboard levels do not produce this. Reissue as **Object Read & Write** on the whole bucket |
| `AccessDenied: s3:AbortMultipartUpload`                                          | The multipart upload started but could not be stopped                        | Same cause and same fix. Until it is granted, an upload that fails part-way leaves its parts in the bucket for up to seven days, taking storage you pay for                                                                                      |
| `AccessDenied: s3:DeleteObject`                                                  | Everything else succeeded, the cleanup did not                               | Unusual on R2, where the Object Read & Write level covers it. Check for a custom Access Policy that grants write without delete                                                                                                                  |
| `NoSuchBucket: s3:PutObject`                                                     | No bucket of that name at this endpoint                                      | Typo in **Bucket name**, or the bucket lives in a jurisdiction and you are hitting the standard endpoint                                                                                                                                         |
| `InvalidAccessKeyId: s3:PutObject`                                               | R2 does not recognise the key ID                                             | Token revoked or deleted, an AWS key pasted into the R2 form, or the Cloudflare API token value pasted instead of the derived Access Key ID                                                                                                      |
| `SignatureDoesNotMatch: s3:PutObject`                                            | Key ID is real, secret does not match it                                     | Truncated paste or trailing whitespace in **Secret access key**. Re-paste, or create a new token                                                                                                                                                 |
| `Could not connect to the endpoint URL: "https://….s3.auto.amazonaws.com/…"`     | The endpoint was empty, so the SDK fell back to AWS with region `auto`       | **Account ID** was left blank or was dropped. Refill it                                                                                                                                                                                          |
| `Could not connect to the endpoint URL: …` on an `r2.cloudflarestorage.com` host | DNS or TCP failure                                                           | Rare. Check outbound connectivity and re-run                                                                                                                                                                                                     |
| `timeout: no response from storage endpoint after 20s`                           | Nothing answered within the 20 second budget                                 | R2 is reachable but slow or degraded. Check Cloudflare status, then retry                                                                                                                                                                        |
| `missing required field(s): …`                                                   | The offline structural check ran instead of the live probe                   | A required field is empty. Fill it and retry                                                                                                                                                                                                     |
| `The test write failed. Double-check the credentials.`                           | The backend returned a failure with no error string                          | Rare frontend fallback. Call `POST /settings/storage/test` directly to see the raw response                                                                                                                                                      |

### `internal error during storage test`

This message is misleading. It is not a Tornado outage and it is not a
credentials problem.

The S3 client is constructed before the probe's error handling begins, and the
AWS SDK validates the endpoint hostname at construction time. When that hostname
is not a legal host, the SDK raises an error outside the family the storage test
handles (`ClientError` and `BotoCoreError`), so it falls through to the generic
catch-all and you get this string instead of anything about the URL.

In practice it means the derived endpoint is malformed, which means the
**Account ID** field contains something that is not an account ID. A label, a
dashboard URL, an email quote, a stray space. Re-copy the bare 32-character hex
value and try again.

A *wrong but well-formed* account ID behaves differently and is worth knowing
apart: `*.r2.cloudflarestorage.com` resolves for any subdomain, so a one-character
typo produces a credentials-shaped error rather than a DNS error. If
`InvalidAccessKeyId` persists with a token you just created, check the account ID
before you regenerate the token.

### Jobs complete, but downloads 403

The bucket contains the files, the job says `Completed`, and every `s3_url`
returns `403`. The probe's read leg makes a plainly unreadable token much less
likely than it used to be, so work down this list in order.

<Steps>
  <Step title="Confirm the object is really there">
    Open the bucket in the R2 dashboard and find the key from the job payload. If
    the file is present, the upload path is fine and this is purely a read
    problem.
  </Step>

  <Step title="Re-run Test connection">
    It now reads. If it comes back `AccessDenied: s3:GetObject`, the token really
    cannot read and you are done diagnosing: reissue it as
    **Object Read & Write**.
  </Step>

  <Step title="If the test passes, suspect the prefix or the token identity">
    A green test with 403 links means the read is allowed on
    `tornado-connection-test/` but not on the delivery path, which points at a
    custom Access Policy. Or the token that signed the link has since been
    revoked: a presigned URL dies with its Access Key ID, expiry or no expiry.
  </Step>
</Steps>

<Note>
  Replacing the token repairs **existing** links only if you keep the same
  credentials. A presigned URL is signed with a specific Access Key ID; revoke
  that token and every link already in your customers' hands stops working, even
  inside its 24 hour window. Create the new token, save it in Tornado, confirm
  deliveries, and only then revoke the old one.
</Note>

***

## Edge cases

<AccordionGroup>
  <Accordion title="Jurisdiction-restricted buckets (EU, FedRAMP)">
    A bucket created with **Specify jurisdiction** is not reachable at the
    standard endpoint. Cloudflare uses a different host:

    * EU: `https://<account-id>.eu.r2.cloudflarestorage.com`
    * FedRAMP: `https://<account-id>.fedramp.r2.cloudflarestorage.com`

    **The wizard cannot express this.** Paste an EU endpoint into **Account ID**
    and the normaliser extracts the account ID and rebuilds the *standard*
    endpoint, silently dropping the `eu`. The probe then looks for your bucket at
    the wrong host and fails with `NoSuchBucket: s3:PutObject`.

    Configure jurisdiction buckets through
    [the API](#5-configuring-through-the-api) instead, setting `endpoint`
    explicitly to the jurisdiction host, or through **Settings** with the
    S3-compatible form, where you type the endpoint yourself.
  </Accordion>

  <Accordion title="Signature version">
    Requests are signed with **AWS Signature Version 4**, forced rather than
    defaulted. Cloudflare documents the R2 S3-compatible API as authenticating
    with SigV4, and boto3's presigned URLs would otherwise fall back to the
    legacy SigV2 scheme, which does not work against R2.

    There is nothing to configure. It matters only if you are comparing Tornado's
    behaviour against another S3 client that still defaults to SigV2 and appears
    to work elsewhere.
  </Accordion>

  <Accordion title="Large files and multipart uploads">
    Uploads above 50 MB are sent as multipart, which is most videos, and with
    Tornado's streaming upload path enabled a copy-only job of any size uses the
    multipart APIs. R2 implements `CreateMultipartUpload`, `UploadPart`,
    `CompleteMultipartUpload`, `AbortMultipartUpload` and `ListMultipartUploads`,
    so nothing special is needed on your side.

    There is no separate permission to grant, because R2 has no per-action IAM:
    **Object Read & Write** covers the whole lifecycle including the abort. This
    is the one place where R2 is genuinely simpler than AWS, where the abort is
    its own IAM action that people forget.

    What the abort protects you from is smaller on R2 than on S3, but not zero.
    Cloudflare gives every bucket a default lifecycle rule to "expire multipart
    uploads seven days after initiation", so an upload orphaned by a failed
    delivery clears itself within a week rather than sitting there forever. Until
    it does, its parts occupy the bucket. `AbortMultipartUpload` is itself a free
    operation on R2, so letting Tornado clean up immediately costs nothing.
  </Accordion>

  <Accordion title="What R2 charges for a Tornado delivery">
    Cloudflare's current pricing page states: "There are no charges for egress
    bandwidth for any storage class." Customer downloads of your delivered files
    do not incur a bandwidth bill.

    What is billed is storage and operations. Cloudflare classifies `PutObject`,
    `CreateMultipartUpload`, `UploadPart` and `CompleteMultipartUpload` as Class
    A operations, `GetObject` as Class B, and `DeleteObject` and
    `AbortMultipartUpload` as free. So each small delivery is one Class A
    operation, each customer download is one Class B operation, and the
    connection test costs two Class A operations (the write and the multipart
    create), one Class B (the read-back), and two free ones (the abort and the
    delete).

    Rates change; read them on
    [Cloudflare's R2 pricing page](https://developers.cloudflare.com/r2/pricing/)
    rather than from this page.
  </Accordion>

  <Accordion title="Rotating the R2 API token">
    R2 tokens cannot be rotated in place. The sequence that avoids a gap:

    <Steps>
      <Step title="Create the replacement">
        Create a second token with the same **Object Read & Write** permission
        and the same bucket scope.
      </Step>

      <Step title="Update Tornado">
        Paste the new pair into Settings and save, or `POST /settings/storage`.
        In-flight jobs finish with whichever credentials they already resolved.
      </Step>

      <Step title="Wait out the presigned window, then revoke">
        Confirm new jobs deliver and new links download. Links signed with the
        old token die the moment it is revoked, so if customers hold live links,
        wait out the 24 hour expiry before revoking.
      </Step>
    </Steps>
  </Accordion>

  <Accordion title="The probe objects">
    The test writes `tornado-connection-test/<random>.txt`, reads it back, and
    deletes it last. It also opens and immediately aborts a multipart upload on
    `tornado-connection-test/<random>.txt.multipart`, with no part uploaded, so
    that key never becomes an object.

    If you see the `tornado-connection-test/` prefix accumulating objects, the
    delete leg is failing; check for a lifecycle or retention rule on the bucket.
    A test that fails at the read, create or abort step still tries to remove its
    probe object on the way out, so leftovers there point at a delete problem
    specifically.
  </Accordion>

  <Accordion title="Where files land inside the bucket">
    By default objects go under a `videos/` top-level folder, with any
    `folder_prefix` nested inside it and the per-job folder below that. Both are
    configurable. See [Custom Storage](/features/custom-storage) for the full path
    layout, including `base_folder`.
  </Accordion>
</AccordionGroup>

***

## Related

<CardGroup cols={2}>
  <Card title="Custom Storage" icon="cloud" href="/features/custom-storage">
    All providers, the per-API-key configuration route, folder layout, and inline per-request credentials
  </Card>

  <Card title="Webhooks" icon="bell" href="/features/webhooks">
    The `job.completed` payload, including the presigned `s3_url` your token has to be allowed to read
  </Card>
</CardGroup>
