Skip to content

Upload & download

This page covers writing and reading object bytes. It walks PutObject (buffered and streaming), GetObject (whole-object and streaming, ranges, versions, response overrides), and HeadObject.

The examples for the legacy HTTP/JSON surface lead with the native client and show the S3 client right after, behind the surface toggle. New Go applications should use the direct binary pkg/lockwellwire client described in the Go SDK guide. Two clients write to the same encrypted, per-tenant store:

  • the legacy native client uses an auto-managed JSON bearer token (NativeClient / lockwellnative / LockwellNativeClient);
  • the S3 client signs every request with SigV4 (Client / lockwellsdk / LockwellClient).

For the operation matrix at a glance, see the native data-plane reference and the S3 operations reference. Copy, list, delete, and conditional writes have their own pages, linked at the bottom.

Constructing a client

The native client takes an endpoint plus an access-key id and secret, then mints and refreshes its own bearer token (single-flight, thread-safe). The S3 client takes the same credentials and signs each request directly.

ts
import { NativeClient, Client } from "@kelphect/sdk";

// Native JSON data plane (token managed for you):
const native = new NativeClient({
  endpoint: "https://objects.example.com", // public listener; /api/v1 is added for you
  accessKeyId: process.env.LOCKWELL_ACCESS_KEY_ID,
  secretKey: process.env.LOCKWELL_SECRET_KEY,
});

// S3 data plane (SigV4):
const s3 = new Client({
  endpoint: "https://objects.example.com",
  accessKeyId: process.env.LOCKWELL_ACCESS_KEY_ID,
  secretKey: process.env.LOCKWELL_SECRET_KEY,
});
go
import (
    "github.com/KelpHect/lockwell/pkg/lockwellnative"
    "github.com/KelpHect/lockwell/pkg/lockwellsdk"
)

native, err := lockwellnative.New(
    "https://objects.example.com",
    os.Getenv("LOCKWELL_ACCESS_KEY_ID"),
    os.Getenv("LOCKWELL_SECRET_KEY"),
)

s3, err := lockwellsdk.New(
    "https://objects.example.com",
    lockwellsdk.Credentials{
        AccessKeyID: os.Getenv("LOCKWELL_ACCESS_KEY_ID"),
        SecretKey:   os.Getenv("LOCKWELL_SECRET_KEY"),
    },
)
java
import com.lockwell.sdk.nativeapi.LockwellNativeClient;
import com.lockwell.sdk.LockwellClient;
import com.lockwell.sdk.Credentials;

var nativeClient = LockwellNativeClient.builder()
    .endpoint("https://objects.example.com")
    .accessKeyId(System.getenv("LOCKWELL_ACCESS_KEY_ID"))
    .secretKey(System.getenv("LOCKWELL_SECRET_KEY"))
    .build();

var s3 = LockwellClient.builder()
    .endpoint("https://objects.example.com")
    .credentials(new Credentials(System.getenv("LOCKWELL_ACCESS_KEY_ID"),
                                 System.getenv("LOCKWELL_SECRET_KEY")))
    .build();

The rest of this page uses native for the native client and s3 for the S3 client.

Put an object (buffered)

Hand the SDK a byte buffer and it does one request. The native client returns the stored object's ETag and, on a versioned bucket, a version id.

ts
const put = await native.putObject("reports", "q1/summary.txt", "hello world", {
  contentType: "text/plain",
  metadata: { author: "finance", quarter: "Q1" },
});
console.log(put.etag, put.versionId);
go
put, err := native.PutObject(ctx, lockwellnative.PutObjectInput{
    Bucket:      "reports",
    Key:         "q1/summary.txt",
    Body:        strings.NewReader("hello world"),
    ContentType: "text/plain",
    Metadata:    map[string]string{"author": "finance", "quarter": "Q1"},
})
fmt.Println(put.ETag, put.VersionID)
java
import com.lockwell.sdk.nativeapi.NativeTypes.PutOptions;

