# Ingots API: SparkVault API Reference

> Store and retrieve encrypted files within vaults. Ingots support files from 1 byte to 5 TB with full encryption at rest.

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

## Overview

Ingots are encrypted data objects stored within [Vaults](/api/docs/vaults/). All ingot operations require two credentials: an API key (or session JWT) to authenticate the caller, plus a valid Vault Access Token (VAT) obtained by [unsealing the vault](/api/docs/vaults/).

> **Looking for key/value storage?**
>
> Named-field key/value data with atomic updates is a separate product, [Structured Ingots](/api/docs/products/structured-ingots/), with its own endpoints and semantics. This page covers file ingots only.

#### Features

| Feature | Description |
| --- | --- |
| **Size** | 1 byte to 5 TB per ingot |
| **Encryption** | AES-256-GCM under the per-vault Vault Access Key (VAK), HKDF-derived from SVMK + AMK + VMK (Triple Zero-Trust). Forge encrypts each 50 MB chunk with the VAK using per-chunk derived nonces |
| **Streaming** | Server-side encryption/decryption via Forge TUS uploads |

> **VAT Required**
>
> All ingot operations require two credentials: an API key (`X-API-Key`) or session JWT (`Authorization: Bearer`) to authenticate the caller, plus the `X-Vault-Access-Token` header with a valid VAT. See the [Vaults API](/api/docs/vaults/) for how to obtain a VAT.

## Standard Ingots

Standard ingots are file-based encrypted storage. Upload any file up to 5 TB through the same Forge TUS flow.

> **Name-Based Addressing**
>
> `{ingot_id}` in the endpoints below accepts either an ingot ID (`ing_...`) or a name reference: a colon followed by the name, e.g. `/ingots/:report.pdf`. Name lookups are case-insensitive and scoped to your account. Names are unique per folder, not per vault: when the same name exists in multiple folders, a name reference returns `409 CONFLICT` with the candidate `ingot_id`/`folder_id` pairs. Use the ingot ID instead.

### Create Standard Ingot

#### `POST /v1/vaults/{vault_id}/ingots`

Create a new standard ingot and get a Forge URL for uploading. Two-step process: create the ingot record, then upload to Forge with TUS.

#### Headers

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `X-Vault-Access-Token` | string | Required | Valid VAT from unseal operation |

#### Request Body

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | Required | File name or identifier |
| `size_bytes` | integer | Required | Exact file size in bytes (1 byte to 5 TB = `5497558138880` bytes) |
| `content_type` | string | Optional | MIME typeDefault: `application/octet-stream` |
| `original_filename` | string | Optional | Original file name, used for the `Content-Disposition` header on downloads |
| `folder_id` | string | Optional | ID of an existing folder to place the ingot in. Mutually exclusive with `folder_path` |
| `folder_path` | string | Optional | Slash-delimited folder path (e.g. `reports/2026`), found or created from the vault root. Mutually exclusive with `folder_id` |

> **Upsert by Name**
>
> Creating an ingot with a name that already exists in the same folder **upserts**: the existing ingot's ID is reused and its content is replaced when the upload completes. An active ingot stays readable until Forge finalizes the new upload.

#### Response

| Field | Type | Description |
| --- | --- | --- |
| `ingot_id` | string | Unique ingot identifier (ing\_...) |
| `name` | string | Ingot name |
| `size_bytes` | integer | Declared size in bytes |
| `content_type` | string | MIME type |
| `status` | string | Initial status: `uploading` |
| `forge_url` | string | Forge URL containing the upload ISTK |
| `expires_at` | integer | Upload URL expiration timestamp |

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

// Step 1: Create ingot record
const file = document.querySelector('input[type="file"]').files[0];

const createResponse = await fetch(
  `https://api.sparkvault.com/v1/vaults/${vaultId}/ingots`,
  {
    method: 'POST',
    headers: {
      'X-API-Key': apiKey,
      'X-Vault-Access-Token': vat,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      name: file.name,
      size_bytes: file.size,
      content_type: file.type
    })
  }
);

const { ingot_id, forge_url } = (await createResponse.json()).data;

// Step 2: Upload file to Forge with TUS
const forgeUrl = new URL(forge_url);
const istk = forgeUrl.searchParams.get('istk');
const upload = new tus.Upload(file, {
  endpoint: `${forgeUrl.origin}/encrypt`,
  chunkSize: 50 * 1024 * 1024,
  headers: { 'X-ISTK': istk },
  retryDelays: [0, 1000, 3000, 5000, 10000]
});

upload.start();

