CloudPe
Knowledge Base Storage CloudPe Object Storage — Integration Guide
Storage Updated 15 June 2026

CloudPe Object Storage — Integration Guide

Applies to: S3-Compatible Object Storage (PureStore backend)
Authentication: AWS Signature Version 4 (SigV4)
Audience: Developers integrating via cURL, Python, or .NET


Table of Contents

  1. Global Configuration Reference
  2. Obtaining Credentials
  3. Authentication Overview
  4. cURL — Using Built-in SigV4 Signing
  5. Python — boto3 SDK
  6. .NET — AWSSDK.S3
  7. Operation Reference
  8. Troubleshooting

1. Global Configuration Reference

All integrations must use the following configuration regardless of client language.

ParameterValue
Storage Endpointhttps://s3.in-west3.purestore.io
Regionin-west3
Service Names3
Signing ProtocolAWS Signature Version 4 (AWS4-HMAC-SHA256)
Virtual-Host URLhttps://<YOUR_BUCKET>.s3.in-west3.purestore.io/<YOUR_OBJECT_KEY>
Path-Style URLhttps://s3.in-west3.purestore.io/<YOUR_BUCKET>/<YOUR_OBJECT_KEY>
Multipart UploadSupported
Pre-Signed URLsSupported (GET & PUT)
Max Object Size (single PUT)~5 GB
Max Object Size (multipart)~5 TB
  • The SDK switches to multipart upload automatically for files above 8 MB (Python) and 16 MB (.NET) by default, so for most workloads the single-PUT limit is not encountered in practice.

URL Style: Path-style is used by default in cURL commands and in the .NET configuration below (ForcePathStyle = true). Python boto3 defaults to virtual-host-style.


2. Obtaining Credentials

Credentials are generated from the CloudPe Management Portal (CMP):

  1. Log in to the CMP Dashboard
  2. Navigate to Object Storage → Access Keys
  3. Click Generate New Key
  4. Copy your Access Key ID and Secret Access Key — the secret is shown only once

See Enabling Access to S3 Storage for a step-by-step guide.


3. Authentication Overview

CloudPe Object Storage uses AWS Signature Version 4 (SigV4). Every authenticated request must carry these headers:

HeaderDescription
HostTarget hostname (s3.in-west3.purestore.io or <YOUR_BUCKET>.s3.in-west3.purestore.io)
x-amz-dateRequest timestamp in ISO 8601 format: YYYYMMDDTHHMMSSZ
x-amz-content-sha256SHA-256 hex digest of the request body (empty body = e3b0c44...)
AuthorizationFull AWS4-HMAC-SHA256 credential string

Authorization header structure:

AWS4-HMAC-SHA256
  Credential=<AccessKeyID>/<Date>/<Region>/s3/aws4_request,
  SignedHeaders=host;x-amz-content-sha256;x-amz-date,
  Signature=<hex-signature>

Signing pipeline (4 steps):

  1. Canonical Request — Standardize HTTP method, URI, query string, headers, and SHA-256 body hash
  2. String to Sign — Combine algorithm name, timestamp, credential scope, and hash of the canonical request
  3. Derive Signing Key — HMAC-SHA256 chain: Secret → Date → Region → "s3" → "aws4_request"
  4. Calculate Signature — Final HMAC-SHA256 of the string to sign using the derived key

SDKs (boto3, AWSSDK.S3) and modern cURL (--aws-sigv4) perform all four steps automatically.


4. cURL — Using Built-in SigV4 Signing

cURL v7.75.0 and later includes native AWS SigV4 support via the --aws-sigv4 flag. This eliminates the need to manually compute canonical strings, HMAC chains, or hex signatures.

Flag syntax:

--aws-sigv4 "aws:amz:<region>:<service>"
--user "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY"

Can cURL generate pre-signed URLs?
No. cURL is an HTTP client — it can only use a pre-signed URL that has already been generated. Generating a pre-signed URL requires cryptographic signing outside of an HTTP request. Use the Python one-liner or AWS CLI shown in the Pre-Signed URL section below to generate the URL, then pass it to cURL.