var put = nativeClient.putObject("reports", "q1/summary.txt", "hello world".getBytes(),
    new PutOptions()
        .contentType("text/plain")
        .metadata("author", "finance")
        .metadata("quarter", "Q1"));
System.out.println(put.etag() + " " + put.versionId());

The same with the S3 client:

ts
const put = await s3.putObject("reports", "q1/summary.txt", Buffer.from("hello world"), {
  contentType: "text/plain",
  metadata: { author: "finance", quarter: "Q1" },
});
console.log(put.etag, put.versionId);
go
put, err := s3.PutObject(ctx, "reports", "q1/summary.txt", []byte("hello world"),
    lockwellsdk.WithContentType("text/plain"),
    lockwellsdk.WithMetadata(map[string]string{"author": "finance", "quarter": "Q1"}),
)
fmt.Println(put.ETag, put.VersionID)
java
var put = s3.putObject("reports", "q1/summary.txt", "hello world".getBytes(),
    new LockwellClient.PutOptions()
        .contentType("text/plain")
        .metadata("author", "finance")
        .metadata("quarter", "Q1"));
System.out.println(put.etag() + " " + put.versionId());

User metadata is stored alongside the object and returned on every read. On the native path it travels as X-Lockwell-Meta-* headers; on the S3 path as x-amz-meta-*. Both SDKs surface it as a plain key-value map (the prefix is stripped for you).

PutObject options

OptionNative (Go / Node / Java)S3 (Go / Node / Java)Effect
Content typeContentType / contentType / .contentTypeWithContentType / contentType / .contentTypeSets the stored media type.
User metadataMetadata / metadata / .metadataWithMetadata / metadata / .metadataArbitrary key-value pairs.
Idempotency keyIdempotencyKey / idempotencyKey / .idempotencyKeyWithIdempotencyKey / idempotencyKey / .idempotencyKeyMakes a retried write replay the stored result. See conditional writes.
ChecksumChecksums / checksums / .checksumWithChecksumAlgorithm / checksumAlgorithm / .checksumServer verifies and persists an end-to-end digest. See checksums.
Create-onlyIfNoneMatch:"*" / ifNoneMatch:'*' / .ifAbsent()WithPutIfNoneMatch("*") / ifNoneMatch: "*" / .ifNoneMatch("*")Write only when the key is absent. See conditional writes.
Overwrite-onlyIfMatch / ifMatch / .ifMatch(use native or copy)Write only when the current ETag matches. See conditional writes.
SSE-S3(always on at rest)WithServerSideEncryption / serverSideEncryption / .serverSideEncryptionRequests server-managed encryption at rest.
Object Lock at writeset with setObjectRetention after writeWithObjectLock* headers via PutOptionsApply retention or a legal hold. See object lock.

Conditional create and overwrite are native-client features. The native putObject takes If-None-Match / If-Match directly. The S3 PutObject does not, so for an existing object reach for a conditional copy. Lockwell issues presigned GET/PUT/HEAD/DELETE URLs on the S3 client; for a browser-direct upload you may also use a native signed URL.

The S3 PutObject supports create-only If-None-Match: *. Use native putObject for overwrite-only

If-Match, or a conditional copy for an existing key. :::

Encryption status and SSE-S3

The native client's encryption posture is a server deployment property. Production configs enable encryption, so native writes are stored as encrypted chunks under per-tenant data keys and the native client has no SSE option. Native GET and HEAD responses expose the X-Lockwell-Encrypted header; Java surfaces it as GetResult.encrypted() and HeadResult.encrypted(). Do not run tenant-handling workloads on an encryption-disabled deployment.

On the S3 client, WithServerSideEncryption / serverSideEncryption: true / .serverSideEncryption() asks for SSE-S3: a server-managed, per-tenant key encrypts the object at rest. The response echoes serverSideEncryption: "AES256". SSE-KMS is a non-goal. The S3 wire and all three first-party S3 clients expose typed SSE-C helpers; supply the same raw 32-byte customer key on every applicable read, write, copy, multipart-part, and completion request, and never log or persist it in application telemetry.