// Status will change: uploading → active
```

### List Ingots

#### `GET /v1/vaults/{vault_id}/ingots`

List all ingots in an unsealed vault.

#### Query Parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | integer | Optional | Maximum ingots to return (max: 200)Default: `100` |
| `sort_by` | string | Optional | Sort field: `name`, `size`, `content_type`, `created_at`, `access_count`, or `bandwidth` |
| `sort_order` | string | Optional | `asc` or `desc`Default: `asc` |

#### Response

| Field | Type | Description |
| --- | --- | --- |
| `ingots` | array | Array of ingot objects |
| `count` | integer | Number of ingots returned |
| `ingots[].ingot_id` | string | Ingot identifier |
| `ingots[].name` | string | Ingot name |
| `ingots[].file_type` | string | Category derived from the file extension (e.g. `image`, `video`, `document`) |
| `ingots[].size_bytes` | integer | Size in bytes |
| `ingots[].content_type` | string | MIME type |
| `ingots[].status` | string | Status: `uploading`, `encrypting`, `active`, or `failed` |
| `ingots[].folder_id` | string | Containing folder ID; `null` for the vault root |
| `ingots[].managed_by` | string | Product that manages this ingot (e.g. `structured-ingots`, `product-messaging`); `null` for standard ingots. Managed ingots reject direct read/download/update/delete with `403`; use the product's endpoints |
| `ingots[].upload_source` | string | Origin of the upload (uploader's email, or `API: {key name}` for API-key uploads); `null` when unknown |
| `ingots[].created_at` | integer | Creation timestamp |
| `ingots[].updated_at` | integer | Last-modified timestamp (bumps on every write) |
| `ingots[].access_count` | integer | Number of downloads |
| `ingots[].total_bandwidth_bytes` | integer | Total bytes served for this ingot |

### Search Ingots

#### `GET /v1/vaults/{vault_id}/ingots/search`

Search and filter ingots within a vault using indexed queries. Filters can be combined.

#### Query Parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `q` | string | Optional | Name prefix search (case-insensitive). Prefix-only: no substring or fuzzy matching |
| `file_type` | string | Optional | Filter by category: `image`, `video`, `audio`, `document`, `spreadsheet`, `presentation`, `archive`, `code`, `design`, `cad`, `ebook`, `font`, or `other` |
| `min_size` | integer | Optional | Minimum file size in bytes |
| `max_size` | integer | Optional | Maximum file size in bytes |
| `limit` | integer | Optional | Maximum results per page (max: 200)Default: `50` |
| `cursor` | string | Optional | Signed pagination cursor from the previous response's `next_cursor` |
| `sort_order` | string | Optional | `asc` or `desc`Default: `desc` |

#### Response

| Field | Type | Description |
| --- | --- | --- |
| `ingots` | array | Matching ingot objects (same shape as List Ingots) |
| `count` | integer | Number of ingots in this page |
| `has_more` | boolean | Whether more results are available |
| `next_cursor` | string? | Signed cursor for the next page (present when `has_more` is true) |
| `filters` | object | Echo of the applied filters: `q`, `file_type`, `min_size`, `max_size` |

```bash
# Files starting with "Q3"
curl "https://api.sparkvault.com/v1/vaults/$VAULT_ID/ingots/search?q=Q3" \
  -H "X-API-Key: sv_live_xxx" \
  -H "X-Vault-Access-Token: $VAT"

# Large videos (over 1 GB), newest first
curl "https://api.sparkvault.com/v1/vaults/$VAULT_ID/ingots/search?file_type=video&min_size=1073741824" \
  -H "X-API-Key: sv_live_xxx" \
  -H "X-Vault-Access-Token: $VAT"
```

### Get Ingot Metadata

#### `GET /v1/vaults/{vault_id}/ingots/{ingot_id}`

Retrieve an ingot's metadata. Use the download endpoint to retrieve decrypted contents.

#### Response

| Field | Type | Description |
| --- | --- | --- |
| `ingot_id` | string | Ingot identifier |
| `name` | string | Ingot name |
| `size_bytes` | integer | Size in bytes |
| `content_type` | string | MIME type |
| `status` | string | Status: `uploading`, `encrypting`, `active`, or `failed` |
| `managed_by` | string | Managing product ID; `null` for standard ingots |
| `created_at` | integer | Creation timestamp |
| `updated_at` | integer | Last-modified timestamp. Pass it as `expected_updated_at` to optimistically lock a subsequent PUT |
| `access_count` | integer | Number of downloads |
| `total_bandwidth_bytes` | integer | Total bytes served for this ingot |

### Download Ingot

#### `POST /v1/vaults/{vault_id}/ingots/{ingot_id}/download`

Get a time-limited download URL for decrypting an ingot via Forge.

#### Request Body

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `stream` | boolean | Optional | Mint a reusable, seek-able streaming decrypt URL (many Range requests can be made against one link, e.g. media playback) instead of the single-use defaultDefault: `false` |

#### Response

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | Status (always `ready`) |
| `download_url` | string | Signed Forge URL for streaming decryption (includes ISTK) |
| `ingot_id` | string | Ingot identifier |
| `name` | string | Original file name |
| `size_bytes` | integer | File size in bytes |
| `content_type` | string | MIME type |
| `original_filename` | string | Original filename for Content-Disposition |
| `expires_at` | integer | Download URL expiration timestamp |
| `stream` | boolean | Whether the URL is a reusable streaming URL |

```javascript
// Step 1: Get download URL
const downloadResponse = await fetch(
  `https://api.sparkvault.com/v1/vaults/${vaultId}/ingots/${ingotId}/download`,
  {
    method: 'POST',
    headers: {
      'X-API-Key': apiKey,
      'X-Vault-Access-Token': vat
    }
  }
);