Setup — Export Credentials Once

Run these in your terminal session before any of the commands below:

export AWS_ACCESS_KEY_ID="YOUR_ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="YOUR_SECRET_ACCESS_KEY"

Windows (PowerShell): Use $env:AWS_ACCESS_KEY_ID = "YOUR_ACCESS_KEY_ID" instead.

Placeholder reference used in all commands below:

PlaceholderReplace with
YOUR_BUCKETYour bucket name (e.g. my-app-bucket)
YOUR_OBJECT_KEYObject path inside the bucket (e.g. reports/jan.pdf)
YOUR_LOCAL_FILELocal file path to upload (e.g. ./jan.pdf)
YOUR_DOWNLOAD_PATHLocal path to save downloaded file (e.g. ./downloaded.pdf)
YOUR_PREFIXFolder-style prefix to filter by (e.g. logs/)

cURL Operations

Create Bucket

curl -X PUT "https://s3.in-west3.purestore.io/YOUR_BUCKET" \
     --aws-sigv4 "aws:amz:in-west3:s3" \
     --user "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY"

A successful create returns HTTP 200 OK with an empty body.

List Buckets

curl -X GET "https://s3.in-west3.purestore.io/" \
     --aws-sigv4 "aws:amz:in-west3:s3" \
     --user "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY"

List Objects in a Bucket (GET Bucket)

Uses the list-type=2 protocol (ListObjectsV2):

curl -X GET "https://s3.in-west3.purestore.io/YOUR_BUCKET?list-type=2" \
     --aws-sigv4 "aws:amz:in-west3:s3" \
     --user "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY"

With prefix filter (folder-style):

curl -X GET "https://s3.in-west3.purestore.io/YOUR_BUCKET?list-type=2&prefix=YOUR_PREFIX" \
     --aws-sigv4 "aws:amz:in-west3:s3" \
     --user "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY"

Upload File (PUT Object)

curl -X PUT "https://s3.in-west3.purestore.io/YOUR_BUCKET/YOUR_OBJECT_KEY" \
     --aws-sigv4 "aws:amz:in-west3:s3" \
     --user "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY" \
     -H "Content-Type: application/octet-stream" \
     --data-binary "@YOUR_LOCAL_FILE"

Upload into a folder prefix:

curl -X PUT "https://s3.in-west3.purestore.io/YOUR_BUCKET/YOUR_PREFIX/YOUR_OBJECT_KEY" \
     --aws-sigv4 "aws:amz:in-west3:s3" \
     --user "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY" \
     -H "Content-Type: application/octet-stream" \
     --data-binary "@YOUR_LOCAL_FILE"

Download File (GET Object)

curl -X GET "https://s3.in-west3.purestore.io/YOUR_BUCKET/YOUR_OBJECT_KEY" \
     --aws-sigv4 "aws:amz:in-west3:s3" \
     --user "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY" \
     -o "YOUR_DOWNLOAD_PATH"

Delete Object

curl -X DELETE "https://s3.in-west3.purestore.io/YOUR_BUCKET/YOUR_OBJECT_KEY" \
     --aws-sigv4 "aws:amz:in-west3:s3" \
     --user "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY"

A successful delete returns HTTP 204 No Content with an empty body.

Delete Bucket

The bucket must be empty before it can be deleted.

curl -X DELETE "https://s3.in-west3.purestore.io/YOUR_BUCKET" \
     --aws-sigv4 "aws:amz:in-west3:s3" \
     --user "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY"

Pre-Signed URL — Full Options Reference

Important: cURL is an HTTP client and cannot cryptographically sign a URL. Pre-signed URLs must be generated by a tool that has access to your secret key (Python, AWS CLI, or .NET SDK), then used by cURL. The recipient of a pre-signed URL needs no credentials at all.


How a Pre-Signed URL Works