User metadata is a lossless, duplicate-preserving user namespace. The merged metadata contract stores it separately from internal SSE-C and Object Lock state, including for historical names such as x-amz-meta-lockwell-sse-customer-key-md5. Caller metadata cannot manufacture an internal encryption marker. This is a metadata-boundary guarantee, not permission to use the native or LNW/1 surfaces for SSE-C; genuine customer-provided keys remain an S3-only capability.

ts
const put = await s3.putObject("vault", "ledger.json", Buffer.from(data), {
  serverSideEncryption: true,
});
console.log(put.serverSideEncryption); // "AES256"
go
put, err := s3.PutObject(ctx, "vault", "ledger.json", data,
    lockwellsdk.WithServerSideEncryption())
fmt.Println(put.ServerSideEncryption) // "AES256"
java
var put = s3.putObject("vault", "ledger.json", data,
    new LockwellClient.PutOptions().serverSideEncryption());
System.out.println(put.serverSideEncryption()); // "AES256"

::::

Put an object (streaming)

Hand the SDK a reader and the body goes to the server without ever sitting whole in memory. Reach for this when a file is larger than you want to buffer.

The native client streams the raw bytes. A streaming body cannot be replayed, so the client mints a fresh token before it starts streaming and a token-expiry 401 retry is never needed. A 401 from a genuinely revoked key still surfaces.

ts
import { createReadStream } from "node:fs";
import { Readable } from "node:stream";

// A ReadableStream / async-iterable body streams without buffering:
const file = Readable.toWeb(createReadStream("./big.bin"));
await native.putObject("reports", "big.bin", file, {
  contentType: "application/octet-stream",
  contentLength: 1_048_576, // set so the server enforces the size cap + quota up front
});
go
f, _ := os.Open("./big.bin")
defer f.Close()
info, _ := f.Stat()

_, err := native.PutObject(ctx, lockwellnative.PutObjectInput{
    Bucket:        "reports",
    Key:           "big.bin",
    Body:          f,
    ContentType:   "application/octet-stream",
    ContentLength: info.Size(), // lets the server enforce the size cap + quota up front
})
java
import java.io.FileInputStream;

// An InputStream supplier streams the body:
nativeClient.putObject("reports", "big.bin",
    () -> {
        try { return new FileInputStream("./big.bin"); }
        catch (Exception e) { throw new RuntimeException(e); }
    },
    new PutOptions().contentType("application/octet-stream"));

Set contentLength (native) where you know the size up front: it lets the server enforce the per-object size cap and the tenant quota before it accepts a single byte, and it avoids chunked transfer encoding. For very large or resumable uploads, use multipart upload instead.

The S3 client wraps the stream as an aws-chunked body with an end-to-end checksum trailer, so a checksum algorithm is required on putObjectStream. An idempotency key is not supported for the S3 stream (the trailer is not known at reservation time); use the buffered PutObject when you need idempotency.

ts
import { createReadStream } from "node:fs";

// Checksum required; sent in an aws-chunked trailer:
await s3.putObjectStream("reports", "big.bin", createReadStream("./big.bin"), "CRC64NVME", {
  contentType: "application/octet-stream",
});
go
g, _ := os.Open("./big.bin")
defer g.Close()
info, _ := g.Stat()

_, err := s3.PutObjectStream(ctx, "reports", "big.bin", g, info.Size(),
    lockwellsdk.ChecksumCRC64NVME,
    lockwellsdk.WithContentType("application/octet-stream"),
)
java
import java.nio.file.Files;
import java.nio.file.Path;

try (var in = Files.newInputStream(Path.of("big.bin"))) {
    s3.putObjectStream("reports", "big.bin", in, "CRC64NVME",
        new LockwellClient.PutOptions().contentType("application/octet-stream"));
}

