Skip to content

Java SDK

The first-party Lockwell SDK for the JVM is a native, encrypted alternative to the AWS S3 SDK for Java 21+ and Spring Boot services. It has zero runtime dependencies (JDK only: java.net.http, javax.crypto, java.util.zip, java.security).

It shares the language-neutral SigV4 signing fixtures with the Go and Node SDKs, so all three sign byte-for-byte identically.

Install

xml
<dependency>
  <groupId>com.lockwell</groupId>
  <artifactId>lockwell-sdk</artifactId>
  <version>0.1.0</version>
</dependency>

Every client uses a fluent builder, is thread-safe, and never renders its secret (Credentials.toString() redacts it). The surfaces live in their own packages:

PackageClientSurface
com.lockwell.sdkLockwellClient, LockwellAsyncClientS3 (SigV4 + XML)
com.lockwell.sdk.nativeapiLockwellNativeClientnative JSON (/api/v1/)
com.lockwell.sdk.adminLockwellAdminClientJSON admin API
com.lockwell.sdk.kitLockwellKitthe app kit
com.lockwell.sdk.springauto-configurationSpring Boot starter

LockwellClient (the S3 client)

java
import com.lockwell.sdk.*;
import java.util.Map;

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

// SSE-S3, a server-verified CRC64NVME checksum, and idempotent retry, in one call.
var put = client.putObject("reports", "q1.txt", "hello".getBytes(),
    new LockwellClient.PutOptions()
        .contentType("text/plain")
        .serverSideEncryption()
        .checksum("CRC64NVME")
        .idempotencyKey("q1-2026"));

var got = client.getObject("reports", "q1.txt", Map.of());
System.out.println(new String(got.body()));

// Stream a large file (checksum sent in an aws-chunked trailer; no buffering).
try (var in = java.nio.file.Files.newInputStream(java.nio.file.Path.of("big.bin"))) {
    client.putObjectStream("reports", "big.bin", in, "CRC64NVME", null);
}

// Presigned GET (Lockwell never issues presigned writes on the S3 client).
String url = client.presignGetObject("reports", "q1.txt", 900);

Builder

LockwellClient.builder() accepts .endpoint(...), .credentials(...), .httpClient(HttpClient), .userAgent(String), .clock(Supplier<Instant>), and .retryPolicy(RetryPolicy). The retry policy mirrors the Go SDK; omit it for the default (safe and idempotent requests retried with backoff and jitter).

Buckets

MethodSignature
createBucketcreateBucket(String bucket) / createBucket(bucket, String objectLockMode, int objectLockDays)
headBucketheadBucket(String bucket)
deleteBucketdeleteBucket(String bucket)
putBucketVersioningputBucketVersioning(bucket, String status) ("Enabled"/"Suspended")
getBucketVersioningString getBucketVersioning(bucket)

Objects

MethodSignature
putObjectPutResult putObject(bucket, key, byte[] body, PutOptions opts)
putObjectStreamPutResult putObjectStream(bucket, key, InputStream source, String checksumAlgorithm, PutOptions opts)
getObjectGetResult getObject(bucket, key, Map<String,String> queryAndRange)
getObjectStreamStreamingGetResult getObjectStream(bucket, key, Map<String,String> queryAndRange)
headObjectGetResult headObject(bucket, key)
deleteObjectdeleteObject(bucket, key)
deleteObjectsDeleteObjectsResult deleteObjects(bucket, List<ObjectIdentifier> objects, boolean quiet)
copyObjectCopyResult copyObject(srcBucket, srcKey, srcVersionId, dstBucket, dstKey, ...)

PutOptions is a fluent builder: .contentType(v), .metadata(k, v), .idempotencyKey(v), .serverSideEncryption(), .checksum(alg), .objectLock(mode, retainUntilRfc3339), .legalHold(boolean). The queryAndRange map on getObject/getObjectStream carries range, partNumber, versionId, and the response-* overrides; pass Map.of() for a plain read.

StreamingGetResult is AutoCloseable and exposes the InputStream body() plus a readAllBytes() convenience; close it (try-with-resources) to release the connection.

Listing and paginators

