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.
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,
});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"),
},
)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.
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);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)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:
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);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)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
| Option | Native (Go / Node / Java) | S3 (Go / Node / Java) | Effect |
|---|---|---|---|
| Content type | ContentType / contentType / .contentType | WithContentType / contentType / .contentType | Sets the stored media type. |
| User metadata | Metadata / metadata / .metadata | WithMetadata / metadata / .metadata | Arbitrary key-value pairs. |
| Idempotency key | IdempotencyKey / idempotencyKey / .idempotencyKey | WithIdempotencyKey / idempotencyKey / .idempotencyKey | Makes a retried write replay the stored result. See conditional writes. |
| Checksum | Checksums / checksums / .checksum | WithChecksumAlgorithm / checksumAlgorithm / .checksum | Server verifies and persists an end-to-end digest. See checksums. |
| Create-only | IfNoneMatch:"*" / ifNoneMatch:'*' / .ifAbsent() | WithPutIfNoneMatch("*") / ifNoneMatch: "*" / .ifNoneMatch("*") | Write only when the key is absent. See conditional writes. |
| Overwrite-only | IfMatch / ifMatch / .ifMatch | (use native or copy) | Write only when the current ETag matches. See conditional writes. |
| SSE-S3 | (always on at rest) | WithServerSideEncryption / serverSideEncryption / .serverSideEncryption | Requests server-managed encryption at rest. |
| Object Lock at write | set with setObjectRetention after write | WithObjectLock* headers via PutOptions | Apply 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.
const put = await s3.putObject("vault", "ledger.json", Buffer.from(data), {
serverSideEncryption: true,
});
console.log(put.serverSideEncryption); // "AES256"put, err := s3.PutObject(ctx, "vault", "ledger.json", data,
lockwellsdk.WithServerSideEncryption())
fmt.Println(put.ServerSideEncryption) // "AES256"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.
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
});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
})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.
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",
});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"),
)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.
// 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);// 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)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:
const s3obj = await s3.getObjectStream("reports", "big.bin");
const all = await s3obj.readAll(); // or iterate s3obj.bodyout, 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)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").
const part = await native.getObjectStream("reports", "big.bin", { range: "bytes=0-1023" });
console.log(part.contentRange);part, _ := native.GetObject(ctx, lockwellnative.GetObjectInput{
Bucket: "reports", Key: "big.bin", Range: "bytes=0-1023",
})
defer part.Close()
fmt.Println(part.ContentRange)// range + optional version:
try (var part = nativeClient.getObject("reports", "big.bin", "bytes=0-1023", null)) {
System.out.println(part.contentRange());
}const s3part = await s3.getObjectStream("reports", "big.bin", { range: "bytes=0-1023" });// Typed range, inclusive:
out, _ := s3.GetObject(ctx, "reports", "big.bin", lockwellsdk.WithRange(0, 1023))
defer out.Body.Close()
fmt.Println(out.ContentRange)// 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.
const old = await native.getObject("reports", "q1/summary.txt", { versionId });old, _ := native.GetObject(ctx, lockwellnative.GetObjectInput{
Bucket: "reports", Key: "q1/summary.txt", VersionID: versionID,
})// range + versionId:
try (var old = nativeClient.getObject("reports", "q1/summary.txt", null, versionId)) { /* ... */ }const s3old = await s3.getObject("reports", "q1/summary.txt", { versionId });out, _ := s3.GetObject(ctx, "reports", "q1/summary.txt", lockwellsdk.WithVersionID(versionID))// 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.
const dl = await s3.getObjectStream("reports", "q1.csv", {
responseContentType: "text/csv",
});out, _ := s3.GetObject(ctx, "reports", "q1.csv",
lockwellsdk.WithResponseContentType("text/csv"),
lockwellsdk.WithResponseContentDisposition(`attachment; filename="q1.csv"`),
)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.
out, _ := s3.GetObject(ctx, "reports", "big.bin", lockwellsdk.WithPartNumber(1))
fmt.Println(out.PartsCount) // total partsHead 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.
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;
}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)var head = nativeClient.headObject("reports", "q1/summary.txt");
System.out.println(head.contentLength() + " " + head.etag());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;
}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)// 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.
| Status | Meaning | Native guard | S3 guard |
|---|---|---|---|
| 404 | no such bucket/key | IsNotFound / isNativeNotFound | IsNotFound / isNotFound |
| 401 | bad/expired token or revoked key | IsUnauthorized / isNativeUnauthorized | (re-sign) |
| 403 | scope or policy denial | IsForbidden / isNativeForbidden | APIError code |
| 409 | already exists | IsAlreadyExists / isNativeConflict | APIError code |
| 412 | precondition not met | IsPreconditionFailed / isNativePreconditionFailed | APIError code |
| 507 | tenant storage quota exceeded | IsQuotaExceeded / isNativeQuotaExceeded | APIError 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
- Listing & pagination: find the objects you stored.
- Copying objects: server-side copy, conditionals, large-object part copy.
- Deleting objects: single, batch, and versioned deletes.
- Conditional writes: create-only, overwrite-only, idempotency.
- Multipart uploads: resumable uploads for very large objects.
- Signed URLs: hand a browser a direct upload or download URL.