A pre-signed URL embeds the SigV4 signature directly into the query string, so any HTTP client can call it without credentials. The server validates the embedded signature on arrival and rejects the request if it has expired or been tampered with.

Anatomy of a generated URL:

https://s3.in-west3.purestore.io/YOUR_BUCKET/YOUR_OBJECT_KEY
  ?X-Amz-Algorithm=AWS4-HMAC-SHA256
  &X-Amz-Credential=YOUR_ACCESS_KEY_ID%2FYYYYMMDD%2Fin-west3%2Fs3%2Faws4_request
  &X-Amz-Date=YYYYMMDDTHHMMSSZ
  &X-Amz-Expires=3600
  &X-Amz-SignedHeaders=host
  &X-Amz-Signature=<64-char-hex>
ParameterDescription
X-Amz-AlgorithmAlways AWS4-HMAC-SHA256
X-Amz-CredentialAccess key + credential scope (date/region/service) — URL-encoded
X-Amz-DateTimestamp at signing time in ISO 8601 format
X-Amz-ExpiresValidity window in seconds from X-Amz-Date
X-Amz-SignedHeadersHeaders that were included in the signature (minimum: host)
X-Amz-Signature64-character hex HMAC-SHA256 signature

Option A — Python one-liner (terminal):

# Valid for 1 hour (3600 seconds)
python3 -c "
import boto3
from botocore.client import Config
s3 = boto3.client(
    's3',
    endpoint_url='https://s3.in-west3.purestore.io',
    aws_access_key_id='YOUR_ACCESS_KEY_ID',
    aws_secret_access_key='YOUR_SECRET_ACCESS_KEY',
    region_name='in-west3',
    config=Config(signature_version='s3v4')
)
print(s3.generate_presigned_url(
    'get_object',
    Params={'Bucket': 'YOUR_BUCKET', 'Key': 'YOUR_OBJECT_KEY'},
    ExpiresIn=3600
))
"

Option B — AWS CLI:

# Valid for 1 hour
aws s3 presign s3://YOUR_BUCKET/YOUR_OBJECT_KEY \
    --endpoint-url https://s3.in-west3.purestore.io \
    --region in-west3 \
    --expires-in 3600

Expiry quick-reference:

Use caseExpiresIn value
One-time short share300 (5 minutes)
Standard API callback900 (15 minutes)
User-facing download link3600 (1 hour)
Overnight batch job86400 (24 hours)
Max practical window604800 (7 days)

Use this to allow a client or third party to upload a file directly to your bucket without exposing your credentials.

Option A — Python one-liner (terminal):

python3 -c "
import boto3
from botocore.client import Config
s3 = boto3.client(
    's3',
    endpoint_url='https://s3.in-west3.purestore.io',
    aws_access_key_id='YOUR_ACCESS_KEY_ID',
    aws_secret_access_key='YOUR_SECRET_ACCESS_KEY',
    region_name='in-west3',
    config=Config(signature_version='s3v4')
)
print(s3.generate_presigned_url(
    'put_object',
    Params={
        'Bucket': 'YOUR_BUCKET',
        'Key': 'YOUR_OBJECT_KEY',
        'ContentType': 'application/octet-stream'
    },
    ExpiresIn=3600
))
"

Option B — AWS CLI:

The AWS CLI s3 presign command only generates GET URLs. Use the Python one-liner above or the .NET SDK to generate a PUT pre-signed URL.


Step 3 — Use the Pre-Signed URL with cURL

No credentials, no --aws-sigv4 flag — just the URL.

Download (GET):

curl -X GET "PASTE_PRESIGNED_GET_URL_HERE" \
     -o "YOUR_DOWNLOAD_PATH"

With progress display:

curl -X GET "PASTE_PRESIGNED_GET_URL_HERE" \
     -o "YOUR_DOWNLOAD_PATH" \
     --progress-bar

Upload (PUT):

curl -X PUT "PASTE_PRESIGNED_PUT_URL_HERE" \
     -H "Content-Type: application/octet-stream" \
     --data-binary "@YOUR_LOCAL_FILE"