MethodSignature
listObjectsV2ListResult listObjectsV2(bucket, String prefix, Integer maxKeys, String continuationToken)
listObjectsListV1Result listObjects(bucket, prefix, marker, Integer maxKeys, delimiter)
listObjectVersionsListVersionsResult listObjectVersions(bucket, ListVersionsOptions opts)
listMultipartUploadsListMultipartUploadsResult listMultipartUploads(bucket, ListUploadsOptions opts)
listPartsListPartsResult listParts(bucket, key, uploadId, ListPartsOptions opts)

The marker-paged lists each have a Paginator<P> that threads markers for you. A paginator is an Iterable<P> of pages: drive it with hasMorePages() / nextPage(), a for-each, or toList().

java
var p = client.listObjectVersionsPaginator("reports",
    new LockwellClient.ListVersionsOptions().prefix("logs/"));
while (p.hasMorePages()) {
    var page = p.nextPage();
    page.versions().forEach(v -> System.out.println(v.key() + " " + v.versionId()));
}

The constructors are listObjectVersionsPaginator, listMultipartUploadsPaginator, and listPartsPaginator. The ListVersionsOptions / ListUploadsOptions / ListPartsOptions builders carry prefix, delimiter, the relevant markers, and the page cap.

Multipart

createMultipartUpload(bucket, key, contentType) returns a CreateMpuResult; the checksum-aware overload createMultipartUpload(bucket, key, contentType, checksumAlgorithm) returns a CreateMpuChecksumResult.

Then uploadPart(...), uploadPartCopy(...), completeMultipartUpload(bucket, key, uploadId, parts), and abortMultipartUpload(bucket, key, uploadId). The checksum-aware uploadPart overload sends a verified per-part digest and folds it into the composite checksum on complete.

Tagging, Object Lock reads, presign

putObjectTagging(bucket, key, Map<String,String> tags), getObjectTagging(bucket, key), deleteObjectTagging(bucket, key), getObjectRetention(bucket, key) (a RetentionResult), getObjectLegalHold(bucket, key) (a boolean), and presignGetObject(bucket, key, long expiresSeconds).

Retention and legal hold are set on the write through PutOptions.objectLock(...) and .legalHold(...). Presign is GET-only; for a signed write URL use the native client's signUrl.

Errors and the async client

Server errors throw ApiException with code(), statusCode(), requestId(), and isNotFound(). A LockwellAsyncClient wraps the same operations with CompletableFuture results, built the same way via its own builder().

LockwellNativeClient (the native client)

The native JSON data plane at /api/v1/. No SigV4, no XML.

Configured with an access-key id plus secret, it auto-manages the bearer token: mints a lwtk_… token on first use, caches it until shortly before expiry, refreshes transparently, and re-mints once on a 401. Token management is thread-safe (single-flight refresh under a lock), so concurrent callers share one in-flight mint.

java
import com.lockwell.sdk.nativeapi.*;
import com.lockwell.sdk.nativeapi.NativeTypes.*;

LockwellNativeClient client = LockwellNativeClient.builder()
    .endpoint("https://objects.example.com")        // public S3 port; native API at /api/v1
    .accessKeyId(System.getenv("LOCKWELL_ACCESS_KEY_ID"))
    .secretKey(System.getenv("LOCKWELL_SECRET_KEY"))
    .build();

client.createBucket("reports");

// An idempotent PUT needs a body-integrity signal: pass an expected checksum.
PutResult put = client.putObject("reports", "q1.txt", "hello".getBytes(),
    new PutOptions().contentType("text/plain").idempotencyKey("q1-2026").checksum("sha256", sha));

// Streaming GET (InputStream body, no whole-object buffering). Caller closes it.
try (GetResult got = client.getObject("reports", "q1.txt")) {
    got.body().transferTo(System.out);
}

// Streaming PUT from an InputStream supplier.
client.putObject("reports", "big.bin", () -> Files.newInputStream(path),
    new PutOptions().contentType("application/octet-stream"));

Buckets and objects

MethodSignature
listBucketsList<Bucket> listBuckets()
createBucketcreateBucket(bucket) / createBucket(bucket, CreateBucketOptions opts)
getBucket / deleteBucketBucket getBucket(bucket) / void deleteBucket(bucket)
getBucketVersioning / setBucketVersioningVersioningState (setBucketVersioning(bucket, status))
putObjectputObject(bucket, key, byte[] body[, PutOptions]) or putObject(bucket, key, Supplier<InputStream> body, PutOptions) (streaming)
getObjectgetObject(bucket, key) / getObject(bucket, key, String range, String versionId)
headObjectheadObject(bucket, key) / headObject(bucket, key, String versionId)
deleteObjectdeleteObject(bucket, key) / deleteObject(bucket, key, String versionId)
listObjectsListObjectsResult listObjects(bucket, ListObjectsOptions opts)
batchDeleteObjectsBatchDeleteResult batchDeleteObjects(bucket, List<ObjectIdentifier> objects)
copyObjectCopyResult copyObject(destBucket, destKey, sourceBucket, sourceKey, CopyOptions opts)

