# Forge: SparkVault API Reference

> Encrypt and decrypt binary data with AES-256-GCM through one TUS upload flow for every file size.

Canonical: https://sparkvault.com/api/docs/forge/ · OpenAPI: https://sparkvault.com/openapi.yaml

## Overview

Forge is SparkVault's encryption and transfer service, the data plane that every [Ingot](/api/docs/ingots/) (an encrypted file stored in a [Vault](/api/docs/vaults/)) passes through on its way into and out of storage. It is a high-performance encryption proxy that handles AES-256-GCM encryption and decryption server-side, so your client never touches key material or implements cryptography.

Uploads use TUS v1.0 resumable sessions with bounded PATCH chunks, so every file size (from a one-page PDF to a 5 TB archive) follows the same protocol and cryptographic format.

AES-256

GCM Encryption

TUS

Resumable Uploads

Server-Side

Cryptography

HTTPS

Transport Security

> **Zero Client-Side Crypto**
>
> Forge eliminates the need for client-side cryptography libraries. Send plaintext to Forge over HTTPS with TUS, and it handles encryption using industry-standard AES-256-GCM.

## Base URL

Forge runs on a dedicated endpoint optimized for resumable encrypted transfer:

```text
https://forge.sparkvault.com
```

> **Separate Service**
>
> Forge operates independently from the main API at api.sparkvault.com. It uses a different authentication mechanism (ISTK) and is optimized for high-throughput resumable operations.

## Authentication

Forge uses **Ingot Secure Transaction Keys (ISTKs)** for authentication. An ISTK is a temporary, scoped credential generated when you access an Ingot in a Vault. It authorizes a specific cryptographic operation (encrypt or decrypt) for a limited time.

An ISTK is an opaque, base64url-encoded 32-byte random value. It carries no embedded claims. All context (account, vault, ingot, operation, key material) lives in a server-side record that Forge looks up when the token is presented.

### How to Obtain an ISTK

The Ingot endpoints issue ISTKs. Creating an ingot (`POST /v1/vaults/{vault_id}/ingots`) returns a `forge_url` for encryption; initiating a download (`POST /v1/vaults/{vault_id}/ingots/{ingot_id}/download`) returns a `download_url` for decryption. Both URLs carry the ISTK as the `istk` query parameter:

```bash
curl -X POST https://api.sparkvault.com/v1/vaults/{vault_id}/ingots \
  -H "X-API-Key: sv_live_xxx" \
  -H "X-Vault-Access-Token: YOUR_VAT" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "large-backup.tar.gz",
    "size_bytes": 1073741824,
    "content_type": "application/gzip"
  }'
```

```json
{
  "data": {
    "ingot_id": "ing_abc123...",
    "name": "large-backup.tar.gz",
    "size_bytes": 1073741824,
    "content_type": "application/gzip",
    "status": "uploading",
    "forge_url": "https://forge.sparkvault.com/encrypt?istk=3q2-7wX9kLmN0pQrStUvWxYzAb1Cd2Ef3Gh4Ij5Kl6M",
    "expires_at": 1782172800
  }
}
```

### Single-Use vs Streaming ISTKs

Encrypt ISTKs and default decrypt ISTKs are **single-use**: Forge atomically claims the token the first time it is presented, and any reuse fails with `401 AUTHENTICATION_ERROR` (`details.code: ISTK_USED`). In practice this means a `download_url` works exactly once.

For media playback (where a player issues many `Range` requests against one URL), pass `{ "stream": true }` in the body of the Ingot download request (`POST /v1/vaults/{vault_id}/ingots/{ingot_id}/download`). This mints a **reusable streaming-decrypt URL** that is not consumed on use. Its blast radius is bounded by a cumulative byte cap and a sliding idle TTL (see the expiration note below).

### Using the ISTK with Forge

Extract the ISTK from the returned `forge_url` and pass it in the `X-ISTK` header when creating the upload session (`POST /encrypt`). That is where the token is validated and atomically consumed. Subsequent `PATCH`/`HEAD`/`DELETE` requests are keyed by the upload ID from the `Location` header and do not read `X-ISTK` (sending it is harmless but unnecessary). For decryption, the ISTK rides along as the `istk` query parameter already embedded in the `download_url`.