Content-Type must match exactly. If ContentType was included when the PUT URL was generated (e.g. application/octet-stream), the upload request must send the same Content-Type header. A mismatch causes HTTP 403 SignatureDoesNotMatch. If no ContentType was set during generation, omit the -H "Content-Type: ..." header from cURL entirely.

With verbose output to confirm headers and response:

curl -v -X PUT "PASTE_PRESIGNED_PUT_URL_HERE" \
     -H "Content-Type: application/octet-stream" \
     --data-binary "@YOUR_LOCAL_FILE"

Quick Verification Test

To confirm a pre-signed GET URL is valid before distributing it:

# Should return HTTP 200 and the file content (or headers only with -I)
curl -I "PASTE_PRESIGNED_GET_URL_HERE"

A 200 OK with headers like Content-Length and ETag confirms the URL is valid and the object exists. A 403 means the URL has expired, the signature is invalid, or the object does not exist.


5. Python — boto3 SDK

Installation

pip install boto3

Client Setup

import boto3
from botocore.client import Config

# boto3 is the AWS-style S3 SDK for Python
# Creating an S3 client pointed at CloudPe's PureStore endpoint
s3 = boto3.client(
    "s3",
    endpoint_url="https://s3.in-west3.purestore.io",
    aws_access_key_id="YOUR_ACCESS_KEY_ID",
    aws_secret_access_key="YOUR_SECRET_ACCESS_KEY",
    region_name="in-west3",
    config=Config(signature_version="s3v4")   # SigV4 is mandatory for this endpoint
)

Tip: To avoid hardcoding credentials, set environment variables instead:

export AWS_ACCESS_KEY_ID="YOUR_ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="YOUR_SECRET_ACCESS_KEY"

Then omit aws_access_key_id and aws_secret_access_key from the client call — boto3 picks them up automatically.


Python Operations

Create Bucket

s3.create_bucket(
    Bucket="YOUR_BUCKET",
    CreateBucketConfiguration={"LocationConstraint": "in-west3"}
)

List Buckets

print("Buckets:")
response = s3.list_buckets()
for b in response["Buckets"]:
    print(b["Name"])

List Objects in a Bucket

print("Bucket objects:")
response = s3.list_objects_v2(Bucket="YOUR_BUCKET")
for obj in response.get("Contents", []):
    print(obj["Key"])

With prefix filter (folder-style):

response = s3.list_objects_v2(
    Bucket="YOUR_BUCKET",
    Prefix="YOUR_PREFIX"    # e.g. "logs/" — behaves like a folder filter
)
for obj in response.get("Contents", []):
    print(obj["Key"])

Upload File

High-level helper (recommended — handles multipart automatically for large files):

s3.upload_file(
    "YOUR_LOCAL_FILE",   # local file path
    "YOUR_BUCKET",       # bucket name
    "YOUR_OBJECT_KEY"    # destination key in bucket
)

Upload into a folder prefix:

s3.upload_file(
    "YOUR_LOCAL_FILE",
    "YOUR_BUCKET",
    "YOUR_PREFIX/YOUR_OBJECT_KEY"   # e.g. "logs/report.txt"
)

Raw PUT — stream a file handle directly:

with open("YOUR_LOCAL_FILE", "rb") as f:
    s3.put_object(
        Bucket="YOUR_BUCKET",
        Key="YOUR_OBJECT_KEY",
        Body=f
    )

Explicit string or bytes body:

s3.put_object(
    Bucket="YOUR_BUCKET",
    Key="YOUR_OBJECT_KEY",
    Body=b"Hello World"
)

Download File

High-level helper:

s3.download_file(
    "YOUR_BUCKET",          # bucket name
    "YOUR_OBJECT_KEY",      # object key
    "YOUR_DOWNLOAD_PATH"    # local destination path
)

Raw GET — read content into memory:

response = s3.get_object(
    Bucket="YOUR_BUCKET",
    Key="YOUR_OBJECT_KEY"
)
data = response["Body"].read()
print(data.decode())

Generate Pre-Signed URL

Pre-signed GET URL (download link — valid for 1 hour):

url = s3.generate_presigned_url(
    "get_object",
    Params={
        "Bucket": "YOUR_BUCKET",
        "Key": "YOUR_OBJECT_KEY"
    },
    ExpiresIn=3600      # seconds — 3600 = 1 hour
)
print("Presigned URL:", url)

Pre-signed PUT URL (upload link):

url = s3.generate_presigned_url(
    "put_object",
    Params={
        "Bucket": "YOUR_BUCKET",
        "Key": "YOUR_OBJECT_KEY"
    },
    ExpiresIn=3600
)
print("Upload link:", url)

The generated URL embeds the signature as query parameters. Anyone with the URL can download or upload within the expiry window — no credentials required.

Delete Object

s3.delete_object(
    Bucket="YOUR_BUCKET",
    Key="YOUR_OBJECT_KEY"
)

Delete Bucket

# Bucket must be empty before deletion
s3.delete_bucket(Bucket="YOUR_BUCKET")

6. .NET — AWSSDK.S3

Installation

Option A — NuGet CLI (from package source):

dotnet add package AWSSDK.S3 --source https://api.nuget.org/v3/index.json

Option B — If .NET was installed via the official installer from the website:

dotnet new nugetconfig
dotnet add package AWSSDK.S3

Or add directly to your .csproj:

<PackageReference Include="AWSSDK.S3"   Version="3.*" />
<PackageReference Include="AWSSDK.Core" Version="3.*" />

Setting Credentials via Environment Variables

The AWSSDK automatically reads credentials from environment variables — no need to hardcode them in source code.

Command Prompt / Server (persists across sessions):

setx AWS_ACCESS_KEY_ID=YOUR_ACCESS_KEY_ID
setx AWS_SECRET_ACCESS_KEY=YOUR_SECRET_ACCESS_KEY
setx AWS_REGION=in-west3

setx writes to the Windows registry. Restart your terminal after running these commands for the values to take effect.

PowerShell (current session only):

$env:AWS_ACCESS_KEY_ID     = "YOUR_ACCESS_KEY_ID"
$env:AWS_SECRET_ACCESS_KEY = "YOUR_SECRET_ACCESS_KEY"
$env:AWS_REGION            = "in-west3"

Full Program Example

The complete working program below uses credentials from environment variables and organises every operation as a named static method. Uncomment the calls in Main to activate each operation.

using System;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using Amazon.S3;
using Amazon.S3.Model;

class Program
{
    private static AmazonS3Client? s3;
    private static readonly HttpClient httpClient = new HttpClient();