Get an object

GetObject streams the body. On both clients you own the body and must close it, and the result carries the object's metadata: content type, length, ETag, version id, and any checksums.

ts
// Streaming download (no whole-object buffering):
const obj = await native.getObjectStream("reports", "big.bin");
console.log(obj.contentType, obj.contentLength, obj.etag);
for await (const chunk of obj.body) {
  /* ...process chunk... */
}

// Buffered convenience (small objects):
const small = await native.getObject("reports", "q1/summary.txt");
console.log(small.body.toString("utf8"), small.metadata);
go
// Streaming; close the reader:
obj, err := native.GetObject(ctx, lockwellnative.GetObjectInput{Bucket: "reports", Key: "big.bin"})
if err != nil {
    return err
}
defer obj.Close()
fmt.Println(obj.ContentType, obj.ContentLength, obj.ETag)
io.Copy(dst, obj)
java
import com.lockwell.sdk.nativeapi.NativeTypes.GetResult;

// Streaming InputStream; try-with-resources closes it:
try (GetResult obj = nativeClient.getObject("reports", "big.bin")) {
    System.out.println(obj.contentType() + " " + obj.contentLength());
    obj.body().transferTo(out);
}

The same with the S3 client:

ts
const s3obj = await s3.getObjectStream("reports", "big.bin");
const all = await s3obj.readAll(); // or iterate s3obj.body
go
out, err := s3.GetObject(ctx, "reports", "big.bin")
if err != nil {
    if lockwellsdk.IsNotFound(err) { /* ... */ }
    return err
}
defer out.Body.Close()
io.Copy(dst, out.Body)
fmt.Println(out.ContentType, out.ContentLength, out.Metadata)
java
import java.util.Map;

// Streaming:
try (var got = s3.getObjectStream("reports", "big.bin", Map.of())) {
    got.body().transferTo(out);
}

// Buffered (small objects):
var small = s3.getObject("reports", "q1/summary.txt", Map.of());
System.out.println(new String(small.body()) + " " + small.metadata());

Byte ranges

Pass a range to fetch part of an object. The server replies 206 Partial Content with a Content-Range. The native client takes the raw HTTP Range value; the S3 client takes a typed range (start, end inclusive, end < 0 means "to end").

ts
const part = await native.getObjectStream("reports", "big.bin", { range: "bytes=0-1023" });
console.log(part.contentRange);
go
part, _ := native.GetObject(ctx, lockwellnative.GetObjectInput{
    Bucket: "reports", Key: "big.bin", Range: "bytes=0-1023",
})
defer part.Close()
fmt.Println(part.ContentRange)
java
// range + optional version:
try (var part = nativeClient.getObject("reports", "big.bin", "bytes=0-1023", null)) {
    System.out.println(part.contentRange());
}
ts
const s3part = await s3.getObjectStream("reports", "big.bin", { range: "bytes=0-1023" });
go
// Typed range, inclusive:
out, _ := s3.GetObject(ctx, "reports", "big.bin", lockwellsdk.WithRange(0, 1023))
defer out.Body.Close()
fmt.Println(out.ContentRange)
java
// Range passed in the query/header map:
try (var part = s3.getObjectStream("reports", "big.bin", Map.of("Range", "bytes=0-1023"))) { /* ... */ }

Reading a specific version

On a versioned bucket, pass a versionId to read a past version rather than the current one.

ts
const old = await native.getObject("reports", "q1/summary.txt", { versionId });
go
old, _ := native.GetObject(ctx, lockwellnative.GetObjectInput{
    Bucket: "reports", Key: "q1/summary.txt", VersionID: versionID,
})
java
// range + versionId:
try (var old = nativeClient.getObject("reports", "q1/summary.txt", null, versionId)) { /* ... */ }
ts
const s3old = await s3.getObject("reports", "q1/summary.txt", { versionId });
go
out, _ := s3.GetObject(ctx, "reports", "q1/summary.txt", lockwellsdk.WithVersionID(versionID))
java
// versionId in the query map:
var s3old = s3.getObject("reports", "q1/summary.txt", Map.of("versionId", versionId));