const { download_url, name } = (await downloadResponse.json()).data;

// Step 2: Fetch decrypted content from Forge
const fileResponse = await fetch(download_url);
const blob = await fileResponse.blob();

// Trigger browser download
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
```

### Replace Ingot Content

#### `PUT /v1/vaults/{vault_id}/ingots/{ingot_id}`

Replace an ingot's content. Same two-step flow as create: the response includes a fresh Forge URL for the TUS upload. An active ingot stays readable until Forge finalizes the new upload.

#### Request Body

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `size_bytes` | integer | Required | Exact size of the replacement content in bytes (1 byte to 5 TB) |
| `name` | string | Optional | Rename the ingot as part of the replace. Must not collide with another name in the same folder |
| `content_type` | string | Optional | New MIME type; defaults to the existing content type |
| `expected_updated_at` | integer | Optional | Optimistic lock: the ingot's `updated_at` from your last read. If the ingot changed since, the request fails with `412 PRECONDITION_FAILED` |

#### Response

| Field | Type | Description |
| --- | --- | --- |
| `ingot_id` | string | Ingot identifier (unchanged) |
| `name` | string | Ingot name (after any rename) |
| `size_bytes` | integer | Declared replacement size in bytes |
| `content_type` | string | MIME type |
| `status` | string | Status: `uploading` |
| `forge_url` | string | Forge URL containing the upload ISTK |
| `expires_at` | integer | Upload URL expiration timestamp |

### Rename or Move Ingot

#### `PATCH /v1/vaults/{vault_id}/ingots/{ingot_id}`

Partially update an ingot: rename it and/or move it to a folder. Content is not modified.

#### Request Body

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | Optional | New name (max 255 characters). Must not collide with another name in the target folder |
| `folder_id` | string|null | Optional | Target folder ID; `null` moves the ingot to the vault root |

At least one of `name` or `folder_id` is required; otherwise the request fails with `400 VALIDATION_ERROR`. Returns the updated ingot object (same shape as List Ingots items).

### Delete Ingot

#### `DELETE /v1/vaults/{vault_id}/ingots/{ingot_id}`

Permanently delete an ingot. This immediately frees pooled storage and stops it counting against your storage pool. Returns 204 No Content and cascade-deletes any SparkLink for the ingot.

> **Irreversible**
>
> Deleted ingots cannot be recovered. The encrypted data is permanently destroyed.

> **In-Flight Upload Guard**
>
> An ingot whose status is `uploading` and which is less than 1 hour old cannot be deleted: the request returns `400 VALIDATION_ERROR` (prevents a race with Forge). A stale `uploading` ingot (older than 1 hour) can be deleted to clear a stuck upload.

## Forge: Streaming Encryption

[Forge](/api/docs/forge/) is SparkVault's resumable encryption proxy. All cryptographic operations happen server-side using AES-256-GCM, eliminating the need for client-side crypto libraries.

> **Why Use Forge**
>
> -   **Resumable uploads**: Recover failed transfers by resuming from the last accepted offset
> -   **Bounded chunks**: Upload bytes in a predictable memory envelope
> -   **No client-side crypto**: Encryption happens server-side over TLS

### How It Works

Forge uses **Ingot Secure Transfer Keys (ISTKs)** for authentication. ISTKs are **automatically embedded** in the URLs returned by the Ingots API:

-   **Upload:** The `forge_url` from Create Ingot includes the ISTK as a query parameter
-   **Download:** The `download_url` from Download Ingot includes the ISTK as a query parameter

You don't need to manually extract or manage ISTKs. Simply use the URLs returned by the API.

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

const forgeUrl = new URL(createResponse.data.forge_url);
const istk = forgeUrl.searchParams.get('istk');

const upload = new tus.Upload(file, {
  endpoint: `${forgeUrl.origin}/encrypt`,
  chunkSize: 50 * 1024 * 1024,
  headers: { 'X-ISTK': istk },
  metadata: {
    filename: file.name,
    filetype: file.type || 'application/octet-stream'
  }
});

upload.start();
```

```bash
# The download_url already contains the ISTK
curl "$DOWNLOAD_URL" --output decrypted-file.tar.gz
```