    static async Task Main(string[] args)
    {
        // Client config — reads AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY
        // from environment variables automatically (no hardcoded credentials)
        var config = new AmazonS3Config
        {
            ServiceURL           = "https://s3.in-west3.purestore.io",
            AuthenticationRegion = "in-west3",
            ForcePathStyle       = true    // path-style: s3.in-west3.purestore.io/bucket/key
        };

        s3 = new AmazonS3Client(config);

        try
        {
            // ── CREATE BUCKET ──────────────────────────────────────────────
            // await CreateBucketAsync("YOUR_BUCKET");

            // ── LIST BUCKETS ───────────────────────────────────────────────
            Console.WriteLine("Buckets");
            await ListBucketsAsync();

            // ── LIST OBJECTS IN BUCKET ─────────────────────────────────────
            await ListObjectsAsync("YOUR_BUCKET");

            // ── LIST OBJECTS WITH PREFIX (FOLDER FILTER) ───────────────────
            // await ListObjectsWithPrefixAsync("YOUR_BUCKET", "YOUR_PREFIX/");

            // ── UPLOAD FILE (PutObjectRequest with FilePath) ───────────────
            // await UploadFileAsync("YOUR_LOCAL_FILE", "YOUR_BUCKET", "YOUR_OBJECT_KEY");

            // ── RAW PUT via pre-signed URL + HttpClient ────────────────────
            // Bypasses SDK signing engine; useful when direct PUT fails
            await PutObjectRawAsync("YOUR_BUCKET", "YOUR_OBJECT_KEY", "YOUR_LOCAL_FILE");

            // ── EXPLICIT STRING PUT ────────────────────────────────────────
            // await PutObjectExplicitAsync("YOUR_BUCKET", "YOUR_OBJECT_KEY", "Hello World");

            // ── DOWNLOAD FILE ──────────────────────────────────────────────
            // await DownloadFileAsync("YOUR_BUCKET", "YOUR_OBJECT_KEY", "YOUR_DOWNLOAD_PATH");

            // ── RAW GET (read content as string) ──────────────────────────
            // await GetObjectExplicitAsync("YOUR_BUCKET", "YOUR_OBJECT_KEY");

            // ── GENERATE PRE-SIGNED URLS ───────────────────────────────────
            GeneratePresignedUrls("YOUR_BUCKET", "YOUR_OBJECT_KEY", "YOUR_UPLOAD_KEY");

            // ── DELETE OBJECT ──────────────────────────────────────────────
            // await DeleteObjectAsync("YOUR_BUCKET", "YOUR_OBJECT_KEY");

            // ── DELETE BUCKET (must be empty first) ───────────────────────
            // await DeleteBucketAsync("YOUR_BUCKET");
        }
        catch (AmazonS3Exception e)
        {
            Console.WriteLine($"S3 Error: {e.Message}");
        }
        catch (Exception e)
        {
            Console.WriteLine($"Critical Error: {e.Message}");
        }
    }

    // =========================================================================
    // OPERATIONS
    // =========================================================================

    static async Task CreateBucketAsync(string bucketName)
    {
        var request = new PutBucketRequest
        {
            BucketName       = bucketName,
            BucketRegionName = "in-west3"
        };
        await s3!.PutBucketAsync(request);
        Console.WriteLine($"Bucket '{bucketName}' created successfully.");
    }

    static async Task ListBucketsAsync()
    {
        var response = await s3!.ListBucketsAsync();
        foreach (var bucket in response.Buckets)
        {
            Console.WriteLine(bucket.BucketName);
        }
    }

    static async Task ListObjectsAsync(string bucketName)
    {
        Console.WriteLine($"\n--- Bucket Objects ({bucketName}) ---");
        var request = new ListObjectsV2Request { BucketName = bucketName };
        var response = await s3!.ListObjectsV2Async(request);

        foreach (var obj in response.S3Objects ?? new System.Collections.Generic.List<S3Object>())
        {
            Console.WriteLine(obj.Key);
        }
    }

    static async Task ListObjectsWithPrefixAsync(string bucketName, string prefix)
    {
        var request = new ListObjectsV2Request
        {
            BucketName = bucketName,
            Prefix     = prefix     // e.g. "logs/" — behaves like a folder filter
        };
        var response = await s3!.ListObjectsV2Async(request);

        foreach (var obj in response.S3Objects ?? new System.Collections.Generic.List<S3Object>())
        {
            Console.WriteLine(obj.Key);
        }
    }

    // High-level upload via PutObjectRequest with FilePath
    static async Task UploadFileAsync(string filePath, string bucketName, string keyName)
    {
        var request = new PutObjectRequest
        {
            BucketName = bucketName,
            Key        = keyName,
            FilePath   = filePath
        };
        await s3!.PutObjectAsync(request);
        Console.WriteLine($"Uploaded {filePath} to {bucketName}/{keyName}");
    }

