# Sparks API: SparkVault API Reference

> Share ephemeral secrets that automatically self-destruct after being read once. Sparks use double zero-trust encryption and are perfect for sharing passwords, API keys, and sensitive data.

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

## 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

#### `POST /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

Request

```bash
curl -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
  }'
```

Response

```json
{
  "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

Request

```bash
curl -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
  }'
```

Response

```json
{
  "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

#### `GET /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_cursor`Default: `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

Request

```bash
curl "https://api.sparkvault.com/v1/sparks?status=active&limit=10" \
  -H "X-API-Key: sv_live_xxx"
```

Response

```json
{
  "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)

#### `GET /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

Request

```bash
curl https://api.sparkvault.com/v1/sparks/spk_abc123 \
  -H "X-API-Key: sv_live_xxx"
```

Response

```json
{
  "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
  }
}
```

> **Destructive Operation**
>
> 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

#### `DELETE /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

Request

```bash
curl -X DELETE https://api.sparkvault.com/v1/sparks/spk_abc123 \
  -H "X-API-Key: sv_live_xxx"
```

Response

```text
(204 No Content)
```

> **When to Delete**
>
> 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: true` to 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

Request

```bash
curl -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
  }'
```

Response

```json
{
  "data": {
    "spark_id": "spk_abc123...",
    "kindling": "kdl_xyz789...",
    "status": "active",
    "created_at": 1702000000,
    "expires_at": 1702000900
  }
}
```

#### Join Existing Kindling Group

Request

```bash
curl -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..."
  }'
```

Response

```json
{
  "data": {
    "spark_id": "spk_def456...",
    "kindling": "kdl_xyz789...",
    "status": "active",
    "created_at": 1702000100,
    "expires_at": 1702001000
  }
}
```

#### Query Sparks by Kindling

Request

```bash
curl "https://api.sparkvault.com/v1/sparks?kindling=kdl_xyz789" \
  -H "X-API-Key: sv_live_xxx"
```

Response

```json
{
  "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 Is Cross-Account by Design**
>
> 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.

#### `PATCH /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_only`Default: `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` |

> **Share Preconditions**
>
> 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

Request

```bash
curl -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" }'
```

Response

```json
{
  "data": {
    "shared": true,
    "share_url": "https://x.sv/abc123...",
    "link_code": "abc123...",
    "visibility": "public",
    "invites": [],
    "expires_at": 1702086400,
    "created_at": 1702000000
  }
}
```

#### `GET /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) |

#### `DELETE /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) |

#### `GET /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 |

> **Anti-Enumeration**
>
> 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.

> **Burn on Read**
>
> 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

```javascript
// 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)

```javascript
// 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

```bash
# 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

```python
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](/pricing/).

| 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:

1.  **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.
2.  **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 |