PutOptions here is a native fluent builder: .contentType(v), .idempotencyKey(v), .ifMatch(v) / .ifNoneMatch(v) for conditional writes, .checksum(alg, value), and metadata. The streaming overload takes a Supplier<? extends InputStream> so the body is opened lazily.

getObjectTags / setObjectTags, getObjectRetention / setObjectRetention(bucket, key, mode, retainUntil), getObjectLegalHold / setObjectLegalHold(bucket, key, status), listObjectVersions(bucket, ListVersionsOptions), and the multipart set (createMultipartUpload, uploadPart, listParts, completeMultipartUpload, abortMultipartUpload, listMultipartUploads).

Signed URLs (GET and PUT)

The native API supports signed write URLs (unlike the S3 presigner). signUrl returns a String usable without a bearer token:

java
String download = client.signUrl("GET", "reports", "q1.txt", 900);
String upload   = client.signUrl("PUT", "reports", "incoming.bin", 600);

See signed URLs.

Bucket notifications

Webhook-only delivery; the per-config signing secret is generated server-side and is never returned (the view reports only hasSecret()).

java
import java.util.List;

NotificationConfig cfg = new NotificationConfig("https://app.example.com/hook",
        List.of("s3:ObjectCreated:*", "s3:ObjectRemoved:*"))
    .filter("prefix", "incoming/");
client.setBucketNotification("reports", cfg);

NotificationConfiguration current = client.getBucketNotification("reports");
boolean signed = current.configs().get(0).hasSecret(); // true; secret itself never returned

client.deleteBucketNotification("reports"); // clear

setBucketNotification also takes a List<NotificationConfig> overload for multiple targets.

Errors

Native errors throw NativeException with code(), statusCode(), requestId() and the predicates isUnauthorized() (401), isForbidden() (403), isNotFound() (404), isConflict() (409), isPreconditionFailed() (412), isQuotaExceeded() (507).

LockwellAdminClient (the admin client)

Targets /admin/api/v1/ on the admin listener (never the public S3 port). It authenticates by an admin API bearer token (lockwell admin-token create).

java
import com.lockwell.sdk.admin.*;
import com.lockwell.sdk.admin.AdminTypes.*;

LockwellAdminClient admin = LockwellAdminClient.builder()
    .endpoint("https://admin.example.com")          // admin listener, NOT the S3 port
    .token(System.getenv("LOCKWELL_ADMIN_TOKEN"))   // Authorization: Bearer <token>
    .build();

for (Tenant t : admin.listTenants()) System.out.println(t.id());
Tenant acme = admin.createTenant("acme", "Acme Inc");

// Every mutation has a *DryRun twin that sends ?dryRun=true and returns the plan.
DryRunPlan plan = admin.deleteTenantDryRun("acme", "offboarding", "acme");

// The secret is returned exactly once on create/rotate. Store it immediately.
NewKey key = admin.createKey("acme", new CreateKeyOptions("sa-1", "read,write,delete", null));
System.out.println(key.secretKey());
MethodDry-run twin
listTenants() / getTenant(id)(read-only)
createTenant(id, name)createTenantDryRun(id, name)
disableTenant(id, reason)disableTenantDryRun(id, reason)
deleteTenant(id, reason, confirm)deleteTenantDryRun(id, reason, confirm)
getQuota(id) / setQuota(id, bytes) / clearQuota(id)setQuotaDryRun, clearQuotaDryRun
getUsage(id)(read-only)
listAccounts(id) / createAccount(id, name)createAccountDryRun(id, name)
listKeys(id) (never returns secrets)(read-only)
createKey(id, CreateKeyOptions)createKeyDryRun(id, opts)
rotateKey(id, keyId, RotateKeyOptions)rotateKeyDryRun(id, keyId, opts)
revokeKey(id, keyId, reason)revokeKeyDryRun(id, keyId, reason)
queryAudit(AuditQuery q)(read-only)

