Overview
Ingots are encrypted data objects stored within 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.
Named-field key/value data with atomic updates is a separate product, 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 |
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 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.
{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
/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); the upload may send no more. What the upload adds to the storage pool is reserved for it while it is in progress: this size, less the size of the content it replaces when it upserts onto an existing ingot |
content_type |
string | Optional | MIME typeDefault: application/octet-stream |
original_filename |
string | Optional | The name of the local file the upload came from, recorded with the ingot. A download saves under the ingot's current name |
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 |
on_name_conflict |
string | Optional | What happens when the name already exists in the target folder: upsert, complete_pending, refuse or new_name (see Upsert by Name)Default: upsert |
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. The new content's content_type and original_filename replace the ingot's, and an original_filename the upload does not send is cleared rather than carried over from the content it replaces.
Send on_name_conflict: "complete_pending" to create without ever replacing content: an existing ingot whose upload never completed is reused so the upload can finish, and any other same-name ingot answers 409 CONFLICT and is left unchanged.
Send on_name_conflict: "refuse" to create only a new ingot: any same-name ingot, its upload finished or not, answers 409 CONFLICT and is left unchanged.
A 409 from complete_pending or refuse names the ingot holding the name: error.details is { "reason": "NAME_HELD", "ingot_id", "status", "size_bytes" }, so the caller can act on it without reading it again.
Send on_name_conflict: "new_name" to keep both: the ingot is created under the first free numbered name (report (2).pdf), returned as the response's name.
When two uploads of the same name overlap (a create retried after its first answer was lost, a second device uploading the same file), the later one is the upload the ingot keeps. The earlier one's Forge URL stops working the moment the later create is made: Forge answers it, and every request on an upload session it opened, 410 UPLOAD_GONE naming UPLOAD_SUPERSEDED. An earlier create the later one overtook before its URL was issued answers 410 UPLOAD_SUPERSEDED itself, with no URL. No new upload remedies either: the later one holds the file.
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 |
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. Forge requires every non-final
// chunk to be exactly X-Chunk-Size (52,428,800 bytes); tus-js-client cannot
// read that header, so the value is pinned.
const forgeUrl = new URL(forge_url);
const istk = forgeUrl.searchParams.get('istk');
const upload = new tus.Upload(file, {
endpoint: `${forgeUrl.origin}/encrypt`,
chunkSize: 52428800,
headers: { 'X-ISTK': istk },
retryDelays: [0, 1000, 3000, 5000, 10000]
});
upload.start();
// Status will change: uploading → active
List Ingots
/v1/vaults/{vault_id}/ingots
List all ingots in an unsealed vault.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
limit |
integer | Optional | Ingots per page, at most 200; a larger value reads as 200 (Pagination)Default: 100 |
sort_by |
string | Optional | Sort field: name, size, content_type, created_at, access_count, or bandwidth |
sort_order |
string | Optional | asc or descDefault: 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 until Forge activates the upload that created the ingot, then active. Replacing an active ingot's content leaves it active |
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); 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
/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 | Results per page, at most 200; a larger value reads as 200 (Pagination)Default: 50 |
cursor |
string | Optional | Sealed pagination cursor from the previous response's next_cursor |
sort_order |
string | Optional | asc or descDefault: 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? | Sealed 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 |
# 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
/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 until Forge activates the upload that created the ingot, then active. Replacing an active ingot's content leaves it active |
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 that replaces the whole content (a replacement built from downloaded content uses expected_content_version instead) |
access_count |
integer | Number of downloads |
total_bandwidth_bytes |
integer | Total bytes served for this ingot |
Download Ingot
/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 | The ingot's current name, which the download saves under |
size_bytes |
integer | File size in bytes |
content_type |
string | MIME type |
original_filename |
string | The name of the local file the content was uploaded from |
expires_at |
integer | Download URL expiration timestamp |
stream |
boolean | Whether the URL is a reusable streaming URL |
// 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 } = (await downloadResponse.json()).data;
// Step 2: Open the Forge URL. Forge streams the decrypted file and names it
// (Content-Disposition) under the ingot's current name, with path separators,
// control characters and bidi controls replaced.
window.location.href = download_url;
Replace Ingot Content
/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. The ingot keeps its original_filename. A replacement another upload of the ingot overtook before its URL was issued answers 410 UPLOAD_SUPERSEDED, with no URL.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
size_bytes |
integer | Required | Exact size of the replacement content in bytes (1 byte to 5 TB); the upload may send no more. What the upload adds to the storage pool is reserved for it while it is in progress: this size less the size of the current content, nothing when it is no larger |
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. Suited to a replacement that does not depend on the current content |
expected_content_version |
string|null | Optional | For a read-modify-write (download, edit, replace): the X-Content-Version header of the Forge download whose bytes you edited, or null if it carried none. The PUT fails with 412 PRECONDITION_FAILED if that content has since been replaced or another replacement of the ingot is still in flight, and the upload's final PATCH is refused with 412 if the content is replaced before it publishes. Use it instead of expected_updated_at here: an ingot keeps serving its old content until a replacement publishes, so updated_at can move ahead of the bytes you downloaded |
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
/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
/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.
Deleted ingots cannot be recovered. The encrypted data is permanently destroyed.
An ingot can be deleted while an upload to it is in progress: the delete revokes that upload's Forge URL and cancels the upload, so Forge answers the URL, and every request on its upload session, 410 UPLOAD_GONE (its X-Upload-Refusal header naming NOT_FOUND: the file it was for no longer exists), and the delete releases the storage the upload reserved. A create or replacement whose ingot is deleted before its Forge URL is issued answers 404 NOT_FOUND.
Forge: Streaming Encryption
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.
- 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_urlfrom Create Ingot includes the ISTK as a query parameter - Download: The
download_urlfrom Download Ingot includes the ISTK as a query parameter
You don't need to manually extract or manage ISTKs. Use the URLs the API returns.
import * as tus from 'tus-js-client';
const forgeUrl = new URL(createResponse.data.forge_url);
const istk = forgeUrl.searchParams.get('istk');
// Every non-final chunk must be exactly Forge's X-Chunk-Size (52,428,800 bytes).
const upload = new tus.Upload(file, {
endpoint: `${forgeUrl.origin}/encrypt`,
chunkSize: 52428800,
headers: { 'X-ISTK': istk },
metadata: {
filename: file.name,
filetype: file.type || 'application/octet-stream'
}
});
upload.start();
# The download_url already contains the ISTK
curl "$DOWNLOAD_URL" --output decrypted-file.tar.gz
See the Forge documentation 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.
/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, at most 100; a larger value reads as 100 (Pagination)Default: 25 |
cursor |
string | Optional | Sealed 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_renamed) |
Response
| Field | Type | Description |
|---|---|---|
entries |
array | Audit event entries, newest first |
entries[].event_type |
string | Event type (e.g. ingot_created, ingot_downloaded) |
entries[].timestamp |
integer | Event timestamp |
entries[].actor_id |
string? | Who performed the action, as in the audit log entry fields: a user (usr_...), or the API key (key_...) a request presented, the request to a SparkVault product such as Structured Ingots included; null for a download through a shared link, whose metadata names the link and any verified identity, and for a file uploaded through the vault's public upload page (ingot_uploaded) |
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 (the link's masked code: its first six characters and ***), visibility, identity, access_mode |
count |
number | Number of entries in this page |
has_more |
boolean | Whether more entries are available |
next_cursor |
string? | Sealed 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
Requestcurl 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"
{
"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_uploaded", "ingot_downloaded", "ingot_renamed", "ingot_moved"
]
}
}
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.
Resource Usage
| Operation | Usage |
|---|---|
| Upload/Download | Counts against pooled bandwidth (included) |
| Storage | Counts against pooled storage (included) |
| Delete | Frees pooled storage |
When pooled storage or bandwidth is exhausted, extend it with a capacity block. See the pricing page 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) |
| 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, or what an upload adds to the storage pool (its size_bytes, less the size of the content it replaces) does not fit beside what the account stores and what its other uploads in progress reserve (resource: storage|bandwidth; a refused upload names required_bytes and reserved_bytes); 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), or deleted before a create or replacement of it could issue its Forge URL |
| 409 | CONFLICT |
Ambiguous name lookup (multiple ingots share the name), target folder is being deleted, or the ingot kept changing while a delete was decided (retry the delete) |
| 410 | UPLOAD_SUPERSEDED |
A create or replacement another upload of the same ingot overtook before its Forge URL was issued (a create retried after its first answer was lost, a second device uploading the same file): the later upload is the one the ingot keeps, and no URL is issued. A new upload is no remedy |
| 412 | PRECONDITION_FAILED |
A PUT precondition no longer holds: expected_updated_at did not match, or the content named by expected_content_version was replaced or another replacement of the ingot is still in flight. Re-read the ingot (re-download it for a read-modify-write) and retry |
| 429 | UPLOAD_LIMIT |
The account already has 500 uploads in progress: finish or cancel one. A slot frees the moment any of them ends, which no time predicts, so Retry-After asks for a retry in 60 seconds |