```bash
curl -i -X POST https://forge.sparkvault.com/encrypt \
  -H "Tus-Resumable: 1.0.0" \
  -H "Upload-Length: $(wc -c < plaintext.file)" \
  -H "X-ISTK: 3q2-7wX9kLmN0pQrStUvWxYzAb1Cd2Ef3Gh4Ij5Kl6M"
```

> **ISTK Expiration**
>
> Single-use ISTKs have a fixed **24-hour** expiry. Reusable streaming-decrypt URLs have a **1-hour sliding idle TTL** (refreshed on each use) with a **12-hour absolute cap**. Always check `expires_at` and obtain a fresh Forge URL if needed before starting a Forge operation.

## API Reference

### Health Check

#### `GET /health`

Check if the Forge service is operational. No authentication required.

```bash
curl https://forge.sparkvault.com/health
```

```json
{
  "status": "healthy",
  "version": "1.0.0",
  "timestamp": "2026-07-02T12:00:00.000Z"
}
```

### Capability Discovery

#### `OPTIONS /encrypt`

tus capability discovery. Returns the supported tus version, protocol extensions, and maximum upload size as headers on a 204 response.

```text
Tus-Resumable: 1.0.0
Tus-Version: 1.0.0
Tus-Extension: creation,termination
Tus-Max-Size: 5497558138880
```

`Tus-Max-Size` is 5,497,558,138,880 bytes (5 TB), the maximum size of a single ingot.

### Create Upload Session

#### `POST /encrypt`

Create a tus resumable upload session. Upload plaintext with PATCH requests to the returned Location.

#### Headers

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `Tus-Resumable` | string | Required | Must be `1.0.0` |
| `Upload-Length` | integer | Required | Total plaintext size in bytes. Rejected with `400` if it exceeds the size cap embedded in the ISTK. |
| `X-ISTK` | string | Required | Single-use encrypt ISTK from the Forge URL. Validated and atomically consumed by this request. |

#### Response (201 Created)

| Field | Type | Description |
| --- | --- | --- |
| `Location` | header | Upload path for PATCH chunks (`/encrypt/{upload_id}`). Resolve it against the Forge origin. |
| `Upload-Offset` | header | Current offset, initially 0 |
| `Upload-Length` | header | Echo of the declared total plaintext size |
| `Tus-Resumable` | header | `1.0.0` |
| `X-Chunk-Size` | header | Maximum PATCH chunk size: 52,428,800 bytes (50 MB) |

```bash
curl -i -X POST https://forge.sparkvault.com/encrypt \
  -H "Tus-Resumable: 1.0.0" \
  -H "Upload-Length: $(wc -c < plaintext.pdf)" \
  -H "X-ISTK: 3q2-7wX9kLmN0pQrStUvWxYzAb1Cd2Ef3Gh4Ij5Kl6M"
```

### Upload Chunk

#### `PATCH /encrypt/{upload_id}`

Upload the next plaintext chunk to the session. Returns 204 No Content with the new Upload-Offset header. No X-ISTK required: the session is keyed by the upload ID.

#### Headers

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `Tus-Resumable` | string | Required | Must be `1.0.0` |
| `Upload-Offset` | integer | Required | Must equal the server's current offset, or the chunk is rejected with `400` |
| `Content-Type` | string | Required | Must be `application/offset+octet-stream` |

```bash
curl -X PATCH https://forge.sparkvault.com/encrypt/{upload_id} \
  -H "Tus-Resumable: 1.0.0" \
  -H "Upload-Offset: 0" \
  -H "Content-Type: application/offset+octet-stream" \
  --upload-file plaintext.pdf
```

> **Fixed Chunk Geometry**
>
> Every non-final chunk must be **exactly** `X-Chunk-Size` (50 MB). Only the final remainder may be smaller. Undersized non-final chunks, chunks larger than `X-Chunk-Size`, and chunks that would push past the declared `Upload-Length` are all rejected with `400`. If two requests race on the same offset, the loser receives `409 CONFLICT`. Re-check the offset with `HEAD` and retry.

### Check Upload Offset

#### `HEAD /encrypt/{upload_id}`

tus offset check. Returns 200 OK with the session's current state in headers. Use it to find where to resume.

#### Response (200 OK)

| Field | Type | Description |
| --- | --- | --- |
| `Upload-Offset` | header | Bytes received and committed so far |
| `Upload-Length` | header | Declared total plaintext size |
| `X-Chunk-Size` | header | Negotiated maximum PATCH chunk size |

### Cancel Upload

#### `DELETE /encrypt/{upload_id}`