    // Raw PUT: generates a short-lived pre-signed PUT URL, then streams the
    // file over standard HttpClient — avoids SDK signing engine entirely.
    // Useful if PutObjectAsync returns signature errors on this endpoint.
    static async Task PutObjectRawAsync(string bucketName, string keyName, string localFilePath)
    {
        // A. Generate a pre-signed PUT URL (valid 15 minutes)
        var putUrlRequest = new GetPreSignedUrlRequest
        {
            BucketName = bucketName,
            Key        = keyName,
            Verb       = HttpVerb.PUT,
            Expires    = DateTime.UtcNow.AddMinutes(15)
        };
        string uploadUrl = s3!.GetPreSignedURL(putUrlRequest);

        // B. Stream the file over a plain HTTP PUT — no special headers needed
        using var fileStream = new FileStream(localFilePath, FileMode.Open, FileAccess.Read);
        using var content    = new StreamContent(fileStream);

        var response = await httpClient.PutAsync(uploadUrl, content);

        if (response.IsSuccessStatusCode)
        {
            Console.WriteLine("Raw stream upload complete.");
        }
        else
        {
            string errorDetails = await response.Content.ReadAsStringAsync();
            Console.WriteLine($"Upload failed. Status: {response.StatusCode} | Body: {errorDetails}");
        }
    }

    // PUT with an inline string body
    static async Task PutObjectExplicitAsync(string bucketName, string keyName, string content)
    {
        var request = new PutObjectRequest
        {
            BucketName  = bucketName,
            Key         = keyName,
            ContentBody = content
        };
        await s3!.PutObjectAsync(request);
        Console.WriteLine("Explicit text payload uploaded.");
    }

    // Download and write directly to a local file path
    static async Task DownloadFileAsync(string bucketName, string keyName, string downloadPath)
    {
        var request = new GetObjectRequest { BucketName = bucketName, Key = keyName };
        using var response = await s3!.GetObjectAsync(request);
        await response.WriteResponseStreamToFileAsync(
            downloadPath, false, System.Threading.CancellationToken.None);
        Console.WriteLine($"Downloaded to: {downloadPath}");
    }

    // GET and read content as a string in memory
    static async Task GetObjectExplicitAsync(string bucketName, string keyName)
    {
        var request = new GetObjectRequest { BucketName = bucketName, Key = keyName };
        using var response = await s3!.GetObjectAsync(request);
        using var reader   = new StreamReader(response.ResponseStream);

        string data = await reader.ReadToEndAsync();
        Console.WriteLine($"Content: {data}");
    }

    // Generates both a GET (download) and PUT (upload) pre-signed URL
    static void GeneratePresignedUrls(string bucketName, string getKey, string putKey)
    {
        // Pre-signed GET — anyone with this URL can download the object for 1 hour
        var getRequest = new GetPreSignedUrlRequest
        {
            BucketName = bucketName,
            Key        = getKey,
            Verb       = HttpVerb.GET,
            Expires    = DateTime.UtcNow.AddHours(1)
        };
        string getUrl = s3!.GetPreSignedURL(getRequest);
        Console.WriteLine($"Presigned URL for {getKey}: {getUrl}");

        // Pre-signed PUT — anyone with this URL can upload to the key for 1 hour
        var putRequest = new GetPreSignedUrlRequest
        {
            BucketName = bucketName,
            Key        = putKey,
            Verb       = HttpVerb.PUT,
            Expires    = DateTime.UtcNow.AddHours(1)
        };
        string putUrl = s3!.GetPreSignedURL(putRequest);
        Console.WriteLine($"Upload link: {putUrl}");
    }

    static async Task DeleteObjectAsync(string bucketName, string keyName)
    {
        var request = new DeleteObjectRequest { BucketName = bucketName, Key = keyName };
        await s3!.DeleteObjectAsync(request);
        Console.WriteLine($"Deleted object: {keyName}");
    }

    static async Task DeleteBucketAsync(string bucketName)
    {
        // Bucket must be empty — delete all objects first
        var request = new DeleteBucketRequest { BucketName = bucketName };
        await s3!.DeleteBucketAsync(request);
        Console.WriteLine($"Deleted bucket: {bucketName}");
    }
}

7. Operation Reference

Quick side-by-side reference for all operations across all three clients.

