# Native data-plane API

The native JSON data-plane API lives at `/api/v1/` on the public listener: the same host:port as the S3 API, a different
path prefix. No SigV4 signing, no XML. JSON in, JSON out.

It is a new transport and auth layer over the same domain pipeline the S3 handler uses (the same object coordinator,
encryption, dedup, checksums, versioning, object-lock, scope enforcement, quota, and audit). A native write is
encrypted-at-rest, deduped, quota-checked, and retention-gated exactly like an S3 write.

This page documents the legacy HTTP/JSON surface. Existing integrations can
use [Go's explicit compatibility client](/sdks/go#lockwellnative-lockwell-sdk-go-legacy-httpjson-compatibility-client),
[Node](/sdks/node#legacynativeclient-httpjson-migration-client), or
[Java](/sdks/java#lockwellnativeclient-the-native-client). New Go applications
should use the direct binary [LNW/1 client](/sdks/go#lockwellwire-lockwell-sdk-go-the-lnw1-native-wire-client).

## Authentication

The native API does not use SigV4 on data calls. A caller exchanges its existing S3 access key for a short-lived native
bearer token.

### Mint a token: `POST /api/v1/auth/token`

OAuth client-credentials style. Present your S3 access-key id plus secret either as HTTP Basic
(`Authorization: Basic base64(accessKeyId:secretKey)`) or a JSON body `{"accessKeyId": "...", "secretKey": "..."}`. On
success you get a short-lived bearer token:

```text
lwtk_<base64url(payloadJSON)>.<base64url(HMAC-SHA256(payloadB64, signingKey))>
```

- TTL is `security.native_api_token_ttl` (default `1h`). Keep it short: a leaked token is replayable until it expires.
- The token is stateless and signed (HMAC under a per-deployment key derived from the at-rest master key), so the hot
  path verifies it with no per-request DB lookup for the token itself.
- Revoking or expiring the underlying access key (or disabling the tenant) invalidates outstanding tokens promptly. The
  verify path re-checks revocation on every request, so a token can never outlive or out-scope the key it points at.
- This endpoint accepts the secret once, so it must run behind TLS. It is rate-limited per access-key id and audited
  (success and failure).

Send the token on every subsequent call as `Authorization: Bearer <token>`. The SDKs cache it until shortly before
expiry, refresh transparently, and re-mint once on a 401.

::: warning The bearer token is replayable until it expires. Keep its TTL short and always mint it over TLS, since
`auth/token` accepts the secret in the clear. :::

### Signed-URL auth (no bearer token)

`GET|PUT /api/v1/signed/{bucket}/{key...}?token=…` is authorized solely by the query `token`: an HMAC-signed, expiring
URL token (`lwurl_…`) minted by `POST /api/v1/sign-url`, under a separate per-deployment key.

At access time the handler re-checks the underlying key's revocation, re-runs the per-operation scope and bucket-policy
gates against the current scope, and enforces that the request method and bucket/key match the signed token. A tampered,
expired, wrong-method, wrong-resource, revoked, or scope-exceeding URL is rejected (401/403). See
[signed URLs](/guide/signed-urls).

Browser preflight for signed URLs uses `OPTIONS /api/v1/signed/{bucket}/{key...}?token=…`. The token still has to
authorize the requested method and resource, and the bucket's stored CORS rules still have to match the origin and
requested headers. CORS never grants object access by itself.

## Routes

All routes except `/healthz`, `/openapi.json`, and `/auth/token` require a valid bearer token. The tenant is taken from
the signed token, never the request path, so cross-tenant access is structurally impossible (another tenant's bucket
returns `404`, never a leak).

### Buckets

| Method + path                             | Purpose                                                    |
| ----------------------------------------- | ---------------------------------------------------------- |
| `GET /buckets`                            | list the tenant's buckets                                  |
| `POST /buckets`                           | create a private bucket (versioning / object-lock options) |
| `GET /buckets/{bucket}`                   | get a bucket                                               |
| `DELETE /buckets/{bucket}`                | delete an empty bucket                                     |
| `GET\|PUT /buckets/{bucket}/versioning`   | read / set versioning state                                |
| `GET\|PUT\|DELETE /buckets/{bucket}/cors` | read / set / clear browser CORS rules                      |

### Objects

| Method + path                                 | Purpose                                                         |
| --------------------------------------------- | --------------------------------------------------------------- |
| `PUT /buckets/{bucket}/objects/{key...}`      | streaming upload                                                |
| `GET /buckets/{bucket}/objects/{key...}`      | streaming download (`Range` supported)                          |
| `HEAD /buckets/{bucket}/objects/{key...}`     | metadata only                                                   |
| `DELETE /buckets/{bucket}/objects/{key...}`   | delete (delete marker in a versioned bucket)                    |
| `GET /buckets/{bucket}/objects`               | list (`prefix` / `delimiter` / `maxKeys` / `continuationToken`) |
| `POST /buckets/{bucket}/objects:batchDelete`  | batch delete, per-key results                                   |
| `POST /buckets/{bucket}/object-copy/{key...}` | same-tenant server-side copy                                    |

The upload supports several controls:

- An `Idempotency-Key` header, mapped onto the same idempotency store the S3 `X-Lockwell-Idempotency-Key` path uses.
- Native conditional writes (`If-Match` / `If-None-Match`).
- Optional server-side checksum verification (`X-Lockwell-Checksum-<alg>` for CRC32, CRC32C, CRC64NVME, SHA1, SHA256). A
  bad digest is rejected before any bytes are committed.

The copy source is the JSON body
(`{sourceBucket, sourceKey, sourceVersionId?, metadataDirective?, …, requireAbsent?, requireMatchEtag?}`). Cross-tenant
copy is impossible, since the source resolves under the token's tenant.

### Versions, tags, and per-object WORM

| Method + path                                             | Purpose                                                                       |
| --------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `GET /buckets/{bucket}/versions`                          | list versions and delete markers (`prefix` / `keyMarker` / `versionIdMarker`) |
| `GET\|PUT\|DELETE /buckets/{bucket}/object-tags/{key...}` | get / replace / clear the JSON tag set                                        |
| `GET\|PUT /buckets/{bucket}/object-retention/{key...}`    | per-object retention (`{mode: GOVERNANCE\|COMPLIANCE, retainUntil}`)          |
| `GET\|PUT /buckets/{bucket}/object-legal-hold/{key...}`   | legal-hold status (`{status: ON\|OFF}`)                                       |

These four sub-resources use a distinct path prefix (not a suffix on `/objects/{key...}`) so the key wildcard preserves
embedded slashes.

::: info Retention can be extended but never shortened. There is no governance bypass on the native path, a deliberate
non-goal that stays closed. :::

### Multipart

| Method + path                                                            | Purpose                  |
| ------------------------------------------------------------------------ | ------------------------ |
| `POST /buckets/{bucket}/multipart/{key...}`                              | create an upload         |
| `PUT /buckets/{bucket}/multipart/{uploadId}/parts/{partNumber}/{key...}` | upload a part            |
| `GET /buckets/{bucket}/multipart/{uploadId}/parts/{key...}`              | list parts               |
| `POST /buckets/{bucket}/multipart/{uploadId}/complete/{key...}`          | complete                 |
| `DELETE /buckets/{bucket}/multipart/{uploadId}/{key...}`                 | abort                    |
| `GET /buckets/{bucket}/multipart`                                        | list in-progress uploads |

### Signed URLs

- `POST /sign-url` (bearer): mint a method-, resource-, and scope-bounded signed URL.
- `GET|PUT /signed/{bucket}/{key...}?token=...` (no bearer): use a signed URL.

`method` is `GET` (download) or `PUT` (upload): the native API supports signed write URLs. The TTL is clamped to
`security.max_presign_ttl`, and the URL can never exceed the minting key's live scope (a read-only key minting a PUT URL
is `403`).

`POST /sign-url` accepts

```text
{method, bucket, key|keyPrefix, ttlSeconds?, contentType?, contentLengthMax?, checksumAlg?, checksumVal?,
 idempotencyKey?, responseContentType?, responseContentDisposition?, reason?}
```

PUT constraints (`contentType`, `contentLengthMax`, `checksum*`, `idempotencyKey`, `keyPrefix`), GET response overrides
(`responseContentType`, `responseContentDisposition`), and the optional audit `reason` are HMAC-covered and enforced or
recorded at dispatch; cross-method fields are rejected.

### Bucket CORS

| Method + path                       | Purpose                                  |
| ----------------------------------- | ---------------------------------------- |
| `GET /buckets/{bucket}/cors`        | get browser CORS rules                   |
| `PUT /buckets/{bucket}/cors`        | set browser CORS rules                   |
| `DELETE /buckets/{bucket}/cors`     | clear browser CORS rules                 |
| `OPTIONS /signed/{bucket}/{key...}` | signed-URL browser preflight (no bearer) |

The native shape is camelCase JSON:

```json
{
  "rules": [
    {
      "id": "browser-direct",
      "allowedOrigins": ["https://app.example.com"],
      "allowedMethods": ["GET", "HEAD", "PUT"],
      "allowedHeaders": ["content-type"],
      "exposeHeaders": ["ETag", "X-Lockwell-Version-Id"],
      "maxAgeSeconds": 600
    }
  ]
}
```

`PUT /buckets/{bucket}/cors` validates through the same CORS validator and bucket-config store as the S3 `?cors` XML
route. It is an admin-scoped bucket operation; ordinary data keys cannot change browser policy. Matching authenticated
or signed URL object responses emit `Access-Control-*` headers, but the normal bearer/signed-token, scope,
bucket-policy, quota, object-lock, and audit gates still run.

### Bucket event notifications

| Method + path                            | Purpose                                    |
| ---------------------------------------- | ------------------------------------------ |
| `GET /buckets/{bucket}/notifications`    | get the configuration                      |
| `PUT /buckets/{bucket}/notifications`    | set it (an empty `configs` list clears it) |
| `DELETE /buckets/{bucket}/notifications` | clear it                                   |

Only the webhook target is supported. An `sns`/`sqs`/`lambda` target is `501`. A new config ID returns the
server-generated `signingSecret` exactly once; it is sealed at rest and omitted from GET and same-ID updates, which
carry only `hasSecret`. See [webhooks](/guide/webhooks).

### Native fields surfaced

Object reads surface Lockwell-native details S3 XML hides: native checksums (CRC32/CRC32C/CRC64NVME/SHA1/SHA256),
encryption and compression status, storage class, version id, retention and legal-hold, and content length. They come
back as JSON fields and `X-Lockwell-*` response headers.

## Error shape (`problem+json`)

Failures return an `application/problem+json` body:

```json
{
  "code": "not_found",
  "message": "bucket \"reports\" not found",
  "status": 404,
  "requestId": "req_01HXY…"
}
```

`code` is a stable machine-readable string. `requestId` correlates the failure with its server-side audit row (also
echoed in the `X-Request-Id` header). Status mapping:

| Status | Meaning                                                       |
| ------ | ------------------------------------------------------------- |
| `401`  | missing/invalid/expired bearer token, or a revoked access key |
| `403`  | access-key scope or bucket-policy denial                      |
| `404`  | bucket/key not found                                          |
| `409`  | bucket already exists                                         |
| `412`  | conditional-write or copy-source precondition not met         |
| `501`  | unsupported notification target (SNS/SQS/Lambda)              |
| `507`  | tenant storage quota exceeded                                 |

## Security parity

The native API is private by default and never served anonymously. Every control the S3 path enforces is enforced
identically here, through the same domain services:

- Tenant isolation (from the token, never the path).
- Per-operation `read`/`write`/`delete`/`admin` plus bucket/prefix scope enforcement.
- Explicit-deny bucket policies.
- Encryption, dedup, quota, object-lock, retention, and legal-hold.
- Audit on every request, including denials.

It does not relax any S3 control or enable any public/anonymous access. See [tenancy and auth](/guide/tenancy-and-auth).

## OpenAPI

The full machine-readable contract is an OpenAPI 3 document, available two ways:

- Served live at `GET /api/v1/openapi.json` (unauthenticated, since you need the contract before you hold a token).
- Committed at
  [`internal/nativeapi/openapi.json`](https://github.com/RusticStack/lockwell/blob/main/internal/nativeapi/openapi.json)
  (the canonical source; the served document is the same bytes).

Each operation carries a unique `operationId` (`putObject`, `listObjects`, `signURL`, `setBucketNotifications`, …), so
you can generate a client in any language with `openapi-generator` (`make codegen` produces TypeScript/Python/Go clients
for both the native and admin specs). Prefer the first-party SDKs for Go, Node, and Java; codegen is the path for every
other language.

If the deployment selects the binary transport, read the separate [Native Wire reference](/reference/native-wire) and
[`/native-wire-v1.json`](/native-wire-v1.json). LNW/1 is not an alternate JSON encoding: it has its own 40-byte frame,
TLV/document rules, handshake, capability mask, and stable error registry. The HTTP OpenAPI document does not describe
that listener.

## Interactive reference

Every native operation below is generated from that OpenAPI document, so it always matches the shipped server. Expand an
operation for its path and query parameters, request and response schemas, and copy-paste `curl` / `fetch` samples. The
example host is a placeholder; swap in your own deployment.

<OASpec />