See the [Forge documentation](/api/docs/forge/) for complete details on resumable encryption, including TUS protocol details and error handling.

## Audit Logs

Per-ingot audit logs track uploads, downloads, sharing changes, and shared-link access. Audit logs are immutable and retained according to the vault's retention setting. For account- and vault-level audit logs, see the [Audit Logs API](/api/docs/audit-logs/).

#### `GET /v1/vaults/{vault_id}/ingots/{ingot_id}/audit-logs`

List audit events for an ingot.

#### Query Parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | integer | Optional | Entries per page (1-100)Default: `25` |
| `cursor` | string | Optional | Signed pagination cursor from the previous response's `next_cursor` |
| `event_types` | string | Optional | Comma-separated event types to filter by (e.g. `ingot_downloaded,ingot_deleted`) |

#### Response

| Field | Type | Description |
| --- | --- | --- |
| `entries` | array | Audit event entries, newest first |
| `entries[].event_type` | string | Event type (e.g. `ingot_created`, `ingot_downloaded`, `sparklink_accessed`) |
| `entries[].timestamp` | integer | Event timestamp |
| `entries[].actor_id` | string | User or API key that performed the action |
| `entries[].ip_address` | string | Client IP address |
| `entries[].user_agent` | string | Client user agent |
| `entries[].metadata` | object | Event-specific details. VAT downloads record `name`, `size_bytes`, `content_type`; SparkLink downloads record `link_code_prefix`, `visibility`, `identity`, `access_mode` |
| `count` | number | Number of entries in this page |
| `has_more` | boolean | Whether more entries are available |
| `next_cursor` | string? | Signed cursor for the next page (present when has\_more is true) |
| `vault_id` | string | Vault identifier |
| `ingot_id` | string | Ingot identifier |
| `event_types` | array | All valid ingot event types, usable as `event_types` filter values |

#### Example

Request

```bash
curl https://api.sparkvault.com/v1/vaults/vlt_abc123/ingots/ing_xyz789/audit-logs \
  -H "X-API-Key: sv_live_xxx" \
  -H "X-Vault-Access-Token: YOUR_VAT"
```

Response

```json
{
  "data": {
    "entries": [
      {
        "event_type": "ingot_downloaded",
        "timestamp": 1704157200,
        "actor_id": "usr_abc123",
        "ip_address": "203.0.113.7",
        "user_agent": "curl/8.5.0",
        "metadata": {
          "name": "report.pdf",
          "size_bytes": 1048576,
          "content_type": "application/pdf"
        }
      }
    ],
    "count": 1,
    "has_more": false,
    "vault_id": "vlt_abc123",
    "ingot_id": "ing_xyz789",
    "event_types": [
      "ingot_created", "ingot_renamed", "ingot_deleted", "ingot_moved",
      "ingot_shared", "ingot_unshared", "invite_created", "invite_revoked",
      "ingot_accessed", "ingot_downloaded", "ingot_uploaded",
      "sparklink_accessed", "upload_error", "download_error"
    ]
  }
}
```

## Usage

Ingot operations consume the pooled storage and bandwidth included with your seat subscription. There is no per-GB fee; usage is tracked and counted against your pool. For subscription tiers and capacity blocks, see the [pricing page](/pricing/).

#### Resource Usage

| Operation | Usage |
| --- | --- |
| Upload/Download | Counts against pooled bandwidth (included) |
| Storage | Counts against pooled storage (included) |
| Delete | Frees pooled storage |

> **Extending Capacity**
>
> When pooled storage or bandwidth is exhausted, extend it with a capacity block. See the [pricing page](/pricing/) for available blocks.

## Error Reference

#### Error Responses

| Status | Code | Description |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Invalid request parameters, including `size_bytes` above the 5 TB maximum (`size_bytes must be at most 5497558138880`) and deleting an ingot whose upload is still in flight |
| 401 | `AUTHENTICATION_ERROR` | Missing, malformed, or unknown Vault Access Token (`X-Vault-Access-Token` header), or a missing/invalid API key (`X-API-Key`) or session JWT (`Authorization: Bearer`) |
| 402 | `PLAN_REQUIRED` | An active subscription is required for this operation |
| 402 | `QUOTA_EXCEEDED` | Pooled storage or bandwidth exhausted (resource: storage|bandwidth); add a capacity block |
| 403 | `FORBIDDEN` | VAT expired or bound to a different vault/account, read-only (Viewer) VAT on a write operation, or direct access to a managed ingot |
| 404 | `NOT_FOUND` | Ingot not found (or name lookup failed) |
| 409 | `CONFLICT` | Ambiguous name lookup (multiple ingots share the name) or target folder is being deleted |
| 412 | `PRECONDITION_FAILED` | `expected_updated_at` did not match on PUT: the ingot changed; fetch the latest metadata and retry |