CreateKeyOptions(accountId, scopes, expiresAt) follows the scope grammar (verb list read,write,delete,admin, or resource form op=read,write,delete:bucket=reports:prefix=in/). The secret on a created or rotated key is on NewKey.secretKey() and is shown exactly once.

Errors throw AdminException with isUnauthorized(), isForbidden(), isNotFound(), isRetentionBlocked() (the 412 retention/legal-hold gate). See the Admin API reference.

NewKey.secretKey() is readable exactly once, on create or rotate. Persist it immediately; it is never

recoverable afterward. :::

LockwellKit (the app kit)

Composes the admin and native clients with near-zero glue. The per-tenant native-client cache is a ConcurrentHashMap, so it is thread-safe.

java
import com.lockwell.sdk.kit.*;
import com.lockwell.sdk.kit.KitTypes.*;
import com.lockwell.sdk.nativeapi.LockwellNativeClient;
import java.time.Duration;

LockwellKit kit = LockwellKit.builder()
    .adminEndpoint("https://admin.example.com")        // admin listener
    .adminToken(System.getenv("LOCKWELL_ADMIN_TOKEN"))
    .nativeEndpoint("https://objects.example.com")     // public S3 port; native API at /api/v1
    .build();

// Provision: ensure the tenant exists (idempotent-ish), mint a fresh read/write/delete
// key (optionally bucket-scoped), optionally create a default bucket. Secret returned ONCE.
ProvisionResult p = kit.provisionTenant("acme",
    new LockwellKit.ProvisionOptions().defaultBucket("uploads"));
store(p.credentials());                                 // {accessKeyId, secretKey}

// A per-tenant native client (cached per (tenant, creds); auto-manages the bearer token).
LockwellNativeClient acme = kit.clientForTenant("acme", p.credentials());
acme.putObject("uploads", "hello.txt", "hi".getBytes());

// Browser direct upload/download. Signed URLs the browser uses with NO bearer token.
BrowserSignedUrl up = kit.signedUploadUrl(p.credentials(), "uploads", "in.bin",
    Duration.ofMinutes(10), "application/octet-stream");
BrowserSignedUrl dl = kit.signedDownloadUrl(p.credentials(), "uploads", "hello.txt",
    Duration.ofMinutes(15));

// Verify an incoming webhook (constant-time HMAC-SHA256).
boolean ok = LockwellKit.verifyWebhook(requestBodyBytes,
    request.getHeader("X-Lockwell-Signature"), mySecret);

ProvisionOptions is a fluent builder: .tenantName(name), .bucketScope(bucket), .defaultBucket(bucket), .keyExpiresAt(rfc3339). The default bucket is created with a transient admin-on-that-bucket key that is revoked immediately, so the long-lived tenant key stays admin-free.

signedUploadUrl and signedDownloadUrl accept either TenantCredentials or a LockwellNativeClient. Reach the underlying admin client via kit.admin() for operations the kit does not wrap. See the app kit guide.

Spring Boot starter

The com.lockwell.sdk.spring package auto-configures a LockwellClient and a LockwellAsyncClient bean when the lockwell.* properties are present. Spring is an optional dependency, so non-Spring consumers never pull it in and the core SDK keeps its zero-runtime-dependency profile.

yaml
# application.yml
lockwell:
  endpoint: https://objects.example.com
  access-key-id: ${LOCKWELL_ACCESS_KEY_ID}
  secret-key: ${LOCKWELL_SECRET_KEY}
  # user-agent: my-service/1.0   # optional override
java
@Service
public class ReportService {
  private final LockwellClient lockwell;
  public ReportService(LockwellClient lockwell) { this.lockwell = lockwell; }
  // ...
}

The supported properties are endpoint, access-key-id, secret-key, and the optional user-agent. Both beans are @ConditionalOnMissingBean, so an application-defined client always wins.

Not supported (by design)

No public-bucket or anonymous access without a token or signed URL, no SSE-KMS/SSE-C, no IAM/STS/bucket-policy, no website/tiering/Select/Object-Lambda, and webhook is the only notification target (SNS/SQS/Lambda are 501 server-side). The S3 LockwellClient additionally exposes no presigned PUT/HEAD/DELETE; presigning there is GET-only.

Released under the Apache-2.0 License. License