Response-header overrides (S3 client)

The S3 GetObject can override the headers the server returns for this one read, so you can force a download filename or a content type without rewriting the object. These are the standard S3 response-* query overrides.

ts
const dl = await s3.getObjectStream("reports", "q1.csv", {
  responseContentType: "text/csv",
});
go
out, _ := s3.GetObject(ctx, "reports", "q1.csv",
    lockwellsdk.WithResponseContentType("text/csv"),
    lockwellsdk.WithResponseContentDisposition(`attachment; filename="q1.csv"`),
)
java
var dl = s3.getObject("reports", "q1.csv", Map.of(
    "response-content-type", "text/csv",
    "response-content-disposition", "attachment; filename=\"q1.csv\""));

Reading one multipart part (S3 client)

WithPartNumber(n) on the S3 GetObject returns the byte range of a single multipart part (1-based) plus the total part count in PartsCount. This lets a downloader fetch the object part by part with the same boundaries it was uploaded with.

go
out, _ := s3.GetObject(ctx, "reports", "big.bin", lockwellsdk.WithPartNumber(1))
fmt.Println(out.PartsCount) // total parts

Head an object

HeadObject returns the same metadata as a read with no body: content type, length, ETag, version id, checksums, and encryption status. A missing object is a not-found error on both clients.

ts
import { isNativeNotFound } from "@kelphect/sdk";

try {
  const head = await native.headObject("reports", "q1/summary.txt");
  console.log(head.contentLength, head.etag, head.metadata);
} catch (err) {
  if (isNativeNotFound(err)) {
    /* not there */
  } else throw err;
}
go
info, err := native.HeadObject(ctx, lockwellnative.GetObjectInput{Bucket: "reports", Key: "q1/summary.txt"})
if err != nil {
    if lockwellnative.IsNotFound(err) { /* not there */ }
    return err
}
fmt.Println(info.ContentLength, info.ETag)
java
var head = nativeClient.headObject("reports", "q1/summary.txt");
System.out.println(head.contentLength() + " " + head.etag());
ts
import { isNotFound } from "@kelphect/sdk";

try {
  const head = await s3.headObject("reports", "q1/summary.txt");
  console.log(head.contentLength, head.etag, head.metadata);
} catch (err) {
  if (isNotFound(err)) {
    /* not there */
  } else throw err;
}
go
info, err := s3.HeadObject(ctx, "reports", "q1/summary.txt")
if err != nil {
    if lockwellsdk.IsNotFound(err) { /* not there */ }
    return err
}
fmt.Println(info.ContentLength, info.ETag, info.Metadata)
java
// Returns a GetResult with an empty body:
var s3head = s3.headObject("reports", "q1/summary.txt");
System.out.println(s3head.contentLength() + " " + s3head.etag());

Errors

Both clients raise a structured error carrying a stable code, the HTTP status, and a request id you can correlate with an audit row.

StatusMeaningNative guardS3 guard
404no such bucket/keyIsNotFound / isNativeNotFoundIsNotFound / isNotFound
401bad/expired token or revoked keyIsUnauthorized / isNativeUnauthorized(re-sign)
403scope or policy denialIsForbidden / isNativeForbiddenAPIError code
409already existsIsAlreadyExists / isNativeConflictAPIError code
412precondition not metIsPreconditionFailed / isNativePreconditionFailedAPIError code
507tenant storage quota exceededIsQuotaExceeded / isNativeQuotaExceededAPIError code

In Java, catch NativeException (native) or ApiException (S3) and branch on statusCode() / code(). Full handling, including the retry policy, is on Errors & retries.

Next steps

Source-available under PolyForm Noncommercial 1.0.0; commercial use requires a written grant. License