OperationcURLPython (boto3).NET (AWSSDK.S3)
Create BucketPUT /YOUR_BUCKETs3.create_bucket(Bucket=...)s3.PutBucketAsync(...)
List BucketsGET /s3.list_buckets()s3.ListBucketsAsync()
List ObjectsGET /YOUR_BUCKET?list-type=2s3.list_objects_v2(Bucket=...)s3.ListObjectsV2Async(...)
List with Prefix?list-type=2&prefix=YOUR_PREFIXlist_objects_v2(Prefix=...)ListObjectsV2Request{ Prefix=... }
Upload (high-level)PUT /YOUR_BUCKET/YOUR_OBJECT_KEY --data-binarys3.upload_file(local, bucket, key)UploadFileAsync via PutObjectRequest{ FilePath }
Upload (raw / stream)same as aboves3.put_object(Bucket, Key, Body)PutObjectRawAsync via pre-signed URL + HttpClient
Upload (inline body)--data "content"put_object(Body=b"...")PutObjectExplicitAsync via ContentBody
Download (to file)GET /bucket/key -o paths3.download_file(bucket, key, local)DownloadFileAsync via WriteResponseStreamToFileAsync
Download (to memory)same + capture outputs3.get_object(Bucket, Key)GetObjectExplicitAsync via StreamReader
Delete ObjectDELETE /YOUR_BUCKET/YOUR_OBJECT_KEYs3.delete_object(Bucket, Key)s3.DeleteObjectAsync(...)
Delete BucketDELETE /YOUR_BUCKETs3.delete_bucket(Bucket)s3.DeleteBucketAsync(...)
Generate Pre-signed GETnot possible — use Python/CLI to generategenerate_presigned_url("get_object")GetPreSignedURL(Verb=GET)
Generate Pre-signed PUTnot possible — use Python/CLI to generategenerate_presigned_url("put_object")GetPreSignedURL(Verb=PUT)
Use Pre-signed URLcurl -X GET/PUT "PRESIGNED_URL"use with requests.get/put(url)use with HttpClient.GetAsync/PutAsync(url)

8. Troubleshooting

ErrorLikely CauseFix
SignatureDoesNotMatchWrong secret key, or pre-signed PUT Content-Type mismatchVerify credentials; ensure Content-Type on upload matches what was signed
InvalidAccessKeyIdAccess key is wrong or has been rotatedRe-generate keys in CMP → Object Storage → Access Keys
RequestTimeTooSkewedSystem clock is > 15 minutes off from server timeLinux: sudo ntpdate pool.ntp.org; Windows: sync via Date & Time settings
NoSuchBucketBucket does not exist or name is misspelledVerify bucket name via List Buckets; confirm region is in-west3
NoSuchKeyObject key path does not exist in the bucketList objects first to confirm the exact key
EntityTooLargeSingle-PUT object exceeds the endpoint’s size limitUse upload_file (Python) or PutObjectRawAsync via multipart — both split large files automatically
SigV2 / auth errorClient is sending deprecated Signature Version 2Set signature_version="s3v4" in boto3; ensure AuthenticationRegion = "in-west3" is explicitly defined in .NET config (SigV4 is handled automatically).
.NET payload errorBlank S3 Error: or dropped connection on uploadThe .NET SDK’s high-level payload formatting is conflicting with PureStore. Use the provided PutObjectRawAsync method to stream via a standard HTTP client instead.
cURL: --aws-sigv4 unknown optioncURL version is older than v7.75.0Upgrade cURL: apt install curl (Linux) / brew install curl (macOS) / curl.se (Windows)
.NET: SSL/TLS errorOutdated .NET runtime or missing CA certificateUpdate .NET SDK; verify system CA certificate store
403 on pre-signed PUTRecipient used wrong Content-Type on uploadThe upload Content-Type header must exactly match the value used when generating the URL
setx values not recognisedTerminal not restarted after setxClose and reopen Command Prompt / PowerShell after running setx