---
layout: article
title: S3 API
description: Connect any S3-compatible client, SDK, or tool to Appwrite Storage. Configure credentials once, then manage buckets, objects, and multipart uploads over the S3 API.
---

Appwrite Storage exposes an S3-compatible API, so you can point the AWS CLI, the AWS SDKs, and third-party tools like rclone or s3cmd at your buckets and files. The API uses AWS Signature Version 4 and maps standard S3 operations onto Appwrite Storage, so most existing S3 code works after you change three settings: the endpoint, the credentials, and the region. Requests can be signed with an `Authorization` header or handed to a client without Appwrite credentials as a [presigned URL](#presigned-urls), including from a [browser](#browser-access).

The S3 API is available on Appwrite Cloud. It needs a project ID and an [API key](/docs/advanced/security/api-keys) with the [storage scopes](#api-key-scopes) described below. It also works alongside your existing data: buckets and files are addressable over both the native Storage API and the S3 API at the same time, with no migration required.

# Connect an S3 client

Configure your client with the following values. S3 clients authenticate with an access key ID and a secret access key, which Appwrite maps to your project ID and an API key secret.

| Setting | Value |
| --- | --- |
| Endpoint | `https://<REGION>.cloud.appwrite.io/v1/s3` |
| Access key ID | Your Appwrite project ID |
| Secret access key | An Appwrite API key secret |
| Region | `auto` or your project's Appwrite region code |
| Signature version | AWS Signature Version 4 (SigV4) |
| Addressing style | Path-style only |

The `<REGION>` in the endpoint is your Appwrite Cloud region (for example, `fra` or `nyc`), the same region you use for the rest of the Appwrite API. The S3 `region` setting accepts `auto` or that same region code; some clients require a region to sign requests, and either value works.

The examples below apply these settings in the [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-services-s3.html) and the AWS SDKs.

```bash
# AWS CLI
aws configure set aws_access_key_id <PROJECT_ID>
aws configure set aws_secret_access_key <API_KEY_SECRET>
aws configure set region auto

# Appwrite serves path-style URLs only, so force path addressing
aws configure set default.s3.addressing_style path

# Pass the Appwrite endpoint on every command
aws s3 ls --endpoint-url https://<REGION>.cloud.appwrite.io/v1/s3
```

```js
// Node.js: @aws-sdk/client-s3 (v3)
import { S3Client } from '@aws-sdk/client-s3';

const client = new S3Client({
    endpoint: 'https://<REGION>.cloud.appwrite.io/v1/s3',
    region: 'auto',
    forcePathStyle: true,
    credentials: {
        accessKeyId: '<PROJECT_ID>',
        secretAccessKey: '<API_KEY_SECRET>'
    }
});
```

```python
# Python: boto3
import boto3
from botocore.config import Config

s3 = boto3.client(
    's3',
    endpoint_url='https://<REGION>.cloud.appwrite.io/v1/s3',
    region_name='auto',
    aws_access_key_id='<PROJECT_ID>',
    aws_secret_access_key='<API_KEY_SECRET>',
    config=Config(signature_version='s3v4', s3={'addressing_style': 'path'})
)
```

```go
// Go: aws-sdk-go-v2
package main

import (
    "context"
    "log"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/credentials"
    "github.com/aws/aws-sdk-go-v2/service/s3"
)

func main() {
    cfg, err := config.LoadDefaultConfig(context.TODO(),
        config.WithRegion("auto"),
        config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
            "<PROJECT_ID>", "<API_KEY_SECRET>", "",
        )),
    )
    if err != nil {
        log.Fatal(err)
    }

    client := s3.NewFromConfig(cfg, func(o *s3.Options) {
        o.BaseEndpoint = aws.String("https://<REGION>.cloud.appwrite.io/v1/s3")
        o.UsePathStyle = true
    })

    buckets, err := client.ListBuckets(context.TODO(), &s3.ListBucketsInput{})
    if err != nil {
        log.Fatal(err)
    }

    for _, bucket := range buckets.Buckets {
        log.Println(*bucket.Name)
    }
}
```

```php
// PHP: aws/aws-sdk-php
use Aws\S3\S3Client;

$client = new S3Client([
    'version' => 'latest',
    'region' => 'auto',
    'endpoint' => 'https://<REGION>.cloud.appwrite.io/v1/s3',
    'use_path_style_endpoint' => true,
    'credentials' => [
        'key' => '<PROJECT_ID>',
        'secret' => '<API_KEY_SECRET>',
    ],
]);
```

# API key scopes

Create an API key in the Appwrite Console under **Overview** > **Integrations** > **API keys**, then grant the storage scopes your workload needs. The project ID identifies your project, while the API key secret both authenticates the request and determines what it is allowed to do.

| Access level | Required scopes |
| --- | --- |
| Read-only (list and download) | `buckets.read`, `files.read` |
| Write (create buckets, upload, delete) | `buckets.write`, `files.write` |
| Copy (`CopyObject`, `UploadPartCopy`) | `files.read`, `files.write` |
| Full read and write | `buckets.read`, `files.read`, `buckets.write`, `files.write` |

Copying an object reads the source and writes the destination, so `CopyObject` and `UploadPartCopy` need both `files.read` and `files.write`. A request signed with a secret that does not match a project API key, or a key missing the required scope, is rejected with `AccessDenied` (HTTP 403).

# Buckets and objects

S3 buckets map directly to Appwrite Storage buckets, and S3 objects map to files. Creating a bucket over S3 creates an Appwrite bucket that uses the S3 bucket name as both its ID and its name, with Appwrite's default settings. Any bucket you already have in your project is usable over S3 immediately.

Object keys are file names, used directly. Uploading to the key `reports/january.pdf` stores a file named `reports/january.pdf`, and read, copy, and delete operations address that object by the same key. Nothing has to be looked up first, and files created through the native Storage API or the Console are addressable over S3 under the names they already have.

Because the key is the name, file names have to be unique within a bucket. Uploading to a key that already exists replaces that object in place: its bytes, size, content type, and user metadata are overwritten, while the underlying Appwrite file keeps its ID and permissions, so anything referencing it through the native Storage API stays valid. Concurrent uploads to the same key are serialized, and the last write wins.

**File names must be unique per bucket**

S3 itself has no concept of two objects sharing a key: [an object key is "the unique identifier for an object within a bucket"](https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html#BasicsKeys), and every object in a bucket has exactly one. Appwrite Storage does let several files in a bucket share a name and folder, but such a bucket has no valid representation as an S3 bucket and is not served over the S3 API: a request whose key resolves to more than one file, or a listing that would return the same key twice, is rejected with `InvalidRequest` (HTTP 400).

So if your workload depends on repeating file names within a bucket, the S3 API is not the right interface for it. Use the [native Storage API](/docs/products/storage/upload-download) instead, which addresses files by ID and lets names repeat freely.

# Folders

Appwrite Storage organizes the files in a bucket with [virtual folders](/docs/products/storage/folders), derived from the paths of your files and never created or deleted on their own. Over the S3 API, folders appear as `/`-separated prefixes in object keys: uploading to `reports/2024/january.pdf` stores a single object whose name contains those prefixes, and does not create `reports/` or `reports/2024/` as separate entities.

To browse a bucket like a directory tree, list objects with the `/` delimiter and, optionally, a `prefix`. Appwrite groups object names at each `/` boundary: names sharing a prefix up to the next `/` collapse into a single entry under `CommonPrefixes` (the "folders"), while objects at the current level are returned under `Contents` (the "files").

Pass a `prefix` such as `reports/` to open that folder and list one level deeper, or list without a delimiter to enumerate a bucket recursively.

Objects under `Contents` are returned with their full keys; use those to read, copy, or delete a file. `CommonPrefixes` are folder paths only, with nothing to download at the prefix itself.

`/` is the only supported delimiter. Setting `delimiter` to any other value returns `NotImplemented` (HTTP 501).

```bash
# List the top level of a bucket: immediate folders (CommonPrefixes) and files
aws s3api list-objects-v2 --bucket my-bucket --delimiter / \
  --endpoint-url https://<REGION>.cloud.appwrite.io/v1/s3

# Open the "reports" folder and list one level down
aws s3api list-objects-v2 --bucket my-bucket --prefix reports/ --delimiter / \
  --endpoint-url https://<REGION>.cloud.appwrite.io/v1/s3

# aws s3 ls uses the / delimiter automatically; omit --recursive to browse by folder
aws s3 ls s3://my-bucket/reports/ \
  --endpoint-url https://<REGION>.cloud.appwrite.io/v1/s3
```

# Example commands

Once your client is configured, everyday S3 commands work as usual. These AWS CLI examples assume you have run `aws configure` as shown above; each command passes the endpoint explicitly with `--endpoint-url`.

```bash
# Create a bucket
aws s3api create-bucket --bucket my-bucket \
  --endpoint-url https://<REGION>.cloud.appwrite.io/v1/s3

# Upload a file (uses multipart automatically for large files)
aws s3 cp ./january.pdf s3://my-bucket/reports/january.pdf \
  --endpoint-url https://<REGION>.cloud.appwrite.io/v1/s3

# List objects
aws s3 ls s3://my-bucket --recursive \
  --endpoint-url https://<REGION>.cloud.appwrite.io/v1/s3

# Download an object by its key
aws s3 cp s3://my-bucket/reports/january.pdf ./january.pdf \
  --endpoint-url https://<REGION>.cloud.appwrite.io/v1/s3
```

# Bucket operations

All operations are addressed with path-style URLs under `/v1/s3`. The following bucket operations are supported.

| Operation | Description |
| --- | --- |
| `ListBuckets` | List the project's buckets. |
| `CreateBucket` | Create a bucket. The S3 bucket name becomes the Appwrite bucket's ID and name, with Appwrite's default settings. |
| `HeadBucket` | Check that a bucket exists and is accessible. |
| `DeleteBucket` | Delete an empty bucket. Deleting a bucket that still contains objects returns `BucketNotEmpty` (HTTP 409). |
| `GetBucketLocation` | Returns the added region. |
| `GetBucketAcl`, `PutBucketAcl` | Compatibility responses only. See [Limitations](#limitations). |

# Object operations

The following object operations are supported.

| Operation | Description |
| --- | --- |
| `PutObject` | Upload an object, replacing anything already stored under that key. Returns an `ETag`. Honors `Content-Type`, `x-amz-meta-*` user metadata, and `x-amz-server-side-encryption`. |
| `GetObject` | Download an object. Supports `Range` requests (HTTP 206), and the `If-None-Match` (HTTP 304) and `If-Match` (HTTP 412) conditional headers. |
| `HeadObject` | Retrieve an object's metadata (size, content type, `ETag`, user metadata) without the body. |
| `CopyObject` | Copy an object using the `x-amz-copy-source` header. Supports `x-amz-metadata-directive: REPLACE` to replace metadata. |
| `DeleteObject` | Delete a single object. |
| `DeleteObjects` | Delete multiple objects in one request, including quiet mode. |
| `ListObjects` | List objects in a bucket, with `prefix` filtering. |
| `ListObjectsV2` | List objects with `prefix`, `delimiter` (only `/`), `max-keys`, `continuation-token`, and `start-after`. Use `delimiter=/` to browse [folders](#folders). |
| `GetObjectAcl`, `PutObjectAcl` | Compatibility responses only. See [Limitations](#limitations). |

Uploads sent with the AWS SDKs' default integrity protections, which frame the body as `Content-Encoding: aws-chunked` with a streaming payload signature, are decoded transparently, and a CRC32 trailer checksum is verified against the stored bytes when the client sends one.

Content type is taken from the `Content-Type` header you send. When that is missing or generic (`application/octet-stream`), Appwrite infers a type from the file extension. Buckets keep their constraints under the S3 API: uploads that exceed the bucket's maximum file size or use a disallowed extension are rejected, and disabled buckets are treated as not found.

# Multipart uploads

Large files are uploaded in parts. Standard SDK multipart helpers, such as the AWS CLI's `aws s3 cp` and the SDK transfer managers, use these operations automatically.

| Operation | Description |
| --- | --- |
| `CreateMultipartUpload` | Begin a multipart upload and receive an `UploadId`. |
| `UploadPart` | Upload a single part. Returns the part's `ETag`. |
| `UploadPartCopy` | Upload a part by copying a byte range from another object, using `x-amz-copy-source` and `x-amz-copy-source-range`. |
| `CompleteMultipartUpload` | Assemble the uploaded parts into the final object. |
| `AbortMultipartUpload` | Discard an in-progress multipart upload and its parts. |
| `ListMultipartUploads` | List in-progress multipart uploads in a bucket. |
| `ListParts` | List the parts already uploaded for an `UploadId`. |

**Multipart requirements**

Parts must be contiguous and start at part number 1. Completing an upload with non-contiguous part numbers (for example, parts 1, 5, and 100) is rejected. SDK multipart helpers number parts contiguously, so they are unaffected, and parts can be uploaded in parallel: the AWS CLI and the SDK transfer managers do this by default. In-progress uploads are kept for 24 hours before they are cleaned up, so complete or abort within that window.

# Presigned URLs

A presigned URL carries its AWS Signature Version 4 authentication in the query string instead of an `Authorization` header, so it can be handed to a client that has no Appwrite credentials of its own: a browser, a mobile app, or anyone you send the link to. Appwrite accepts presigned URLs for the supported S3 operations, generated by the standard helpers in the AWS SDKs, the AWS CLI, rclone, and s5cmd.

```bash
# AWS CLI (GET only)
aws s3 presign s3://my-bucket/reports/january.pdf --expires-in 900 \
  --endpoint-url https://<REGION>.cloud.appwrite.io/v1/s3

# rclone
rclone link --expire 15m appwrite:my-bucket/reports/january.pdf

# s5cmd
s5cmd --endpoint-url https://<REGION>.cloud.appwrite.io/v1/s3 \
  presign --expire 15m s3://my-bucket/reports/january.pdf
```

```js
// Node.js: @aws-sdk/client-s3 with @aws-sdk/s3-request-presigner
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const downloadUrl = await getSignedUrl(
    client,
    new GetObjectCommand({ Bucket: 'my-bucket', Key: 'reports/january.pdf' }),
    { expiresIn: 900 }
);

const uploadUrl = await getSignedUrl(
    client,
    new PutObjectCommand({
        Bucket: 'my-bucket',
        Key: 'reports/january.pdf',
        ContentType: 'application/pdf'
    }),
    { expiresIn: 900 }
);
```

```python
# Python: boto3
download_url = s3.generate_presigned_url(
    'get_object',
    Params={'Bucket': 'my-bucket', 'Key': 'reports/january.pdf'},
    ExpiresIn=900,
)

upload_url = s3.generate_presigned_url(
    'put_object',
    Params={
        'Bucket': 'my-bucket',
        'Key': 'reports/january.pdf',
        'ContentType': 'application/pdf',
    },
    ExpiresIn=900,
)
```

```php
// PHP: aws/aws-sdk-php
$command = $client->getCommand('GetObject', [
    'Bucket' => 'my-bucket',
    'Key' => 'reports/january.pdf',
]);

$url = (string) $client->createPresignedRequest($command, '+15 minutes')->getUri();
```

The signature covers the HTTP method, the object key, and every other query parameter in the URL, so a presigned URL authorizes exactly one request. A URL signed for `GetObject` cannot be replayed as a `PutObject`, and changing the key or any signed parameter invalidates the signature.

The request body is not signed. A presigned upload URL is generated before the bytes exist, so Appwrite verifies it as `UNSIGNED-PAYLOAD` and the client supplies the body afterwards. Anything else the client has to send, such as `Content-Type`, must be signed into the URL when you generate it and then sent unchanged with the request.

## Expiry

Set the lifetime when you generate the URL. Appwrite accepts 1 second to 604800 seconds (7 days), the same ceiling AWS applies to SigV4 presigned URLs, and rejects anything outside that range. The URL is valid from the timestamp it was signed with until that timestamp plus the expiry; a request that arrives after the window closes returns `AccessDenied` (HTTP 403).

Presigned URLs are stateless, so an individual URL cannot be revoked once issued. To cut one short, delete or rotate the API key that signed it, which invalidates every URL signed with that secret at once. Keep expiry windows as short as your workload allows.

## What a presigned URL grants

A presigned URL is signed with an API key secret and inherits that key's [scopes](#api-key-scopes) for the single operation it encodes. The secret is never part of the URL, and the URL cannot be used to recover it or to perform any other operation. Anyone holding the URL can still make that one request until it expires, so treat it as a credential: send it over HTTPS, keep it out of logs, and share it only with the client that needs it.

**Sign presigned URLs on the server**

Generating a presigned URL requires the API key secret, so do it on a server or in an [Appwrite Function](/docs/products/functions), never in a browser or mobile app where that secret would be exposed. Hand the finished URL to the client instead.

## Required parameters

SDK and CLI helpers add these for you. If you sign URLs yourself, all six are required, and the request must not also carry an `Authorization` header.

| Parameter | Value |
| --- | --- |
| `X-Amz-Algorithm` | `AWS4-HMAC-SHA256`. Any other algorithm is rejected. |
| `X-Amz-Credential` | `<PROJECT_ID>/<YYYYMMDD>/<REGION>/s3/aws4_request`. The date must match `X-Amz-Date`. |
| `X-Amz-Date` | The request timestamp, in `YYYYMMDDTHHMMSSZ` format. |
| `X-Amz-Expires` | The lifetime in seconds, from `1` to `604800`. |
| `X-Amz-SignedHeaders` | Lowercase and semicolon-separated, sorted, and including `host`. |
| `X-Amz-Signature` | The 64-character hexadecimal SigV4 signature. |

A URL that omits one of these parameters, repeats one, or combines query authentication with an `Authorization` header is rejected with `AccessDenied` (HTTP 403).

# Browser access

The S3 endpoint answers cross-origin requests, so a presigned URL can be used directly from a web page. Downloading an object, uploading to a presigned `PutObject` URL, and streaming a `Range` request all work from browser JavaScript with no proxy in front.

Requests are allowed from any origin, and preflight `OPTIONS` requests are answered by the endpoint without reaching your buckets. Because every request authenticates from its own signature, cross-origin responses are credential-free: cookies are neither sent nor accepted (`Access-Control-Allow-Credentials` is `false`), and you do not need to register the origin as a platform in your project. Preflight responses are cacheable for 24 hours.

These response headers are exposed to browser JavaScript, so client code can read them off a `fetch` response:

| Exposed header | Use |
| --- | --- |
| `Content-Length` | The size of the object body. |
| `Accept-Ranges`, `Content-Range` | Range request support, and the byte range returned. |
| `ETag` | The object's entity tag, for conditional requests and integrity checks. |
| `Last-Modified` | When the object was last written. |
| `x-amz-server-side-encryption` | The server-side encryption applied to the object. |

Browser requests may send the standard content and caching headers, including `Content-Type`, `Range`, and `Cache-Control`. Custom `x-amz-*` request headers are not permitted cross-origin, so set user metadata such as `x-amz-meta-*` from a server-side client rather than from the browser.

**Never ship an API key secret to a browser**

Cross-origin support does not make it safe to configure an S3 client with your API key secret in front-end code. A secret in a browser is readable by anyone who opens the page, and it grants every scope on the key. Use [presigned URLs](#presigned-urls) signed on a server instead.

# Events and usage

Writes over the S3 API are ordinary Storage writes, so they take part in the rest of your project. Uploading, replacing, or deleting an object fires the matching `buckets.[bucketId].files.[fileId].create`, `.update`, or `.delete` [event](/docs/advanced/platform/events), and creating or deleting a bucket fires the corresponding bucket event. Those events reach [webhooks](/docs/advanced/platform/webhooks), [realtime](/docs/apis/realtime) subscribers, and event-triggered [functions](/docs/products/functions/execute) exactly as native Storage writes do.

S3 traffic is metered like the rest of your project's API traffic: requests and inbound and outbound bandwidth accrue to your project's network usage.

# Limitations

The S3 API targets the operations most clients depend on. Keep the following in mind.

- **Path-style addressing only.** Virtual-hosted-style URLs (`https://<bucket>.host/...`) are not supported. Enable path-style addressing in your client, as shown in [Connect an S3 client](#connect-an-s3-client).
- **Signature Version 4 only.** Requests are authenticated with SigV4, either with an `Authorization` header or as a [presigned URL](#presigned-urls) that carries the signature in the query string, but never both at once. A header-signed request must carry a timestamp within 15 minutes of the server's clock, while a presigned URL is valid for up to 7 days from the timestamp it was signed with.
- **File names must be unique within a bucket.** Object keys are file names, so a bucket cannot hold two objects under one key, and uploading to an existing key replaces the object stored there. A bucket that already contains files sharing a name and folder is not served over the S3 API: requests whose key resolves ambiguously return `InvalidRequest` (HTTP 400). See [Buckets and objects](#buckets-and-objects).
- **ACLs are compatibility responses.** `GetObjectAcl`, `PutObjectAcl`, `GetBucketAcl`, and `PutBucketAcl` return a canned private ACL and are accepted for tooling compatibility. They do not change access. Manage access with [Appwrite permissions](/docs/products/storage/permissions) and API key scopes instead.
- **No folder resource or folder markers.** Folders are virtual: they are `/`-separated prefixes in object keys, browsed by listing with `delimiter=/`. See [Folders](#folders). Using any other delimiter value or uploading a folder-marker object (a key ending in `/`) returns `NotImplemented` (HTTP 501).
- **Prefer `ListObjectsV2` for large buckets.** `ListObjects` returns every matching object in a single response, while `ListObjectsV2` honors `max-keys` (capped at 1000) and paginates with `IsTruncated` and a `NextContinuationToken`. No objects are silently dropped.
- **No bucket CORS configuration.** The `cors` bucket sub-resource is not implemented. It is not needed to call the endpoint from a browser: cross-origin requests are allowed from any origin by default. See [Browser access](#browser-access).
- **Unsupported S3 features.** Object tagging, lifecycle, bucket policies and policy status, encryption configuration, ownership controls, notifications, versioning, object locking, and restores are not supported. Any request carrying a sub-resource or query parameter the API does not implement returns `NotImplemented` (HTTP 501) rather than being partially applied.

# Error responses

Errors are returned as standard S3 XML error documents with an S3 error code and HTTP status.

| S3 error code | HTTP status | Meaning |
| --- | --- | --- |
| `NoSuchBucket` | 404 | The bucket does not exist, is disabled, or is not accessible. |
| `NoSuchKey` | 404 | The object key does not exist. |
| `BucketNotEmpty` | 409 | The bucket still contains objects and cannot be deleted. |
| `BucketAlreadyOwnedByYou` | 409 | A bucket with that ID already exists in your project. |
| `AccessDenied` | 403 | The signature is invalid, the API key is expired or missing a required scope, or a presigned URL has expired or is malformed. |
| `InvalidRange` | 416 | The requested `Range` cannot be satisfied. |
| `PreconditionFailed` | 412 | An `If-Match` precondition did not hold. |
| `NotImplemented` | 501 | The requested S3 feature is not supported. |
| `InvalidRequest` | 400 | The request was malformed, violated a bucket constraint, or its key matched more than one file. |
| `InternalError` | 500 | An unexpected server error occurred. |
