Overview
Sparks are ephemeral, self-destructing secrets. When someone reads a Spark, the plaintext is returned and the Spark is immediately destroyed ("burned"). The encrypted data can never be accessed again.
Key Features
- Read-Once: Data is permanently destroyed after first read
- Auto-Expiration: Unread Sparks expire after TTL (default 24 hours, max 24 hours)
- Double Zero-Trust: Encrypted with both system and account keys
- Audit Trail: Track when Sparks are created, read, and destroyed
Content Types
Sparks support both text and binary content. The server treats text/* types (plus application/json, application/xml, application/javascript, and types with +json/+xml suffixes) as text: send those payloads as UTF-8 strings. Any other content type is treated as binary: base64-encode the payload and set content_type to the appropriate MIME type.
| Content Type | Encoding | Example |
|---|---|---|
text/plain |
UTF-8 string | Passwords, API keys, text |
application/pdf |
Base64 | PDF documents |
application/json |
UTF-8 string | JSON configuration |
application/octet-stream |
Base64 | Generic binary files |
text/uri-list |
UTF-8 string (a single http/https URL) | Link Sparks: burn-on-open redirect links |
Spark States
| Status | Description |
|---|---|
active |
Spark is available and has not been read yet |
reading |
Transient: an atomic read lock is held while a burn-read is in flight. Concurrent readers receive 409 CONFLICT; a stale lock recovers after 30 seconds |
ash |
Spark has been read and destroyed ("burned") |
expired |
Spark TTL elapsed without being read |
Create Spark
/v1/sparks
Create a new ephemeral secret. The payload is encrypted and can only be read once.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
payload |
string | Required | The secret data to encrypt (max 256 KB / 256,000 bytes). For binary content, send as base64; the 256,000-byte limit applies to the base64-encoded payload (roughly 192 KB of raw binary), while size_bytes reports the decoded size. For link Sparks (text/uri-list), a single http/https URL. |
ttl_minutes |
integer | Optional | Time-to-live in minutes (1-1440, max 24 hours)Default: 1440 (24 hours) |
content_type |
string | Optional | MIME type of payload. Types the server does not treat as text indicate base64-encoded binary. text/uri-list creates a link Spark (see below).Default: text/plain |
filename |
string | Optional | Original filename for file uploads (preserved for download)Default: null |
with_kindling |
boolean | Optional | Generate a new kindling ID for grouping related SparksDefault: false |
kindling |
string | Optional | Join an existing kindling group (e.g., kdl_xxx)Default: null |
bound_ip |
string | Optional | Link Sparks only: bind the auto-minted x.sv link to a single IP addressDefault: null |
Response
| Field | Type | Description |
|---|---|---|
spark_id |
string | Unique spark identifier (spk_...) |
size_bytes |
integer | Payload size in bytes |
status |
string | Status (active) |
created_at |
integer | Unix timestamp of creation |
expires_at |
integer | Unix timestamp when Spark expires |
ttl_minutes |
integer | TTL in minutes |
filename |
string? | Original filename (only present if provided for file uploads) |
kindling |
string? | Kindling ID (only present if with_kindling or kindling was provided) |
link_url |
string? | Link Sparks only: shareable https://x.sv/{code} URL |
link_code |
string? | Link Sparks only: SparkLink code of the minted link |
Basic Example
Requestcurl -X POST https://api.sparkvault.com/v1/sparks \
-H "X-API-Key: sv_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"payload": "super_secret_password_123",
"ttl_minutes": 60
}'
{
"data": {
"spark_id": "spk_abc123def456...",
"size_bytes": 25,
"status": "active",
"created_at": 1702000000,
"expires_at": 1702003600,
"ttl_minutes": 60
}
}
Link Sparks (text/uri-list)
Setting content_type to text/uri-list creates a link Spark: the payload must be a single http/https URL (validated before sealing), and the destination is encrypted at rest, never stored in plaintext. The server automatically mints a public x.sv SparkLink for the Spark, so the response additionally carries link_url (https://x.sv/{code}) and link_code. The destination burns on first open. Pass bound_ip to bind the link to a single IP address. List responses surface link_url for link Sparks.
Create a Link Spark
Requestcurl -X POST https://api.sparkvault.com/v1/sparks \
-H "X-API-Key: sv_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"payload": "https://example.com/one-time-download",
"content_type": "text/uri-list",
"ttl_minutes": 60
}'
{
"data": {
"spark_id": "spk_abc123...",
"size_bytes": 38,
"status": "active",
"created_at": 1702000000,
"expires_at": 1702003600,
"ttl_minutes": 60,
"link_url": "https://x.sv/abc123...",
"link_code": "abc123..."
}
}
List Sparks
/v1/sparks
List Sparks created by your account. Supports filtering by status and cursor-based pagination.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
status |
string | Optional | Exact-match filter on stored status: active or ash. Omit the parameter to return all Sparks. (expired is computed per item in the response, not a filterable stored status.)Default: none (all Sparks) |
kindling |
string | Optional | Filter by kindling group (e.g., kdl_xxx)Default: null |
limit |
integer | Optional | Maximum results per page (1-500)Default: 100 |
cursor |
string | Optional | Opaque signed pagination cursor from a previous response's next_cursorDefault: null |
Response
| Field | Type | Description |
|---|---|---|
sparks |
array | Array of Spark objects |
sparks[].spark_id |
string | Spark identifier |
sparks[].account_id |
string | Owning account ID |
sparks[].content_type |
string | MIME type of payload |
sparks[].filename |
string? | Original filename (if provided for file uploads) |
sparks[].status |
string | Current status: active, ash, or expired (computed at response time) |
sparks[].size_bytes |
integer | Payload size |
sparks[].created_at |
integer | Creation timestamp |
sparks[].expires_at |
integer | Expiration timestamp |
sparks[].burned_at |
integer? | When read (null unless burned) |
sparks[].time_remaining |
integer | Seconds until expiration (0 if expired) |
sparks[].kindling |
string? | Kindling group ID (kdl_...), when the Spark belongs to a kindling group |
sparks[].link_url |
string? | Shareable https://x.sv/{code} URL (link Sparks only) |
count |
integer | Number of Sparks in this page |
has_more |
boolean | Whether more pages exist |
next_cursor |
string? | Signed opaque cursor for the next page (present when has_more is true); pass it back as ?cursor= |
Filter Active Sparks
Requestcurl "https://api.sparkvault.com/v1/sparks?status=active&limit=10" \
-H "X-API-Key: sv_live_xxx"
{
"data": {
"sparks": [
{
"spark_id": "spk_abc123...",
"account_id": "acc_xyz789...",
"content_type": "text/plain",
"status": "active",
"size_bytes": 25,
"created_at": 1702000000,
"expires_at": 1702086400,
"burned_at": null,
"time_remaining": 82800
}
],
"count": 1,
"has_more": false
}
}
Read Spark (Burn)
/v1/sparks/{spark_id}
Read and destroy a Spark. The decrypted payload is returned and the Spark is immediately burned. This operation cannot be undone.
Response
| Field | Type | Description |
|---|---|---|
spark_id |
string | Spark identifier |
payload |
string | The decrypted secret data (base64 if the content_type is not treated as text) |
content_type |
string | MIME type. Types the server does not treat as text (see Content Types) indicate the payload is base64-encoded. |
filename |
string? | Original filename (if provided for file uploads) |
size_bytes |
integer | Payload size |
status |
string | Status (now ash) |
created_at |
integer | Creation timestamp |
burned_at |
integer | Timestamp when burned |
was_active |
boolean | Always true on a successful burn-read |
Read Standard Spark
Requestcurl https://api.sparkvault.com/v1/sparks/spk_abc123 \
-H "X-API-Key: sv_live_xxx"
{
"data": {
"spark_id": "spk_abc123def456...",
"payload": "super_secret_password_123",
"content_type": "text/plain",
"size_bytes": 25,
"status": "ash",
"created_at": 1702000000,
"burned_at": 1702001000,
"was_active": true
}
}
Reading a Spark permanently destroys the encrypted data. The payload is returned exactly once. There is no way to read a Spark again after this call.
Attempting to read an already-burned Spark returns 403 FORBIDDEN ("Spark has already been burned (read once)"), and an expired Spark returns 403 FORBIDDEN ("Spark has expired"). Only a nonexistent spark_id returns 404 NOT_FOUND. A concurrent in-flight read returns 409 CONFLICT.
Error Responses
| Status | Code | Description |
|---|---|---|
| 403 | FORBIDDEN |
Spark already burned or expired; also returned when a concurrent request wins the burn race |
| 404 | NOT_FOUND |
Spark does not exist |
| 409 | CONFLICT |
Spark is currently being read by another request |
Delete Spark
/v1/sparks/{spark_id}
Delete an unread Spark without reading its contents. Use this to cancel a Spark before it's read. Returns 204 No Content on success.
Example
Requestcurl -X DELETE https://api.sparkvault.com/v1/sparks/spk_abc123 \
-H "X-API-Key: sv_live_xxx"
(204 No Content)
Use this if you created a Spark by mistake or need to revoke access before the recipient reads it. Deletion is idempotent: deleting an already-burned Spark returns 204 as a no-op, and burned (ash) Sparks remain visible in listings until their natural expiry. Deleting a Spark also cascade-removes any SparkLink attached to it.
Kindling (Grouping Sparks)
Kindling allows you to group related Sparks together. This is useful for multi-step workflows like verification flows, retry sequences, or any scenario where multiple Sparks belong to the same logical operation.
How Kindling Works
- Start a Group: Create a Spark with
with_kindling: trueto generate a new kindling ID - Join a Group: Create subsequent Sparks with
kindling: "kdl_xxx"to add them to the family - Query by Kindling: List all Sparks in a group using
?kindling=kdl_xxx
Kindling Parameters (Create Spark)
| Parameter | Type | Required | Description |
|---|---|---|---|
with_kindling |
boolean | Optional | Set to true to generate a new kindling ID for this SparkDefault: false |
kindling |
string | Optional | Existing kindling ID to join (e.g., kdl_abc123)Default: null |
Create Spark with New Kindling
Requestcurl -X POST https://api.sparkvault.com/v1/sparks \
-H "X-API-Key: sv_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"payload": "verification code data",
"ttl_minutes": 15,
"with_kindling": true
}'
{
"data": {
"spark_id": "spk_abc123...",
"kindling": "kdl_xyz789...",
"status": "active",
"created_at": 1702000000,
"expires_at": 1702000900
}
}
Join Existing Kindling Group
Requestcurl -X POST https://api.sparkvault.com/v1/sparks \
-H "X-API-Key: sv_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"payload": "retry data",
"ttl_minutes": 15,
"kindling": "kdl_xyz789..."
}'
{
"data": {
"spark_id": "spk_def456...",
"kindling": "kdl_xyz789...",
"status": "active",
"created_at": 1702000100,
"expires_at": 1702001000
}
}
Query Sparks by Kindling
Requestcurl "https://api.sparkvault.com/v1/sparks?kindling=kdl_xyz789" \
-H "X-API-Key: sv_live_xxx"
{
"data": {
"sparks": [
{
"spark_id": "spk_abc123...",
"kindling": "kdl_xyz789...",
"status": "ash",
"created_at": 1702000000
},
{
"spark_id": "spk_def456...",
"kindling": "kdl_xyz789...",
"status": "active",
"created_at": 1702000100
}
],
"count": 2,
"has_more": false
}
}
Kindling enables cross-account sharing (SparkSync): possession of the 128-bit kindling ID is the authorization. Any authenticated caller who knows the ID of a kindling-scoped Spark can read it via GET /v1/sparks/{spark_id}, and ?kindling= list queries are not filtered by account. Treat kindling IDs like secrets. Only Sparks without a kindling are strictly scoped to the owning account.
Sharing Sparks
Share sparks via x.sv URLs using the /v1/sparks/:id/share endpoint. SparkLinks provide shareable URLs with visibility controls.
/v1/sparks/{spark_id}/share
Share a spark and get a public x.sv URL.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
visibility |
string | Optional | Visibility mode: public, authenticated, or invite_onlyDefault: public |
invites |
string[] | Optional | For invite_only visibility: invited identities (max 1)Default: [] |
expires_in_seconds |
integer | Optional | Link expiration TTL in seconds (min 60). The link expiry is capped at the Spark's own expires_at. Applied only when first sharing a Spark; on re-share (updating an existing link) it is ignored and the original link expiry is retained.Default: the Spark's own expiry |
Only active, non-expired Sparks can be shared. Burned or expired Sparks return 403 FORBIDDEN; a nonexistent (or non-owned) spark_id returns 404 NOT_FOUND. Re-sharing an already-shared Spark returns the existing link with already_shared: true.
Response
| Field | Type | Description |
|---|---|---|
shared |
boolean | true: the Spark is shared |
share_url |
string | Shareable https://x.sv/{code} URL |
link_code |
string | SparkLink code for the URL |
visibility |
string | Current visibility setting |
invites |
array? | Invited identities (invite_only visibility) |
expires_at |
integer? | Unix timestamp when the link expires (never after the Spark itself) |
created_at |
integer | Unix timestamp the link was created |
already_shared |
boolean? | Present (true) when the Spark was already shared and the existing link was returned |
Share a Spark
Requestcurl -X PATCH https://api.sparkvault.com/v1/sparks/spk_xxx/share \
-H "X-API-Key: sv_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "visibility": "public" }'
{
"data": {
"shared": true,
"share_url": "https://x.sv/abc123...",
"link_code": "abc123...",
"visibility": "public",
"invites": [],
"expires_at": 1702086400,
"created_at": 1702000000
}
}
/v1/sparks/{spark_id}/share
Get the sharing status for a spark.
Response
| Field | Type | Description |
|---|---|---|
shared |
boolean | Whether the Spark is currently shared. When false, only spark_id accompanies it. |
spark_id |
string | Spark identifier |
share_url |
string? | Shareable https://x.sv/{code} URL (when shared) |
link_code |
string? | SparkLink code (when shared) |
visibility |
string? | Visibility setting (when shared) |
invites |
array? | Invited identities (when shared) |
status |
string? | Single-use link lifecycle: active → consumed | revoked |
expires_at |
integer? | Unix timestamp when the link expires (when shared) |
created_at |
integer? | Unix timestamp the link was created (when shared) |
/v1/sparks/{spark_id}/share
Unshare a spark, deleting its SparkLink. The Spark itself remains intact.
Response
| Field | Type | Description |
|---|---|---|
shared |
boolean | false: the Spark is no longer shared |
message |
string | SparkLink deleted successfully, or Spark was not shared if there was no link (idempotent) |
/v1/sparks/shared/{spark_id}/meta
Get metadata for a spark WITHOUT burning it. Authenticated owners can always check their own sparks; unauthenticated access works when the spark has a SparkLink.
Response
| Field | Type | Description |
|---|---|---|
spark_id |
string | Spark identifier |
status |
string | Always active (non-active Sparks are not viewable) |
expires_at |
integer | Unix timestamp when the Spark expires |
time_remaining |
integer | Seconds until expiration |
has_filename |
boolean | Whether the Spark carries a filename (file upload) |
content_type |
string | MIME type of payload |
Unauthenticated callers receive a single opaque 404 NOT_FOUND for every non-viewable case (nonexistent, unshared, burned, or expired), so the endpoint cannot be used to enumerate Spark IDs. Authenticated owners get granular reasons.
Shared sparks still burn when read. Once the recipient views the spark via the x.sv URL, the secret is destroyed.
Common Use Cases
Share Database Credentials
// Create a Spark with database credentials
const response = await fetch('https://api.sparkvault.com/v1/sparks', {
method: 'POST',
headers: {
'X-API-Key': process.env.SPARKVAULT_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
payload: JSON.stringify({
host: 'db.example.com',
username: 'admin',
password: 'super_secret_password'
}),
ttl_minutes: 60 // Expires in 1 hour
})
});
const { spark_id } = (await response.json()).data;
// Mint a burn-on-read x.sv link for the recipient
const shareResponse = await fetch(`https://api.sparkvault.com/v1/sparks/${spark_id}/share`, {
method: 'PATCH',
headers: {
'X-API-Key': process.env.SPARKVAULT_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({ visibility: 'public' })
});
const { share_url } = (await shareResponse.json()).data;
// Hand out share_url via Slack, email, etc.
console.log(`Share this link: ${share_url}`);
Share Binary Files (PDF, Images)
// Read file and convert to base64
const fs = require('fs');
const fileBuffer = fs.readFileSync('contract.pdf');
const base64Content = fileBuffer.toString('base64');
const response = await fetch('https://api.sparkvault.com/v1/sparks', {
method: 'POST',
headers: {
'X-API-Key': process.env.SPARKVAULT_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
filename: 'Signed Contract.pdf',
payload: base64Content,
content_type: 'application/pdf', // Binary content
ttl_minutes: 1440
})
});
// Recipient reads the Spark
const readResponse = await fetch(`https://api.sparkvault.com/v1/sparks/${spark_id}`, {
headers: { 'X-API-Key': recipientApiKey }
});
const { payload, content_type } = (await readResponse.json()).data;
// The server treats text/*, application/json, application/xml,
// application/javascript, and +json/+xml types as UTF-8 text;
// every other content type arrives base64-encoded.
const isText = content_type.startsWith('text/')
|| ['application/json', 'application/xml', 'application/javascript'].includes(content_type)
|| content_type.endsWith('+json')
|| content_type.endsWith('+xml');
if (!isText) {
const buffer = Buffer.from(payload, 'base64');
fs.writeFileSync('downloaded.pdf', buffer);
}
Secure CI/CD Secret Injection
# Read the Spark and inject into environment
CREDENTIALS=$(curl -s "https://api.sparkvault.com/v1/sparks/$SPARK_ID" \
-H "X-API-Key: $SPARKVAULT_API_KEY" | jq -r '.data.payload')
# Parse and export
export DB_PASSWORD=$(echo $CREDENTIALS | jq -r '.password')
# The Spark is now burned and cannot be read again
# Even if someone obtains the SPARK_ID, the secret is gone
API Key Rotation
import requests
import os
# Generate new API key and share via Spark
new_api_key = generate_new_api_key()
response = requests.post(
'https://api.sparkvault.com/v1/sparks',
headers={
'X-API-Key': os.environ['SPARKVAULT_API_KEY'],
'Content-Type': 'application/json'
},
json={
'payload': new_api_key,
'ttl_minutes': 1440 # 24 hours to claim
}
)
spark_id = response.json()['data']['spark_id']
notify_team(f"New API key ready: {spark_id}")
Usage
Spark operations are covered by your seat subscription. Usage is tracked against the pool included with your plan; there are no per-operation charges. For subscription tiers and capacity blocks, see the pricing page.
| Operation | Usage |
|---|---|
| Create Spark | Included with subscription |
| Read Spark (burn) | Included with subscription |
| List Sparks | Included with subscription |
| Delete Spark | Included with subscription |
Security Model
Double Zero-Trust Encryption
Sparks use two independent encryption keys:
- SparkVault Master Key (SVMK): A post-quantum ML-KEM-1024 keypair retrieved at runtime from AWS SSM Parameter Store. Sealing a Spark performs a software ML-KEM-1024 encapsulation to produce a per-Spark shared secret.
- Account Master Key (AMK): Your account's unique HMAC key managed in a FIPS 140-3 validated key management system (AWS KMS). The API calls KMS
GenerateMac(HMAC-SHA512) to produce a per-Spark salt; the key itself never leaves KMS.
The per-Spark encryption key is derived with HKDF from the ML-KEM-1024 shared secret and the KMS HMAC salt, and the payload is sealed with AES-256-GCM. Both keys are required to decrypt a Spark. Even with full database access, SparkVault cannot decrypt Sparks without combining these keys, which are stored separately with strict access controls.
Atomic Burn
When a Spark is read, the decryption and deletion happen atomically behind a read lock. The encrypted data is deleted from storage before the plaintext is returned. This ensures that even in the event of a system failure, the Spark cannot be read twice: if the burn cannot be completed, the plaintext is withheld and the request fails with 500 SPARK_BURN_FAILED.
Error Reference
Error Responses
| Status | Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR |
Invalid request parameters (e.g., TTL out of range, payload over the 256 KB limit) |
| 401 | AUTHENTICATION_ERROR |
Invalid or missing API key |
| 402 | PLAN_REQUIRED |
An active subscription is required |
| 403 | FORBIDDEN |
Spark already burned or expired, or share preconditions not met |
| 404 | NOT_FOUND |
Spark does not exist |
| 409 | CONFLICT |
Spark is currently being read by another request |
| 429 | RATE_LIMIT_EXCEEDED |
Too many requests |
| 500 | SPARK_BURN_FAILED |
Burn failed after read: the plaintext is withheld and the request aborted |