Cancel an in-progress upload. Deletes the staged encrypted chunks, bills the partial transfer, and returns 204 No Content. A no-op if the upload already finalized.

### Decrypt Stream

#### `GET /decrypt`

Decrypt an ingot that was previously encrypted with Forge. The download URL (including ISTK) is provided by the Ingot download endpoint. Returns the plaintext stream in the response. Default download URLs are single-use.

#### Query Parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `istk` | string | Required | Ingot Secure Transaction Key (included in the `download_url` from the Ingot download endpoint). May alternatively be sent as an `X-ISTK` header; the query parameter takes precedence. |

`GET /decrypt` honors single-range `Range: bytes=` requests, returning `206 Partial Content` with a `Content-Range` header (or `416` when the range is unsatisfiable). This is the basis for resumable/partial downloads and media seeking with reusable streaming URLs.

#### Response

| Field | Type | Description |
| --- | --- | --- |
| `Body` | binary | The decrypted plaintext stream |
| `Content-Type` | header | The ingot's stored content type (falls back to `application/octet-stream`) |
| `Content-Disposition` | header | `attachment` with a filename derived from the ingot |
| `Content-Length` | header | Bytes served (the full size, or the range length for 206 responses) |
| `Accept-Ranges` | header | `bytes` |
| `X-Ingot-ID` | header | The ingot being decrypted |
| `Cache-Control` | header | `private, no-store`: decrypted plaintext is never cacheable |

```bash
# The download_url from the Ingot download endpoint includes the ISTK
curl "https://forge.sparkvault.com/decrypt?istk=3q2-7wX9kLmN0pQrStUvWxYzAb1Cd2Ef3Gh4Ij5Kl6M" \
  --output decrypted.pdf
```

```bash
curl "https://forge.sparkvault.com/decrypt?istk=3q2-7wX9kLmN0pQrStUvWxYzAb1Cd2Ef3Gh4Ij5Kl6M" \
  -H "Range: bytes=1048576-2097151" \
  --output part.bin
```

### Check Progress

#### `GET /progress`

Poll transfer progress for an ISTK-scoped operation. Reading progress does not consume the ISTK, so it is safe to poll during a transfer.

#### Query Parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `istk` | string | Required | The ISTK of the operation to inspect. Returns `404` if unknown. |

```json
{
  "success": true,
  "progress": {
    "bytes_downloaded": 52428800,
    "bytes_processed": 52428800,
    "total_bytes": 1073741824,
    "status": "forging"
  }
}
```

> **HEAD Preflight**
>
> A `HEAD` request to `/decrypt`, `/encrypt`, or `/progress` returns `200` without consuming the ISTK, useful for availability checks before committing a single-use token.

## Examples

### Encrypt and Store a File

```bash
# 1. Create the ingot - the response's forge_url carries the ISTK
RESPONSE=$(curl -s -X POST https://api.sparkvault.com/v1/vaults/vlt_xxx/ingots \
  -H "X-API-Key: $API_KEY" \
  -H "X-Vault-Access-Token: $VAT" \
  -H "Content-Type: application/json" \
  -d "{
    \"name\": \"backup.tar.gz\",
    \"size_bytes\": $(wc -c < backup.tar.gz),
    \"content_type\": \"application/gzip\"
  }")

FORGE_URL=$(echo $RESPONSE | jq -r '.data.forge_url')
ISTK="${FORGE_URL#*istk=}"

# 2. Create a TUS upload session (the only request that needs X-ISTK)
HEADER_FILE=$(mktemp)
curl -s -D "$HEADER_FILE" -o /dev/null -X POST https://forge.sparkvault.com/encrypt \
  -H "Tus-Resumable: 1.0.0" \
  -H "Upload-Length: $(wc -c < backup.tar.gz)" \
  -H "X-ISTK: $ISTK"

# Location is a path ("/encrypt/{upload_id}") - resolve it against the Forge origin
UPLOAD_URL="https://forge.sparkvault.com$(awk 'tolower($1) == "location:" { print $2 }' "$HEADER_FILE" | tr -d '\r')"

# 3. Upload plaintext through the TUS session. A file up to 50 MB fits in a
#    single PATCH; larger files send one PATCH per 50 MB chunk (see below).
curl -X PATCH "$UPLOAD_URL" \
  -H "Tus-Resumable: 1.0.0" \
  -H "Upload-Offset: 0" \
  -H "Content-Type: application/offset+octet-stream" \
  --upload-file backup.tar.gz
```

