> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bota.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Encrypted Upload v2

> Capability-gated batch ciphertext staging, verification, publication, and completion receipt

Encrypted Upload v2 uploads the device's existing `bota_enc_v2` recording
ciphertext unchanged. Apps relay the authorization, ciphertext, manifest, and
completion receipt as opaque bytes. The backend verifies and decrypts the
ciphertext in an isolated worker before publishing the final OGG recording.

<Warning>
  This API is batch-only. It does not define encrypted streaming behavior, and
  calling it does not enable v2 on any SDK or device. Use it only after explicit
  capability and backend-policy negotiation.
</Warning>

## Authentication

React Native applications can use the SDK's managed native backend adapter
in App SDK `2.0.0-beta.11` through `createEncryptedUploadV2Backend`.
It owns session persistence, manifest
replay, verification polling and receipt delivery. Configure your authenticated
backend URL, fresh app-token callback, exact identity and cancellation scope;
invoke sync again after reopening/reconnecting. See
[Client SDKs](/api-reference/client-sdks#managed-encrypted-v2-integration).
The existing beta.10 package does not contain this helper. A backend proxy
exposing only legacy `upload-complete` must expose the v2 routes; upgrading
the package alone does not migrate a legacy integration.

All operations accept a secret API key, a restricted key with
`recordings:write`, or the device token bound to the exact device and end user.
Every recording, device, and session lookup is scoped to the authenticated
project.

The first-party Cognito application has an equivalent dashboard bridge under
`/dashboard/projects/{projectId}/recordings/{recordingId}/encrypted-upload-v2/sessions`.
It checks organization ownership of the project and uses the same scoped
session services. Customer mobile apps must not embed secret API keys; keep
their session provider on their own authenticated backend.

## Lifecycle

1. Create a session and receive a signed 408-byte authorization plus a private,
   checksum-bound staging target.
2. Before delivering that authorization to the device, establish a fresh
   upload-only credential context using the device's independent context nonce
   and opaque proof. This does not change pairing.
3. `PUT` the device ciphertext directly to the returned URL with every returned
   header exactly as supplied.
4. Submit the exact authenticated 580-byte manifest.
5. Poll session status while the worker verifies, decrypts, validates OGG, and
   publishes the final recording.
6. Reacquire fresh context and relay the completion receipt. Delete the device
   recording only after status is `published` and the device has
   verified the signed 336-byte completion receipt for the exact recording and
   generation.

An S3 PUT response, `202` manifest response, or `processing` state is never
device-deletion authorization.

## Establish upload context

<Warning>
  Upload context is an additive source preview. It requires matching firmware,
  standalone React Native SDK, backend migration, and dedicated environment
  signing-key configuration. It does not enable v2 or alter legacy provisioning.
</Warning>

`POST /v1/devices/{id}/encrypted-upload-v2/contexts`

```json theme={null}
{ "nonce_base64": "<canonical base64 for the device's fresh 16-byte context nonce>" }
```

The `201` response contains `context_id` (UUID), `state: "challenge"`,
`challenge_base64` (196 opaque bytes), `result_base64: null`, and `expires_at`.
Relay the challenge unchanged to the device. The device verifies it and returns
an encrypted proof of its installed token; the app never supplies or opens the
token. This nonce is separate from `auth_nonce_base64` and Grant nonces.

`POST /v1/devices/{id}/encrypted-upload-v2/contexts/{context_id}/proof`

```json theme={null}
{ "proof_base64": "<canonical base64 for the device's opaque 116–366-byte proof>" }
```

The response has the same shape: `202` with `state: "pending"`, or `200` with
`state: "complete"`. Poll
`GET /v1/devices/{id}/encrypted-upload-v2/contexts/{context_id}` until complete,
then relay the 264-byte `result_base64` unchanged. The backend worker checks the
actual credential against the current confirmed binding and rejects revoked,
provisional, cross-device or stale credentials and active factory resets.

All three operations have a first-party dashboard equivalent under
`/dashboard/projects/{projectId}/devices/{deviceId}/encrypted-upload-v2/contexts`.
A device token may use `me` in the API path, only for that exact device.

The challenge expires after 60 seconds; the device permits at most 30 seconds
for the whole exchange. Polling and identical retries never extend either
deadline. Retrying create with the same nonce returns the exact challenge;
retrying the same proof repairs a lost enqueue. A different proof conflicts.
Expired/failed contexts require a new device-generated attempt. Unknown fields
and noncanonical base64 are rejected. Never substitute app time or trust keys.

At most eight unexpired contexts may coexist for a device. Capacity exhaustion
returns `429` (`rate_limit_exceeded`); retry with bounded backoff, respecting
the original attempt deadline. If that deadline expires, start a fresh device
attempt. Keep the recording and its session identity while waiting, including
when publication succeeded but fresh context for receipt validation is pending.

## Create a session

`POST /v1/recordings/{id}/encrypted-upload-v2/sessions`

The recording must be a pending device recording whose immutable device,
recording UUID, generation, and binding generation match the request.

<RequestExample>
  ```bash cURL theme={null}
  curl -X POST https://api.bota.dev/v1/recordings/rec_abc123/encrypted-upload-v2/sessions \
    -H "Authorization: Bearer sk_live_..." \
    -H "Content-Type: application/json" \
    -d '{
      "device_id": "dev_abc123",
      "binding_generation": 4,
      "recording_uuid": "22222222-2222-4222-8222-222222222222",
      "recording_generation": 7,
      "storage_format": "bota_enc_v2",
      "channel": "ble",
      "capabilities_base64": "AQIYAH8BAACYAUQCtAAQAAQAAAAIAAAA",
      "auth_nonce_base64": "EREREREREREREREREREREQ==",
      "ciphertext_length": 16384,
      "ciphertext_sha256": "abababababababababababababababababababababababababababababababab"
    }'
  ```
</RequestExample>

| Field | Type | Required | Contract |
| - | - | -: | - |
| `device_id` | string | Yes | Exact device for the recording, 1–128 characters |
| `binding_generation` | integer | Yes | Current non-negative binding generation, maximum `2147483647` |
| `recording_uuid` | string | Yes | Canonical lowercase RFC 4122 UUID |
| `recording_generation` | integer | Yes | Unsigned 32-bit content generation |
| `storage_format` | string | Yes | Must be `bota_enc_v2` |
| `channel` | string | Yes | `ble`, `wifi`, or `cellular` |
| `capabilities_base64` | string | Yes | Canonical base64 for exactly 24 capability bytes; required batch-v2 flags and bounds must be present. The upload-context runtime additionally requires bit 8 (`0x100`); the example advertises `0x17f` |
| `auth_nonce_base64` | string | Yes | Canonical base64 for exactly 16 fresh nonce bytes |
| `ciphertext_length` | integer | Yes | Positive safe integer; exact staged object length |
| `ciphertext_sha256` | string | Yes | 64 lowercase hexadecimal characters |

Unknown fields are rejected.

<ResponseExample>
  ```json 201 theme={null}
  {
    "profile": "encrypted_upload_v2",
    "session_id": "11111111-2222-4333-8444-555555555555",
    "owner_revision": 1,
    "authorization_base64": "<544-character base64 for 408 bytes>",
    "authorization_sha256": "2222222222222222222222222222222222222222222222222222222222222222",
    "staging": {
      "url": "https://private-staging.example/presigned-put",
      "method": "PUT",
      "headers": {
        "x-amz-checksum-sha256": "<base64 SHA-256>",
        "x-amz-server-side-encryption": "AES256",
        "x-amz-meta-session-id": "11111111-2222-4333-8444-555555555555",
        "x-amz-meta-ciphertext-length": "16384"
      }
    },
    "expires_at": "2026-09-03T20:15:00.000Z",
    "policy": "v2_preferred"
  }
  ```
</ResponseExample>

The authorization is always canonical base64 for exactly 408 bytes. The
session and presigned target expire after 15 minutes. Every returned staging
header is authenticated by the presigned URL and must be sent exactly as
returned; omitting or changing one makes the PUT fail. Treat the URL as opaque: use its exact host, path and query, including after renewal.

Upload the ciphertext without decoding or transforming it:

```bash theme={null}
curl -X PUT "$STAGING_URL" \
  -H "x-amz-checksum-sha256: $CIPHERTEXT_SHA256_BASE64" \
  -H "x-amz-server-side-encryption: AES256" \
  -H "x-amz-meta-session-id: $SESSION_ID" \
  -H "x-amz-meta-ciphertext-length: $CIPHERTEXT_LENGTH" \
  --data-binary @recording.botaenc
```

## Submit the manifest

### Recover the staging target before submission

`POST /v1/recordings/{id}/encrypted-upload-v2/sessions/{session_id}/staging-url`

If an app retained session identifiers but no presigned URL, request the same
target with this body:

```json theme={null}
{ "owner_revision": 1 }
```

The `200` response contains `url`, `method: "PUT"`, and required `headers`, in
the same shape as the session's `staging` object. It re-signs the original
checksum-bound ciphertext object only. It does not create a new session or
recipient, change owner, or extend the original 15-minute expiry. Renewal
requires `staging` state and unchanged live binding generation, pending
recording identity, policy and configuration. A stale context returns `409`.
After manifest acceptance, reconcile session status instead of re-uploading.

### Replace a pre-manifest session (capability gated)

<Warning>
  Recovery requires a matching backend, SDK/provider and firmware
  with capability bit `0x200`. It is not enabled by a backend key or app setting
  alone. Target validation is pending; older firmware must not receive a
  replacement authorization.
</Warning>

`POST /v1/recordings/{id}/encrypted-upload-v2/sessions/{session_id}/recover`

```json theme={null}
{
  "owner_revision": 1,
  "auth_nonce_base64": "<canonical base64 of the current 16-byte device nonce>",
  "capabilities_base64": "<canonical base64 of the device's 24-byte capability>"
}
```

The optional `reason` is `"expired"` (default when omitted),
`"nonce_changed"`, or `"channel_changed"`. Keep the default for expiry recovery. On reconnect or device reboot,
`nonce_changed` permits recovery before expiry only when the supplied device
nonce differs from the immutable signed authorization and no manifest was ever
accepted. The required capability mask is `0x37f`, including upload context
and recovery. This is not an app assertion that device admission never happened:
the device must independently validate the signed replacement, current nonce,
fresh authenticated context, increasing owner revision and absence of receipts.

The response identifies the durable successor:

```json theme={null}
{
  "profile": "encrypted_upload_v2",
  "session_id": "33333333-4444-4555-8666-777777777777",
  "owner_revision": 2
}
```

Save that identity under the same recording, then retrieve its status and
staging target. The new signed authorization remains 408 bytes and contains
the replacement flag `0x0008` and a higher owner revision. The recording and
ciphertext remain unchanged; the recipient key, session and staging object are
new. Do not erase device or app evidence to force a retry.

Do not confuse the binary document version (`2`) with its transport profile:
`BOTAAUT2` byte 13 is profile `3`, and byte 14 is storage format `3` for both
initial and replacement authorizations.

Only a pre-manifest session satisfying the selected reason is eligible. Current
project/device/binding/recording/policy checks still apply. Failed, cancelled,
processing and published sessions require reconciliation, not replacement.
An identical parent retry returns its existing successor; it never creates a
second child or extends the old authorization. If that child also expired or
has a stale nonce, follow that exact child and use the applicable reason within
a bounded retry limit. Never refresh its nonce in place. Accepted-manifest/processing/published sessions must
resume status reconciliation rather than replacement or restaging.

Direct WiFi/cellular handoff uses `reason: "channel_changed"` together with
`channel: "wifi"` or `"cellular"`. Both parent and target must be different
direct channels; this does not take over a BLE owner. Other reasons must omit
`channel`. Close the previous transport before handoff. Same-channel retries
retain the session and renew staging credentials; handoff creates the signed
successor described above and restarts ciphertext from byte zero.

Provisioned devices can reconcile lost create/recovery responses with
`POST /v1/devices/me/encrypted-upload-v2/reconcile`, authenticated by their
device token. The request uses the identity fields below. It returns `null` or
the recording/session/revision lookup, plus `device_id` and the actual direct
`channel`. A WiFi caller can discover its cellular owner and vice versa, but
this lookup does not authorize upload, handoff or deletion. Firmware direct-v2
remains disabled by default pending transport and hardware acceptance.
The direct firmware coordinator distinguishes transient network/pending-worker
results from terminal authorization or integrity failures. Context retries
reuse the same nonce and original deadline; successful HTTP transfer still
does not permit deleting the local recording without its signed receipt.
The October 2 recovery source update schedules transient results after 30, 60,
120, then 300 seconds, with further attempts capped at 300 seconds. A 30-second
timer checks eligibility, so actual starts can be later. Bluetooth preference,
another upload or worker allocation failure defers the attempt without losing
it; reset cancels it. Each attempt releases its radio ownership after a bounded
publication check. Restart restores the durable recording/session identity;
the in-memory backoff restarts. This update still requires firmware rollout and
physical interruption qualification; default direct-v2 gates remain unchanged.

The dashboard bridge exposes the same `/recover` action. Recovery does not
change pairing, permit plaintext fallback or authorize deletion. Deletion still
requires a published, exact-session signed receipt verified by the device.

### Reconcile a lost creation response (first-party dashboard)

The organization-authorized dashboard provides
`POST /dashboard/projects/{projectId}/devices/{deviceId}/encrypted-upload-v2/reconcile`.
This is not an additional public API-key endpoint. Send the create-session
identity fields except `device_id` (owned by the path) and `auth_nonce_base64`:
`binding_generation`, `recording_uuid`, `recording_generation`, `storage_format`,
`channel`, `capabilities_base64`, `ciphertext_length` and `ciphertext_sha256`.

After current tenant/binding and exact physical recording checks, `200`
returns `null` when no matching recording exists, or an identity-only result:

```json theme={null}
{
  "profile": "encrypted_upload_v2",
  "recording_id": "rec_abc123",
  "session_id": "33333333-4444-4555-8666-777777777777",
  "owner_revision": 2
}
```

`session_id` and `owner_revision` are both null when the recording committed
but no session did. When a session exists, exact ciphertext/channel/profile
checks must also pass before returning the latest retained owner. Deleted or
conflicting identities fail closed. The result contains no signed document,
receipt, key or staging URL and is not upload/deletion authority. Reconcile an
uncertain create before retrying, including after a concurrent-create conflict;
do not invent a new recording UUID or reset terminal history to revision 1.

### Submit the authenticated bytes

`POST /v1/recordings/{id}/encrypted-upload-v2/sessions/{session_id}/manifest`

```json theme={null}
{
  "owner_revision": 1,
  "manifest_base64": "<776-character canonical base64 for 580 bytes>",
  "manifest_sha256": "5555555555555555555555555555555555555555555555555555555555555555"
}
```

| Field | Type | Required | Contract |
| - | - | -: | - |
| `owner_revision` | integer | Yes | Exact session revision, `1`–`4294967295` |
| `manifest_base64` | string | Yes | Canonical base64 for exactly 580 bytes; exactly 776 characters |
| `manifest_sha256` | string | Yes | SHA-256 of the decoded manifest, 64 lowercase hexadecimal characters |

A newly accepted manifest returns `202`; an exact replay after publication
returns `200`.

```json theme={null}
{
  "state": "ready",
  "idempotent": false
}
```

`state` is `ready`, `processing`, or `published`. Conflicting bytes or a stale
owner revision fail closed.

For an uncertain PUT or manifest response, reconcile the saved session first.
If it is `ready` or `processing`, poll that session; if `published`, obtain its
signed receipt. If it remains before manifest acceptance and the saved owner
is still valid, replay the **exact saved manifest** before repeating the PUT.
An accepted replay proceeds to status polling without uploading bytes again.

The recovery source update adds `409 encrypted_upload_v2_staging_missing` when
S3 affirmatively reports that the session's object is absent. Only this result
permits a fresh staging URL and whole-object PUT for that same owner. Timeouts,
403, 503, or an unrelated 409 do not prove absence. An expired/replaced owner
still requires the existing signed recovery procedure. Deploy the backend
update before firmware that relies on the new error; older backends may return
503 for an absent object and the new firmware retains the file while retrying.

## Get session status

`GET /v1/recordings/{id}/encrypted-upload-v2/sessions/{session_id}`

```json theme={null}
{
  "profile": "encrypted_upload_v2",
  "session_id": "11111111-2222-4333-8444-555555555555",
  "state": "published",
  "owner_revision": 1,
  "channel": "ble",
  "policy": "v2_preferred",
  "ciphertext_length": 16384,
  "ciphertext_sha256": "abababababababababababababababababababababababababababababababab",
  "plaintext_length": 16000,
  "plaintext_sha256": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
  "authorization_base64": "<544-character base64 for 408 bytes>",
  "authorization_sha256": "2222222222222222222222222222222222222222222222222222222222222222",
  "completion_receipt_base64": "<448-character base64 for 336 bytes>",
  "completion_receipt_sha256": "8888888888888888888888888888888888888888888888888888888888888888",
  "expires_at": "2026-09-03T20:15:00.000Z",
  "published_at": "2026-09-03T20:10:00.000Z",
  "terminal_reason": null
}
```

Possible states are `created`, `staging`, `staged`, `ready`, `processing`,
`published`, `failed`, `cancelled`, and `expired`. Plaintext fields appear only
after the manifest establishes them. Receipt and publication fields appear
only for `published` sessions. The receipt is canonical base64 for exactly 336
bytes and remains valid for 30 days from issuance.

## Cancel a session

`DELETE /v1/recordings/{id}/encrypted-upload-v2/sessions/{session_id}`

```json theme={null}
{
  "owner_revision": 1
}
```

Cancellation returns `204` and is allowed only before worker processing owns
the session. It schedules idempotent cleanup of staged ciphertext and live
one-time recipient material; it never deletes the device recording.

## Errors

| HTTP | Code | Meaning |
| -: | - | - |
| 400 | `validation_error` | Request shape, canonical encoding, or field bounds are invalid |
| 400 | `encrypted_upload_v2_invalid_manifest` | Staging metadata or authenticated manifest verification failed |
| 403 | `insufficient_permissions` | Credential does not own the exact recording/device/session scope |
| 404 | `resource_not_found` | Recording, device, or tenant-scoped session was not found |
| 409 | `encrypted_upload_v2_unsupported_capability` | Capability bytes do not authorize canonical batch v2 |
| 409 | `encrypted_upload_v2_session_conflict` | Identity, state, replay bytes, or owner revision conflicts |
| 409 | `encrypted_upload_v2_staging_missing` | Recovery update: S3 confirmed this session's staged object is absent; renew staging credentials and repeat the exact object's PUT |
| 409 | `encrypted_upload_v2_cancellation_conflict` | Processing or publication has already passed the cancellation boundary |
| 410 | `encrypted_upload_v2_expired` | Session expired before manifest acceptance |
| 503 | `encrypted_upload_v2_unavailable` | Staging, signing identity, key provider, or worker authority is unavailable |

Cryptographic failures use stable public errors and do not expose private
verification details.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.