Overview
Forge is SparkVault's encryption and transfer service, the data plane that every Ingot (an encrypted file stored in a Vault) 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.
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:
https://forge.sparkvault.com
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:
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"
}'
{
"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.
An upload's ISTK stops working the moment a later upload of the same file is created (a retried create
whose first answer was lost, or a second device uploading the same name): the later upload is the one
kept. From then on the earlier upload URL, and every request on an upload session it opened, is answered
410 UPLOAD_GONE naming UPLOAD_SUPERSEDED in details.coreCode and in
the X-Upload-Refusal header, no further byte of it is stored, and what it had stored is
discarded. A new upload is no remedy: the later one already holds the file.
It stops working the same way the moment the ingot it uploads to is deleted: the upload URL, and every
request on an upload session it opened, is answered 410 UPLOAD_GONE ("The file this upload was
for no longer exists") with X-Upload-Refusal: NOT_FOUND, and what it had stored is discarded.
A new upload can store the file again.
An ISTK is the 43-character base64url string SparkVault issues; any other value is answered as an ISTK
Forge does not know (401, details.code: INVALID_ISTK), with nothing read.
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, unless Forge answers 503: a service it depends on failed for a moment
before anything was spent, the token is handed back unconsumed, and the same request may be sent again
after its Retry-After. 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.
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"
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
/health
Check if the Forge service is operational. No authentication required.
curl https://forge.sparkvault.com/health
{
"status": "healthy",
"version": "1.0.0",
"timestamp": "2026-07-02T12:00:00.000Z"
}
Capability Discovery
/encrypt
tus capability discovery. Returns the supported tus version, protocol extensions, and maximum upload size as headers on a 204 response.
Tus-Resumable: 1.0.0
Tus-Version: 1.0.0
Tus-Extension: creation,termination,expiration
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
/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; a 503 hands it back unconsumed, so the same request may be sent again after its Retry-After. |
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 | PATCH chunk size: 52,428,800 bytes (50 MB). Every non-final chunk must be exactly this size; read it from this response rather than assuming it. |
Upload-Expires |
header | When the upload lapses if no chunk arrives, as an HTTP-date (the tus expiration extension; see Retrying an Upload) |
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
/encrypt/{upload_id}
Upload the next plaintext chunk to the session. Returns 204 No Content with the new Upload-Offset and Upload-Expires headers. 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 refused with 409 OFFSET_MISMATCH, whose Upload-Offset header names the offset to resume from |
Content-Type |
string | Required | Must be application/offset+octet-stream |
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
Forge may answer a PATCH with an error before its body is fully sent, so a client must stop
sending once a response arrives: listen for the response, not only for write back-pressure.
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 with
Retry-After. Re-check the offset with HEAD and retry.
Check Upload Offset
/encrypt/{upload_id}
tus offset check. Returns 200 OK with the session's current state in headers. Use it to find where to resume. An offset equal to Upload-Length means every byte is received, not that the upload is published: confirm publication with a zero-length PATCH at that offset, which answers 204 once the upload is published, 503 with Retry-After while its publication is deferred, and 409 with Retry-After while another request is publishing it. For an upload that has received every byte but is not yet published, HEAD also tries the publication again.
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 | The session's PATCH chunk size (same value as the create response); every non-final chunk must be exactly this size |
Upload-Expires |
header | When the upload lapses if no further chunk arrives, as an HTTP-date |
A HEAD whose new attempt to publish is refused for good answers 410 (or 412) with
no body: its X-Upload-Refusal header names the reason, as the final PATCH's
details.coreCode does (UPLOAD_POOL_FULL, UPLOAD_SUPERSEDED,
UPLOAD_PROTOCOL_RETIRED, PRECONDITION_FAILED, or NOT_FOUND when the
file it was for no longer exists). A HEAD or PATCH of an upload a later upload of the same file replaced
answers 410 with X-Upload-Refusal: UPLOAD_SUPERSEDED at any offset, the PATCH
before any of its body is stored, and one whose file was deleted answers 410 with
X-Upload-Refusal: NOT_FOUND the same way.
Cancel Upload
/encrypt/{upload_id}
Cancel an in-progress upload. Deletes the staged encrypted chunks (the partial transfer counts against your bandwidth pool) and returns 204 No Content, an upload a later upload of the same file replaced included. A no-op if the upload already finalized. While another request is writing to the upload, it answers 409 CONFLICT with Retry-After; retry the cancel after the interval. When the cancel could not delete every staged chunk yet, it answers 503 SERVICE_UNAVAILABLE with Retry-After: the upload stays cancelled, and repeating the DELETE after the interval finishes the cleanup.
Decrypt Stream
/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. |
Headers
| Parameter | Type | Required | Description |
|---|---|---|---|
Range |
string | Optional | One byte range: bytes=START-END, bytes=START- or bytes=-SUFFIX. Answered 206 Partial Content with a Content-Range header, or 416 when it cannot be satisfied (it starts past the end of the file, ends before it starts, or asks for the last zero bytes, bytes=-0). A multi-range or malformed value is ignored and the whole file is served. |
If-Range |
string | Optional | The ETag of an earlier response for this ingot. The Range is honored only while this is, byte for byte, the ETag of the content published now; otherwise the whole file is served with 200. A weak validator (W/"…") or a date never matches. |
Byte ranges are the basis for resumable and partial downloads and for media seeking with reusable
streaming URLs. To resume a download, send the bytes you still need as Range and the
ETag you were served as If-Range (from a fresh download URL when yours was
single-use): a 206 continues the same content, while a 200 means the ingot was
replaced since, and its whole new content follows from the first byte.
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, naming the file by the ingot's current name (path separators, control characters and bidi controls replaced), completed with the extension its content type names when the name lacks it |
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 |
X-Content-Version |
header | Identifies the published content these bytes belong to (absent for content published without one). To replace the ingot with an edited copy of these bytes, send it as expected_content_version on the Replace Ingot Content request |
ETag |
header | A strong validator naming the same published content, quoted: the value to send as If-Range. Absent for content published without one. It is also sent on every refusal below but a 503 from reading the ingot's own record |
Cache-Control |
header | private, no-store, no-transform: decrypted plaintext is never cacheable, and no intermediary may compress or rewrite it, so its bytes and its ETag arrive as sent |
Every refusal below carries the same JSON error envelope as Forge's other errors
({ "success": false, "error": { "code", "message" } }, Content-Type: application/json)
beside the headers it names.
Error Responses
| Status | Code | Description |
|---|---|---|
| 416 | RANGE_NOT_SATISFIABLE |
The Range cannot be satisfied: it starts past the end of the file, ends before it starts, or is bytes=-0. Content-Range: bytes */SIZE carries the file's size. |
| 403 | STREAM_CAP_REACHED |
A reusable streaming URL whose cumulative byte cap is spent (the URL is revoked: later requests fail 401 with details.code: ISTK_USED), or whose remaining cap can never hold the first chunk the request reads: a request holds each chunk it touches (50 MB; the last, the file's remainder) whole against the cap while it reads it, however few of its bytes it serves, and the cap keeps only the bytes served. A transfer whose cap refuses a later chunk ends short, its connection reset after the last chunk the cap took: resume it like any interrupted download, and the resume is answered 403 or 429 as the cap then stands. |
| 429 | STREAM_TOKEN_BUSY |
A reusable streaming URL that already has four requests in flight on the Forge server that took this one (each server counts its own). Retry the same URL after Retry-After (1 second). |
| 429 | STREAM_CAP_HELD |
A reusable streaming URL whose remaining byte cap is held by its own requests in flight, which settle to the bytes they serve. Retry the same URL after Retry-After (5 seconds). |
| 503 | SERVICE_UNAVAILABLE |
Forge is at its decryption capacity, or a storage service it reads from failed transiently before anything was served. Retry the same URL after Retry-After (5 seconds): a single-use URL is handed back unconsumed. |
# The download_url from the Ingot download endpoint includes the ISTK
curl "https://forge.sparkvault.com/decrypt?istk=3q2-7wX9kLmN0pQrStUvWxYzAb1Cd2Ef3Gh4Ij5Kl6M" \
--output decrypted.pdf
curl "https://forge.sparkvault.com/decrypt?istk=3q2-7wX9kLmN0pQrStUvWxYzAb1Cd2Ef3Gh4Ij5Kl6M" \
-H "Range: bytes=1048576-2097151" \
--output part.bin
# The status line is printed: 206 is the rest of the same content; 200 means
# the ingot was replaced, and rest.bin holds its whole new content.
curl -D - "https://forge.sparkvault.com/decrypt?istk=3q2-7wX9kLmN0pQrStUvWxYzAb1Cd2Ef3Gh4Ij5Kl6M" \
-H "Range: bytes=1048576-" \
-H 'If-Range: "tus_9f2c41d07be3a856"' \
--output rest.bin
Keep the bytes you received and the response's ETag. When the transfer fails part-way (the
connection drops or goes silent), ask for the rest: Range: bytes=RECEIVED- with
If-Range set to that ETag. A 206 whose Content-Range
starts at RECEIVED continues your bytes; a 200 means the ingot was replaced, so
discard what you hold and keep the whole new body. Resume only from a strong ETag (one that
does not start with W/) on a response that carried Accept-Ranges: bytes;
otherwise start again from the first byte.
A single-use download URL is claimed the moment its request reaches Forge, even when no answer comes
back, so every retry needs a fresh URL from the Ingot download endpoint: the spent one answers
401 (ISTK_USED). Only a 503 tells you the URL was handed back
unclaimed; a request that ended before any byte reached you (you gave up, or the Forge server shut down)
hands it back too, but nothing tells you so, so fetch a fresh URL after one. A reusable streaming URL is retried as it is: every byte it serves counts against its byte
cap, four times the file's size, so a resume that asks for the missing range alone costs only that
range, and each Forge server serves at most four of its requests at a time (a fifth there is answered
429 with Retry-After: 1). A browser's download manager handed a streaming URL can resume it this way
itself.
Retry a request that got no answer, or a 408, 425, 429 or
5xx, after an exponential backoff with jitter, never sooner than Retry-After;
every other 4xx is final. Bound the retries by failures in a row and by time without new
bytes, and start both over whenever bytes arrive.
Check Progress
/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. The transfer's own response is its completion signal: the status field changes once Forge has recorded the transfer, which can trail its last byte.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
istk |
string | Required | The ISTK of the operation to inspect. Returns 404 if unknown. |
{
"success": true,
"progress": {
"bytes_processed": 52428800,
"total_bytes": 1073741824,
"status": "forging"
}
}
A HEAD request to /decrypt or /encrypt returns 200
without reading the ISTK, useful for availability checks before committing a single-use token.
HEAD /progress is GET /progress without the body: it reads the ISTK's record
and answers 200, 400 without an istk, or 404 for an
unknown one, consuming nothing.
Examples
Encrypt and Store a File
# 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
# 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
Retrying an Upload
A request whose answer was lost may still have landed, so an upload never resends a chunk from an offset the server has not just named. What to do with each answer:
-
No answer (a dropped connection, a timeout), or
408,425,429or5xx: wait an exponential backoff with jitter, never shorter thanRetry-After, thenHEADthe upload and continue from itsUpload-Offset. -
409 OFFSET_MISMATCH: continue at once from the response's ownUpload-Offset; noHEADis needed. -
409 CONFLICT(noUpload-Offset): another request holds the upload's write lease. WaitRetry-After, thenHEADand continue. -
404or410: the upload no longer exists (cancelled, expired, its file removed, or refused for good at publication). Only a new upload from byte 0, on a fresh Forge URL, can store the file, unless the answer namesUPLOAD_POOL_FULLorUPLOAD_SUPERSEDED(details.coreCode, or theX-Upload-Refusalheader): no new upload remedies either. -
Any other
4xxis the server's verdict on the request: sending it again earns the same answer.
Bound the retries by failures in a row and by time without progress, and start both over whenever the
offset moves, so a slow upload that keeps landing bytes is never cut off while a dead one still ends. An
Upload-Offset equal to Upload-Length proves every byte arrived, not that the
upload is published: finish with a zero-length PATCH at that offset, which answers
204 once the upload is published; on a 409 or 503, send it again
after Retry-After.
Every answer about an upload that still stands carries Upload-Expires (the tus
expiration extension), but for one given before the upload is read (a malformed
Upload-Offset) and a server error other than a deferred publication's 503
(a shutting-down Forge's 503, a cancel's 503 for chunks it has yet to delete,
a failure of the session store):
the time the upload lapses if nothing more happens. While it
receives, that is 72 hours from its creation or its last chunk, and never later than the time its size
allows (its size at 1 MiB per second, and at least 7 days from its creation). Once every byte has
arrived, it is 72 hours from the last chunk or from the latest attempt to publish it, past that allowance
if need be. Past it an upload that never finished answers 410; one SparkVault published
answers its full offset instead.
JavaScript / Node.js
-
It never reads
Retry-After, so itsretryDelaysmust outlast Forge's own (2 seconds while another request holds the upload, 5 seconds while a publication is deferred or a service Forge relies on is down): use at least[0, 2000, 5000, 10000, 30000]. -
It retries a failed request with a
HEADand continues from that offset, ignoring a409's ownUpload-Offset; both are safe with Forge. Its retry count starts over whenever the offset advances. -
It gives up at once while the browser reports itself offline (
navigator.onLineis false). Callstart()again when the browser is back online: it resumes from the offset aHEADreports. -
It reports success when a
HEADshows every byte received, which Forge answers before the upload is published. AfteronSuccess, confirm the publication with a zero-lengthPATCHat the final offset, as below.
import * as tus from 'tus-js-client';
// Forge fixes every session's chunk geometry: each non-final PATCH must be
// exactly X-Chunk-Size (52,428,800 bytes). tus-js-client cannot size its
// PATCHes from a response header, so pin the value here.
const FORGE_CHUNK_SIZE = 52428800;
function uploadWithForge(file, forgeUrl) {
const url = new URL(forgeUrl);
const istk = url.searchParams.get('istk');
return new Promise((resolve, reject) => {
const upload = new tus.Upload(file, {
endpoint: `${url.origin}/encrypt`,
chunkSize: FORGE_CHUNK_SIZE,
// tus-js-client sends headers on every request; Forge only requires
// X-ISTK on session creation and ignores it elsewhere.
headers: { 'X-ISTK': istk },
// tus-js-client never reads Retry-After: these delays outlast Forge's.
retryDelays: [0, 2000, 5000, 10000, 30000],
metadata: {
filename: file.name,
filetype: file.type || 'application/octet-stream'
},
// onSuccess also follows a HEAD that shows every byte received, which
// is not yet publication: confirm it before resolving.
onSuccess: () => confirmPublished(upload.url, file.size).then(resolve, reject),
onError: reject
});
upload.start();
});
}
// The zero-length PATCH at the final offset answers 204 once the upload is
// published, and 409 or 503 with Retry-After while its publication is in
// progress or deferred.
async function confirmPublished(uploadUrl, size) {
for (let attempt = 0; attempt < 60; attempt += 1) {
const response = await fetch(uploadUrl, {
method: 'PATCH',
headers: {
'Tus-Resumable': '1.0.0',
'Upload-Offset': String(size),
'Content-Type': 'application/offset+octet-stream'
}
});
if (response.ok) return;
if (response.status !== 409 && response.status !== 503) {
throw new Error(`Upload not published (status ${response.status})`);
}
const seconds = Number(response.headers.get('Retry-After')) || 5;
await new Promise((wait) => setTimeout(wait, seconds * 1000));
}
throw new Error('Upload not published yet: check the ingot later');
}
Python
import os
from urllib.parse import parse_qs, urljoin, urlparse
import requests
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'])
# Forge dictates the chunk geometry: every non-final PATCH must be
# exactly this many bytes, so never substitute a value of your own.
chunk_size = int(create.headers['X-Chunk-Size'])
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:
- The Core API envelope-encrypts the vault's VAK into the ISTK record: an HSM-derived key-encryption key wraps a random data key, which wraps the VAK
- Forge validates the ISTK, derives the same KEK from the HSM, and unwraps the data key, then the VAK
- The VAK is used directly as the AES-256-GCM key for the operation
- Isolation between ingots and chunks comes from the per-upload random base nonce and the per-chunk AAD binding, not from separate keys
- 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.
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 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 not the 43-character base64url string SparkVault issues), ISTK_EXPIRED, or ISTK_USED (single-use token already claimed). Obtain a fresh Forge URL from the ingot create or download endpoint. |
| 403 | STREAM_CAP_REACHED |
A reusable streaming-decrypt URL whose cumulative byte cap is exhausted, or whose remaining cap can never hold the chunk the request reads next. An exhausted token is revoked, so subsequent requests fail with 401 AUTHENTICATION_ERROR (details.code: ISTK_USED). |
| 404 | NOT_FOUND |
Upload session not found (an id that is not an upload's included), or the target ingot is missing or not active. |
| 409 | CONFLICT |
Another request is writing to the upload or finalizing it (a PATCH or DELETE meeting a held write lease, with Retry-After), or a PATCH's write was taken over by another request. Re-check the offset with HEAD and retry. |
| 409 | OFFSET_MISMATCH |
A PATCH's Upload-Offset is not the upload's current offset. The response's Upload-Offset header names the offset to resume from; nothing was written. |
| 410 | UPLOAD_GONE |
The upload was cancelled or has expired (see Upload-Expires), the file it was for no longer exists (X-Upload-Refusal: NOT_FOUND; deleted while the upload was in flight, from the moment of the delete, to its upload URL and every request on a session it opened), or SparkVault refused it for good, with details.coreCode and the X-Upload-Refusal header naming why: UPLOAD_POOL_FULL (an upload the vault owner's storage pool cannot take at publication) or UPLOAD_SUPERSEDED (a later upload to the same file replaced it: answered to its upload URL, on /encrypt and /decrypt alike, and to every request on a session it opened, from the moment the later upload was created). A HEAD's answer has no body, so the header alone names the reason there. It can never complete: start a new upload from a fresh Forge URL, except for UPLOAD_POOL_FULL and UPLOAD_SUPERSEDED, which no new upload remedies. |
| 410 | UPLOAD_PROTOCOL_RETIRED |
The final PATCH (or HEAD, whose X-Upload-Refusal header names this code) of an upload created under an upload contract SparkVault no longer publishes. It can never complete and its staged bytes are discarded: start a new upload from a fresh Forge URL. |
| 412 | PRECONDITION_FAILED |
The final PATCH (or HEAD, whose X-Upload-Refusal header names this code) of a replacement made with expected_content_version: the content it was built from was replaced before this upload could publish, so it is not published and its staged bytes are discarded. Retrying the upload cannot succeed; download the current content, reapply your change, and replace again. |
| 416 | RANGE_NOT_SATISFIABLE |
A download's Range cannot be satisfied; Content-Range: bytes */SIZE carries the file's size. |
| 429 | STREAM_TOKEN_BUSY |
A reusable streaming-decrypt URL with four requests already in flight on the Forge server that took this one. Retry after Retry-After. |
| 429 | STREAM_CAP_HELD |
A reusable streaming-decrypt URL whose remaining byte cap is held by its own requests in flight. Retry after Retry-After. |
| 500 | CRYPTOGRAPHIC_ERROR |
Encryption or decryption failed: authentication-tag mismatch, corrupted key material, or stored ingot metadata that fails its integrity checks. |
| 500 | INTERNAL_ERROR |
Unexpected server error, a failed object-storage read or write included. Retry with exponential backoff; an upload re-checks its offset with HEAD and resends from there. |
| 503 | SERVICE_UNAVAILABLE |
The request could not finish for a moment: a service Forge relies on (the key service among them) could not answer before the transfer began, so nothing was spent, a single-use ISTK stays usable and the same URL can be retried; or the final PATCH's publication is deferred (every byte is kept); or the Forge instance serving an upload request is shutting down, and nothing the request did was committed but a cancel it cut short once the upload was cancelled, which stays cancelled with its received bytes charged; or a cancel could not delete every staged chunk yet (the upload stays cancelled). Retry the same request after Retry-After. |
{
"success": false,
"error": {
"code": "AUTHENTICATION_ERROR",
"message": "ISTK expired",
"details": {
"code": "ISTK_EXPIRED"
}
}
}
Best Practices
-
Check ISTK expiration before starting: Verify that
expires_atgives 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 what can succeed when sent again (a request that got no
answer, a
408,425,429or5xx) after an exponential backoff with jitter that never undercutsRetry-After. An upload's409names the offset to continue from or asks you to wait, and its404or410calls for a new upload (Retrying an Upload, above); every other4xxis final. Continue a download from the bytes you hold (Resuming a Download). - 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.