### Resumable Uploads

```bash
# Ask Forge where to resume (keyed by the upload URL - no ISTK needed)
OFFSET=$(curl -sI "$UPLOAD_URL" \
  -H "Tus-Resumable: 1.0.0" | awk 'tolower($1) == "upload-offset:" { print $2 }' | tr -d '\r')

# Continue at that offset with the next plaintext chunk (non-final chunks
# must be exactly 50 MB = 52428800 bytes)
tail -c +$((OFFSET + 1)) backup.tar.gz | head -c 52428800 > chunk.bin
curl -X PATCH "$UPLOAD_URL" \
  -H "Tus-Resumable: 1.0.0" \
  -H "Upload-Offset: $OFFSET" \
  -H "Content-Type: application/offset+octet-stream" \
  --upload-file chunk.bin
```

### JavaScript / Node.js

```javascript
import * as tus from 'tus-js-client';

function uploadWithForge(file, forgeUrl) {
  const url = new URL(forgeUrl);
  const istk = url.searchParams.get('istk');

  const upload = new tus.Upload(file, {
    endpoint: `${url.origin}/encrypt`,
    chunkSize: 50 * 1024 * 1024,
    // tus-js-client sends headers on every request; Forge only requires
    // X-ISTK on session creation and ignores it elsewhere.
    headers: { 'X-ISTK': istk },
    retryDelays: [0, 1000, 3000, 5000, 10000],
    metadata: {
      filename: file.name,
      filetype: file.type || 'application/octet-stream'
    }
  });

  upload.start();
}
```

### Python

```python
import os
from urllib.parse import parse_qs, urljoin, urlparse

import requests

CHUNK_SIZE = 50 * 1024 * 1024

def upload_file(forge_url: str, input_path: str):
    """Upload a file through Forge with TUS."""
    parsed = urlparse(forge_url)
    istk = parse_qs(parsed.query)['istk'][0]
    endpoint = f'{parsed.scheme}://{parsed.netloc}/encrypt'
    size = os.path.getsize(input_path)

    # X-ISTK is only needed here, on session creation
    create = requests.post(
        endpoint,
        headers={
            'Tus-Resumable': '1.0.0',
            'Upload-Length': str(size),
            'X-ISTK': istk,
        },
    )
    create.raise_for_status()
    upload_url = urljoin(endpoint, create.headers['Location'])

    offset = 0
    with open(input_path, 'rb') as f:
        while offset < size:
            chunk = f.read(CHUNK_SIZE)
            patch = requests.patch(
                upload_url,
                headers={
                    'Tus-Resumable': '1.0.0',
                    'Upload-Offset': str(offset),
                    'Content-Type': 'application/offset+octet-stream',
                },
                data=chunk,
            )
            patch.raise_for_status()
            offset = int(patch.headers['Upload-Offset'])


def decrypt_file(download_url: str, output_path: str):
    """Decrypt a file through Forge.

    The download_url is provided by the Ingot download endpoint
    and includes the ISTK as a query parameter. Default download
    URLs are single-use.
    """
    response = requests.get(download_url, stream=True)
    response.raise_for_status()

    with open(output_path, 'wb') as out:
        for chunk in response.iter_content(chunk_size=8192):
            out.write(chunk)

    print(f'Decrypted: {output_path}')


# Usage
upload_file(forge_url, 'report.xlsx')
decrypt_file(download_url, 'report-restored.xlsx')
```

## Common Use Cases

#### Database Backups

Encrypt database dumps before storing them as ingots, using the same resumable upload flow as every other file.

#### Video Content Protection

Encrypt video files of any size for DRM or content protection before distribution.

#### Log Encryption

Encrypt log files containing sensitive data before archival to meet compliance requirements.

#### File Transfer Security

Add end-to-end encryption to file transfers without modifying client applications.

#### IoT Data Encryption

Encrypt sensor data and telemetry from devices that lack cryptographic capabilities.

#### Medical Imaging

Encrypt DICOM files and medical images for HIPAA-compliant storage.

## Security Details

### Encryption Algorithm

Forge uses AES-256-GCM (Galois/Counter Mode) for authenticated chunk encryption:

-   **Key Size**: 256 bits (32 bytes), the vault-level Vault Access Key (VAK)
-   **IV/Nonce**: a random 96-bit base nonce per upload; each chunk's nonce is derived deterministically as base nonce XOR chunk index
-   **Authentication Tag**: 128 bits (16 bytes) per chunk for integrity verification
-   **Additional Authenticated Data**: every chunk's AAD binds the account, vault, ingot, and chunk index, so ciphertext cannot be replayed across ingots or reordered within one
-   **Mode**: Galois/Counter Mode provides both confidentiality and authenticity

### Key Handling

Forge never derives or stores long-lived keys of its own. The key arrives with the ISTK:

1.  The Core API envelope-encrypts the vault's VAK into the ISTK record: a KMS-derived key-encryption key wraps a random data key, which wraps the VAK
2.  Forge validates the ISTK, derives the same KEK from KMS, and unwraps the data key, then the VAK
3.  The VAK is used directly as the AES-256-GCM key for the operation
4.  Isolation between ingots and chunks comes from the per-upload random base nonce and the per-chunk AAD binding, not from separate keys
5.  The VAK is zeroized from memory as soon as the operation completes or fails

### Transport Security

All traffic to and from Forge is carried over HTTPS. TLS terminates at the platform edge before requests reach the service, so plaintext never crosses the public internet unencrypted.

> **Defense in Depth**
>
> Data is protected by two layers of encryption: TLS during transport to Forge, and AES-256-GCM for the stored ciphertext. Even if transport encryption were compromised, the encrypted data remains secure.

## Error Responses

#### Error Responses

| Status | Code | Description |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Missing tus headers or a missing `istk` on `/decrypt` (`details.code: MISSING_HEADERS`), a missing `X-ISTK` header, an ISTK presented for the wrong operation, an `Upload-Offset` mismatch, an oversize or undersized chunk, or an `Upload-Length` exceeding the ISTK's size cap. |
| 401 | `AUTHENTICATION_ERROR` | ISTK rejected. `details.code` is one of `INVALID_ISTK` (unknown or malformed), `ISTK_EXPIRED`, or `ISTK_USED` (single-use token already claimed). Obtain a fresh Forge URL from the ingot create or download endpoint. |
| 403 | `(empty body)` | A reusable streaming-decrypt URL whose cumulative byte cap is exhausted. The response has an **empty body** (no JSON error envelope) and the token is revoked, so subsequent requests fail with `401 AUTHENTICATION_ERROR` (`details.code: ISTK_USED`). |
| 404 | `NOT_FOUND` | Upload session not found, or the target ingot is missing or not active. |
| 409 | `CONFLICT` | A concurrent PATCH lost the exclusive write lease, or finalization is in progress. Re-check the offset with HEAD and retry. |
| 500 | `CRYPTOGRAPHIC_ERROR` | Encryption or decryption failed: authentication-tag mismatch or corrupted key material. |
| 500 | `STORAGE_ERROR` | Object-storage upload or download failed. Retry the chunk at the same offset. |
| 500 | `INTERNAL_ERROR` | Unexpected server error. Retry with exponential backoff. |

```json
{
  "success": false,
  "error": {
    "code": "AUTHENTICATION_ERROR",
    "message": "ISTK expired",
    "details": {
      "code": "ISTK_EXPIRED"
    }
  }
}
```

## Best Practices

-   **Check ISTK expiration before starting**: Verify that `expires_at` gives enough time to complete your operation. Obtain a fresh Forge URL if needed.
-   **Use tus clients**: Keep upload logic on the resumable tus path and send bounded PATCH chunks with `Upload-Offset`.
-   **Store metadata separately**: Forge only encrypts ingot bytes. Store filename, content type, and other metadata in SparkVault ingot metadata.
-   **Implement retry logic**: Retry with exponential backoff on transient network failures. On `409 CONFLICT`, re-check the current offset with `HEAD` before retrying.
-   **Verify with checksums**: Calculate checksums of plaintext before encryption and after decryption to verify data integrity end-to-end.
-   **Use separate Vaults for different data types**: Create distinct Vaults for different data categories (backups, documents, media). Each Vault has its own VAK, giving you key isolation and independent access control.

## Usage

Forge operations consume pooled bandwidth included with your seat subscription. Both encrypt and decrypt operations count the processed data size against your pooled bandwidth. There is no per-GB charge. When pooled bandwidth is exhausted, extend it with a capacity block.

For subscription tiers and capacity blocks, see the [Pricing page](/pricing/).
