# SparkVault — REST API > Everything for implementing against https://api.sparkvault.com/v1 directly — auth, envelope, errors, and every resource. Machine-readable spec: https://sparkvault.com/openapi.yaml --- # Overview: SparkVault API Reference > Build secure applications with post-quantum encryption. The SparkVault API provides programmatic access to encrypted vaults, ephemeral secrets, identity products, and integrations. Canonical: https://sparkvault.com/api/docs/ · OpenAPI: https://sparkvault.com/openapi.yaml ## Quick Start Every call is plain JSON over HTTPS, authenticated with an API key: [grab yours](https://app.sparkvault.com/api/keys), then seal your first secret. It burns the moment it's read: ```bash curl https://api.sparkvault.com/v1/sparks \ -H "X-API-Key: sv_test_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"payload": "the launch code is 793-alpha", "ttl_minutes": 60}' ``` The response's `data.spark_id` is the one-time handle to your secret: [read it once](/api/docs/sparks/) and it's gone. The [Authentication](#authentication) and [Response Format](#response-format) sections below cover the envelope, or skip the plumbing and start from the [JavaScript SDK](/api/docs/sdk-js/). ## Explore the API ### Elements - [Sparks](/api/docs/sparks/): Ephemeral secrets that burn on read (`/v1/sparks`) - [Vaults](/api/docs/vaults/): Triple zero-trust encrypted storage (`/v1/vaults`) - [Ingots](/api/docs/ingots/): Encrypted files, 1 byte to 5 TB (`/v1/vaults/{id}/ingots`) - [SparkLinks](/api/docs/sparklinks/): Trackable single-use share links (`/v1/sparklinks`) - [Entropy](/api/docs/entropy/): HSM-backed cryptographic randomness (`/v1/entropy`) - [Forge](/api/docs/forge/): Streaming AES-256-GCM for any file size (`forge.sparkvault.com`) ### [Products](/api/docs/products/) - [Identity](/api/docs/products/identity/): OIDC IdP with passkeys, OTP, and social login - [Notify](/api/docs/products/notify/): Sealed notifications with cryptographic receipts - [Messaging](/api/docs/products/messaging/): Encrypted two-way threads with account-less recipients - [Structured Ingots](/api/docs/products/structured-ingots/): Encrypted key/value storage with atomic operations ### [Integrations](/api/docs/integrations/) - [Slack](/api/docs/integrations/slack/): Self-destructing secrets via /secret (`/v1/apps/slack`) - [HubSpot](/api/docs/integrations/hubspot/): Encrypted files on every CRM record (`/v1/apps/hubspot`) - [Salesforce](/api/docs/integrations/salesforce/): Encrypted files on Accounts, Opportunities, Cases (`/v1/apps/salesforce`) ### Platform - [Authentication](/api/docs/authentication/): API keys and JWTs (`X-API-Key`) - [API Keys](https://app.sparkvault.com/api/keys): Create and manage keys in the app (`app.sparkvault.com`) - [Reporting](/api/docs/reporting/): Usage metrics, analytics, and activity (`/v1/analytics`) - [Audit Logs](/api/docs/audit-logs/): Security and operational events (`/v1/audit-logs`) - [JavaScript SDK](/api/docs/sdk-js/): Browsers and Node.js (`@sparkvault/sdk-js`) - [Mobile SDK](/api/docs/sdk-mobile/): React Native and Expo (`@sparkvault/sdk-mobile`) ## Base URL All API requests should be made to the following base URL: ```text https://api.sparkvault.com/v1 ``` > **HTTPS Required** > > All API requests must be made over HTTPS. Requests over plain HTTP will be rejected. ## Authentication SparkVault supports two authentication methods: - **API Keys**: Best for server-to-server integrations and automation. Keys are prefixed `sv_live_` (production) or `sv_test_` (test). - **JWT Tokens**: Best for user-facing applications with session management Include your API key in the `X-API-Key` header: ```bash curl https://api.sparkvault.com/v1/sparks \ -H "X-API-Key: sv_live_YOUR_API_KEY" ``` > **Vault Access Tokens** > > Vault operations additionally require an `X-Vault-Access-Token: {vat}` header, issued when you unseal the vault. See the [Vaults API](/api/docs/vaults/) for details. See the [Authentication guide](/api/docs/authentication/) for complete details. ## Response Format All successful responses follow a consistent JSON structure with `data` and `meta` fields: ```json { "data": { "spark_id": "spk_abc123...", "account_id": "acc_def456...", "content_type": "text/plain", "size_bytes": 1024, "status": "active", "created_at": 1702000000, "expires_at": 1702003600, "burned_at": null, "time_remaining": 3600 }, "meta": { "api_version": "1.x.y", "request_id": "req_xyz789...", "response_ms": 42, "timestamp": 1702000000, "pools": { "storage": { "used_gb": 12.5, "limit_gb": 100, "pct": 0.125, "state": "ok" }, "bandwidth": { "used_gb": 3.25, "limit_gb": 100, "pct": 0.0325, "state": "ok", "cycle_resets_at": 1704067200 }, "identity": { "used": 15, "limit": 500, "pct": 0.03, "state": "ok" } }, "quota": { "limit": 300, "used": 15, "remaining": 285, "resets_at": 1702000060 } } } ``` #### Meta Fields - `api_version`: Semantic version of the deployed API release - `request_id`: Unique identifier for this request (useful for support) - `response_ms`: Server processing time in milliseconds - `timestamp`: Unix timestamp when response was generated - `pools`: Pooled-capacity status for your subscription's storage, bandwidth, and identity pools, attached only on authenticated, successful responses to billable routes. Each pool reports usage against its limit, a utilization fraction (`pct`), and a `state` of `ok` | `notice` | `warning` | `exhausted`. This is usage tracking, not a spendable balance. - `quota`: Rate limit status for your account (included on authenticated responses with status below 400) ## Error Handling Errors return a consistent structure with machine-readable codes and human-friendly messages: ```json { "error": { "code": "VALIDATION_ERROR", "message": "The 'name' field is required", "details": { "field": "name", "reason": "missing" } }, "meta": { "api_version": "1.x.y", "request_id": "req_xyz789..." } } ``` #### Common Error Codes | Status | Code | Description | | --- | --- | --- | | 400 | `VALIDATION_ERROR` | Invalid request parameters or body | | 401 | `AUTHENTICATION_ERROR` | Missing or invalid authentication credentials | | 401 | `UNAUTHORIZED` | Presented token, grant, or key is invalid, expired, or revoked | | 402 | `PLAN_REQUIRED` | An active subscription is required for this operation | | 402 | `QUOTA_EXCEEDED` | A pooled-capacity or seat limit is reached (`details.resource` is `storage`, `bandwidth`, `seats`, or `identity`). `details.action_required` indicates which add-on or seat amendment unblocks you | | 403 | `FORBIDDEN` | Valid credentials but insufficient permissions | | 404 | `NOT_FOUND` | Requested resource does not exist | | 404 | `UPLOAD_SOURCE_DISABLED` | Vault exists but the requested upload source (portal or embedded widget) is not enabled on it | | 409 | `CONFLICT` | Resource conflict (e.g., duplicate name) | | 412 | `PRECONDITION_FAILED` | A required precondition was not met (e.g., invalid VMK) | | 413 | `PAYLOAD_TOO_LARGE` | Request body or upload exceeds the size limit | | 429 | `RATE_LIMIT_EXCEEDED` | Too many requests, slow down | | 500 | `INTERNAL_ERROR` | Unexpected server error (contact support) | ## Rate Limiting API requests are rate limited to **300 requests per minute** per account, measured in 60-second windows. The current rate limit status is included in the `meta.quota` field of every authenticated response with a status below 400. ```json { "meta": { "quota": { "limit": 300, "used": 42, "remaining": 258, "resets_at": 1702000060 } } } ``` > **Rate Limit Exceeded** > > When you exceed the rate limit, requests return `429 Too Many Requests` with a `Retry-After` header set to the number of seconds until the window resets. The error's `details` include `limit`, `used`, and `resets_at` so you know when your quota resets. ## Pagination List endpoints use cursor-based pagination with `limit` and `cursor` query parameters. Cursors are signed per account and bound to the resource they page over. A tampered, cross-account, or cross-resource cursor returns `400 VALIDATION_ERROR`. ```bash curl "https://api.sparkvault.com/v1/sparks?limit=20&cursor=eyJzcGFya19pZCI6..." \ -H "X-API-Key: sv_live_YOUR_API_KEY" ``` Paginated responses include the items, a `count`, and cursor metadata: ```json { "data": { "sparks": [...], "count": 20, "has_more": true, "next_cursor": "eyJzcGFya19pZCI6..." } } ``` `next_cursor` is present only when `has_more` is `true`. Pass it back as the `cursor` parameter to fetch the next page. A `total` field appears only on endpoints that compute one. Default and maximum `limit` vary per endpoint (default 25-100, max 100-500); check each endpoint's reference page for exact values. For example, `/sparks` and `/vaults` both default to 100 (max 500). ## Security > **Post-Quantum Encryption** > > SparkVault uses ML-KEM-1024 (CRYSTALS-Kyber) for key encapsulation and AES-256-GCM for data encryption. This provides protection against both classical and quantum computer attacks. #### Zero-Knowledge Architecture SparkVault is designed so that we **cannot access your encrypted data**, even with full system access. Our multi-key architecture uses three types of master keys: - **SparkVault Master Key (SVMK)**: SparkVault's system-wide software ML-KEM-1024 keypair, stored in AWS SSM Parameter Store - **Account Master Key (AMK)**: Your account's unique HMAC key managed in AWS KMS (FIPS 140-3 validated) - **Vault Master Key (VMK)**: A per-vault key that you generate and control Different features require different combinations of these keys: - **Sparks (Double Zero-Trust)**: Requires both the SVMK and your AMK - **Vaults (Triple Zero-Trust)**: Requires the SVMK, AMK, and your VMK #### Key Management Self-managed Vault Master Keys (VMKs) are generated client-side and are **never stored** by SparkVault. If you lose a self-managed VMK, the data in that vault is permanently unrecoverable, so always store it in a secure password manager or key management system. Vaults using the **Hosted VMK** option instead store the VMK KMS-wrapped (a per-vault key under the HVMK root KEK), which lets SparkVault derive vault access server-side for features like public sharing. ## Client Libraries Official SDKs handle authentication, encryption, and resumable transfers for you: - **[JavaScript SDK](/api/docs/sdk-js/)**: `@sparkvault/sdk-js` for browsers and Node.js - **[Mobile SDK](/api/docs/sdk-mobile/)**: `@sparkvault/sdk-mobile` for React Native and Expo For other languages, any HTTP client works: the API is plain JSON over HTTPS. ## Support Need help? Include the `request_id` from the response when contacting support. This helps us quickly locate your request in our logs. - **Email:** support@sparkvault.com --- # Authentication: SparkVault API Reference > Learn how to authenticate your API requests using API keys or JWT tokens. SparkVault supports multiple authentication methods to fit different integration patterns. Canonical: https://sparkvault.com/api/docs/authentication/ · OpenAPI: https://sparkvault.com/openapi.yaml ## Overview SparkVault supports two authentication methods: | Method | Header | Best For | | --- | --- | --- | | **API Key** | `X-API-Key` | Server-to-server integrations, automation, CI/CD pipelines | | **JWT Token** | `Authorization: Bearer` | User-facing applications, browser sessions | > **Which should I use?** > > For most integrations, **API keys** are the simplest choice. Use JWT tokens only if you're building a user-facing application that needs to manage individual user sessions. ## API Key Authentication API keys provide simple, persistent authentication for server-side integrations. Create an API key from the [API Keys page](https://app.sparkvault.com/api/keys) and include it in the `X-API-Key` header. ```bash curl https://api.sparkvault.com/v1/sparks \ -H "X-API-Key: sv_live_abc123xyz789..." ``` #### API Key Format API keys follow a consistent format: - `sv_live_...`: Production keys (live data) > **Keep API keys secret** > > API keys grant full access to your account. Never expose them in client-side code, public repositories, or logs. > > If a key is compromised, revoke it immediately from the [API Keys page](https://app.sparkvault.com/api/keys). #### Example: Creating a Spark with an API key ```bash curl -X POST https://api.sparkvault.com/v1/sparks \ -H "X-API-Key: sv_live_abc123xyz789..." \ -H "Content-Type: application/json" \ -d '{ "payload": "super_secret_password_123" }' ``` ## Managing API Keys Programmatically API keys can be managed in the app at [app.sparkvault.com/api/keys](https://app.sparkvault.com/api/keys), or programmatically with the endpoints below. All three endpoints require authentication (JWT or API key). Creating and revoking keys additionally require an **admin or owner** role. Non-admin users receive `403 FORBIDDEN`. #### `POST /v1/api-keys` Create a new API key. Requires an admin or owner role. The full key string is returned once and is never retrievable again. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | Required | Display name identifying the key's purpose (max 100 characters). | | `expires_in_days` | integer | Optional | Days until the key expires (1-3650). Omit for a non-expiring key. | #### Response Fields | Field | Type | Description | | --- | --- | --- | | `api_key_id` | string | Unique key identifier (`key_...`). Use this id to revoke the key. | | `api_key` | string | The full API key (`sv_live_...`). Shown only in this response. Store it securely. | | `key_preview` | string | Masked preview for display (e.g. `sv_live_ab...wxyz`). | | `name` | string | The key's display name. | | `created_at` | integer | Creation time (Unix epoch seconds). | | `expires_at` | integer | Expiration time (Unix epoch seconds), or `null` if the key does not expire. | | `warning` | string | Reminder that the key will not be shown again. | #### Example Request ```bash curl -X POST https://api.sparkvault.com/v1/api-keys \ -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \ -H "Content-Type: application/json" \ -d '{ "name": "CI/CD pipeline", "expires_in_days": 90 }' ``` Response ```json { "data": { "api_key_id": "key_abc123...", "api_key": "sv_live_abc123xyz789...", "key_preview": "sv_live_ab...z789", "name": "CI/CD pipeline", "created_at": 1783036800, "expires_at": 1790812800, "warning": "Store this API key securely. It will not be shown again." } } ``` #### `GET /v1/api-keys` List the account's API keys with cursor-based pagination. Key strings are never returned, only masked previews. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | Optional | Maximum keys per page (1-100).Default: `50` | | `cursor` | string | Optional | Pagination cursor from a previous response's `next_cursor`. | #### Response Fields | Field | Type | Description | | --- | --- | --- | | `api_keys` | array | Key objects: `api_key_id`, `name`, `key_preview`, `status` (`active` | `expired` | `revoked`), `created_at`, `last_used_at`, `expires_at`, `revoked_at`. | | `count` | integer | Number of keys in this page. | | `has_more` | boolean | Whether more pages are available. | | `next_cursor` | string | Cursor for the next page. Present only when `has_more` is `true`. | | `active` | integer | Number of active keys in this page. | #### `DELETE /v1/api-keys/:id` Revoke an API key by its api_key_id. Requires an admin or owner role. Idempotent: revoking an already-revoked key succeeds and returns its original revocation time. #### Response Fields | Field | Type | Description | | --- | --- | --- | | `api_key_id` | string | The revoked key's identifier. | | `name` | string | The key's display name. Returned only on first revocation. When the key was already revoked, the response omits `name` and includes a `message` field instead. | | `status` | string | Always `revoked`. | | `revoked_at` | integer | Revocation time (Unix epoch seconds). | ## JWT Token Authentication JWT tokens are used for user session management in browser-based applications. SparkVault uses the [Identity Product](/api/docs/products/identity/) as an OIDC provider for user authentication. After successful authentication via Identity, you'll receive SparkVault session tokens. ```bash curl https://api.sparkvault.com/v1/sparks \ -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." ``` #### Token Types | Type | Lifetime | Purpose | | --- | --- | --- | | **Access Token** | 1 hour | Short-lived token for API requests | | **Refresh Token** | 30 days | Single-use token to obtain new access tokens (rotated on every refresh) | ## User Authentication Flow SparkVault uses an OIDC (OpenID Connect) flow with the Identity Product for user authentication. This provides passwordless login via passkeys, magic links, social login, and more. #### Flow Overview 1. **Initiate Login**: Redirect user to Identity Product with PKCE challenge 2. **User Authenticates**: Via passkey, magic link, or social login 3. **Callback**: Identity Product redirects back with authorization code 4. **Token Exchange**: Exchange code for SparkVault session tokens 5. **Registration (New Users)**: Complete profile setup if needed > **Identity Product** > > For detailed OIDC integration instructions, see the [Identity Product documentation](/api/docs/products/identity/). The SparkVault web app uses Identity Product for all user authentication. ## Session Endpoints #### `POST /v1/auth/identity/token` Exchange an OIDC authorization code from Identity Product for SparkVault session tokens. This is called after the user completes authentication via Identity Product. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `code` | string | Required | Authorization code from Identity Product callback (max 512 characters). | | `code_verifier` | string | Required | PKCE code verifier that must match the code\_challenge sent to Identity Product. 43-128 characters, per RFC 7636. | #### Response (Existing User) | Field | Type | Description | | --- | --- | --- | | `access_token` | string | JWT access token (1 hour lifetime) | | `refresh_token` | string | JWT refresh token (30 day lifetime) | | `user` | object | User profile information | | `account` | object | Account information | #### Response (New User) | Field | Type | Description | | --- | --- | --- | | `access_token` | string | Identity-only JWT (limited access) | | `refresh_token` | string | Identity-only refresh token | | `user` | null | null indicates registration required | | `account` | null | null indicates registration required | #### Example Request ```bash curl -X POST https://api.sparkvault.com/v1/auth/identity/token \ -H "Content-Type: application/json" \ -d '{ "code": "auth_code_from_identity_product", "code_verifier": "your_pkce_code_verifier" }' ``` Response ```json { "data": { "access_token": "eyJhbGciOiJSUzI1NiIs...", "refresh_token": "eyJhbGciOiJSUzI1NiIs...", "user": { "user_id": "usr_abc123...", "email": "user@example.com", "name": "John Doe", "role": "admin" }, "account": { "account_id": "acc_xyz789...", "organization_name": "Acme Corp", "status": "active" } } } ``` #### `POST /v1/auth/identity/verify` Directly verify an Identity Product JWT token without using the OIDC redirect flow. Useful for XHR-based authentication where redirect flows are not practical. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `token` | string | Required | JWT token received from Identity Product verification | #### Response | Field | Type | Description | | --- | --- | --- | | `access_token` | string | JWT access token (1 hour lifetime) | | `refresh_token` | string | JWT refresh token (30 day lifetime) | | `user` | object | User profile (null if registration required) | | `account` | object | Account information (null if registration required) | #### Example Request ```bash curl -X POST https://api.sparkvault.com/v1/auth/identity/verify \ -H "Content-Type: application/json" \ -d '{"token": "eyJhbGciOiJFZDI1NTE5..."}' ``` Response ```json { "data": { "access_token": "eyJhbGciOiJSUzI1NiIs...", "refresh_token": "eyJhbGciOiJSUzI1NiIs...", "user": { "user_id": "usr_abc123...", "email": "user@example.com", "name": "John Doe", "role": "admin" }, "account": { "account_id": "acc_xyz789...", "organization_name": "Acme Corp", "status": "active" } } } ``` > **When to use this endpoint** > > Use this endpoint when you're using the Identity Product SDK in XHR mode (without redirects). The SDK will return a JWT token directly after verification, which you can exchange for SparkVault session tokens using this endpoint. #### `POST /v1/auth/complete-signup` Complete registration for new users. Requires an identity-only JWT (from token exchange with user: null). Creates the account and upgrades to full session tokens. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `organization_name` | string | Required | Name of the organization/company (1-255 characters) | | `full_name` | string | Optional | User's display name (up to 255 characters) | #### Response | Field | Type | Description | | --- | --- | --- | | `access_token` | string | Full JWT access token | | `refresh_token` | string | Full JWT refresh token | | `user` | object | Created user profile | | `account` | object | Created account object | > **Authentication Required** > > This endpoint requires an identity-only JWT in the Authorization header. The JWT proves email ownership via the Identity Product verification. > **Claimed Domains** > > Signup returns `403 Forbidden` if the email's domain is already claimed by another organization. The user must be invited by that organization's admin instead. #### `POST /v1/auth/refresh` Exchange a refresh token for a new access token and a new refresh token. Use this when your access token expires. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `refresh_token` | string | Required | Valid refresh token | #### Response | Field | Type | Description | | --- | --- | --- | | `access_token` | string | New JWT access token | | `refresh_token` | string | New rotated refresh token. Replace your stored token | | `token_type` | string | Always "Bearer" | | `expires_in` | integer | Token lifetime in seconds | #### Example Request ```bash curl -X POST https://api.sparkvault.com/v1/auth/refresh \ -H "Content-Type: application/json" \ -d '{"refresh_token": "eyJhbGciOiJSUzI1NiIs..."}' ``` Response ```json { "data": { "access_token": "eyJhbGciOiJSUzI1NiIs...", "refresh_token": "eyJhbGciOiJSUzI1NiIs...", "token_type": "Bearer", "expires_in": 3600 } } ``` > **Refresh Tokens Rotate on Every Use** > > Refresh tokens are **one-time-use**. Every call to `/v1/auth/refresh` revokes the presented token and returns a new `refresh_token`. Always replace your stored token with the one in the response. Reusing an already-rotated token fails with `401 AUTHENTICATION_ERROR`; if two requests race with the same token, the loser fails with `401 TOKEN_REPLAY`. In either case, discard stored tokens and sign the user in again. #### `POST /v1/auth/logout` Revoke a refresh token. Requires authentication. Returns 204 No Content on success. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `refresh_token` | string | Required | Refresh token to revoke | #### Example Request ```bash curl -X POST https://api.sparkvault.com/v1/auth/logout \ -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \ -H "Content-Type: application/json" \ -d '{"refresh_token": "eyJhbGciOiJSUzI1NiIs..."}' ``` Response ```text (204 No Content) ``` #### `POST /v1/auth/viewer-token` Generate a short-lived viewer token for cross-domain direct-access viewing on x.sv. Requires authentication. Takes no request body. #### Response | Field | Type | Description | | --- | --- | --- | | `viewer_token` | string | Short-lived JWT for the x.sv viewer (5 minute lifetime) | | `expires_in` | integer | Token lifetime in seconds, always 300 | ## Vault Access Tokens (VAT) Vault Access Tokens are special, short-lived tokens required for reading and writing data in encrypted vaults. They are obtained by "unsealing" a vault with its Vault Master Key (VMK). | Property | Value | | --- | --- | | **Header** | `X-Vault-Access-Token` | | **Lifetime** | 1 hour (default), up to 24 hours | | **Scope** | Single vault only | | **Revocation** | Automatic on expiry, or manual via seal operation | #### `POST /v1/vaults/:id/unseal` Unseal a vault with its Vault Master Key to obtain a Vault Access Token. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `vmk` | string | Required | The Vault Master Key. Also accepts a `dvak_` token (Delegated Vault Access Key) in place of the raw VMK. | | `ttl_seconds` | integer | Optional | VAT lifetime in seconds (1-86400).Default: `3600` | #### Response Fields | Field | Type | Description | | --- | --- | --- | | `vat` | string | The Vault Access Token (`vat_...`). Send it in the `X-Vault-Access-Token` header. | | `vault_id` | string | The unsealed vault's id. | | `issued_at` | integer | Issue time (Unix epoch seconds). | | `expires_at` | integer | Expiration time (Unix epoch seconds). | | `ttl_seconds` | integer | Effective VAT lifetime in seconds. | | `warning` | string | Reminder to store the VAT securely. | ```bash # First, unseal the vault to get a VAT curl -X POST https://api.sparkvault.com/v1/vaults/vlt_abc123/unseal \ -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \ -H "Content-Type: application/json" \ -d '{"vmk": "YOUR_VAULT_MASTER_KEY"}' # Then use the VAT for ingot operations curl https://api.sparkvault.com/v1/vaults/vlt_abc123/ingots \ -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \ -H "X-Vault-Access-Token: vat_xyz789..." ``` > **VAT Best Practices** > > - Obtain a VAT only when you need to access vault contents > - Store VATs securely in memory, never persist to disk > - VATs are vault-specific. Each vault requires its own VAT. > - Consider sealing vaults when done to immediately invalidate VATs ## Authentication Errors #### Error Responses | Status | Code | Description | | --- | --- | --- | | 400 | `VALIDATION_ERROR` | Missing or invalid request parameters | | 401 | `AUTHENTICATION_ERROR` | Invalid credentials, expired access token, invalid or revoked refresh token, or suspended account | | 401 | `TOKEN_REPLAY` | Refresh token was consumed by a concurrent rotation. Sign in again | | 403 | `FORBIDDEN` | Insufficient permissions: e.g. identity-only token on a protected endpoint (complete registration first), or admin role required | | 429 | `RATE_LIMIT_EXCEEDED` | Too many requests | ## Security Best Practices #### API Keys - Generate separate API keys for each integration or service - Use descriptive names to identify key purpose - Rotate keys periodically (every 90 days recommended) - Revoke keys immediately if compromised - Never commit keys to version control. Use environment variables. #### JWT Tokens - Store access tokens in memory only (not localStorage) - Implement automatic token refresh before expiry - Persist the rotated refresh token from every refresh response. The old one is immediately revoked - Clear tokens on logout and tab close - Handle 401 errors by redirecting to login #### PKCE Security - Always use S256 code challenge method (never plain) - Generate cryptographically random code verifiers (32+ bytes) - Store PKCE parameters in sessionStorage (not localStorage) - Validate the state parameter to prevent CSRF attacks #### General - Always use HTTPS. Never send credentials over plain HTTP. - Implement proper error handling for auth failures - Log authentication events for security monitoring - Use passkeys where possible for phishing resistance --- # Secure Entropy: SparkVault API Reference > Generate cryptographically secure random data backed by AWS KMS hardware security modules (FIPS 140-3 Level 3 validated, NIST SP800-90A CTR_DRBG with AES-256), XORed with a local CSPRNG for hybrid defense-in-depth. Canonical: https://sparkvault.com/api/docs/entropy/ · OpenAPI: https://sparkvault.com/openapi.yaml ## Overview The Secure Entropy API provides enterprise-grade, hardware-backed random number generation suitable for cryptographic operations. Unlike software-based PRNGs (which can be predictable if seeded improperly), every request is served by AWS KMS `GenerateRandom`, which draws from FIPS 140-3 Level 3 validated hardware security modules using a NIST SP800-90A CTR\_DRBG with AES-256, seeded from a 384-bit entropy source (these are platform properties of AWS KMS). For defense-in-depth, the KMS output is XORed with an independent local CSPRNG before it is returned: the hybrid result is at least as random as the stronger of the two sources, so even if one source were compromised the output remains cryptographically secure. There is no software fallback. If the KMS hardware entropy source is unavailable, the request hard-fails rather than degrading to weaker entropy. FIPS 140-3 Level 3 Validated HSMs 384-bit Entropy Source AES-256 CTR\_DRBG Algorithm NIST SP800-90A Compliant > **Cryptographic Grade** > > This entropy source is suitable for generating encryption keys, session tokens, nonces, salts, and any other application requiring true cryptographic randomness. The underlying AWS KMS DRBG passes NIST statistical randomness tests and provides prediction resistance (guarantees of the AWS KMS platform), and the hybrid XOR construction adds an independent local entropy layer on top. ## API Reference #### `POST /v1/entropy/generate` Generate cryptographically secure random bytes in the specified format. > **Authentication** > > This endpoint accepts standard SparkVault authentication: a session JWT via `Authorization: Bearer …` or an API key via the `X-API-Key` header (API keys are prefixed `sv_live_`). The examples below use API-key auth. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `format` | string | Optional | Output encoding format. See Format Options below for valid values.Default: `hex` | | `num_bytes` | integer | Required | Number of random bytes to generate. Range: 1-1024 (1-512 for `alphanumeric`, `alphanumeric-mixed`, and `password`). For character-based formats, this is the output length in characters. For `uuid`, the value is still required and validated but the output always uses 16 bytes. | #### Response Fields | Field | Type | Description | | --- | --- | --- | | `value` | string | array | The generated random data in the requested format | | `format` | string | The format used for encoding | | `num_bytes` | integer | Number of raw random bytes actually generated. For `hex`, `base64`, `base64url`, `numeric`, and `bytes` this equals the requested value. For the character-set formats (`alphanumeric`, `alphanumeric-mixed`, `password`) it is the raw bytes consumed by rejection sampling (2x the requested length). Because the hardware draw is capped at 1024 raw bytes, character-set requests support at most 512 characters; larger values are rejected with `400 VALIDATION_ERROR`. For `uuid` it is always 16, regardless of the request. | | `reference_id` | string | Unique reference ID for this request (for audit logging) | ## Format Options | Format | Character Set | Use Case | | --- | --- | --- | | `hex` | 0-9, a-f | Encryption keys, debugging, hex-based systems | | `base64` | A-Z, a-z, 0-9, +, / | General encoding, email-safe data | | `base64url` | A-Z, a-z, 0-9, -, \_ | URLs, tokens, API keys, JWT | | `alphanumeric` | A-Z, 0-9 | Case-insensitive codes, serial numbers | | `alphanumeric-mixed` | A-Z, a-z, 0-9 | Case-sensitive codes, identifiers | | `password` | A-Z, a-z, 0-9, !@#$%^&\*()\_+-=\[\]{}|;:,.<>? | Secure password generation | | `numeric` | 0-9 | PINs, verification codes, OTPs | | `uuid` | UUID v4 format | Unique identifiers, database keys | | `bytes` | Raw byte array (JSON) | Direct cryptographic use, custom encoding | ## Examples ### Generate Hex-Encoded Random Data ```bash curl -X POST https://api.sparkvault.com/v1/entropy/generate \ -H "X-API-Key: sv_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "format": "hex", "num_bytes": 32 }' ``` ```json { "data": { "value": "a3f9b2c8d4e6f1a87b3c5d9e0f2a4b6c8d1e3f5a7b9c0d2e4f6a8b0c2d4e6f80", "format": "hex", "num_bytes": 32, "reference_id": "ent_a1b2c3d4" }, "meta": { "api_version": "1.2.828", "response_ms": 42, "request_id": "9b2f6d1c-4e3a-4c8b-9f0d-2a7e5b1c8d3f", "timestamp": 1782864000 } } ``` ### Generate a UUID ```bash curl -X POST https://api.sparkvault.com/v1/entropy/generate \ -H "X-API-Key: sv_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "format": "uuid", "num_bytes": 16 }' ``` ```json { "data": { "value": "550e8400-e29b-41d4-a716-446655440000", "format": "uuid", "num_bytes": 16, "reference_id": "ent_b2c3d4e5" }, "meta": { "api_version": "1.2.828", "response_ms": 38, "request_id": "4c7a1e9d-2b5f-4d3a-8c6e-0f9b3a7d5e21", "timestamp": 1782864060 } } ``` ### Generate a Secure Password ```bash curl -X POST https://api.sparkvault.com/v1/entropy/generate \ -H "X-API-Key: sv_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "format": "password", "num_bytes": 24 }' ``` ```json { "data": { "value": "Kx9!mP@qR2#vL5^nW8&jT3*b", "format": "password", "num_bytes": 48, "reference_id": "ent_c3d4e5f6" }, "meta": { "api_version": "1.2.828", "response_ms": 35, "request_id": "7e3b9f5a-1d8c-4a2e-b6f4-5c0d2e9a1b7c", "timestamp": 1782864120 } } ``` > **Why num\_bytes is 48 here** > > Character-set formats (`alphanumeric`, `alphanumeric-mixed`, `password`) use rejection sampling to avoid modulo bias, so the service consumes 2x the requested bytes and the response echoes the raw bytes consumed. Because the hardware draw is capped at 1024 raw bytes, these formats support at most 512 characters per request. Larger values are rejected with `400 VALIDATION_ERROR`. The `value` is still exactly the requested length in characters (24 here). ### Generate a 6-Digit PIN ```bash curl -X POST https://api.sparkvault.com/v1/entropy/generate \ -H "X-API-Key: sv_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "format": "numeric", "num_bytes": 6 }' ``` ```json { "data": { "value": "847291", "format": "numeric", "num_bytes": 6, "reference_id": "ent_d4e5f6g7" }, "meta": { "api_version": "1.2.828", "response_ms": 31, "request_id": "2a8d4c6f-9e1b-4f7a-a3c5-8b6e0d4f2a19", "timestamp": 1782864180 } } ``` ## Language Examples ### JavaScript / Node.js ```javascript const response = await fetch('https://api.sparkvault.com/v1/entropy/generate', { method: 'POST', headers: { 'X-API-Key': process.env.SPARKVAULT_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ format: 'base64url', num_bytes: 32 }) }); const result = await response.json(); const sessionToken = result.data.value; console.log('Generated token:', sessionToken); ``` ### Python ```python import os import requests response = requests.post( 'https://api.sparkvault.com/v1/entropy/generate', headers={ 'X-API-Key': os.environ['SPARKVAULT_API_KEY'], 'Content-Type': 'application/json' }, json={ 'format': 'hex', 'num_bytes': 32 } ) result = response.json() encryption_key = result['data']['value'] print(f"Generated key: {encryption_key}") ``` ### Go ```go package main import ( "bytes" "encoding/json" "net/http" "os" ) func generateEntropy() (string, error) { payload := map[string]interface{}{ "format": "base64url", "num_bytes": 32, } body, _ := json.Marshal(payload) req, _ := http.NewRequest("POST", "https://api.sparkvault.com/v1/entropy/generate", bytes.NewBuffer(body)) req.Header.Set("X-API-Key", os.Getenv("SPARKVAULT_API_KEY")) req.Header.Set("Content-Type", "application/json") client := &http.Client{} resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() var result map[string]interface{} json.NewDecoder(resp.Body).Decode(&result) data := result["data"].(map[string]interface{}) return data["value"].(string), nil } ``` ## Error Responses #### Error Responses | Status | Code | Description | | --- | --- | --- | | 400 | `VALIDATION_ERROR` | Invalid format or num\_bytes parameter. Check that format is a valid option and num\_bytes is between 1 and 1024. | | 401 | `AUTHENTICATION_ERROR` | Missing or invalid credentials. Provide a session JWT or a valid X-API-Key header. | | 402 | `PLAN_REQUIRED` | An active subscription is required for this operation. Subscribe to continue. | | 402 | `QUOTA_EXCEEDED` | The account bandwidth pool is exhausted (details include `resource: bandwidth`). Add a capacity block to continue. | | 429 | `RATE_LIMIT_EXCEEDED` | Too many requests. Wait and retry. Check Retry-After header. | | 500 | `CRYPTOGRAPHIC_ERROR` | Entropy generation failed because the KMS hardware entropy source was unavailable. There is no software fallback. This is rare. Retry the request. | A `503` response can only originate from upstream infrastructure, not from the API itself; such responses do not carry the SparkVault error envelope or an error `code`. Retry with exponential backoff. ## Usage | Operation | Usage | | --- | --- | | Entropy Generation (any format, any size) | Draws pooled bandwidth (included with subscription) | Entropy generation is covered by your seat subscription and tracked as usage. Each customer call also draws the account bandwidth pool by its request plus response size; there is no per-request charge. Requests are subject to your plan's rate limits regardless of the number of bytes requested (up to the 1024 byte limit). When the bandwidth pool is exhausted, the call is refused with `402 QUOTA_EXCEEDED`; extend the pool with a capacity block to continue. ## Try It You can test secure entropy generation interactively in the SparkVault dashboard. Open the Entropy panel from the dashboard shortcut, the command palette, or the console footer. [Open Dashboard](https://app.sparkvault.com/entropy) --- # 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 | --- # SparkLinks API: SparkVault API Reference > Create and manage shared links for sparks and ingots. SparkLinks provide secure, trackable, single-use access to your content with fine-grained visibility controls. Canonical: https://sparkvault.com/api/docs/sparklinks/ · OpenAPI: https://sparkvault.com/openapi.yaml ## Overview SparkLinks are the unified sharing mechanism for SparkVault. They provide a single, consistent way to share sparks and ingots with configurable visibility, single-use access, and expiration. Every SparkLink works exactly once, then expires. ### Key Features - **Unified Sharing**: One system for sparks and ingots - **Visibility Controls**: Public, authenticated, or invite-only access - **Single-Use**: Every link opens exactly once, then is consumed - **Expiration**: Automatic link expiration - **Audit Trail**: Track who accessed what and when - **Link Safety**: A link-spark destination URL is checked for safety before redirect ### Link Types | Type | Purpose | Created Via | | --- | --- | --- | | `spark` | Share ephemeral secrets, including a **link-spark** (a `text/uri-list` redirect / magic link), burn-on-read | `PATCH /v1/sparks/{spark_id}/share`, or `POST /v1/sparks` with `content_type: 'text/uri-list'` for a link-spark | | `ingot` | Share vault files | `PUT /v1/vaults/{vault_id}/ingots/{ingot_id}/sharing` | ### Visibility Modes | Mode | Who Can Access | Identity Verification | | --- | --- | --- | | `public` | Anyone with the link | No | | `authenticated` | Anyone who verifies their identity | Yes (via Identity Product) | | `invite_only` | Specific invited identities only | Yes (must match invite) | ### URL Format SparkLinks always use the short domain format `x.sv/{link_code}`. ## List SparkLinks #### `GET /v1/sparklinks` List all SparkLinks created by your account. Supports filtering by type and pagination. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `type` | string | Optional | Filter by link type: `spark` or `ingot`Default: `all` | | `limit` | integer | Optional | Maximum results to return (1-100)Default: `50` | | `cursor` | string | Optional | Pagination cursor from previous responseDefault: `null` | #### Response | Field | Type | Description | | --- | --- | --- | | `sparklinks` | array | Array of SparkLink objects | | `sparklinks[].link_code` | string | Unique link code | | `sparklinks[].link_url` | string | Full URL: https://x.sv/{link\_code} | | `sparklinks[].link_type` | string | Type: spark or ingot | | `sparklinks[].asset_id` | string? | Associated asset ID (spk\_xxx or ing\_xxx) | | `sparklinks[].vault_id` | string? | Vault ID for ingot links | | `sparklinks[].visibility` | string | Visibility mode | | `sparklinks[].invites` | array | Invited identities (email/phone strings) | | `sparklinks[].status` | string | Single-use lifecycle: active → consumed (opened once) | revoked | | `sparklinks[].bound_ip` | string? | IP address the link is bound to | | `sparklinks[].session_length` | integer? | Access session length in seconds | | `sparklinks[].expires_at` | integer? | Expiration timestamp | | `sparklinks[].created_at` | integer | Creation timestamp | | `sparklinks[].verified_by` | string? | Last verified identity (for authenticated/invite\_only) | | `sparklinks[].verified_at` | integer? | Last verification timestamp | | `count` | integer | Number of items in this page | | `cursor` | string? | Pagination cursor for the next page (null when there are no more pages) | #### List All SparkLinks Request ```bash curl "https://api.sparkvault.com/v1/sparklinks?type=spark&limit=10" \ -H "X-API-Key: sv_live_xxx" ``` Response ```json { "data": { "sparklinks": [ { "link_code": "ABCDefgh1234567890_-xy", "link_url": "https://x.sv/ABCDefgh1234567890_-xy", "link_type": "spark", "asset_id": "spk_abc123", "visibility": "public", "invites": [], "status": "active", "expires_at": 1702086400, "created_at": 1702000000 } ], "count": 1, "cursor": null } } ``` ## Create a Redirect / Magic Link (Link-Spark) A single-use redirect or magic link is a **link-spark**: a Spark whose encrypted payload is the destination URL, marked by `content_type: 'text/uri-list'`. The destination is sealed at rest (never stored in plaintext) and burns on first open. Create it on the Sparks API: the server validates the URL, seals it, and auto-mints a PUBLIC `spark` SparkLink grant, returning `link_url` and `link_code`. There is no `POST /v1/sparklinks` create path. #### `POST /v1/sparks` Create a link-spark by sealing a destination URL. Pass content_type 'text/uri-list' and the URL as the payload. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `payload` | string | Required | The http/https destination URL to seal | | `content_type` | string | Required | Must be 'text/uri-list' to mint a link-spark | | `ttl_minutes` | integer | Optional | Time-to-live in minutesDefault: `1440 (24 hours)` | #### Response | Field | Type | Description | | --- | --- | --- | | `spark_id` | string | The sealed link-spark (spk\_xxx) | | `size_bytes` | integer | Size of the sealed payload in bytes | | `status` | string | The spark's status: active | | `created_at` | integer | Creation timestamp | | `expires_at` | integer | Expiration timestamp | | `ttl_minutes` | integer | Time-to-live in minutes | | `link_url` | string | Full URL: https://x.sv/{link\_code} | | `link_code` | string | Unique link code | | `filename` | string? | Echoed back when provided in the request | | `kindling` | string? | External kindling ID, when provided in the request | #### Create Magic Link (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://myapp.com/auth/callback?token=xyz", "content_type": "text/uri-list", "ttl_minutes": 15 }' ``` Response ```json { "data": { "spark_id": "spk_abc123", "size_bytes": 41, "status": "active", "created_at": 1702000000, "expires_at": 1702000900, "ttl_minutes": 15, "link_url": "https://x.sv/ABCDefgh1234567890_-xy", "link_code": "ABCDefgh1234567890_-xy" } } ``` > **Bot-Resistant Deferred Burn** > > Opening `x.sv/{link_code}` serves a "Decrypting the secure SparkLink destination…" page **without** burning. Page JS then posts to burn: the server decrypts the URL, runs a link-safety check, and returns it, and the page redirects to the destination host. A non-JS scanner that issues only a bare GET never consumes the single-use link. If the URL is flagged unsafe, the page shows a warning instead of redirecting. ## Get SparkLink #### `GET /v1/sparklinks/{link_code}` Get details about a specific SparkLink. #### Response | Field | Type | Description | | --- | --- | --- | | `link_code` | string | Unique link code | | `link_url` | string | Full URL: https://x.sv/{link\_code} | | `link_type` | string | Type: spark or ingot | | `asset_id` | string? | Associated asset ID (spk\_xxx or ing\_xxx) | | `vault_id` | string? | Vault ID for ingot links | | `visibility` | string | Visibility mode | | `invites` | array | Invite objects `{ identity, type }` where type is email or phone (invite\_only visibility) | | `status` | string | Single-use lifecycle: active → consumed (opened once) | revoked | | `bound_ip` | string? | IP address the link is bound to | | `session_length` | integer? | Access session length in seconds | | `expires_at` | integer? | Expiration timestamp | | `created_at` | integer | Creation timestamp | | `verified_by` | string? | Last verified identity (for authenticated/invite\_only) | | `verified_at` | integer? | Last verification timestamp | #### Get SparkLink Details Request ```bash curl "https://api.sparkvault.com/v1/sparklinks/ABCDefgh1234567890_-xy" \ -H "X-API-Key: sv_live_xxx" ``` Response ```json { "data": { "link_code": "ABCDefgh1234567890_-xy", "link_url": "https://x.sv/ABCDefgh1234567890_-xy", "link_type": "spark", "asset_id": "spk_abc123", "visibility": "public", "invites": [], "status": "active", "expires_at": 1702086400, "created_at": 1702000000 } } ``` > **Unknown Link Codes** > > An unknown link code, or one owned by another account, returns `400 VALIDATION_ERROR` with message `SparkLink not found`, not a 404. ## Delete SparkLink #### `DELETE /v1/sparklinks/{link_code}` Delete a SparkLink. The associated asset (spark/ingot) will no longer be shared, but the asset itself is not deleted. #### Response | Field | Type | Description | | --- | --- | --- | | `deleted` | boolean | Always true on success | | `link_code` | string | The deleted link code | #### Delete SparkLink Request ```bash curl -X DELETE "https://api.sparkvault.com/v1/sparklinks/ABCDefgh1234567890_-xy" \ -H "X-API-Key: sv_live_xxx" ``` Response ```json { "data": { "deleted": true, "link_code": "ABCDefgh1234567890_-xy" } } ``` > **One SparkLink Per Asset** > > Each spark or ingot can have at most one SparkLink. Deleting a SparkLink makes the asset private again. To change a shared spark's settings, PATCH `/v1/sparks/{spark_id}/share` again, which updates the existing link in place. ## Sharing Sparks Share an existing spark by creating a SparkLink for it. The spark remains burn-on-read but can be accessed via the short URL. #### `PATCH /v1/sparks/{spark_id}/share` Create a SparkLink for an existing spark. If the spark is already shared, its existing link is updated in place. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `visibility` | string | Optional | Visibility mode: public, authenticated, or invite\_onlyDefault: `public` | | `invites` | array | Optional | Identities (email/phone) to invite for invite\_only visibility (max 1, invitation sent automatically)Default: `[]` | | `expires_in_seconds` | integer | Optional | Link expiration TTL (min 60, capped at spark expiration). Applies only on the first share; it is ignored when the spark already has a SparkLink.Default: `Spark expiration` | #### Response | Field | Type | Description | | --- | --- | --- | | `shared` | boolean | Whether the spark is shared | | `share_url` | string | Full share URL: https://x.sv/{link\_code} | | `link_code` | string | Unique link code | | `visibility` | string | Visibility mode | | `invites` | array | Invited identities (invite\_only visibility) | | `expires_at` | integer | Link expiration timestamp | | `created_at` | integer | Creation timestamp | | `already_shared` | boolean? | true when the spark already had a SparkLink. The existing link is updated in place and returned, including `invites` and `expires_at`. | #### Share Spark (Public) Request ```bash curl -X PATCH "https://api.sparkvault.com/v1/sparks/spk_abc123/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/ABCDefgh1234567890_-xy", "link_code": "ABCDefgh1234567890_-xy", "visibility": "public", "invites": [], "expires_at": 1702086400, "created_at": 1702000000 } } ``` #### Share Spark (Invite Only) Request ```bash curl -X PATCH "https://api.sparkvault.com/v1/sparks/spk_abc123/share" \ -H "X-API-Key: sv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "visibility": "invite_only", "invites": ["recipient@example.com"] }' ``` Response ```json { "data": { "shared": true, "share_url": "https://x.sv/ABCDefgh1234567890_-xy", "link_code": "ABCDefgh1234567890_-xy", "visibility": "invite_only", "invites": ["recipient@example.com"], "expires_at": 1702086400, "created_at": 1702000000 } } ``` ### Get Sharing Status #### `GET /v1/sparks/{spark_id}/share` Get the current sharing status for a spark. #### Response | Field | Type | Description | | --- | --- | --- | | `shared` | boolean | Whether the spark is shared. When false, only `shared` and `spark_id` are returned | | `spark_id` | string | Spark ID | | `share_url` | string? | Full share URL (when shared) | | `link_code` | string? | Unique link code (when shared) | | `visibility` | string? | Visibility mode (when shared) | | `invites` | array? | Invited identities (when shared) | | `status` | string? | Single-use lifecycle: active → consumed (opened once) | revoked | | `expires_at` | integer? | Link expiration timestamp (when shared) | | `created_at` | integer? | Creation timestamp (when shared) | ### Unshare a Spark #### `DELETE /v1/sparks/{spark_id}/share` Unshare a spark by deleting its SparkLink. The spark itself is not deleted. #### Response | Field | Type | Description | | --- | --- | --- | | `shared` | boolean | Always false after unsharing | | `message` | string | Result message (also returned when the spark was not shared) | > **Automatic Invitation** > > When sharing with `invite_only` visibility, an invitation email/SMS is automatically sent to each invited identity with the pre-populated share URL. ## Sharing Ingots Share vault files (ingots) with external users via SparkLinks. Every SparkLink grant is single-use regardless of type: it is minted `active`, atomically flipped to `consumed` on the one open, and then rejects all further access. Re-access requires minting a new link. #### `PUT /v1/vaults/{vault_id}/ingots/{ingot_id}/sharing` Enable, update, or disable sharing for an ingot. Requires a Vault Access Token (X-Vault-Access-Token header) and Public File Sharing enabled on the vault. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `shared` | boolean | Optional | true to enable sharing, false to disableDefault: `Current state` | | `visibility` | string | Optional | Visibility mode: public, authenticated, or invite\_only (required when enabling sharing)Default: `null` | | `invites` | array | Optional | Identities (email/phone) to invite for invite\_only visibilityDefault: `[]` | #### Response | Field | Type | Description | | --- | --- | --- | | `ingot_id` | string | Ingot ID | | `shared` | boolean | Whether the ingot is shared | | `visibility` | string? | Visibility mode (null when not shared) | | `public_url` | string? | Full share URL: https://x.sv/{link\_code} | | `link_code` | string? | Unique link code | | `updated_at` | integer | Update timestamp | #### Share Ingot Request ```bash curl -X PUT "https://api.sparkvault.com/v1/vaults/vlt_abc123/ingots/ing_xyz789/sharing" \ -H "X-API-Key: sv_live_xxx" \ -H "X-Vault-Access-Token: YOUR_VAT" \ -H "Content-Type: application/json" \ -d '{ "shared": true, "visibility": "authenticated" }' ``` Response ```json { "data": { "ingot_id": "ing_xyz789", "shared": true, "visibility": "authenticated", "public_url": "https://x.sv/ABCDefgh1234567890_-xy", "link_code": "ABCDefgh1234567890_-xy", "updated_at": 1702000000 } } ``` > **Related Endpoints** > > - `GET /v1/vaults/{vault_id}/ingots/{ingot_id}/sharing`: Get the current sharing configuration > - `POST /v1/vaults/{vault_id}/ingots/{ingot_id}/sharing/invite`: Invite an identity (body: `identity`, optional `expires_in_seconds`) > - `DELETE /v1/vaults/{vault_id}/ingots/{ingot_id}/sharing/invite/{invite_id}`: Revoke an invite > - `POST /v1/vaults/{vault_id}/ingots/{ingot_id}/sharing/invite/{invite_id}/resend`: Resend an invitation ## Custom Domains Prove your organization owns a domain with a DNS TXT record. Verified domains authorize cross-domain SSO, Identity SDK browser origins, and signup reservation under that domain for the claiming organization. SparkLinks are always served from `x.sv`, so there is no link-hosting step here. Listing domains works with any authenticated caller (JWT or API key). Adding, deleting, and verifying domains are **admin-only** (JWT admin session). #### `GET /v1/domains` List the account's custom domains and their ownership-verification status. #### Response | Field | Type | Description | | --- | --- | --- | | `domains` | array | Array of domain objects | | `domains[].domain` | string | The hostname | | `domains[].status` | string | Ownership verification: pending | verified | failed | | `domains[].verification_method` | string | Always dns\_txt | | `domains[].txt_name` | string | TXT record name to create (\_sparkvault.{domain}) | | `domains[].txt_value` | string | TXT record value (sparkvault-domain-verification=svdv\_...) | | `domains[].created_at` | integer | Creation timestamp | | `domains[].verified_at` | integer? | Ownership verification timestamp | | `domains[].last_checked_at` | integer? | Last TXT check timestamp | | `domains[].last_check_result` | string? | Last TXT check outcome | #### `POST /v1/domains` Add a custom domain (admin only). The domain starts pending. Prove ownership via the verify endpoint. Maximum 10 domains per account. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `domain` | string | Required | Hostname to add (e.g. app.example.com). SparkVault-owned domains are rejected. | #### Response | Field | Type | Description | | --- | --- | --- | | `domain` | object | The created domain object (see GET /v1/domains fields) | | `message` | string | Setup instructions | #### Add a Custom Domain Request ```bash curl -X POST https://api.sparkvault.com/v1/domains \ -H "Authorization: Bearer ADMIN_JWT" \ -H "Content-Type: application/json" \ -d '{ "domain": "app.acme.com" }' ``` Response ```json { "data": { "domain": { "domain": "app.acme.com", "status": "pending", "verification_method": "dns_txt", "txt_name": "_sparkvault.app.acme.com", "txt_value": "sparkvault-domain-verification=svdv_...", "created_at": 1702000000, "verified_at": null, "last_checked_at": null, "last_check_result": null }, "message": "Domain added. Create the TXT record to verify ownership." } } ``` #### `DELETE /v1/domains/{domain}` Remove a custom domain (admin only) and release its global claim. #### Response | Field | Type | Description | | --- | --- | --- | | `deleted` | boolean | Always true on success | | `domain` | string | The removed hostname | #### `POST /v1/domains/{domain}/verify` Verify DNS ownership via a TXT record (admin only). Rate limited: one attempt per domain every 10 minutes (429 RATE_LIMITED with retry_after). #### Response | Field | Type | Description | | --- | --- | --- | | `domain` | object | The updated domain object | | `verified` | boolean | Whether ownership verification passed | | `result` | string | TXT ownership check outcome (e.g. success, txt\_not\_found, txt\_wrong\_value) | | `message` | string | Human-readable result with the next step | | `actual_value` | string? | TXT value found, when it differs from the expected value | #### Verify a Custom Domain Request ```bash curl -X POST https://api.sparkvault.com/v1/domains/app.acme.com/verify \ -H "Authorization: Bearer ADMIN_JWT" ``` Response ```json { "data": { "domain": { "domain": "app.acme.com", "status": "verified", "verified_at": 1702000600 }, "verified": true, "result": "success", "message": "Domain verified successfully" } } ``` > **Global Domain Claims** > > Successful verification claims the domain globally: a domain verified by one organization cannot be verified by another (the attempt fails with `400 VALIDATION_ERROR`). A verified domain authorizes cross-domain SSO and Identity SDK browser origins for the claiming organization. ## Access History Track who accessed your SparkLinks using the Audit Log API. SparkLink access events include IP address, user agent, and verified identity (for authenticated/invite\_only links). Responses are paginated: entries are returned under `entries` with `count`, `has_more`, and (when more pages exist) `next_cursor`, and each response includes an `event_types` array listing every valid event type for the scope. Filter with the comma-separated `event_types` query parameter. #### Query SparkLink Access Events Request ```bash curl "https://api.sparkvault.com/v1/audit-logs?event_types=sparklink_accessed" \ -H "X-API-Key: sv_live_xxx" ``` Response ```json { "data": { "entries": [ { "event_id": "evt_abc123", "event_type": "sparklink_accessed", "timestamp": 1702001000, "details": { "link_code": "ABCDefgh1234567890_-xy", "link_type": "ingot", "visibility": "authenticated", "verified_by": "user@example.com", "ip": "192.168.1.1", "user_agent": "Mozilla/5.0..." } } ], "count": 1, "has_more": false } } ``` > **Event Types** > > - `sparklink_created`: SparkLink was created > - `sparklink_updated`: SparkLink settings were updated in place > - `sparklink_accessed`: SparkLink was accessed (spark / link-spark / ingot) > - `sparklink_deleted`: SparkLink was deleted > - `sparklink_revoked`: SparkLink was revoked > - `sparklink_signed`, `sparklink_approved`, `sparklink_denied`, `sparklink_replied`: interaction receipts, signed, JWKS-verifiable proof of the recipient's action ## Common Use Cases ### Passwordless Authentication (Magic Links) ```javascript // Create a magic auth link as a link-spark: the destination URL is // sealed (encrypted at rest) and burns on the first open. 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: `https://myapp.com/auth/verify?token=${authToken}`, content_type: 'text/uri-list', ttl_minutes: 15 // single-use, burns on first hop }) }); const { link_url } = (await response.json()).data; // Send link_url to user via email await sendEmail(user.email, `Login here: ${link_url}`); ``` ### Secure File Sharing with Identity Verification ```javascript // Share a confidential document requiring identity verification const response = await fetch( 'https://api.sparkvault.com/v1/vaults/vlt_abc/ingots/ing_xyz/sharing', { method: 'PUT', headers: { 'X-API-Key': process.env.SPARKVAULT_API_KEY, 'X-Vault-Access-Token': vaultAccessToken, 'Content-Type': 'application/json' }, body: JSON.stringify({ shared: true, visibility: 'invite_only', invites: ['client@example.com'] }) } ); // Recipient automatically receives invitation email // They must verify their email before accessing the file ``` ### Time-Limited Share Links ```python import requests import os # Share a spark with public visibility that expires in 1 hour response = requests.patch( f'https://api.sparkvault.com/v1/sparks/{spark_id}/share', headers={ 'X-API-Key': os.environ['SPARKVAULT_API_KEY'], 'Content-Type': 'application/json' }, json={ 'visibility': 'public', 'expires_in_seconds': 3600 } ) share_url = response.json()['data']['share_url'] print(f"Share this link: {share_url}") # Valid for 1 hour (capped at spark expiration) ``` ## Usage SparkLink operations are covered by your seat subscription. Bandwidth consumed by link access 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 SparkLink | Included with subscription | | Access SparkLink | Counts against pooled bandwidth (included) | | List SparkLinks | Included with subscription | | Delete SparkLink | Included with subscription | ## Error Reference #### Error Responses | Status | Code | Description | | --- | --- | --- | | 400 | `VALIDATION_ERROR` | Invalid request parameters. Includes malformed or non-http(s) link-spark destination URLs, and unknown or unowned link codes on `GET /v1/sparklinks/{link_code}` (message: SparkLink not found) | | 401 | `AUTHENTICATION_ERROR` | Invalid or missing API key | | 402 | `PLAN_REQUIRED` | An active subscription is required | | 402 | `QUOTA_EXCEEDED` | Pooled capacity exhausted (details include `resource: bandwidth`). Add a capacity block | | 403 | `FORBIDDEN` | Denied at link open: visibility requirements not met, link already consumed, revoked, expired, or IP-binding mismatch. Also returned when sharing a non-active or expired spark | | 404 | `NOT_FOUND` | Spark or ingot not found on the share endpoints; unknown or expired link code at public access (SparkLink not found or expired) | | 412 | `PRECONDITION_FAILED` | Public File Sharing is not enabled on the vault when enabling ingot sharing (enable it on the vault first) | | 429 | `RATE_LIMIT_EXCEEDED` | Too many requests | | 429 | `RATE_LIMITED` | Domain verification attempted again within 10 minutes (details include `retry_after`) | > **Re-Sharing Is Not an Error** > > Sharing a spark that already has a SparkLink does not conflict. The existing link is updated in place and returned with `already_shared: true`, and its `link_code` is never rotated. Changing `visibility` or `invites` applies to the same link, so switching an `invite_only` link to `public` downgrades access on the existing URL. --- # Vaults API: SparkVault API Reference > Create encrypted vaults and store sensitive data with triple zero-trust, post-quantum encryption. Vaults provide persistent, encrypted storage for files and data of any size. Canonical: https://sparkvault.com/api/docs/vaults/ · OpenAPI: https://sparkvault.com/openapi.yaml ## Overview Vaults are encrypted containers for storing sensitive data. Unlike Sparks (which are ephemeral and read-once), Vaults provide persistent storage that you can access repeatedly. #### Key Concepts | Term | Description | | --- | --- | | **Vault** | An encrypted container that holds multiple Ingots | | **Ingot** | An individual encrypted file or data blob stored in a Vault | | **Standard Ingot** | A file-based ingot (PDF, image, document, etc.) | | **Structured Ingot** | A key/value data store (up to 10,000 keys) for structured data like PII fields | | **VMK** | Vault Master Key. The secret key required to access vault contents. | | **VAT** | Vault Access Token. A temporary session token for vault operations. | | **DVAK** | Delegated Vault Access Key. A revocable credential that unseals a vault in place of the VMK. | | **Sealed** | Vault state where contents are encrypted and inaccessible | | **Unsealed** | Vault state where contents can be read/written (requires active VAT) | > **VMK Security** > > The Vault Master Key (VMK) is **only shown once** when you create a vault. SparkVault does not store the VMK and cannot recover it if lost. The one sanctioned delegation path is a [DVAK](#delegated-vault-access-keys-dvaks) issued while you still hold the VMK: a DVAK can unseal the vault later without it. > > Always store VMKs in a secure password manager or hardware security module (HSM). ## Vault Lifecycle Working with vaults follows this flow: 1. **Create**: Create a vault and receive the VMK (save it securely!) 2. **Unseal**: Provide the VMK to get a temporary VAT (1-hour default, up to 24 hours) 3. **Read/Write**: Use the VAT to create, read, update, or delete Ingots 4. **Seal**: (Optional) Manually seal to invalidate all VATs, or let them expire ```bash # 1. Create a vault curl -X POST https://api.sparkvault.com/v1/vaults \ -H "X-API-Key: sv_live_xxx" \ -H "Content-Type: application/json" \ -d '{"name": "Production Secrets"}' # Returns: vault_id and vmk (SAVE THE VMK!) # 2. Unseal the vault to get a VAT curl -X POST https://api.sparkvault.com/v1/vaults/{vault_id}/unseal \ -H "X-API-Key: sv_live_xxx" \ -H "Content-Type: application/json" \ -d '{"vmk": "YOUR_VMK_HERE"}' # Returns: vat (valid for 1 hour by default, up to 24 hours) # 3. Create an ingot (upload data) 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": "config.json", "size_bytes": 1024}' # Returns: forge_url for upload # 4. Upload file to Forge with tus protocol # Use the ISTK from forge_url as X-ISTK when creating the tus upload session ``` ## Create Vault #### `POST /v1/vaults` Create a new encrypted vault. Returns the Vault Master Key (VMK) which is required to access the vault. The VMK is only shown once. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | Required | Human-readable vault name. Max 200 characters. Must be unique within the account (uniqueness is case-insensitive); a duplicate returns `409 CONFLICT`. | #### Response | Field | Type | Description | | --- | --- | --- | | `vault_id` | string | Unique vault identifier (vlt\_...) | | `name` | string | Vault name | | `vmk` | string | Vault Master Key. SAVE THIS! Never shown again. | | `vmk_warning` | string | Warning message about VMK security | | `created_at` | integer | Unix timestamp of creation | #### Example Request ```bash curl -X POST https://api.sparkvault.com/v1/vaults \ -H "X-API-Key: sv_live_xxx" \ -H "Content-Type: application/json" \ -d '{"name": "Production Secrets"}' ``` Response ```json { "data": { "vault_id": "vlt_abc123def456...", "name": "Production Secrets", "vmk": "X9k2P8mQ7vH5nL3wR6tY...", "vmk_warning": "SAVE THIS KEY! SparkVault cannot recover it if lost. Store securely.", "created_at": 1702000000 } } ``` > **Critical: Save your VMK** > > The VMK is shown **only once** in the response. If you lose it, all data in the vault is permanently inaccessible. Store it immediately in a secure location. ## List Vaults #### `GET /v1/vaults` List all vaults in your account. Results are paginated. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | Optional | Maximum number of vaults to return (max 500).Default: `100` | | `cursor` | string | Optional | Signed pagination cursor from a previous response (`next_cursor`). | | `status` | string | Optional | Filter by stored vault status (e.g. `active`). Note this is the stored status, not the displayed `sealed`/`unsealed` value. | #### Response | Field | Type | Description | | --- | --- | --- | | `vaults` | array | Array of vault objects | | `vaults[].vault_id` | string | Vault identifier | | `vaults[].name` | string | Vault name | | `vaults[].status` | string | Vault status. Active vaults display as `sealed` in list view. Use Get Vault for live sealed/unsealed status. | | `vaults[].ingot_count` | integer | Number of ingots in vault | | `vaults[].storage_bytes` | integer | Total storage used in bytes | | `vaults[].total_access_count` | integer | Total ingot accesses recorded for the vault | | `vaults[].total_bandwidth_bytes` | integer | Total transfer bandwidth recorded for the vault, in bytes | | `vaults[].created_at` | integer | Creation timestamp | | `count` | integer | Number of vaults in this page | | `has_more` | boolean | Whether more pages are available | | `next_cursor` | string | Cursor for the next page (present when `has_more` is true) | ## Get Vault #### `GET /v1/vaults/{vault_id}` Get details about a specific vault. #### Response | Field | Type | Description | | --- | --- | --- | | `vault_id` | string | Vault identifier | | `name` | string | Vault name | | `description` | string | Vault description | | `status` | string | `sealed` or `unsealed`, derived from whether any VAT session is currently active | | `ingot_count` | integer | Number of ingots | | `storage_bytes` | integer | Total storage used | | `created_at` | integer | Creation timestamp | | `created_by_user_id` | string | User who created the vault | | `last_unsealed_at` | integer | Unix timestamp of the most recent unseal (null if never unsealed) | | `unseal_count` | integer | Number of times the vault has been unsealed | ## Update Vault #### `PUT /v1/vaults/{vault_id}` Update vault metadata (name and/or description). At least one field is required. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | Optional | New vault name. Max 200 characters; must be unique within the account (case-insensitive); a duplicate returns `409 CONFLICT`. | | `description` | string | Optional | New vault description. Max 1000 characters. Pass `null` to clear. | #### Response | Field | Type | Description | | --- | --- | --- | | `vault_id` | string | Vault identifier | | `name` | string | Vault name | | `description` | string | Vault description | | `updated_at` | integer | Update timestamp | ## Unseal Vault #### `POST /v1/vaults/{vault_id}/unseal` Unseal a vault by providing the VMK (or a DVAK). Returns a Vault Access Token (VAT) for accessing ingots. The VAT is valid for 1 hour by default, configurable up to 24 hours. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `vmk` | string | Required | The Vault Master Key from vault creation, or a DVAK token (prefix `dvak_`) issued for this vault. With a DVAK, the API recovers the VMK server-side; the resulting VAT inherits the DVAK's access level (`read` or `read_write`). Direct VMK unseal yields a full read+write VAT. | | `ttl_seconds` | integer | Optional | VAT lifetime in seconds (default: 3600, max: 86400) | #### Response | Field | Type | Description | | --- | --- | --- | | `vat` | string | Vault Access Token for ingot operations | | `vault_id` | string | Vault identifier | | `issued_at` | integer | Unix timestamp when VAT was issued | | `expires_at` | integer | Unix timestamp when VAT expires | | `ttl_seconds` | integer | VAT lifetime in seconds | | `warning` | string | Security reminder about VAT usage | #### Example Request ```bash curl -X POST https://api.sparkvault.com/v1/vaults/vlt_abc123/unseal \ -H "X-API-Key: sv_live_xxx" \ -H "Content-Type: application/json" \ -d '{"vmk": "X9k2P8mQ7vH5nL3wR6tY..."}' ``` Response ```json { "data": { "vat": "vat_xyz789...", "vault_id": "vlt_abc123def456...", "issued_at": 1702000000, "expires_at": 1702003600, "ttl_seconds": 3600, "warning": "Store this VAT securely. Required for all Ingot operations until expiration." } } ``` > **VAT Usage** > > Include the VAT in the `X-Vault-Access-Token` header for all ingot operations. You can request new VATs at any time; previous VATs remain valid until they expire. #### Error Responses | Status | Code | Description | | --- | --- | --- | | 401 | `UNAUTHORIZED` | The provided DVAK is invalid or has been revoked | | 403 | `FORBIDDEN` | The DVAK is not authorized for the requesting user | | 412 | `PRECONDITION_FAILED` | Invalid VMK: the provided VMK is incorrect for this vault | ## Delegated Vault Access Keys (DVAKs) DVAKs let vault admins delegate unseal access without sharing the VMK. A DVAK is used in place of the VMK in the [Unseal Vault](#unseal-vault) request body: the API recovers the VMK server-side and mints a VAT whose access level (`read` or `read_write`) is inherited from the DVAK. The full DVAK token is shown **once** at creation; afterwards only its last 4 characters are visible. | Type | Description | | --- | --- | | **user** | Issued to a specific team member; usable only by that user | | **nhi** | Issued to a Non-Human Identity (bot, AI agent, service), labelled with a free-text description | | **system** | Internal keys minted automatically for vault features (sharing, upload portal, upload widget), never created through this endpoint | > **Admin only** > > All DVAK management operations require the admin role; non-admin callers receive `403 FORBIDDEN`. Creating a DVAK requires the VMK, which is verified against the vault. ### Create DVAK #### `POST /v1/vaults/{vault_id}/dvaks` Create a User or NHI DVAK for a vault. Requires the VMK. The full DVAK token is returned once and never shown again. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `vmk` | string | Required | The Vault Master Key, verified before the DVAK is issued | | `dvak_type` | string | Optional | `user` or `nhi`Default: `user` | | `authorized_user_id` | string | Optional | User authorized to use this DVAK. **Required** when `dvak_type` is `user`; the user must belong to your account. | | `nhi_description` | string | Optional | What the non-human identity uses the key for. **Required** when `dvak_type` is `nhi`. | | `access_level` | string | Optional | `read` or `read_write`. Issuers on a Viewer (read-only) seat can only mint `read` DVAKs.Default: `read_write` | #### Response | Field | Type | Description | | --- | --- | --- | | `dvak_token` | string | The full DVAK token (dvak\_...). **Shown only once.** | | `dvak_type` | string | `user` or `nhi` | | `authorized_user_id` | string | Authorized user (user DVAKs; null for NHI) | | `authorized_user_email` | string | Authorized user's email (user DVAKs; null for NHI) | | `nhi_description` | string | NHI description (null for user DVAKs) | | `last_4_chars` | string | Last 4 characters of the token, used to identify and revoke the DVAK | | `created_at` | integer | Creation timestamp | | `warning` | string | Reminder that the token is not shown again | #### Example: create a DVAK, then unseal with it Request ```bash # Issue a read-only DVAK to a teammate curl -X POST https://api.sparkvault.com/v1/vaults/vlt_abc123/dvaks \ -H "X-API-Key: sv_live_xxx" \ -H "Content-Type: application/json" \ -d '{"vmk": "X9k2P8mQ7vH5nL3wR6tY...", "dvak_type": "user", "authorized_user_id": "usr_def456", "access_level": "read"}' # The teammate unseals with the DVAK instead of the VMK curl -X POST https://api.sparkvault.com/v1/vaults/vlt_abc123/unseal \ -H "X-API-Key: sv_live_yyy" \ -H "Content-Type: application/json" \ -d '{"vmk": "dvak_kQ8n...w2Xz"}' ``` ### List DVAKs #### `GET /v1/vaults/{vault_id}/dvaks` List DVAKs for a vault. Full tokens are never returned, only the last 4 characters. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | Optional | Maximum items to return (max 100)Default: `25` | | `cursor` | string | Optional | Pagination cursor from a previous response | | `status` | string | Optional | Filter by status: `active` or `revoked` | #### Response | Field | Type | Description | | --- | --- | --- | | `dvaks` | array | Array of DVAK objects | | `dvaks[].last_4_chars` | string | Last 4 characters of the token | | `dvaks[].dvak_type` | string | `user`, `nhi`, or `system` | | `dvaks[].authorized_user_id` | string | Authorized user (user DVAKs) | | `dvaks[].authorized_user_email` | string | Authorized user's email (user DVAKs) | | `dvaks[].system_purpose` | string | Feature a system DVAK serves (`sharing`, `upload_portal`, `upload_widget`) | | `dvaks[].nhi_description` | string | NHI description (NHI DVAKs) | | `dvaks[].status` | string | `active` or `revoked` | | `dvaks[].created_at` | integer | Creation timestamp | | `dvaks[].last_used_at` | integer | Timestamp of most recent use | | `dvaks[].use_count` | integer | Number of times the DVAK has been used | | `has_more` | boolean | Whether more pages are available | | `cursor` | string | Cursor for the next page | ### Revoke DVAK #### `DELETE /v1/vaults/{vault_id}/dvaks/{last4}` Revoke a DVAK by its last 4 characters. A revoked DVAK can no longer unseal the vault. #### Response | Field | Type | Description | | --- | --- | --- | | `last_4_chars` | string | Last 4 characters of the revoked DVAK | | `status` | string | Always `revoked` | | `revoked_at` | integer | Revocation timestamp | #### Error Responses | Status | Code | Description | | --- | --- | --- | | 400 | `VALIDATION_ERROR` | DVAK is already revoked, or `last4` is not exactly 4 characters | | 404 | `NOT_FOUND` | No DVAK with those last 4 characters exists for this vault | ## Seal Vault #### `POST /v1/vaults/{vault_id}/seal` Manually seal a vault, immediately invalidating all active VATs. This is optional. Vaults automatically seal when all VATs expire. > **Rarely Needed** > > Manual sealing is typically only needed for emergency revocation if you believe a VAT has been compromised. Normal usage should let VATs expire naturally. #### Example Request ```bash curl -X POST https://api.sparkvault.com/v1/vaults/vlt_abc123/seal \ -H "X-API-Key: sv_live_xxx" ``` Response ```text (204 No Content) ``` ## Delete Vault #### `DELETE /v1/vaults/{vault_id}` Delete a vault and all its contents. Deletion is asynchronous: the API returns 202 Accepted and a cascade worker removes ingots, folders, audit logs, DVAKs, SparkLinks, and stored objects in phases. This action is irreversible. #### Headers | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `X-Confirm-Name` | string | Required | Must match the vault name **exactly (case-sensitive)** to confirm deletion. A mismatch returns `400 VALIDATION_ERROR`. | #### Response (202 Accepted) | Field | Type | Description | | --- | --- | --- | | `message` | string | Confirmation that the deletion cascade has been enqueued | | `vault_id` | string | Vault identifier | | `started_at` | integer | Unix timestamp when the deletion cascade started | > **Irreversible** > > Deleting a vault permanently destroys all encrypted data. This cannot be undone. The `X-Confirm-Name` header is required as a safety measure. > **Idempotent** > > Repeating the DELETE while a deletion cascade is already in progress returns `202` again with the original `started_at`. It does not restart the cascade. The vault disappears from list and get responses as soon as deletion begins. #### Example Request ```bash curl -X DELETE https://api.sparkvault.com/v1/vaults/vlt_abc123 \ -H "X-API-Key: sv_live_xxx" \ -H "X-Confirm-Name: Production Secrets" ``` Response ```json { "data": { "message": "Vault deletion enqueued; cascade will run asynchronously", "vault_id": "vlt_abc123def456...", "started_at": 1702000000 } } ``` ## Ingot Operations Once a vault is unsealed, you can create, read, update, and delete Ingots (encrypted data objects). SparkVault supports two types of ingots: | Type | Description | Use Case | | --- | --- | --- | | **Standard Ingots** | File-based encrypted storage (PDF, images, documents, etc.) | Store any file up to 5 TB | | **Structured Ingots** | Key/value data store (up to 10,000 keys) | PII fields, credentials, structured sensitive data | **See the [Ingots API Documentation](/api/docs/ingots/)** for complete details on creating, reading, updating, and deleting ingots. ## Folders Folders provide hierarchical organization within a vault. All folder operations require an unsealed vault: include a valid VAT in the `X-Vault-Access-Token` header. A read-only VAT (minted from a `read` DVAK) can list and read folders but cannot create, modify, or delete them (`403 FORBIDDEN`). - Folder names: max 255 characters; names starting with `_` are reserved for system folders. - Duplicate names within the same parent return `409 CONFLICT`. - Maximum nesting depth: 20 levels (enforced across the whole subtree when moving folders). - System folders cannot be modified, deleted, or used as parents. ### Create Folder #### `POST /v1/vaults/{vault_id}/folders` Create a folder in a vault. Omit parent_id to create at the root. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | Required | Folder name (max 255 characters; cannot start with `_`) | | `parent_id` | string | Optional | Parent folder ID (`fld_...`). Omit for a root-level folder. | #### Response | Field | Type | Description | | --- | --- | --- | | `folder_id` | string | Unique folder identifier (fld\_...) | | `name` | string | Folder name | | `parent_id` | string | Parent folder ID (null at root) | | `pinned` | boolean | Whether the folder is pinned | | `created_at` | integer | Creation timestamp | ### List Folders #### `GET /v1/vaults/{vault_id}/folders` List every folder in the vault as a flat array (build the tree client-side from parent_id). #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `include_system` | boolean | Optional | Set to `true` to include system foldersDefault: `false` | #### Response | Field | Type | Description | | --- | --- | --- | | `folders` | array | All folders in the vault, pinned first. Each item: `folder_id`, `name`, `parent_id`, `pinned`, `created_at`, `updated_at`. | ### Get Folder #### `GET /v1/vaults/{vault_id}/folders/{folder_id}` Get folder details, including its breadcrumb path and subfolder count. #### Response | Field | Type | Description | | --- | --- | --- | | `folder_id` | string | Folder identifier | | `name` | string | Folder name | | `parent_id` | string | Parent folder ID (null at root) | | `pinned` | boolean | Whether the folder is pinned | | `created_at` | integer | Creation timestamp | | `updated_at` | integer | Last update timestamp | | `breadcrumb` | array | Path from the vault root: `{ folder_id, name }` entries, starting with Root (`folder_id: null`) | | `subfolder_count` | integer | Number of direct subfolders | ### Update Folder #### `PUT /v1/vaults/{vault_id}/folders/{folder_id}` Rename, move, or pin/unpin a folder. Returns the updated folder. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | Optional | New folder name (same rules as create) | | `parent_id` | string | Optional | New parent folder ID; `null` moves the folder to the root. A folder cannot be moved into its own subtree or into a system folder, and the move must keep every descendant within the 20-level depth limit. | | `pinned` | boolean | Optional | Pin or unpin the folder (pinned folders sort first) | ### Delete Folder #### `DELETE /v1/vaults/{vault_id}/folders/{folder_id}` Delete a folder and everything inside it, recursively. Deletion is asynchronous: returns 202 Accepted with { message, folder_id, started_at }, and repeating the call while the cascade is in progress returns 202 idempotently. ### List Folder Contents #### `GET /v1/vaults/{vault_id}/contents` List the subfolders and ingots inside a folder. Omit folder_id to list the vault root. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `folder_id` | string | Optional | Folder to list. Omit for the vault root. | | `include_system` | boolean | Optional | Set to `true` to include system foldersDefault: `false` | | `sort_by` | string | Optional | Ingot sort column: `name`, `size`, `content_type`, `access_count`, `bandwidth`, or `created`Default: `created` | | `sort_order` | string | Optional | `asc` or `desc`Default: `desc` | #### Response | Field | Type | Description | | --- | --- | --- | | `folder` | object | The current folder (`folder_id`, `name`, `parent_id`), null at the root | | `breadcrumb` | array | Path from the vault root: `{ folder_id, name }` entries, starting with Root | | `folders` | array | Direct subfolders, pinned first | | `ingots` | array | Ingots in this folder, sorted per `sort_by`/`sort_order` | ## Vault Sharing Vault sharing is the vault-level switch for secure public ingot sharing. Enabling it creates a [system DVAK](#delegated-vault-access-keys-dvaks) stored encrypted on SparkVault servers, so shared ingots can be decrypted for recipients without your SVMK or AMK. Per-ingot sharing controls (visibility, invitations) are documented on the [SparkLinks API](/api/docs/sparklinks/). ### Get Sharing Configuration #### `GET /v1/vaults/{vault_id}/sharing` Get the sharing configuration for a vault. #### Response | Field | Type | Description | | --- | --- | --- | | `vault_id` | string | Vault identifier | | `public_sharing_enabled` | boolean | Whether sharing is enabled | | `all_ingots_public` | boolean | Whether every ingot in the vault is shareable (vs. per-ingot opt-in) | | `session_length_seconds` | integer | Recipient session length in seconds (null when sharing is disabled) | | `enabled_at` | integer | Timestamp sharing was enabled (null when disabled) | ### Enable Sharing #### `POST /v1/vaults/{vault_id}/sharing/enable` Enable secure ingot sharing for a vault. Requires the VMK, unless an upload portal or widget system DVAK is already active. In that case the API recovers and verifies the VMK server-side. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `vmk` | string | Optional | The Vault Master Key. **Required** unless an upload portal/widget channel with an active system DVAK is already enabled on the vault. | | `all_ingots_public` | boolean | Optional | Make every ingot in the vault shareableDefault: `false` | | `session_length_seconds` | integer | Optional | Recipient session length in seconds (min 60, max 86400)Default: `3600` | #### Response | Field | Type | Description | | --- | --- | --- | | `vault_id` | string | Vault identifier | | `public_sharing_enabled` | boolean | Always `true` | | `all_ingots_public` | boolean | Effective all-ingots setting | | `session_length_seconds` | integer | Effective session length | | `enabled_at` | integer | Timestamp sharing was enabled | | `security_notice` | string | Reminder that sharing uses a server-held encrypted DVAK | > **Server-held key material** > > Enabling sharing stores a delegated vault access key (DVAK) encrypted on SparkVault servers so shared ingots can be decrypted for recipients. Disable sharing to delete this key. Enabling when sharing is already active returns `400 VALIDATION_ERROR`. ### Update Sharing Configuration #### `PUT /v1/vaults/{vault_id}/sharing` Update all_ingots_public and/or session_length_seconds without re-entering the VMK. Sharing must already be enabled. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `all_ingots_public` | boolean | Optional | Make every ingot in the vault shareable | | `session_length_seconds` | integer | Optional | Recipient session length in seconds (min 60, max 86400) | #### Response | Field | Type | Description | | --- | --- | --- | | `vault_id` | string | Vault identifier | | `public_sharing_enabled` | boolean | Always `true` | | `all_ingots_public` | boolean | Effective all-ingots setting | | `session_length_seconds` | integer | Effective session length | | `updated_at` | integer | Update timestamp | ### Disable Sharing #### `POST /v1/vaults/{vault_id}/sharing/disable` Disable secure ingot sharing. Revokes every SparkLink for the vault's ingots and deletes the sharing system DVAK. #### Response | Field | Type | Description | | --- | --- | --- | | `vault_id` | string | Vault identifier | | `public_sharing_enabled` | boolean | Always `false` | | `disabled_at` | integer | Timestamp sharing was disabled | | `shares_revoked` | integer | Number of SparkLinks deleted | ## Upload Portal & Widget Each vault has two optional public upload channels: the hosted **Upload Portal** (a page at `files.sv` for external users) and the embeddable **Upload Widget** (drop-zone in your own site via the JavaScript SDK). Enabling a channel mints its own [system DVAK](#delegated-vault-access-keys-dvaks); encryption keys are derived server-side and only the ISTK reaches the browser. The VMK and VAK never leave the server. Public uploaders can add files but cannot retrieve them. To embed the widget, see the JavaScript SDK guide: [Use Case: Accept Secure File Uploads](/api/docs/sdk-js/#use-case-upload). ### Get Upload Configuration #### `GET /v1/vaults/{vault_id}/upload` Get the upload configuration for a vault: both channels plus the shared settings. #### Response | Field | Type | Description | | --- | --- | --- | | `vault_id` | string | Vault identifier | | `upload_portal_enabled` | boolean | Whether the hosted portal is enabled | | `upload_portal_enabled_at` | integer | Timestamp the portal was enabled (null when disabled) | | `upload_widget_enabled` | boolean | Whether the embeddable widget is enabled | | `upload_widget_enabled_at` | integer | Timestamp the widget was enabled (null when disabled) | | `max_size_bytes` | integer | Shared per-file size limit for public uploads (null = default) | | `notification_email` | string | Email notified on public uploads (null = none) | | `portal_url` | string | Public portal URL (`https://files.sv/in/...`), present only while the portal is enabled | ### Update Upload Configuration #### `PUT /v1/vaults/{vault_id}/upload` Update the shared upload settings (max file size, notification email). These apply to both channels and persist even when both are disabled. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `max_size_bytes` | integer | Optional | Per-file size limit for public uploads. Min 1048576 (1 MB), max 5497558138880 (5 TB, the ingot maximum). | | `notification_email` | string | Optional | Email to notify on public uploads. Pass `null` to clear. | #### Response | Field | Type | Description | | --- | --- | --- | | `vault_id` | string | Vault identifier | | `max_size_bytes` | integer | Effective per-file size limit | | `notification_email` | string | Effective notification email | | `updated_at` | integer | Update timestamp | ### Enable / Disable the Upload Portal #### `POST /v1/vaults/{vault_id}/upload/portal/enable` Enable the hosted upload portal (files.sv). Requires the VMK in the request body. Returns the public portal_url. #### `POST /v1/vaults/{vault_id}/upload/portal/disable` Disable the hosted upload portal and delete its system DVAK. #### Request Body (enable) | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `vmk` | string | Required | The Vault Master Key, verified before the channel's system DVAK is created | #### Example Request ```bash curl -X POST https://api.sparkvault.com/v1/vaults/vlt_abc123/upload/portal/enable \ -H "X-API-Key: sv_live_xxx" \ -H "Content-Type: application/json" \ -d '{"vmk": "X9k2P8mQ7vH5nL3wR6tY..."}' ``` Response ```json { "data": { "vault_id": "vlt_abc123def456...", "upload_portal_enabled": true, "enabled_at": 1702000000, "portal_url": "https://files.sv/in/abc123def456" } } ``` ### Enable / Disable the Upload Widget #### `POST /v1/vaults/{vault_id}/upload/widget/enable` Enable the embeddable SDK upload widget. Requires the VMK in the request body. #### `POST /v1/vaults/{vault_id}/upload/widget/disable` Disable the upload widget and delete its system DVAK. #### Request Body (enable) | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `vmk` | string | Required | The Vault Master Key, verified before the channel's system DVAK is created | > **Idempotency & self-healing** > > Enabling a channel that is already enabled and healthy returns `400 VALIDATION_ERROR`. If a channel is flagged enabled but its system DVAK is missing or revoked, calling enable again re-provisions the channel with a fresh DVAK. Disable responses return `{ vault_id, upload__enabled: false, disabled_at }`; disabling an already-disabled channel returns `400`. ## Access Log Retention Configure how long access logs are retained for shared ingots in a vault. Logs count against your pooled storage (see [pricing](/pricing/)). ### Get Access Log Retention #### `GET /v1/vaults/{vault_id}/access-log-retention` Get the current access log retention setting for a vault. #### Response | Field | Type | Description | | --- | --- | --- | | `vault_id` | string | Vault identifier | | `access_log_retention_seconds` | integer | Retention period in seconds | ### Update Access Log Retention #### `PUT /v1/vaults/{vault_id}/access-log-retention` Update the access log retention period for a vault. Existing logs are not affected. They will expire according to their original TTL. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `access_log_retention_seconds` | integer | Required | Retention period in seconds. Valid values: 0 (disabled), 86400 (24h), 604800 (7d), 2592000 (1mo), 5184000 (2mo), 7776000 (3mo, default), 15552000 (6mo), 23328000 (9mo), 31536000 (1yr), 63072000 (2yr), 94608000 (3yr) | #### Response | Field | Type | Description | | --- | --- | --- | | `vault_id` | string | Vault identifier | | `access_log_retention_seconds` | integer | New retention period in seconds | | `updated_at` | integer | Update timestamp | #### Example Request ```bash curl -X PUT https://api.sparkvault.com/v1/vaults/vlt_abc123/access-log-retention \ -H "X-API-Key: sv_live_xxx" \ -H "Content-Type: application/json" \ -d '{"access_log_retention_seconds": 31536000}' ``` Response ```json { "data": { "vault_id": "vlt_abc123def456...", "access_log_retention_seconds": 31536000, "updated_at": 1702000000 } } ``` > **Retention Values** > > Valid retention periods: > > - `0`: Disabled (no logging) > - `86400`: 24 Hours > - `604800`: 7 Days > - `2592000`: 1 Month > - `5184000`: 2 Months > - `7776000`: 3 Months (Default) > - `15552000`: 6 Months > - `23328000`: 9 Months > - `31536000`: 1 Year > - `63072000`: 2 Years > - `94608000`: 3 Years ## Usage Vault operations are covered by your seat subscription. Storage and bandwidth consumption 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 Vault | Included with subscription | | Unseal/Seal Vault | Included with subscription | | Ingot Storage | Counts against pooled storage (included) | | Access Logs | Counts against pooled storage (included) | For ingot transfer usage, see the [Ingots API Documentation](/api/docs/ingots/). ## Error Reference #### Error Responses | Status | Code | Description | | --- | --- | --- | | 400 | `VALIDATION_ERROR` | Invalid request parameters (including an `X-Confirm-Name` mismatch on vault deletion) | | 401 | `AUTHENTICATION_ERROR` | Invalid or missing authentication; missing, invalid, or expired VAT (`X-Vault-Access-Token header required` / `Invalid or expired VAT`) | | 401 | `UNAUTHORIZED` | Invalid or revoked DVAK presented to unseal | | 402 | `PLAN_REQUIRED` | An active subscription is required for this operation | | 402 | `QUOTA_EXCEEDED` | Pooled storage or bandwidth exhausted (resource: storage|bandwidth). Add a capacity block | | 403 | `FORBIDDEN` | VAT expired; VAT issued for a different vault or account; read-only VAT attempting a write; DVAK not authorized for this user; admin role required for DVAK management | | 404 | `NOT_FOUND` | Vault, ingot, folder, or DVAK not found | | 409 | `CONFLICT` | Vault name already exists, or duplicate folder name within the same parent | | 412 | `PRECONDITION_FAILED` | Invalid VMK: the provided VMK does not match this vault | | 413 | `PAYLOAD_TOO_LARGE` | File exceeds maximum size (5 TB) | --- # Ingots API: SparkVault API Reference > Store and retrieve encrypted files within vaults. Ingots support files from 1 byte to 5 TB with full encryption at rest. Canonical: https://sparkvault.com/api/docs/ingots/ · OpenAPI: https://sparkvault.com/openapi.yaml ## Overview Ingots are encrypted data objects stored within [Vaults](/api/docs/vaults/). All ingot operations require two credentials: an API key (or session JWT) to authenticate the caller, plus a valid Vault Access Token (VAT) obtained by [unsealing the vault](/api/docs/vaults/). > **Looking for key/value storage?** > > Named-field key/value data with atomic updates is a separate product, [Structured Ingots](/api/docs/products/structured-ingots/), with its own endpoints and semantics. This page covers file ingots only. #### Features | Feature | Description | | --- | --- | | **Size** | 1 byte to 5 TB per ingot | | **Encryption** | AES-256-GCM under the per-vault Vault Access Key (VAK), HKDF-derived from SVMK + AMK + VMK (Triple Zero-Trust). Forge encrypts each 50 MB chunk with the VAK using per-chunk derived nonces | | **Streaming** | Server-side encryption/decryption via Forge TUS uploads | > **VAT Required** > > All ingot operations require two credentials: an API key (`X-API-Key`) or session JWT (`Authorization: Bearer`) to authenticate the caller, plus the `X-Vault-Access-Token` header with a valid VAT. See the [Vaults API](/api/docs/vaults/) for how to obtain a VAT. ## Standard Ingots Standard ingots are file-based encrypted storage. Upload any file up to 5 TB through the same Forge TUS flow. > **Name-Based Addressing** > > `{ingot_id}` in the endpoints below accepts either an ingot ID (`ing_...`) or a name reference: a colon followed by the name, e.g. `/ingots/:report.pdf`. Name lookups are case-insensitive and scoped to your account. Names are unique per folder, not per vault: when the same name exists in multiple folders, a name reference returns `409 CONFLICT` with the candidate `ingot_id`/`folder_id` pairs. Use the ingot ID instead. ### Create Standard Ingot #### `POST /v1/vaults/{vault_id}/ingots` Create a new standard ingot and get a Forge URL for uploading. Two-step process: create the ingot record, then upload to Forge with TUS. #### Headers | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `X-Vault-Access-Token` | string | Required | Valid VAT from unseal operation | #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | Required | File name or identifier | | `size_bytes` | integer | Required | Exact file size in bytes (1 byte to 5 TB = `5497558138880` bytes) | | `content_type` | string | Optional | MIME typeDefault: `application/octet-stream` | | `original_filename` | string | Optional | Original file name, used for the `Content-Disposition` header on downloads | | `folder_id` | string | Optional | ID of an existing folder to place the ingot in. Mutually exclusive with `folder_path` | | `folder_path` | string | Optional | Slash-delimited folder path (e.g. `reports/2026`), found or created from the vault root. Mutually exclusive with `folder_id` | > **Upsert by Name** > > Creating an ingot with a name that already exists in the same folder **upserts**: the existing ingot's ID is reused and its content is replaced when the upload completes. An active ingot stays readable until Forge finalizes the new upload. #### Response | Field | Type | Description | | --- | --- | --- | | `ingot_id` | string | Unique ingot identifier (ing\_...) | | `name` | string | Ingot name | | `size_bytes` | integer | Declared size in bytes | | `content_type` | string | MIME type | | `status` | string | Initial status: `uploading` | | `forge_url` | string | Forge URL containing the upload ISTK | | `expires_at` | integer | Upload URL expiration timestamp | ```javascript import * as tus from 'tus-js-client'; // Step 1: Create ingot record const file = document.querySelector('input[type="file"]').files[0]; const createResponse = await fetch( `https://api.sparkvault.com/v1/vaults/${vaultId}/ingots`, { method: 'POST', headers: { 'X-API-Key': apiKey, 'X-Vault-Access-Token': vat, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: file.name, size_bytes: file.size, content_type: file.type }) } ); const { ingot_id, forge_url } = (await createResponse.json()).data; // Step 2: Upload file to Forge with TUS const forgeUrl = new URL(forge_url); const istk = forgeUrl.searchParams.get('istk'); const upload = new tus.Upload(file, { endpoint: `${forgeUrl.origin}/encrypt`, chunkSize: 50 * 1024 * 1024, headers: { 'X-ISTK': istk }, retryDelays: [0, 1000, 3000, 5000, 10000] }); upload.start(); // Status will change: uploading → active ``` ### List Ingots #### `GET /v1/vaults/{vault_id}/ingots` List all ingots in an unsealed vault. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | Optional | Maximum ingots to return (max: 200)Default: `100` | | `sort_by` | string | Optional | Sort field: `name`, `size`, `content_type`, `created_at`, `access_count`, or `bandwidth` | | `sort_order` | string | Optional | `asc` or `desc`Default: `asc` | #### Response | Field | Type | Description | | --- | --- | --- | | `ingots` | array | Array of ingot objects | | `count` | integer | Number of ingots returned | | `ingots[].ingot_id` | string | Ingot identifier | | `ingots[].name` | string | Ingot name | | `ingots[].file_type` | string | Category derived from the file extension (e.g. `image`, `video`, `document`) | | `ingots[].size_bytes` | integer | Size in bytes | | `ingots[].content_type` | string | MIME type | | `ingots[].status` | string | Status: `uploading`, `encrypting`, `active`, or `failed` | | `ingots[].folder_id` | string | Containing folder ID; `null` for the vault root | | `ingots[].managed_by` | string | Product that manages this ingot (e.g. `structured-ingots`, `product-messaging`); `null` for standard ingots. Managed ingots reject direct read/download/update/delete with `403`; use the product's endpoints | | `ingots[].upload_source` | string | Origin of the upload (uploader's email, or `API: {key name}` for API-key uploads); `null` when unknown | | `ingots[].created_at` | integer | Creation timestamp | | `ingots[].updated_at` | integer | Last-modified timestamp (bumps on every write) | | `ingots[].access_count` | integer | Number of downloads | | `ingots[].total_bandwidth_bytes` | integer | Total bytes served for this ingot | ### Search Ingots #### `GET /v1/vaults/{vault_id}/ingots/search` Search and filter ingots within a vault using indexed queries. Filters can be combined. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `q` | string | Optional | Name prefix search (case-insensitive). Prefix-only: no substring or fuzzy matching | | `file_type` | string | Optional | Filter by category: `image`, `video`, `audio`, `document`, `spreadsheet`, `presentation`, `archive`, `code`, `design`, `cad`, `ebook`, `font`, or `other` | | `min_size` | integer | Optional | Minimum file size in bytes | | `max_size` | integer | Optional | Maximum file size in bytes | | `limit` | integer | Optional | Maximum results per page (max: 200)Default: `50` | | `cursor` | string | Optional | Signed pagination cursor from the previous response's `next_cursor` | | `sort_order` | string | Optional | `asc` or `desc`Default: `desc` | #### Response | Field | Type | Description | | --- | --- | --- | | `ingots` | array | Matching ingot objects (same shape as List Ingots) | | `count` | integer | Number of ingots in this page | | `has_more` | boolean | Whether more results are available | | `next_cursor` | string? | Signed cursor for the next page (present when `has_more` is true) | | `filters` | object | Echo of the applied filters: `q`, `file_type`, `min_size`, `max_size` | ```bash # Files starting with "Q3" curl "https://api.sparkvault.com/v1/vaults/$VAULT_ID/ingots/search?q=Q3" \ -H "X-API-Key: sv_live_xxx" \ -H "X-Vault-Access-Token: $VAT" # Large videos (over 1 GB), newest first curl "https://api.sparkvault.com/v1/vaults/$VAULT_ID/ingots/search?file_type=video&min_size=1073741824" \ -H "X-API-Key: sv_live_xxx" \ -H "X-Vault-Access-Token: $VAT" ``` ### Get Ingot Metadata #### `GET /v1/vaults/{vault_id}/ingots/{ingot_id}` Retrieve an ingot's metadata. Use the download endpoint to retrieve decrypted contents. #### Response | Field | Type | Description | | --- | --- | --- | | `ingot_id` | string | Ingot identifier | | `name` | string | Ingot name | | `size_bytes` | integer | Size in bytes | | `content_type` | string | MIME type | | `status` | string | Status: `uploading`, `encrypting`, `active`, or `failed` | | `managed_by` | string | Managing product ID; `null` for standard ingots | | `created_at` | integer | Creation timestamp | | `updated_at` | integer | Last-modified timestamp. Pass it as `expected_updated_at` to optimistically lock a subsequent PUT | | `access_count` | integer | Number of downloads | | `total_bandwidth_bytes` | integer | Total bytes served for this ingot | ### Download Ingot #### `POST /v1/vaults/{vault_id}/ingots/{ingot_id}/download` Get a time-limited download URL for decrypting an ingot via Forge. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `stream` | boolean | Optional | Mint a reusable, seek-able streaming decrypt URL (many Range requests can be made against one link, e.g. media playback) instead of the single-use defaultDefault: `false` | #### Response | Field | Type | Description | | --- | --- | --- | | `status` | string | Status (always `ready`) | | `download_url` | string | Signed Forge URL for streaming decryption (includes ISTK) | | `ingot_id` | string | Ingot identifier | | `name` | string | Original file name | | `size_bytes` | integer | File size in bytes | | `content_type` | string | MIME type | | `original_filename` | string | Original filename for Content-Disposition | | `expires_at` | integer | Download URL expiration timestamp | | `stream` | boolean | Whether the URL is a reusable streaming URL | ```javascript // Step 1: Get download URL const downloadResponse = await fetch( `https://api.sparkvault.com/v1/vaults/${vaultId}/ingots/${ingotId}/download`, { method: 'POST', headers: { 'X-API-Key': apiKey, 'X-Vault-Access-Token': vat } } ); const { download_url, name } = (await downloadResponse.json()).data; // Step 2: Fetch decrypted content from Forge const fileResponse = await fetch(download_url); const blob = await fileResponse.blob(); // Trigger browser download const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); ``` ### Replace Ingot Content #### `PUT /v1/vaults/{vault_id}/ingots/{ingot_id}` Replace an ingot's content. Same two-step flow as create: the response includes a fresh Forge URL for the TUS upload. An active ingot stays readable until Forge finalizes the new upload. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `size_bytes` | integer | Required | Exact size of the replacement content in bytes (1 byte to 5 TB) | | `name` | string | Optional | Rename the ingot as part of the replace. Must not collide with another name in the same folder | | `content_type` | string | Optional | New MIME type; defaults to the existing content type | | `expected_updated_at` | integer | Optional | Optimistic lock: the ingot's `updated_at` from your last read. If the ingot changed since, the request fails with `412 PRECONDITION_FAILED` | #### Response | Field | Type | Description | | --- | --- | --- | | `ingot_id` | string | Ingot identifier (unchanged) | | `name` | string | Ingot name (after any rename) | | `size_bytes` | integer | Declared replacement size in bytes | | `content_type` | string | MIME type | | `status` | string | Status: `uploading` | | `forge_url` | string | Forge URL containing the upload ISTK | | `expires_at` | integer | Upload URL expiration timestamp | ### Rename or Move Ingot #### `PATCH /v1/vaults/{vault_id}/ingots/{ingot_id}` Partially update an ingot: rename it and/or move it to a folder. Content is not modified. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | Optional | New name (max 255 characters). Must not collide with another name in the target folder | | `folder_id` | string|null | Optional | Target folder ID; `null` moves the ingot to the vault root | At least one of `name` or `folder_id` is required; otherwise the request fails with `400 VALIDATION_ERROR`. Returns the updated ingot object (same shape as List Ingots items). ### Delete Ingot #### `DELETE /v1/vaults/{vault_id}/ingots/{ingot_id}` Permanently delete an ingot. This immediately frees pooled storage and stops it counting against your storage pool. Returns 204 No Content and cascade-deletes any SparkLink for the ingot. > **Irreversible** > > Deleted ingots cannot be recovered. The encrypted data is permanently destroyed. > **In-Flight Upload Guard** > > An ingot whose status is `uploading` and which is less than 1 hour old cannot be deleted: the request returns `400 VALIDATION_ERROR` (prevents a race with Forge). A stale `uploading` ingot (older than 1 hour) can be deleted to clear a stuck upload. ## Forge: Streaming Encryption [Forge](/api/docs/forge/) is SparkVault's resumable encryption proxy. All cryptographic operations happen server-side using AES-256-GCM, eliminating the need for client-side crypto libraries. > **Why Use Forge** > > - **Resumable uploads**: Recover failed transfers by resuming from the last accepted offset > - **Bounded chunks**: Upload bytes in a predictable memory envelope > - **No client-side crypto**: Encryption happens server-side over TLS ### How It Works Forge uses **Ingot Secure Transfer Keys (ISTKs)** for authentication. ISTKs are **automatically embedded** in the URLs returned by the Ingots API: - **Upload:** The `forge_url` from Create Ingot includes the ISTK as a query parameter - **Download:** The `download_url` from Download Ingot includes the ISTK as a query parameter You don't need to manually extract or manage ISTKs. Simply use the URLs returned by the API. ```javascript import * as tus from 'tus-js-client'; const forgeUrl = new URL(createResponse.data.forge_url); const istk = forgeUrl.searchParams.get('istk'); const upload = new tus.Upload(file, { endpoint: `${forgeUrl.origin}/encrypt`, chunkSize: 50 * 1024 * 1024, headers: { 'X-ISTK': istk }, metadata: { filename: file.name, filetype: file.type || 'application/octet-stream' } }); upload.start(); ``` ```bash # The download_url already contains the ISTK curl "$DOWNLOAD_URL" --output decrypted-file.tar.gz ``` See the [Forge documentation](/api/docs/forge/) for complete details on resumable encryption, including TUS protocol details and error handling. ## Audit Logs Per-ingot audit logs track uploads, downloads, sharing changes, and shared-link access. Audit logs are immutable and retained according to the vault's retention setting. For account- and vault-level audit logs, see the [Audit Logs API](/api/docs/audit-logs/). #### `GET /v1/vaults/{vault_id}/ingots/{ingot_id}/audit-logs` List audit events for an ingot. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | Optional | Entries per page (1-100)Default: `25` | | `cursor` | string | Optional | Signed pagination cursor from the previous response's `next_cursor` | | `event_types` | string | Optional | Comma-separated event types to filter by (e.g. `ingot_downloaded,ingot_deleted`) | #### Response | Field | Type | Description | | --- | --- | --- | | `entries` | array | Audit event entries, newest first | | `entries[].event_type` | string | Event type (e.g. `ingot_created`, `ingot_downloaded`, `sparklink_accessed`) | | `entries[].timestamp` | integer | Event timestamp | | `entries[].actor_id` | string | User or API key that performed the action | | `entries[].ip_address` | string | Client IP address | | `entries[].user_agent` | string | Client user agent | | `entries[].metadata` | object | Event-specific details. VAT downloads record `name`, `size_bytes`, `content_type`; SparkLink downloads record `link_code_prefix`, `visibility`, `identity`, `access_mode` | | `count` | number | Number of entries in this page | | `has_more` | boolean | Whether more entries are available | | `next_cursor` | string? | Signed cursor for the next page (present when has\_more is true) | | `vault_id` | string | Vault identifier | | `ingot_id` | string | Ingot identifier | | `event_types` | array | All valid ingot event types, usable as `event_types` filter values | #### Example Request ```bash curl https://api.sparkvault.com/v1/vaults/vlt_abc123/ingots/ing_xyz789/audit-logs \ -H "X-API-Key: sv_live_xxx" \ -H "X-Vault-Access-Token: YOUR_VAT" ``` Response ```json { "data": { "entries": [ { "event_type": "ingot_downloaded", "timestamp": 1704157200, "actor_id": "usr_abc123", "ip_address": "203.0.113.7", "user_agent": "curl/8.5.0", "metadata": { "name": "report.pdf", "size_bytes": 1048576, "content_type": "application/pdf" } } ], "count": 1, "has_more": false, "vault_id": "vlt_abc123", "ingot_id": "ing_xyz789", "event_types": [ "ingot_created", "ingot_renamed", "ingot_deleted", "ingot_moved", "ingot_shared", "ingot_unshared", "invite_created", "invite_revoked", "ingot_accessed", "ingot_downloaded", "ingot_uploaded", "sparklink_accessed", "upload_error", "download_error" ] } } ``` ## Usage Ingot operations consume the pooled storage and bandwidth included with your seat subscription. There is no per-GB fee; usage is tracked and counted against your pool. For subscription tiers and capacity blocks, see the [pricing page](/pricing/). #### Resource Usage | Operation | Usage | | --- | --- | | Upload/Download | Counts against pooled bandwidth (included) | | Storage | Counts against pooled storage (included) | | Delete | Frees pooled storage | > **Extending Capacity** > > When pooled storage or bandwidth is exhausted, extend it with a capacity block. See the [pricing page](/pricing/) for available blocks. ## Error Reference #### Error Responses | Status | Code | Description | | --- | --- | --- | | 400 | `VALIDATION_ERROR` | Invalid request parameters, including `size_bytes` above the 5 TB maximum (`size_bytes must be at most 5497558138880`) and deleting an ingot whose upload is still in flight | | 401 | `AUTHENTICATION_ERROR` | Missing, malformed, or unknown Vault Access Token (`X-Vault-Access-Token` header), or a missing/invalid API key (`X-API-Key`) or session JWT (`Authorization: Bearer`) | | 402 | `PLAN_REQUIRED` | An active subscription is required for this operation | | 402 | `QUOTA_EXCEEDED` | Pooled storage or bandwidth exhausted (resource: storage|bandwidth); add a capacity block | | 403 | `FORBIDDEN` | VAT expired or bound to a different vault/account, read-only (Viewer) VAT on a write operation, or direct access to a managed ingot | | 404 | `NOT_FOUND` | Ingot not found (or name lookup failed) | | 409 | `CONFLICT` | Ambiguous name lookup (multiple ingots share the name) or target folder is being deleted | | 412 | `PRECONDITION_FAILED` | `expected_updated_at` did not match on PUT: the ingot changed; fetch the latest metadata and retry | --- # Forge: SparkVault API Reference > Encrypt and decrypt binary data with AES-256-GCM through one TUS upload flow for every file size. Canonical: https://sparkvault.com/api/docs/forge/ · OpenAPI: https://sparkvault.com/openapi.yaml ## Overview Forge is SparkVault's encryption and transfer service, the data plane that every [Ingot](/api/docs/ingots/) (an encrypted file stored in a [Vault](/api/docs/vaults/)) 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. AES-256 GCM Encryption TUS Resumable Uploads Server-Side Cryptography HTTPS Transport Security > **Zero Client-Side Crypto** > > 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: ```text https://forge.sparkvault.com ``` > **Separate Service** > > 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: ```bash 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" }' ``` ```json { "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. 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. 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`. ```bash 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" ``` > **ISTK Expiration** > > 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 #### `GET /health` Check if the Forge service is operational. No authentication required. ```bash curl https://forge.sparkvault.com/health ``` ```json { "status": "healthy", "version": "1.0.0", "timestamp": "2026-07-02T12:00:00.000Z" } ``` ### Capability Discovery #### `OPTIONS /encrypt` tus capability discovery. Returns the supported tus version, protocol extensions, and maximum upload size as headers on a 204 response. ```text Tus-Resumable: 1.0.0 Tus-Version: 1.0.0 Tus-Extension: creation,termination 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 #### `POST /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. | #### 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 | Maximum PATCH chunk size: 52,428,800 bytes (50 MB) | ```bash 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 #### `PATCH /encrypt/{upload_id}` Upload the next plaintext chunk to the session. Returns 204 No Content with the new Upload-Offset header. 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 rejected with `400` | | `Content-Type` | string | Required | Must be `application/offset+octet-stream` | ```bash 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 ``` > **Fixed Chunk Geometry** > > 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`. Re-check the offset with `HEAD` and retry. ### Check Upload Offset #### `HEAD /encrypt/{upload_id}` tus offset check. Returns 200 OK with the session's current state in headers. Use it to find where to resume. #### 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 | Negotiated maximum PATCH chunk size | ### Cancel Upload #### `DELETE /encrypt/{upload_id}` Cancel an in-progress upload. Deletes the staged encrypted chunks, bills the partial transfer, and returns 204 No Content. A no-op if the upload already finalized. ### Decrypt Stream #### `GET /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. | `GET /decrypt` honors single-range `Range: bytes=` requests, returning `206 Partial Content` with a `Content-Range` header (or `416` when the range is unsatisfiable). This is the basis for resumable/partial downloads and media seeking with reusable streaming URLs. #### 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` with a filename derived from the ingot | | `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 | | `Cache-Control` | header | `private, no-store`: decrypted plaintext is never cacheable | ```bash # The download_url from the Ingot download endpoint includes the ISTK curl "https://forge.sparkvault.com/decrypt?istk=3q2-7wX9kLmN0pQrStUvWxYzAb1Cd2Ef3Gh4Ij5Kl6M" \ --output decrypted.pdf ``` ```bash curl "https://forge.sparkvault.com/decrypt?istk=3q2-7wX9kLmN0pQrStUvWxYzAb1Cd2Ef3Gh4Ij5Kl6M" \ -H "Range: bytes=1048576-2097151" \ --output part.bin ``` ### Check Progress #### `GET /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. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `istk` | string | Required | The ISTK of the operation to inspect. Returns `404` if unknown. | ```json { "success": true, "progress": { "bytes_downloaded": 52428800, "bytes_processed": 52428800, "total_bytes": 1073741824, "status": "forging" } } ``` > **HEAD Preflight** > > A `HEAD` request to `/decrypt`, `/encrypt`, or `/progress` returns `200` without consuming the ISTK, useful for availability checks before committing a single-use token. ## Examples ### Encrypt and Store a File ```bash # 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 ```bash # 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 ``` ### JavaScript / Node.js ```javascript import * as tus from 'tus-js-client'; function uploadWithForge(file, forgeUrl) { const url = new URL(forgeUrl); const istk = url.searchParams.get('istk'); const upload = new tus.Upload(file, { endpoint: `${url.origin}/encrypt`, chunkSize: 50 * 1024 * 1024, // tus-js-client sends headers on every request; Forge only requires // X-ISTK on session creation and ignores it elsewhere. headers: { 'X-ISTK': istk }, retryDelays: [0, 1000, 3000, 5000, 10000], metadata: { filename: file.name, filetype: file.type || 'application/octet-stream' } }); upload.start(); } ``` ### Python ```python import os from urllib.parse import parse_qs, urljoin, urlparse import requests CHUNK_SIZE = 50 * 1024 * 1024 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']) 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: 1. The Core API envelope-encrypts the vault's VAK into the ISTK record: a KMS-derived key-encryption key wraps a random data key, which wraps the VAK 2. Forge validates the ISTK, derives the same KEK from KMS, and unwraps the data key, then the VAK 3. The VAK is used directly as the AES-256-GCM key for the operation 4. Isolation between ingots and chunks comes from the per-upload random base nonce and the per-chunk AAD binding, not from separate keys 5. 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. > **Defense in Depth** > > 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 `Upload-Offset` mismatch, 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 malformed), `ISTK_EXPIRED`, or `ISTK_USED` (single-use token already claimed). Obtain a fresh Forge URL from the ingot create or download endpoint. | | 403 | `(empty body)` | A reusable streaming-decrypt URL whose cumulative byte cap is exhausted. The response has an **empty body** (no JSON error envelope) and the token is revoked, so subsequent requests fail with `401 AUTHENTICATION_ERROR` (`details.code: ISTK_USED`). | | 404 | `NOT_FOUND` | Upload session not found, or the target ingot is missing or not active. | | 409 | `CONFLICT` | A concurrent PATCH lost the exclusive write lease, or finalization is in progress. Re-check the offset with HEAD and retry. | | 500 | `CRYPTOGRAPHIC_ERROR` | Encryption or decryption failed: authentication-tag mismatch or corrupted key material. | | 500 | `STORAGE_ERROR` | Object-storage upload or download failed. Retry the chunk at the same offset. | | 500 | `INTERNAL_ERROR` | Unexpected server error. Retry with exponential backoff. | ```json { "success": false, "error": { "code": "AUTHENTICATION_ERROR", "message": "ISTK expired", "details": { "code": "ISTK_EXPIRED" } } } ``` ## Best Practices - **Check ISTK expiration before starting**: Verify that `expires_at` gives 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 with exponential backoff on transient network failures. On `409 CONFLICT`, re-check the current offset with `HEAD` before retrying. - **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](/pricing/). --- # Products: SparkVault API Reference > Global, install-free SparkVault capabilities composed over platform primitives. Canonical: https://sparkvault.com/api/docs/products/ · OpenAPI: https://sparkvault.com/openapi.yaml ## Overview Products are always available to every account. They may still hold per-account configuration, but they do not require an integration installation record before their APIs can be used. | Product | Purpose | Base Path | Auth | | --- | --- | --- | --- | | [Identity](/api/docs/products/identity/) | OIDC, passkeys, social login, managed sessions, and the identity portal. | `/v1/products/identity` | Public: self-authenticating (OIDC, OTP, passkeys, social, SAML) | | [Notify](/api/docs/products/notify/) | Secure, verified, identity-gated notifications across channels with portable cryptographic receipts. | `/v1/products/notify` | JWT or API key | | [Messaging](/api/docs/products/messaging/) | Durable two-way encrypted conversations with verifiable receipts, recall, and legal hold. | `/v1/products/messaging` | JWT or API key | | [Structured Ingots](/api/docs/products/structured-ingots/) | Encrypted key/value records stored inside Vaults. | `/v1/products/structured-ingots` | JWT or API key + Vault Access Token | > **Products vs Integrations** > > Integrations are installed per account. Products are global and install-free. Every account can call a product's API without an installation record. --- # Identity: SparkVault API Reference > Complete API reference for SparkVault Identity, an enterprise-grade OIDC Identity Provider. Supports passkeys, OTP (email/SMS/voice), magic links, and social login. Each account operates as an independent IdP tenant. Canonical: https://sparkvault.com/api/docs/products/identity/ · OpenAPI: https://sparkvault.com/openapi.yaml ## Overview SparkVault Identity provides passwordless authentication for your applications. It acts as an OpenID Connect (OIDC) Identity Provider, allowing you to integrate secure authentication without managing passwords, passkeys, or verification infrastructure yourself. ### OIDC 1.0 Standards Compliant ### Ed25519 EdDSA Signatures ### PKCE S256 Required ### Multi-Tenant Per-Account Keys ### Authentication Methods - **SparkLink**: Magic links sent via email, one-click verification - **OTP**: One-time codes sent via email, SMS, or voice - **Passkeys**: WebAuthn/FIDO2 with biometric authentication - **Social Login**: Google, GitHub, Apple, Microsoft, LinkedIn, Facebook > **Managed Identity** > > SparkVault Identity maintains a durable SparkVault ID (SVID) and managed sessions for each verified person. Your application still owns its product profile and authorization model; Identity provides authentication, session lifecycle, passkeys, and revocation signals. ## Quick Start Get authentication working in under 5 minutes using the JavaScript SDK. The browser handles the branded verification UI; your backend verifies the signed JWT and maps the person by SVID. ### 1\. Add the SDK ```html ``` ### 2\. Add a Login Button ```javascript const sv = SparkVault.init({ accountId: 'acc_your_account_id' }); document.querySelector('#login-btn').onclick = async () => { const { identity, token } = await sv.products.identity.signIn(); console.log(identity, token); // "user@example.com", "eyJhbG..." }; ``` ### 3\. Verify Token (Backend) ```node.js import jwt from 'jsonwebtoken'; import jwksClient from 'jwks-rsa'; const client = jwksClient({ jwksUri: 'https://auth.sparkvault.com/acc_xxx/.well-known/jwks.json' }); const getKey = (header, cb) => client.getSigningKey(header.kid, (err, key) => cb(err, key?.getPublicKey())); const decoded = jwt.verify(token, getKey, { algorithms: ['EdDSA'], issuer: 'https://auth.sparkvault.com/acc_xxx', audience: 'acc_xxx' }); const svid = decoded.svid || decoded.sub; ``` ```python import jwt import requests jwks_url = "https://auth.sparkvault.com/acc_xxx/.well-known/jwks.json" jwks = requests.get(jwks_url).json() public_key = jwt.algorithms.OKPAlgorithm.from_jwk(jwks["keys"][0]) decoded = jwt.decode( token, public_key, algorithms=["EdDSA"], issuer="https://auth.sparkvault.com/acc_xxx", audience="acc_xxx" ) svid = decoded.get("svid") or decoded["sub"] ``` ```go import "github.com/golang-jwt/jwt/v5" jwksURL := "https://auth.sparkvault.com/acc_xxx/.well-known/jwks.json" keySet, _ := jwk.Fetch(ctx, jwksURL) token, _ := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) { key, _ := keySet.LookupKeyID(t.Header["kid"].(string)) return key.Key, nil }) svid := token.Claims.(jwt.MapClaims)["svid"].(string) ``` ```ruby require 'jwt' require 'net/http' jwks_url = "https://auth.sparkvault.com/acc_xxx/.well-known/jwks.json" jwks = JSON.parse(Net::HTTP.get(URI(jwks_url))) key = JWT::JWK.import(jwks["keys"].first).public_key decoded = JWT.decode(token, key, true, { algorithm: 'EdDSA', iss: 'https://auth.sparkvault.com/acc_xxx', aud: 'acc_xxx', verify_iss: true, verify_aud: true }).first svid = decoded["svid"] || decoded["sub"] ``` ```php use Firebase\JWT\JWT; use Firebase\JWT\JWK; $jwksUrl = "https://auth.sparkvault.com/acc_xxx/.well-known/jwks.json"; $jwks = json_decode(file_get_contents($jwksUrl), true); $keys = JWK::parseKeySet($jwks); $decoded = JWT::decode($token, $keys); $svid = $decoded->svid ?? $decoded->sub; ``` ```c# using Microsoft.IdentityModel.Tokens; using System.IdentityModel.Tokens.Jwt; var handler = new JwtSecurityTokenHandler(); var jwksUrl = "https://auth.sparkvault.com/acc_xxx/.well-known/jwks.json"; var keys = new JsonWebKeySet(await httpClient.GetStringAsync(jwksUrl)); var principal = handler.ValidateToken(token, new TokenValidationParameters { IssuerSigningKeys = keys.Keys, ValidateIssuer = true, ValidIssuer = "https://auth.sparkvault.com/acc_xxx", ValidateAudience = true, ValidAudience = "acc_xxx" }, out _); var svid = principal.FindFirst("svid")?.Value ?? principal.FindFirst("sub")?.Value; ``` > **That's It!** > > The SDK handles the entire authentication UI, passkey enrollment, email/SMS verification, social login flows, and error handling. You just call `signIn()` and get back a verified identity. ### Integration Options ### Programmatic `sv.products.identity.signIn()`: Opens the first-party sign-in popup. Best for most web apps. Also call `sv.products.identity.handleRedirectResult()` on page load. It delivers the result and silently self-heals a sign-in whose popup result was lost (e.g. a mobile tab discarded mid-login). ### Attach to a Button `sv.products.identity.attach('.login-btn')`: Binds the popup to clicks on matching elements. ### Hosted Page Server mints a verify-session, redirects the user to `/verify?session=...`. No JavaScript required. ## Session Management Every successful SparkVault Identity verification resolves the person to a canonical SVID and opens a managed Identity session. SDK/simple integrations receive a signed verification JWT that includes the SVID and session ID. Full OIDC integrations also receive short-lived access tokens plus rotating refresh tokens. > **How It Works** > > 1. User authenticates via SparkVault Identity (passkey, OTP, social login, etc.) > 2. SparkVault resolves or creates the person's SVID and records the connected site session > 3. SDK/simple mode returns a signed JWT; OIDC returns `id_token`, `access_token`, and `refresh_token` > 4. **Your backend verifies the token** (signature, issuer, audience, expiration) > 5. **Your application maps product data by SVID**; use OIDC/BFF when you want SparkVault to own refresh, revocation, and session lifecycle ### Refresh and Introspection OIDC clients refresh through `/token` with `grant_type=refresh_token`. Refresh rotates the session token and mints a new short-lived access token. Logout calls `/revoke`, which deletes the managed Identity session. Servers can call `/introspect` to verify that an access token is still active, was issued after the session's current cutoff, and is not blocked by the user's connected-site policy. SDK/simple mode keeps the integration lightweight: - **You verify the signed JWT** using the product JWKS endpoint. - **You map users by SVID** from `sub` or `svid`, not by email hash. - **You own only your app-specific state**; use OIDC/BFF when you want SparkVault-owned refresh-token lifecycle, revocation, and introspection. > **BFF Pattern** > > For browser and server-rendered apps, exchange OIDC codes server-side and store refresh/session material only in encrypted or signed `HttpOnly`, `Secure`, `SameSite=Lax` cookies. Keep refresh tokens out of browser JavaScript. Validate access JWTs locally with cached JWKS for normal API requests, and reserve `/introspect` for sensitive mutations, account settings, admin/moderation, suspicious sessions, or short cached hard checks. > **Lockdown and Site Blocks** > > Lockdown revokes live sessions and forces old access tokens inactive through `min_iat`. Connected-site blocks prevent new logins, refresh-token redemption, and active introspection for that site. > **End-User Portal (auth.sv)** > > People manage their own SparkVault identity in the **auth.sv** portal: active sessions, connected sites, passkeys, backup email/phone identifiers, two-factor authentication, and lockdown. The JavaScript SDK's `portalUrl()` helper builds deep links into the portal (home, identity, security, sessions, or sites screens) so your app can hand people off to self-service session and passkey management instead of rebuilding those surfaces. ### Recommended Pattern ```javascript // Frontend: Trigger verification async function login() { const result = await sparkvault.products.identity.signIn(); // Send token to YOUR backend for verification const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: result.token }) }); // Backend verifies the signed proof and maps the person by SVID. // For production session replacement, use the OIDC/BFF flow below. } // Backend: Verify token and map your app data by SVID app.post('/api/auth/login', async (req, res) => { const { token } = req.body; // Verify SparkVault token (see JWKS verification examples below) const claims = await verifySparkVaultToken(token); const svid = claims.svid || claims.sub; // Use SVID as the stable foreign key. Keep profile, roles, uploads, // preferences, and moderation state in your own database. const user = await findOrCreateUserBySvid(svid, { identity: claims.identity, identityType: claims.identity_type }); res.cookie('app_session', createAppSessionToken(user.id), { httpOnly: true, secure: true }); res.json({ success: true, user }); }); ``` ## SparkLinks (Magic Links) SparkLinks are encrypted, single-use verification links. When a user clicks a SparkLink, they are instantly verified with no code entry required. SparkLinks are ideal for email-based verification where one-click convenience is prioritized. > **How SparkLinks Work** > > 1. SDK opens a WebSocket to `wss://ws.sparkvault.com` and receives a connectionId > 2. SDK requests a SparkLink with the connectionId: SparkVault seals the signed verify URL inside an encrypted, single-use spark and sends the email > 3. User receives email with the SparkLink URL: `https://x.sv/{link_code}` > 4. User clicks the link → `x.sv` serves an interstitial page **without burning the link**; the page's JavaScript POSTs back to burn the spark, decrypt the verify URL, and redirect. Crawlers and email scanners that don't execute JavaScript never trigger the POST, so they can never consume the link > 5. The verify callback completes verification, generates the JWT, and pushes it to the SDK via WebSocket using the connectionId (with a single-use kindling result as the fallback when the socket was lost) > 6. SDK receives the token instantly. Authentication complete ### Key Characteristics - **Single-use:** Each SparkLink can only be used once. After verification, the spark is destroyed. - **Time-limited:** SparkLinks expire after a configurable TTL (default 15 minutes). - **Encrypted and signed:** The spark payload is the encrypted verify URL, and the verify callback parameters are HMAC-SHA256 signed against tampering. - **Scanner-proof by deferred burn:** A plain GET of the link never consumes it; only the interstitial page's JavaScript POST burns the spark, so non-JS link-preview bots and email scanners cannot use it up. - **Branded ceremony:** Users see your organization branding during the verification process. - **WebSocket push:** Verification result is delivered instantly to the SDK via WebSocket: no polling, no iframes, no CSP requirements. - **IP binding:** Optional IP address binding prevents link usage from a different network (default: on). - **Auto-resend:** Optionally auto-sends a new link on IP mismatch or expiry (default: off). ### URL Structure ```text https://x.sv/{link_code} Example: https://x.sv/aB3xK9mQpR7sT2uVwY4zC1 ``` The URL carries a single opaque, single-use link code. No account ID, identity, or destination appears in the link. Opening it serves a deferred-burn interstitial: the page's JavaScript POSTs to consume the grant, the server burns the spark and decrypts the sealed verify URL, and the browser is redirected. Because a bare GET never burns the link, email scanners cannot consume it. ### SDK Usage ```javascript // The popup offers every method you have enabled, including // SparkLink, and the person picks one. Pre-fill the email to // skip the first step. const result = await sparkvault.products.identity.signIn({ email: 'user@example.com' }); ``` ### SparkLink API Endpoints #### Send SparkLink #### `POST /{account_id}/sparklink/send` Send a SparkLink magic link to an email address. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `email` | string | Required | Email address to send the SparkLink to | | `connection_id` | string | Required | WebSocket connection ID for real-time token delivery (obtained by the SDK when it opens its socket). Requests without it are rejected. | | `auth_request_id` | string | Optional | OIDC auth request ID when completing a hosted-login authorization-code flow | | `simple_mode` | object | Optional | Simple redirect completion context | ```bash curl -X POST 'https://auth.sparkvault.com/{account_id}/sparklink/send' \ -H 'Content-Type: application/json' \ -d '{ "email": "user@example.com", "connection_id": "conn_abc123..." }' ``` ```json { "data": { "link_code": "aB3xK9mQpR7sT2uVwY4zC1", "expiresAt": 1704067800000, "kindling": "kdl_abc123..." } } ``` `expiresAt` is in **milliseconds**. `kindling` is a durable fallback handle: if the SDK's WebSocket is lost (for example, a suspended mobile tab), it reclaims the verified result with `GET /{account_id}/sparklink/result` below. #### Verify SparkLink #### `GET /{account_id}/sparklink/verify` Internal callback reached after the x.sv interstitial burns the spark. Completes verification and pushes the result to the SDK. Not intended to be called directly. #### Query Parameters (generated and signed by SparkVault) | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `session_id` | string | Required | Opaque verify-session pointer. The verified identity and any redirect context live in the session row, never in the URL. | | `connection_id` | string | Required | WebSocket connection ID the result is pushed to | | `expires` | integer | Required | Expiration timestamp (Unix epoch seconds) | | `sig` | string | Required | HMAC-SHA256 signature binding all parameters against post-issue tampering | | `auth_request_id` | string | Optional | OIDC hosted-login request being completed | | `kindling` | string | Optional | WebSocket-loss fallback handle | This endpoint is the decrypted destination of the sealed spark payload. The x.sv interstitial redirects here after the burn. It validates the HMAC signature, completes verification, pushes the signed JWT to the SDK over the WebSocket (or writes the kindling result spark as fallback), and renders a success page. The person who clicked the link never receives the JWT in their browser. #### Claim Result (WebSocket-Loss Fallback) #### `GET /{account_id}/sparklink/result` Claim the verified result by kindling when the WebSocket push could not reach the SDK. Single-use: reading the result burns it. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `kindling` | string | Required | The kindling handle returned by the send call | ```json { "data": { "pending": true } } { "data": { "type": "sparklink_verified", "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...", "identity": "user@example.com", "identityType": "email", "svid": "ing_019e66a4e27875f2822ede0e4f5d8792", "session_id": "sess_abc123", "refresh_token": "rt_..." } } ``` Returns `pending: true` until the click finishes processing. The SDK calls this automatically when it refocuses after a lost socket; the result is burn-on-read, so it can never become a second standing auth path. #### Alternative: Via OTP Endpoint You can also send SparkLinks via the OTP API with `method: "sparklink"` (`connection_id` is required for this method too): ```bash curl -X POST 'https://auth.sparkvault.com/{account_id}/otp/send' \ -H 'Content-Type: application/json' \ -d '{ "recipient": "user@example.com", "method": "sparklink", "connection_id": "conn_abc123..." }' ``` ```json { "success": true, "method": "sparklink", "message": "Magic link sent" } ``` ### How Verification Is Delivered The SDK receives the verification result instantly via WebSocket push: no polling, no iframes required. The SDK opens a WebSocket connection and receives a connectionId, which is embedded in the SparkLink payload. When the user clicks the link, the server pushes the token directly to the SDK through the WebSocket connection. If the socket was suspended with the tab, the SDK reclaims the result once through the kindling handle. ### Configuration #### SparkLink Settings (methods.sparklink) | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `enabled` | boolean | Optional | Enable/disable SparkLinksDefault: `true` | | `ttl_minutes` | integer | Optional | Time before SparkLink expires (range: 5-60)Default: `15` | | `require_ip_binding` | boolean | Optional | Bind link to requester IP addressDefault: `true` | | `auto_resend_on_expiry` | boolean | Optional | Auto-send a new link on IP mismatch or expiryDefault: `false` | These four keys are the only valid `methods.sparklink` settings. Anything else is rejected as an unknown setting. White-label link domains are configured through the separate account-level `custom_domains` list, not per method. > **Security Note** > > SparkLink payloads are encrypted at rest. The destination verify URL is sealed inside a single-use spark and derived live at burn time, never stored or embedded in plaintext. Verify-callback parameters are HMAC-SHA256 signed. Scanner protection comes from the deferred-burn interstitial: a GET never consumes the link, so crawlers that don't execute JavaScript cannot burn it. WebSocket push uses TLS encryption and connectionId-based routing. Each spark is burned atomically on first use, making replay attacks impossible. Optional IP binding adds network-level protection. ## Integration Options Choose the integration method that best fits your application architecture: [ ### JavaScript SDK (Recommended) Drop-in SDK with popup-based sign-in. One line of code to verify identity. Best for SPAs and modern web apps. - Easiest to implement - First-party popup flow - Works with any framework **View SDK Guide →** ](/api/docs/sdk-js/) ### Full OIDC Flow Standard OpenID Connect authorization code flow with PKCE. Ideal for server-rendered apps and SSO integrations. - Industry standard - Signed ID tokens (JWT) - Full security model ### Simple Redirect Simpler flow for basic integrations. Redirect to verify, receive signed callback. No token exchange required. - Simple implementation - No client secret - Ed25519 signed callbacks ## JavaScript SDK (Recommended) > **Full SDK Documentation** > > For comprehensive SDK documentation including installation, TypeScript types, error handling, and React integration examples, see the [JavaScript SDK Guide](/api/docs/sdk-js/). The simplest way to add identity verification. You trigger it, SparkVault handles everything in the first-party sign-in popup, then returns a cryptographically signed pass/fail result. Your code just waits for the result. No need to understand the verification internals. > **How It Works** > > 1\. You call `signIn()` → 2. SparkVault popup opens → 3. User verifies (email/passkey/etc) → 4. You get signed result ### 1\. Include the SDK ```html ``` Or install via npm: `npm install @sparkvault/sdk-js` ### 2\. Initialize & Verify ```javascript // Initialize with your account ID const sparkvault = SparkVault.init({ accountId: 'acc_YOUR_ACCOUNT_ID' // From your dashboard }); // Trigger verification - SparkVault handles everything async function login() { const result = await sparkvault.products.identity.signIn(); // User verified! result.token is a signed JWT proving verification console.log('Verified:', result.identity); // "user@example.com" or "+14155551234" console.log('Type:', result.identityType); // "email", "phone", or "social" // Send the signed token to your backend for verification and SVID mapping await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: result.token }) }); } ``` ### 3\. Verify Token (Server-Side) ```javascript // CRITICAL: Always verify tokens on your backend using JWKS import * as jose from 'jose'; const ACCOUNT_ID = 'acc_your_account_id'; app.post('/api/auth/verify', async (req, res) => { const { token } = req.body; try { // Fetch JWKS and verify Ed25519 signature const jwksUrl = `https://auth.sparkvault.com/${ACCOUNT_ID}/.well-known/jwks.json`; const JWKS = jose.createRemoteJWKSet(new URL(jwksUrl)); const { payload } = await jose.jwtVerify(token, JWKS, { issuer: `https://auth.sparkvault.com/${ACCOUNT_ID}`, audience: ACCOUNT_ID, algorithms: ['EdDSA'] }); // Token is valid. SVID is the stable identity key for your app. const svid = payload.svid || payload.sub; const user = await findOrCreateUserBySvid(svid, { identity: payload.identity, // email or phone identityType: payload.identity_type, // 'email' or 'phone' method: payload.method // 'otp', 'passkey', 'sparklink', 'social:google', ... }); res.json({ success: true, user }); } catch (error) { res.status(401).json({ error: 'Invalid token' }); } }); ``` ### SDK Options #### signIn() Options | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `email` | string | Optional | Pre-fill email address (mutually exclusive with phone) | | `phone` | string | Optional | Pre-fill phone number in E.164 format, e.g. `+14155551234` (mutually exclusive with email) | | `backdropBlur` | boolean | Optional | Apply blur effect to the background overlay behind the popupDefault: `true` | | `flow` | string | Optional | "auto" (default) and "popup" open the first-party popup; "redirect" navigates the current tab to the sign-in page instead: the opt-out for hosts that prefer a full-page redirect over a popup on mobile (its return leg is `handleRedirectResult()`) | | `onSuccess` | function | Optional | Callback with the verified result before the promise resolves | | `onError` | function | Optional | Callback with the error before the promise rejects | | `onCancel` | function | Optional | Callback when the person closes the popup without signing in | ### Resilience: handleRedirectResult() Call `handleRedirectResult()` once on every page load. It redeems a returned one-time, PKCE-bound code (from a `flow: 'redirect'` sign-in, or the silent SSO self-heal), never the token itself, and, when a popup's result was lost (a mobile browser discarded the backgrounded tab mid-login), it silently re-mints from the SSO session so the sign-in still completes. It no-ops (resolves `null`) otherwise. Wiring it is strongly recommended: it is what makes the popup reliable on mobile. The result arrives here as the same `SignInResult` the popup delivers. ```javascript // Runs on every page load; redeems a returned code or self-heals a lost result. sv.products.identity.handleRedirectResult({ onSuccess: (result) => createSession(result.token), // same handler as signIn() }).catch((error) => showError(error)); ``` #### handleRedirectResult() Options | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `onSuccess` | function | Optional | Callback with the verified result (same shape as signIn()) before the promise resolves | | `onError` | function | Optional | Callback with the error before the promise rejects | | `onCancel` | function | Optional | Callback when the person cancelled sign-in on the hosted page (the promise resolves null) | ### Response Object #### SignInResult | Field | Type | Description | | --- | --- | --- | | `token` | string | Signed JWT - send to your backend for validation | | `identity` | string | The verified identity (email or phone number) | | `identityType` | string | "email", "phone", or "social" (social-only logins return "social") | | `svid` | string? | Canonical SparkVault ID for the person | | `sessionId` | string? | Managed SparkVault Identity session ID | | `refreshToken` | string? | Managed-session refresh token, when the flow returns one | | `redirect` | string? | Redirect URL, present only for OIDC/simple mode flows | | `recommendations` | array? | Ordered post-login nudges (default-mode logins only): `{ type: "passkey" }` or `{ type: "backup_identifier", missingType: "email" | "phone" }` | ### SDK Token Claims When you decode the JWT token from the SDK, it contains these claims: #### Token Payload | Field | Type | Description | | --- | --- | --- | | `iss` | string | Issuer: https://auth.sparkvault.com/{account\_id} | | `sub` | string | Subject; equals the SVID for managed Identity tokens | | `aud` | string | Audience: your account\_id | | `jti` | string | Unique token ID (UUID) | | `svid` | string | Canonical SparkVault ID for the person | | `session_id` | string | Managed Identity session ID | | `identity` | string | The verified email or phone number | | `identity_type` | string | "email" or "phone" | | `method` | string | Auth method used (AMR value): "otp", "passkey", "sparklink", "social:google", "social:apple", "social:microsoft", "social:github", "social:facebook", "social:linkedin", "sso", "totp", "recovery", "saml:okta", etc. | | `amr` | string\[\] | All factors presented, e.g. `["otp"]` or `["otp", "totp"]` when a second factor cleared the login | | `acr` | string | `urn:sparkvault:identity:1` (single factor) or `urn:sparkvault:identity:mfa` (distinct second factor presented) | | `action_hash` | string? | Present only on action-bound passkey approvals: hex SHA-256 of the approved action (see Passkey API) | | `verified_at` | integer | Verification timestamp (Unix epoch seconds) | | `iat` | integer | Issued at timestamp (Unix epoch seconds) | | `exp` | integer | Expiration timestamp (Unix epoch seconds) | ```json { "iss": "https://auth.sparkvault.com/acc_example", "sub": "ing_019e66a4e27875f2822ede0e4f5d8792", "aud": "acc_example", "jti": "6f1b2a34-8c9d-4e5f-a012-3456789abcde", "svid": "ing_019e66a4e27875f2822ede0e4f5d8792", "session_id": "sess_abc123", "identity": "user@example.com", "identity_type": "email", "method": "otp", "amr": ["otp"], "acr": "urn:sparkvault:identity:1", "verified_at": 1703977200, "iat": 1703977200, "exp": 1703977500 } ``` > **Short-Lived Proof** > > SDK-mode tokens default to a **300-second (5 minute) TTL**. They are a proof of verification to hand to your backend promptly, not a session credential. Mint your own app session (or use the OIDC/BFF flow) after verifying. The 3600-second `id_token_ttl_seconds` setting applies to OIDC ID tokens only. ### Error Handling ```javascript try { await sparkvault.products.identity.signIn(); } catch (error) { switch (error.code) { case 'user_cancelled': // User closed the popup break; case 'network_error': // Connection failed break; case 'invalid_config': // Bad accountId or malformed configuration break; case 'ORIGIN_NOT_ALLOWED': // This page's domain is not a verified break; // company domain for the account (HTTP 400). // Add the domain in the dashboard; retrying // never clears an origin block. } } ``` Rate limiting does not surface as a dedicated error code: too many attempts fail as a validation error whose message includes the wait time (for example, "Too many attempts. Please try again in 5 minutes."). > **Available Methods** > > **passkey**: WebAuthn biometric authentication > **otp\_email**: 6-digit code via email > **otp\_sms**: 6-digit code via SMS > **otp\_voice**: 6-digit code via voice call > **sparklink**: One-click magic link via email > **social\_google**: Sign in with Google > **social\_apple**: Sign in with Apple > **social\_microsoft**: Sign in with Microsoft > **social\_github**: Sign in with GitHub > **social\_facebook**: Sign in with Facebook > **social\_linkedin**: Sign in with LinkedIn > **enterprise\_okta / enterprise\_entra / enterprise\_onelogin / enterprise\_ping / enterprise\_jumpcloud**: Enterprise SSO via SAML ### React Example ```jsx import { useCallback, useState } from 'react'; import SparkVault from '@sparkvault/sdk-js'; // Initialize once, outside the component const sparkvault = SparkVault.init({ accountId: 'acc_YOUR_ACCOUNT_ID' }); function LoginButton() { const [isVerifying, setIsVerifying] = useState(false); const handleLogin = useCallback(async () => { setIsVerifying(true); try { // Open the sign-in popup const result = await sparkvault.products.identity.signIn(); // Send token to backend await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: result.token }) }); } catch (err) { if (err.code !== 'user_cancelled') throw err; } finally { setIsVerifying(false); } }, []); return ( ); } ``` ## Quick Start (OIDC) Implement authentication in 4 steps: ### 1\. Generate PKCE Challenge ```javascript // Generate cryptographically secure PKCE parameters function generateCodeVerifier() { const array = new Uint8Array(32); crypto.getRandomValues(array); return base64urlEncode(array); } async function generateCodeChallenge(verifier) { const encoder = new TextEncoder(); const data = encoder.encode(verifier); const hash = await crypto.subtle.digest('SHA-256', data); return base64urlEncode(new Uint8Array(hash)); } function base64urlEncode(buffer) { return btoa(String.fromCharCode(...buffer)) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); } // Generate and store for later verification const codeVerifier = generateCodeVerifier(); const codeChallenge = await generateCodeChallenge(codeVerifier); const state = crypto.randomUUID(); const nonce = crypto.randomUUID(); sessionStorage.setItem('pkce_verifier', codeVerifier); sessionStorage.setItem('auth_state', state); sessionStorage.setItem('auth_nonce', nonce); ``` ### 2\. Redirect to Authorization ```javascript const authUrl = new URL('https://auth.sparkvault.com/{account_id}/authorize'); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('client_id', 'your-client-id'); authUrl.searchParams.set('redirect_uri', 'https://your-app.com/auth/callback'); authUrl.searchParams.set('scope', 'openid email'); authUrl.searchParams.set('state', state); authUrl.searchParams.set('nonce', nonce); authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge_method', 'S256'); // Redirect user to Identity hosted login window.location.href = authUrl.toString(); ``` ### 3\. Handle Callback & Exchange Code ```javascript // In your /auth/callback route handler const params = new URLSearchParams(window.location.search); const code = params.get('code'); const returnedState = params.get('state'); // Validate state to prevent CSRF if (returnedState !== sessionStorage.getItem('auth_state')) { throw new Error('Invalid state parameter'); } // Exchange code for tokens (do this server-side!) const response = await fetch('https://auth.sparkvault.com/{account_id}/token', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Basic ' + btoa('client_id:client_secret') }, body: JSON.stringify({ grant_type: 'authorization_code', code: code, redirect_uri: 'https://your-app.com/auth/callback', client_id: 'your-client-id', code_verifier: sessionStorage.getItem('pkce_verifier') }) }); const { id_token } = await response.json(); // Clean up sessionStorage.removeItem('pkce_verifier'); sessionStorage.removeItem('auth_state'); sessionStorage.removeItem('auth_nonce'); ``` ### 4\. Verify ID Token ```javascript import { ed25519 } from '@noble/curves/ed25519'; async function verifyIdToken(idToken, accountId, clientId, nonce) { const [headerB64, payloadB64, signatureB64] = idToken.split('.'); // Fetch public key from JWKS const jwksResponse = await fetch( `https://auth.sparkvault.com/${accountId}/.well-known/jwks.json` ); const jwks = await jwksResponse.json(); const publicKey = base64urlDecode(jwks.keys[0].x); // Verify Ed25519 signature const signedData = `${headerB64}.${payloadB64}`; const signature = base64urlDecode(signatureB64); const isValid = ed25519.verify( signature, new TextEncoder().encode(signedData), publicKey ); if (!isValid) throw new Error('Invalid signature'); // Decode and validate claims const payload = JSON.parse(atob(payloadB64.replace(/-/g,'+').replace(/_/g,'/'))); if (payload.iss !== `https://auth.sparkvault.com/${accountId}`) { throw new Error('Invalid issuer'); } if (payload.aud !== clientId) { throw new Error('Invalid audience'); } if (payload.nonce !== nonce) { throw new Error('Invalid nonce'); } if (payload.exp < Math.floor(Date.now() / 1000)) { throw new Error('Token expired'); } return payload; // { sub: svid, identity, identity_type, email?, phone_number?, amr, ... } } ``` > **Token Exchange Server-Side** > > Always perform the token exchange (step 3) on your backend server. Never expose your client\_secret in frontend code. The PKCE flow provides additional security even if the code is intercepted. ## OIDC Endpoints Base URL: `https://auth.sparkvault.com/{account_id}` ### Discovery Document #### `GET /{account_id}/.well-known/openid-configuration` Returns the OIDC discovery document with all endpoint URLs and supported features. ```json { "issuer": "https://auth.sparkvault.com/acc_example", "authorization_endpoint": "https://auth.sparkvault.com/acc_example/authorize", "token_endpoint": "https://auth.sparkvault.com/acc_example/token", "introspection_endpoint": "https://auth.sparkvault.com/acc_example/introspect", "revocation_endpoint": "https://auth.sparkvault.com/acc_example/revoke", "jwks_uri": "https://auth.sparkvault.com/acc_example/.well-known/jwks.json", "response_types_supported": ["code"], "response_modes_supported": ["query", "json"], "grant_types_supported": ["authorization_code", "refresh_token"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["EdDSA"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic", "none"], "revocation_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic", "none"], "claims_supported": ["iss", "sub", "aud", "exp", "iat", "auth_time", "nonce", "email", "email_verified", "phone_number", "phone_number_verified", "identity", "identity_type", "svid", "session_id", "amr", "acr"], "code_challenge_methods_supported": ["S256"], "scopes_supported": ["openid", "email", "phone"] } ``` ### JWKS (Public Keys) #### `GET /{account_id}/.well-known/jwks.json` Returns the JSON Web Key Set containing the tenant's Ed25519 public key for verifying ID token signatures. ```json { "keys": [ { "kty": "OKP", "crv": "Ed25519", "x": "base64url-encoded-public-key", "kid": "key-id-for-rotation", "use": "sig", "alg": "EdDSA" } ] } ``` > **JWKS Caching** > > Cache the JWKS response for 5-10 minutes to avoid excessive requests. The key ID (kid) in the JWT header indicates which key to use. Re-fetch if you encounter an unknown kid. ### Authorization Endpoint #### `GET /{account_id}/authorize` Initiates the authorization flow. Validates parameters and redirects to the hosted login page. #### Query Parameters (Required) | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `response_type` | string | Required | Must be `code` | | `client_id` | string | Required | Your registered client ID | | `redirect_uri` | string | Required | Must match a pre-registered URI | | `scope` | string | Required | Space-separated scopes. Must include `openid` | | `state` | string | Required | Opaque value for CSRF protection. Returned unchanged. | | `code_challenge` | string | Required | Base64url-encoded SHA256 hash of code\_verifier | | `code_challenge_method` | string | Required | Must be `S256` | #### Query Parameters (Optional) | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `nonce` | string | Optional | Binds ID token to request. Returned in token claims. | ### Token Endpoint #### `POST /{account_id}/token` Exchanges an authorization code for tokens or rotates a refresh token. Requires client authentication. #### Authorization Code Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `grant_type` | string | Required | Must be `authorization_code` | | `code` | string | Required | The authorization code from callback | | `redirect_uri` | string | Required | Must match the authorize request | | `client_id` | string | Required | Your registered client ID | | `code_verifier` | string | Required | Original PKCE code verifier | #### Refresh Token Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `grant_type` | string | Required | Must be `refresh_token` | | `refresh_token` | string | Required | Refresh token from the previous token response | | `client_id` | string | Required | Your registered client ID | > **Client Authentication Required** > > Include your client\_secret via Basic auth header (`Authorization: Basic base64(client_id:client_secret)`) or in the request body as `client_secret`. The token endpoint requires authentication. #### Success Response | Field | Type | Description | | --- | --- | --- | | `id_token` | string | EdDSA-signed JWT containing user claims (authorization\_code grant) | | `access_token` | string | Short-lived bearer token containing svid and session\_id | | `refresh_token` | string | Rotating refresh token bound to the managed Identity session | | `token_type` | string | Always "Bearer" | | `expires_in` | integer | Access-token lifetime in seconds (typically 900) | > **Refresh Token Rotation** > > Each successful refresh returns a new `refresh_token`. Persist it atomically before making the next refresh request; replaying an older token fails. ### Introspection Endpoint #### `POST /{account_id}/introspect` Checks whether an access token is still active for its managed Identity session. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `token` | string | Required | Access token returned by the token endpoint | | `client_id` | string | Required | Your registered client ID | #### Active Response | Field | Type | Description | | --- | --- | --- | | `active` | boolean | true when signature, expiry, client, and managed session are valid | | `token_type` | string | Always "access" for tokens issued by the token endpoint | | `svid` | string | SparkVault ID for the person | | `session_id` | string | Managed Identity session ID | | `sub` | string | Access-token subject; equals the SVID | | `aud` | string | Client ID audience | | `exp` | integer | Access-token expiration time | Inactive, expired, revoked, or foreign-client tokens return `{ "active": false }` with no other fields. > **When to introspect** > > Validate JWT signatures locally with cached JWKS for ordinary requests. Use `/introspect` for sensitive mutations, account settings, admin/moderation actions, suspicious sessions, or short cached hard checks where immediate revocation matters. ### Revocation Endpoint #### `POST /{account_id}/revoke` Revokes the managed Identity session identified by a refresh token or access token. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `token` | string | Required | Current refresh token, or an access token for the session being signed out | | `client_id` | string | Required | Your registered client ID | | `token_type_hint` | string | Optional | Accepted per RFC 7009 but ignored: the server detects the token type itself, so the hint does not change behavior | > **Client Authentication Required** > > Include the client secret with HTTP Basic auth or `client_secret` in the request body for confidential clients. #### Response | Field | Type | Description | | --- | --- | --- | | `revoked` | boolean | true when a same-client managed session was revoked. Invalid or foreign tokens return false with HTTP 200. | ## ID Token Claims > **Two Token Formats** > > SparkVault Identity issues two different token formats depending on the integration method: > > **OIDC ID Tokens** (this section): `sub` is the SVID. Email logins include `email`/`email_verified`; phone logins include `phone_number`/`phone_number_verified`. > > **SDK/Simple Mode Tokens**: `sub` is also the SVID when the verification opened a managed session, with `identity`, `identity_type`, `session_id`, `verified_at`, and `method`. See the SDK section above for these claims. The OIDC ID token is a JWT signed with Ed25519 (EdDSA algorithm). After verification, extract these claims: #### Standard Claims | Field | Type | Description | | --- | --- | --- | | `iss` | string | Issuer URL: https://auth.sparkvault.com/{account\_id} | | `sub` | string | Subject identifier; equals the SVID | | `aud` | string | Audience: your client\_id | | `exp` | integer | Expiration time (Unix timestamp) | | `iat` | integer | Issued at time (Unix timestamp) | | `auth_time` | integer | Time of user authentication (Unix timestamp) | | `nonce` | string | Nonce from authorization request (if provided) | #### Identity Claims | Field | Type | Description | | --- | --- | --- | | `svid` | string | Canonical SparkVault ID for the person | | `identity` | string | Verified email or phone identity value | | `identity_type` | string | "email" or "phone" | | `email` | string | The verified email address, only for email identities | | `email_verified` | boolean | True for email identities | | `phone_number` | string | The verified E.164 phone number, only for phone identities | | `phone_number_verified` | boolean | True for phone identities | | `amr` | string\[\] | Every factor presented: `["passkey"]`, `["sparklink"]`, `["otp"]`, `["social:google"]`, `["sso"]`, `["saml:okta"]`, or a primary plus second factor like `["otp", "totp"]` / `["otp", "recovery"]` | | `acr` | string | Authentication context class: `urn:sparkvault:identity:1` (single factor) or `urn:sparkvault:identity:mfa` when a distinct second factor (authenticator app or recovery code) cleared the login | ```json { "iss": "https://auth.sparkvault.com/acc_example", "sub": "ing_019e66a4e27875f2822ede0e4f5d8792", "aud": "your-client-id", "exp": 1703980800, "iat": 1703977200, "auth_time": 1703977195, "nonce": "abc123", "svid": "ing_019e66a4e27875f2822ede0e4f5d8792", "identity": "user@example.com", "identity_type": "email", "email": "user@example.com", "email_verified": true, "amr": ["passkey"], "acr": "urn:sparkvault:identity:1" } ``` ## Simple Redirect Verification For simpler integrations that don't need full OIDC, use the redirect verification flow. The user verifies their identity, and you receive a signed, opaque callback confirming the verification. No email or other PII travels in the callback. The flow is session-based: the redirect targets, identity hint, context label, and state are committed server-side when the session is minted, so they never appear in URLs: not in browser history, not in `Referer` headers, not in access logs. The user-visible URL carries only an opaque session token. ### Mint a Verify Session #### `POST /{account_id}/verify/session` Mint an opaque verify session and get back its verify URL. Internal, service-to-service only. External HTTP calls are rejected. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `success_url` | string | Required | Post-verification redirect target (must be pre-registered) | | `failure_url` | string | Optional | Post-failure redirect target (defaults to success\_url) | | `identity` | string | Optional | Pre-fill hint shown to the user | | `context` | string | Optional | Human-readable label rendered above the login form | | `state` | string | Optional | Opaque caller state echoed back in the callback | ```json { "session_id": "nZk4tW8xQ2mVbC7dEfGh1A", "verify_url": "https://auth.sparkvault.com/{account_id}/verify?session=nZk4tW8xQ2mVbC7dEfGh1A", "expires_in_seconds": 900 } ``` This mint endpoint is internal (service-to-service): SparkVault surfaces that need an identity-gated hand-off (for example, identity-gated SparkLinks) mint sessions through it, so a third party can never pre-warm sessions for arbitrary success URLs. Sessions expire after 15 minutes. ### Start Verification #### `GET /{account_id}/verify` Entry point for a minted verify session. Redirects to the hosted login page, then back to the session's success_url with signed callback parameters. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `session` | string | Required | Opaque verify-session ID from the session mint. This is the only parameter. Everything else is read back server-side from the session. | ### Success Callback On successful verification, the user is redirected to the session's success\_url with these query parameters: #### Callback Parameters | Field | Type | Description | | --- | --- | --- | | `verified` | string | "true" on success | | `session_id` | string | Opaque verify-session ID. SparkVault resolves the verified identity from this row server-side; the raw identity never travels in the URL. | | `timestamp` | string | Verification time (Unix timestamp) | | `nonce` | string | Unique nonce for replay protection | | `account_id` | string | Your tenant account ID | | `signature` | string | Ed25519 signature over verified, session\_id, timestamp, nonce, and account\_id | | `state` | string | Your state value (if provided) | ### Verify Callback Signature ```javascript import { ed25519 } from '@noble/curves/ed25519'; async function verifyCallback(params, accountId) { // Fetch public key from JWKS const jwksResponse = await fetch( `https://auth.sparkvault.com/${accountId}/.well-known/jwks.json` ); const jwks = await jwksResponse.json(); const publicKeyBytes = base64urlDecode(jwks.keys[0].x); // Check timestamp is recent (5 minute window) const timestamp = parseInt(params.timestamp, 10); const now = Math.floor(Date.now() / 1000); if (timestamp < now - 300 || timestamp > now + 60) { throw new Error('Timestamp out of range'); } // Rebuild the exact string the server signed (field order matters). const dataToVerify = [ 'verified=true', `session_id=${params.session_id}`, `timestamp=${params.timestamp}`, `nonce=${params.nonce}`, `account_id=${params.account_id}` ].join('&'); // Decode and verify signature const signatureBytes = base64urlDecode(params.signature); const isValid = ed25519.verify( signatureBytes, new TextEncoder().encode(dataToVerify), publicKeyBytes ); if (!isValid) throw new Error('Invalid signature'); // The callback carries no PII. A valid signature confirms verification for // the session you initiated; SparkVault resolves the verified identity // server-side, so you never receive the raw email here. return { verified: true, sessionId: params.session_id, method: 'simple_redirect' }; } ``` ### Failure Callback On failure, the user is redirected to the session's failure\_url with: #### Failure Parameters | Field | Type | Description | | --- | --- | --- | | `verified` | string | "false" | | `error` | string | Error code (e.g., "access\_denied", "expired\_link") | | `error_description` | string | Human-readable error message | | `state` | string | Your state value (if provided) | ## Server-to-Server API (Direct OTP) For applications that want to maintain their own authentication UI, you can call the OTP endpoints directly. This allows you to build a custom login experience while using SparkVault Identity for secure email/SMS verification. > **When to Use Direct API** > > Use the direct OTP API when you want full control over your authentication UI. Use the OIDC flow when you want the convenience of a hosted login page with multiple auth methods. ### Send Verification Code #### `POST /{account_id}/otp/send` Send a 6-digit verification code to an email address or phone number. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `recipient` | string | Required | Email address or phone number (E.164 format for SMS/voice) | | `method` | string | Required | "email", "sms", "voice", or "sparklink" | | `connection_id` | string | Optional | Required when method is "sparklink": WebSocket connection ID for token delivery | | `ttl_minutes` | integer | Optional | Code lifetime in minutes (clamped to 1-60)Default: `15` | ```bash curl -X POST 'https://auth.sparkvault.com/{account_id}/otp/send' \ -H 'Content-Type: application/json' \ -d '{ "recipient": "user@example.com", "method": "email" }' ``` ```json { "success": true, "kindling": "kdl_abc123...", "expires_at": 1704067800, "method": "email" } ``` `method` echoes the request's method value (`"email"`, `"sms"`, or `"voice"`). > **Kindling Token** > > The `kindling` token is required when verifying the code. It binds the verification attempt to the original send request. Pass it along with the code to the verify endpoint. ### Verify Code #### `POST /{account_id}/otp/verify` Verify the code entered by the user and receive a signed JWT token. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `recipient` | string | Required | The email/phone that received the code | | `pin` | string | Required | 6-digit verification code | | `kindling` | string | Required | Kindling token from the send response | ```bash curl -X POST 'https://auth.sparkvault.com/{account_id}/otp/verify' \ -H 'Content-Type: application/json' \ -d '{ "recipient": "user@example.com", "pin": "123456", "kindling": "kdl_abc123..." }' ``` ```json { "verified": true, "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...", "identity": "user@example.com", "identity_type": "email", "method": "otp", "svid": "ing_019e66a4e27875f2822ede0e4f5d8792", "session_id": "sess_abc123", "refresh_token": "rt_...", "recommendations": [ { "type": "passkey" } ] } ``` Beyond the token, the default verified response includes the opened managed session (`svid`, `session_id`, `refresh_token`) and `recommendations`: ordered post-login nudges such as a passkey upsell or a backup-identifier prompt (`{ "type": "backup_identifier", "missing_type": "phone" }`). > **JWT Token** > > The returned token is a JWT signed with Ed25519. You can verify it using the JWKS endpoint exactly like ID tokens. The token contains the verified identity and can be used as proof of identity. ### Server-to-Server Example (Node.js) ```javascript const IDENTITY_URL = 'https://auth.sparkvault.com'; const ACCOUNT_ID = 'acc_youraccountid'; // Step 1: Send verification code async function sendVerificationCode(email) { const response = await fetch(`${IDENTITY_URL}/${ACCOUNT_ID}/otp/send`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ recipient: email, method: 'email' }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || 'Failed to send code'); } return response.json(); // { success: true, kindling: "kdl_...", expires_at: 1234567890, method: "email" } } // Step 2: Verify code entered by user (include kindling from step 1) async function verifyCode(email, pin, kindling) { const response = await fetch(`${IDENTITY_URL}/${ACCOUNT_ID}/otp/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ recipient: email, pin, kindling // Required - from send response }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || 'Verification failed'); } return response.json(); // { verified, token, identity, identity_type, method, // svid, session_id, refresh_token, recommendations } } // Step 3: Verify the returned JWT using JWKS import * as jose from 'jose'; async function verifyToken(token) { const jwksUrl = `${IDENTITY_URL}/${ACCOUNT_ID}/.well-known/jwks.json`; const JWKS = jose.createRemoteJWKSet(new URL(jwksUrl)); const { payload } = await jose.jwtVerify(token, JWKS, { issuer: `${IDENTITY_URL}/${ACCOUNT_ID}`, audience: ACCOUNT_ID, algorithms: ['EdDSA'] }); return payload; // { identity, identity_type, method, verified_at, ... } } ``` ### Server-to-Server Example (Python) ```python import requests IDENTITY_URL = 'https://auth.sparkvault.com' ACCOUNT_ID = 'acc_youraccountid' def send_verification_code(email: str) -> dict: """Send a verification code to the email address.""" response = requests.post( f'{IDENTITY_URL}/{ACCOUNT_ID}/otp/send', json={ 'recipient': email, 'method': 'email' } ) response.raise_for_status() return response.json() # Returns: {'success': True, 'kindling': 'kdl_...', 'expires_at': ..., 'method': 'email'} def verify_code(email: str, pin: str, kindling: str) -> dict: """Verify the code and get a signed JWT.""" response = requests.post( f'{IDENTITY_URL}/{ACCOUNT_ID}/otp/verify', json={ 'recipient': email, 'pin': pin, 'kindling': kindling # Required - from send response } ) response.raise_for_status() return response.json() # Returns: {'verified': True, 'token': '...', 'identity': '...', 'identity_type': '...', # 'method': '...', 'svid': '...', 'session_id': '...', 'refresh_token': '...', # 'recommendations': [...]} ``` ### Incorrect Code A wrong code (before it expires) is not an error. `POST /otp/verify` returns HTTP 200 with `verified: false` and the same `kindling` so the user can retry. Once backoff applies, a `retry_after` field (Unix seconds) tells you when the next attempt is allowed. ```json { "verified": false, "kindling": "kdl_abc123...", "expires_at": 1704067800, "retry_after": 1704067830 } ``` ### Error Responses #### Error Responses | Status | Code | Description | | --- | --- | --- | | 400 | `invalid_request` | Missing or invalid required parameter | | 400 | `expired_code` | Verification code has expired (default 15 min TTL) | | 400 | `rate_limited` | Too many attempts. Surfaces as a validation error whose message includes the wait time, not a 429. | | 500 | `send_failed` | Failed to send verification code | ## Passkey API (WebAuthn) Passkeys provide phishing-resistant, biometric authentication using the WebAuthn standard. Users can register a passkey (fingerprint, Face ID, Windows Hello) and use it for fast, secure authentication without codes or links. > **SDK Handles This** > > The JavaScript SDK handles the full passkey flow automatically. These endpoints are documented for custom implementations or server-to-server integrations. ### Check Passkey Exists #### `POST /{account_id}/passkey/check` Resolve an email or phone to an SVID and report whether that person has passkeys. Passkeys live on the identity ingot, not on the identifier. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `email` | string | Optional | Email address to check. Exactly one of email or phone must be provided. | | `phone` | string | Optional | Phone number in E.164 format. Exactly one of email or phone must be provided. | ```bash curl -X POST 'https://auth.sparkvault.com/{account_id}/passkey/check' \ -H 'Content-Type: application/json' \ -d '{ "email": "user@example.com" }' ``` ```json { "identity_valid": true, "has_passkey": true } ``` ### Start Registration #### `POST /{account_id}/passkey/register` Start passkey registration. Returns WebAuthn options for navigator.credentials.create() plus a one-time session. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `email` | string | Optional | Freshly verified email whose SVID receives the new passkey (mutually exclusive with phone) | | `phone` | string | Optional | Freshly verified phone whose SVID receives the new passkey (mutually exclusive with email) | | `registration_token` | string | Required | Fresh token from a successful verification for the same identifier | | `device_name` | string | Optional | Optional label for the new passkey (shown in passkey management) | ```json { "options": { "challenge": "base64url-encoded-challenge", "rp": { "name": "SparkVault Identity", "id": "sparkvault.com" }, "user": { "id": "base64url-user-id", "name": "user@example.com", "displayName": "User" }, "pubKeyCredParams": [ { "type": "public-key", "alg": -7 }, { "type": "public-key", "alg": -257 } ], "timeout": 60000, "attestation": "none", "excludeCredentials": [ { "type": "public-key", "id": "base64url-existing-credential-id" } ], "authenticatorSelection": { "residentKey": "required", "requireResidentKey": true, "userVerification": "required" } }, "session": { "sessionId": "9f2c4e8a1b3d5f7a9c0e2468acefdb13579bdf02468ace13579bdf024680acef", "challenge": "base64url-encoded-challenge", "identity": "user@example.com", "identityType": "email", "deviceName": null, "rpId": "sparkvault.com" } } ``` `pubKeyCredParams` supports ES256 (`-7`) and RS256 (`-257`). `excludeCredentials` lists the person's existing passkeys so the authenticator won't double-register. User verification (biometric/PIN) is **required** and enforced server-side on completion. `session.sessionId` is a 64-character hex value; the authoritative session state is stored server-side and is one-time use. ### Complete Registration #### `POST /{account_id}/passkey/register/complete` Complete passkey registration with the credential from navigator.credentials.create(). #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `session` | object | Required | The `session` object from the register start response (must include `sessionId`; consumed atomically, one-time use) | | `credential` | object | Required | PublicKeyCredential from navigator.credentials.create() | | `auth_request_id` | string | Optional | OIDC auth request ID (for immediate login after registration) | | `simple_mode` | object | Optional | Simple redirect completion context | ```json { "verified": true, "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...", "identity": "user@example.com", "identity_type": "email", "method": "passkey", "svid": "ing_019e66a4e27875f2822ede0e4f5d8792", "session_id": "sess_abc123", "refresh_token": "rt_...", "recommendations": [] } ``` ### Start Authentication #### `POST /{account_id}/passkey/verify` Start passkey authentication. Supply an optional email or phone to scope the ceremony to that person's passkeys (the response includes allowCredentials so the authenticator offers only the matching credential); omit it for a discoverable ceremony. Either way the selected credential returns a WebAuthn userHandle containing the SVID, which completion verifies against. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `email` | string | Optional | Scope the ceremony to this email's passkeys (mutually exclusive with phone) | | `phone` | string | Optional | Scope the ceremony to this phone's passkeys, E.164 (mutually exclusive with email) | | `action` | object | Optional | Optional action descriptor (e.g. `{ amount, recipient, nonce }`) to bind the biometric ceremony to a specific action. Canonicalized and hashed server-side at start; mutually exclusive with action\_hash. | | `action_hash` | string | Optional | Optional pre-computed 64-character hex SHA-256 of the action (e.g. a document content hash). Mutually exclusive with action. | | `auth_request_id` | string | Optional | OIDC auth request ID when completing an authorization-code flow | | `simple_mode` | object | Optional | Simple redirect completion context | ```json { "options": { "challenge": "base64url-encoded-challenge", "timeout": 60000, "rpId": "sparkvault.com", "userVerification": "required", "allowCredentials": [ { "type": "public-key", "id": "base64url-credential-id" } ] }, "session": { "sessionId": "9f2c4e8a1b3d5f7a9c0e2468acefdb13579bdf02468ace13579bdf024680acef", "challenge": "base64url-encoded-challenge", "rpId": "sparkvault.com" } } ``` > **Action-Bound Approvals** > > When `action` or `action_hash` is supplied, the hash is stored on the one-shot server-side session at start and is never re-sent on complete. The action the biometric authorized cannot be swapped mid-ceremony. The resulting token carries an `action_hash` claim, making it a signed approval receipt: "identity S approved action H at time T", not merely "S verified". ### Complete Authentication #### `POST /{account_id}/passkey/verify/complete` Complete passkey authentication with the assertion from navigator.credentials.get(). #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `session` | object | Required | The `session` object from the verify start response (must include `sessionId`; consumed atomically, one-time use) | | `credential` | object | Required | PublicKeyCredential from navigator.credentials.get() | | `auth_request_id` | string | Optional | OIDC auth request ID | | `simple_mode` | object | Optional | Simple redirect completion context | ```json { "verified": true, "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...", "identity": "user@example.com", "identity_type": "email", "method": "passkey", "svid": "ing_019e66a4e27875f2822ede0e4f5d8792", "session_id": "sess_abc123", "refresh_token": "rt_...", "recommendations": [] } ``` ### Passkey Management Authenticated endpoints for managing a person's passkeys. Both require a valid Identity JWT from a recent authentication in the `Authorization: Bearer` header, and operate on the identity's passkeys across the whole platform (passkeys are SVID-scoped under a single global RP, not per tenant). #### `GET /{account_id}/passkey/list` List the authenticated person's passkeys. Requires Authorization: Bearer . ```json { "passkeys": [ { "credential_id": "base64url-credential-id", "device_name": "MacBook Pro", "created_at": 1703977200, "last_used": 1704067800, "backup_eligible": true, "backup_state": true } ], "count": 1 } ``` #### `DELETE /{account_id}/passkey/:credential_id` Remove a passkey. Requires Authorization: Bearer . Sends a passkey-removed notification to the identity's email or phone. #### Path Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `credential_id` | string | Required | Base64url-encoded credential ID to delete | ```json { "success": true, "credential_id": "base64url-credential-id" } ``` ### Passkey Popup #### `GET /{account_id}/passkey/popup` Serves the passkey popup page for cross-origin WebAuthn ceremonies. Used internally by the SDK for cross-origin passkey flows. Opens in a popup window to handle the WebAuthn ceremony on the Identity domain. ## Two-Factor Authentication (TOTP) People can enroll an authenticator app (TOTP) as a second factor in the auth.sv portal. When 2FA is enabled, Identity holds every non-passkey login at a second-factor gate after the primary factor succeeds: no session is opened, no tokens are minted, and the one-shot OIDC/simple-mode context is left unconsumed until the second factor clears. Passkeys are phishing-resistant MFA on their own (possession + biometric), so a passkey login never triggers the gate. SDK and API callers receive a JSON challenge from the primary verify call instead of the verified result: ```json { "verified": false, "second_factor_required": true, "ticket": "opaque-64-char-hex-ticket", "methods": ["authenticator", "recovery_code"], "identity": "user@example.com", "identity_type": "email" } ``` Top-level browser logins that cannot consume a JSON body (social, SAML, SparkLink) are redirected to the hosted code-entry page instead. The ticket is opaque, short-lived, one-shot, and attempt-limited. Too many wrong codes retires it and the person must sign in again. ### Hosted Code-Entry Page #### `GET /{account_id}/second-factor` Render the hosted second-factor code-entry page for top-level browser logins. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `ticket` | string | Required | Pending-login ticket minted at the second-factor gate | ### Verify Second Factor #### `POST /{account_id}/second-factor/verify` Submit the authenticator code (or a recovery code) and finish the held login. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `ticket` | string | Required | Pending-login ticket from the challenge | | `code` | string | Required | 6-digit authenticator code, or a single-use recovery code (anything that isn't a 6-digit code is treated as a recovery code) | On success, the response is the same completion shape as a primary login (token, managed session, recommendations). Tokens minted through the gate carry both factors in `amr` (e.g. `["otp", "totp"]` or `["otp", "recovery"]`) and `acr: urn:sparkvault:identity:mfa`. ## Social Login API Social login allows users to authenticate using their existing accounts from Google, Apple, Microsoft, GitHub, Facebook, or LinkedIn. The Identity Product handles the full OAuth flow. ### Initiate Social Login #### `GET /{account_id}/social/:provider` Redirects user to the social provider's OAuth consent page. #### Path Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `provider` | string | Required | Provider ID: google, apple, microsoft, github, facebook, linkedin | #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `redirect_uri` | string | Optional | Registered return URL for direct SDK token redirects. **Required unless auth\_request\_id or simple\_mode is provided**, and must be registered for the account. | | `state` | string | Optional | Opaque value echoed on direct SDK token redirects for CSRF protection | | `auth_request_id` | string | Optional | OIDC hosted-login auth request to complete with an authorization code | | `simple_mode` | string | Optional | JSON simple-verify context; session-backed flows send only session\_id | | `client_id` | string | Optional | OIDC client ID when relaying an authorization-code flow | | `nonce` | string | Optional | OIDC nonce relayed into the resulting ID token | | `code_challenge` | string | Optional | PKCE challenge relayed for the OIDC flow | | `code_challenge_method` | string | Optional | PKCE method (S256) accompanying code\_challenge | | `opener_origin` | string | Optional | Popup opener origin for postMessage result delivery | ```text GET https://auth.sparkvault.com/{account_id}/social/google?redirect_uri=https://yourapp.com/callback&state=abc123 → Redirects to Google OAuth consent page → After consent, redirects to /social/google/callback → Finally redirects to your redirect_uri with #token=...&state=abc123 ``` ### Social Login Callback #### `GET /social/:provider/callback` Handles the OAuth callback from the social provider. Validates the token and completes the stored SparkVault login flow. This endpoint is called by the social provider after user consent. You don't call it directly. On success, SparkVault completes the stored OIDC, simple-verify, portal-link, or SDK redirect flow. ### Supported Providers | Provider | ID | Returns | | --- | --- | --- | | Google | `google` | Email, verified status | | Apple | `apple` | Email (may be private relay) | | Microsoft | `microsoft` | Email, verified status | | GitHub | `github` | Primary email | | Facebook | `facebook` | Email | | LinkedIn | `linkedin` | Email | ## Enterprise SSO (SAML) SAML integration allows enterprise customers to use their existing identity providers (Okta, Azure AD/Entra, OneLogin, Ping Identity, JumpCloud) for single sign-on. > **Enterprise Feature** > > SAML integration requires configuration of your IdP. Contact support to set up SAML for your organization. ### Initiate SAML Login #### `GET /{account_id}/saml/:provider` Redirects user to the configured SAML Identity Provider for authentication. #### Path Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `provider` | string | Required | Provider ID: okta, entra, onelogin, ping, jumpcloud | #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `redirect_uri` | string | Optional | Return URL for direct SDK token redirects. Optional: the SAML flow otherwise completes through auth\_request\_id or the server-side relay-state context. | | `state` | string | Optional | Opaque value for CSRF protection | ### SAML Assertion Consumer Service (ACS) #### `POST /{account_id}/saml/:provider/acs` Receives SAML assertions from the IdP. Validates the assertion and redirects to your app with a signed JWT. This endpoint is called by your SAML IdP after authentication. Configure your IdP to POST assertions to this URL. On success, the user is redirected to your `redirect_uri` with a signed token. ### Supported SAML Providers | Provider | ID | Notes | | --- | --- | --- | | Okta | `okta` | Full SAML 2.0 support | | Microsoft Entra ID (Azure AD) | `entra` | Full SAML 2.0 support | | OneLogin | `onelogin` | Full SAML 2.0 support | | Ping Identity | `ping` | Full SAML 2.0 support | | JumpCloud | `jumpcloud` | Full SAML 2.0 support | ### IdP Configuration ```text Entity ID: https://auth.sparkvault.com/{account_id} ACS URL: https://auth.sparkvault.com/{account_id}/saml/{provider}/acs Binding: HTTP-POST NameID: Email address (required) ``` ## Hosted Login Page The hosted login page provides a complete, branded authentication experience with every method you have enabled (passkey, OTP, SparkLink, social, SAML). You don't link users to it directly with your own parameters. People arrive through one of the two entry points, each of which hands the page an opaque context token: - **OIDC:** `/authorize` validates your request and redirects here with `auth_request=`. - **Simple verify:** `/verify?session=` redirects here with `mode=simple&session=`. #### `GET /{account_id}` Serves the hosted login page with all enabled authentication methods. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `auth_request` | string | Optional | OIDC auth request ID, set by the /authorize redirect | | `mode` | string | Optional | "simple" when arriving from the simple-verify entry point | | `session` | string | Optional | Opaque verify-session ID, required when `mode=simple`; every other field (redirect URLs, identity hint, context label, state) is loaded server-side from the session | ```text https://auth.sparkvault.com/{account_id}?auth_request=req_abc123 ← from /authorize https://auth.sparkvault.com/{account_id}?mode=simple&session=nZk4tW8x ← from /verify → Shows the branded login page with all enabled methods → User chooses a method and verifies → The stored OIDC or simple-verify flow completes (code callback or signed redirect) ``` There are no direct `redirect_uri`, `email`, `phone`, or `methods` query parameters and no standalone token-callback flow. Email pre-fill comes from the OIDC auth request's login hint or from the `identity` committed when the verify session was minted. > **Custom Domains** > > You can use a custom domain (e.g., `login.yourcompany.com`) for white-label hosted login. The hosted page respects your organization branding settings. ## SDK Configuration The SDK configuration endpoint returns the available authentication methods for your account. The JavaScript SDK calls this automatically on initialization. Requests must originate from a page on one of the account's verified company domains (the Origin check that also guards the other SDK endpoints). #### `GET /{account_id}/config` Returns SDK configuration including enabled authentication methods and branding. ```json { "data": { "accountId": "acc_example", "branding": { "companyName": "Your Company", "logoLight": "https://yourcompany.com/logo-light.png", "logoDark": "https://yourcompany.com/logo-dark.png", "themeMode": "light" }, "allowedIdentityTypes": ["email", "phone"], "methods": [ "passkey", "otp_email", "otp_sms", "sparklink", "social_google", "social_apple" ], "sso": { "enabled": true, "forced": false }, "wsDomain": "ws.sparkvault.com" } } ``` `methods` can also include the enterprise SSO IDs `enterprise_okta`, `enterprise_entra`, `enterprise_onelogin`, `enterprise_ping`, and `enterprise_jumpcloud`. `sso` tells the SDK whether to attempt a silent cross-domain session check (`enabled`) and whether the hosted page is the only sign-in surface (`forced`). The response is cacheable: `Cache-Control: public, max-age=300, stale-while-revalidate=600`. ## Server-Side Examples (OIDC) ### Node.js / Express ```javascript import express from 'express'; import { ed25519 } from '@noble/curves/ed25519'; const app = express(); // Configuration const IDENTITY_URL = 'https://auth.sparkvault.com/acc_youraccountid'; const CLIENT_ID = 'your-client-id'; const CLIENT_SECRET = process.env.IDENTITY_CLIENT_SECRET; const REDIRECT_URI = 'https://your-app.com/auth/callback'; // JWKS cache let jwksCache = null; let jwksCacheExpiry = 0; async function getPublicKey() { if (jwksCache && Date.now() < jwksCacheExpiry) { return jwksCache; } const res = await fetch(`${IDENTITY_URL}/.well-known/jwks.json`); const jwks = await res.json(); jwksCache = base64urlDecode(jwks.keys[0].x); jwksCacheExpiry = Date.now() + 5 * 60 * 1000; // 5 min return jwksCache; } // Login route - redirect to Identity app.get('/login', async (req, res) => { const codeVerifier = generateCodeVerifier(); const codeChallenge = await generateCodeChallenge(codeVerifier); const state = crypto.randomUUID(); const nonce = crypto.randomUUID(); // Store in session req.session.pkce = { codeVerifier, state, nonce }; const authUrl = new URL(`${IDENTITY_URL}/authorize`); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('client_id', CLIENT_ID); authUrl.searchParams.set('redirect_uri', REDIRECT_URI); authUrl.searchParams.set('scope', 'openid email'); authUrl.searchParams.set('state', state); authUrl.searchParams.set('nonce', nonce); authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge_method', 'S256'); res.redirect(authUrl.toString()); }); // Callback route - exchange code for token app.get('/auth/callback', async (req, res) => { const { code, state, error } = req.query; const { codeVerifier, state: savedState, nonce } = req.session.pkce || {}; // Handle errors if (error) { return res.redirect(`/login?error=${error}`); } // Validate state if (state !== savedState) { return res.status(400).send('Invalid state'); } // Exchange code for tokens const tokenRes = await fetch(`${IDENTITY_URL}/token`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64') }, body: JSON.stringify({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: codeVerifier }) }); const { id_token, access_token, refresh_token } = await tokenRes.json(); // Verify ID token const claims = await verifyIdToken(id_token, nonce); // Clear PKCE data delete req.session.pkce; // BFF boundary: keep token material server-side or in encrypted/signed // HttpOnly, Secure, SameSite=Lax cookies. Browser JS should never see it. req.session.identity = { accessToken: access_token, refreshToken: refresh_token }; req.session.user = { svid: claims.svid || claims.sub, identity: claims.identity || claims.email || claims.phone_number }; res.redirect('/dashboard'); }); app.post('/logout', async (req, res) => { const refreshToken = req.session.identity?.refreshToken; if (refreshToken) { await fetch(`${IDENTITY_URL}/revoke`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64') }, body: JSON.stringify({ client_id: CLIENT_ID, token: refreshToken }) }); } req.session.destroy(() => res.sendStatus(204)); }); async function verifyIdToken(idToken, expectedNonce) { const [headerB64, payloadB64, signatureB64] = idToken.split('.'); const publicKey = await getPublicKey(); // Verify signature const signedData = `${headerB64}.${payloadB64}`; const signature = base64urlDecode(signatureB64); const isValid = ed25519.verify( signature, new TextEncoder().encode(signedData), publicKey ); if (!isValid) throw new Error('Invalid signature'); const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()); // Validate claims if (payload.iss !== IDENTITY_URL) throw new Error('Invalid issuer'); if (payload.nonce !== expectedNonce) throw new Error('Invalid nonce'); if (payload.exp < Math.floor(Date.now() / 1000)) throw new Error('Token expired'); if (payload.aud !== CLIENT_ID) throw new Error('Invalid audience'); return payload; } ``` ### Python / Flask ```python import os import base64 import hashlib import secrets from flask import Flask, redirect, request, session import requests from nacl.signing import VerifyKey from nacl.exceptions import BadSignatureError app = Flask(__name__) app.secret_key = os.environ['FLASK_SECRET_KEY'] IDENTITY_URL = 'https://auth.sparkvault.com/acc_youraccountid' CLIENT_ID = 'your-client-id' CLIENT_SECRET = os.environ['IDENTITY_CLIENT_SECRET'] REDIRECT_URI = 'https://your-app.com/auth/callback' # JWKS cache _jwks_cache = {'key': None, 'expires': 0} def get_public_key(): import time if _jwks_cache['key'] and time.time() < _jwks_cache['expires']: return _jwks_cache['key'] response = requests.get(f'{IDENTITY_URL}/.well-known/jwks.json') jwks = response.json() key_b64 = jwks['keys'][0]['x'] # Add padding if needed padding = 4 - (len(key_b64) % 4) if padding != 4: key_b64 += '=' * padding key_bytes = base64.urlsafe_b64decode(key_b64) _jwks_cache['key'] = key_bytes _jwks_cache['expires'] = time.time() + 300 # 5 min return key_bytes def base64url_encode(data): return base64.urlsafe_b64encode(data).rstrip(b'=').decode('ascii') def generate_pkce(): code_verifier = secrets.token_urlsafe(32) code_challenge = base64url_encode( hashlib.sha256(code_verifier.encode()).digest() ) return code_verifier, code_challenge @app.route('/login') def login(): code_verifier, code_challenge = generate_pkce() state = secrets.token_urlsafe(16) nonce = secrets.token_urlsafe(16) session['pkce'] = { 'code_verifier': code_verifier, 'state': state, 'nonce': nonce } auth_url = ( f'{IDENTITY_URL}/authorize' f'?response_type=code' f'&client_id={CLIENT_ID}' f'&redirect_uri={REDIRECT_URI}' f'&scope=openid%20email' f'&state={state}' f'&nonce={nonce}' f'&code_challenge={code_challenge}' f'&code_challenge_method=S256' ) return redirect(auth_url) @app.route('/auth/callback') def callback(): code = request.args.get('code') state = request.args.get('state') error = request.args.get('error') if error: return redirect(f'/login?error={error}') pkce = session.get('pkce', {}) if state != pkce.get('state'): return 'Invalid state', 400 # Exchange code credentials = base64.b64encode( f'{CLIENT_ID}:{CLIENT_SECRET}'.encode() ).decode() response = requests.post( f'{IDENTITY_URL}/token', headers={ 'Content-Type': 'application/json', 'Authorization': f'Basic {credentials}' }, json={ 'grant_type': 'authorization_code', 'code': code, 'redirect_uri': REDIRECT_URI, 'client_id': CLIENT_ID, 'code_verifier': pkce['code_verifier'] } ) token_response = response.json() id_token = token_response['id_token'] claims = verify_id_token(id_token, pkce['nonce']) session.pop('pkce', None) # BFF boundary: keep token material server-side or in encrypted/signed # HttpOnly, Secure, SameSite=Lax cookies. Browser JS should never see it. session['identity'] = { 'access_token': token_response['access_token'], 'refresh_token': token_response['refresh_token'] } session['user'] = { 'svid': claims.get('svid') or claims['sub'], 'identity': claims.get('identity') or claims.get('email') or claims.get('phone_number') } return redirect('/dashboard') @app.route('/logout', methods=['POST']) def logout(): refresh_token = session.get('identity', {}).get('refresh_token') if refresh_token: credentials = base64.b64encode( f'{CLIENT_ID}:{CLIENT_SECRET}'.encode() ).decode() requests.post( f'{IDENTITY_URL}/revoke', headers={ 'Content-Type': 'application/json', 'Authorization': f'Basic {credentials}' }, json={ 'client_id': CLIENT_ID, 'token': refresh_token }, timeout=5 ) session.clear() return '', 204 def verify_id_token(id_token, expected_nonce): import json import time parts = id_token.split('.') header_b64, payload_b64, signature_b64 = parts # Decode payload padding = 4 - (len(payload_b64) % 4) if padding != 4: payload_b64 += '=' * padding payload = json.loads(base64.urlsafe_b64decode(payload_b64)) # Verify signature public_key = get_public_key() verify_key = VerifyKey(public_key) signed_data = f'{parts[0]}.{parts[1]}'.encode() padding = 4 - (len(signature_b64) % 4) if padding != 4: signature_b64 += '=' * padding signature = base64.urlsafe_b64decode(signature_b64) try: verify_key.verify(signed_data, signature) except BadSignatureError: raise ValueError('Invalid signature') # Validate claims if payload['iss'] != IDENTITY_URL: raise ValueError('Invalid issuer') if payload['nonce'] != expected_nonce: raise ValueError('Invalid nonce') if payload['exp'] < time.time(): raise ValueError('Token expired') if payload['aud'] != CLIENT_ID: raise ValueError('Invalid audience') return payload ``` ## Error Handling ### Authorization Errors Request-validation failures at `/authorize` (missing or malformed parameters, a `response_type` other than `code`, a scope without `openid`, or an unregistered client or redirect\_uri) return an HTTP 400 error response and never redirect. Only errors reached after the request validates, namely user cancellation and an unsatisfiable `prompt=none`, are delivered as query parameters on your `redirect_uri`. #### Error Responses | Status | Code | Description | | --- | --- | --- | | 400 | `invalid_request` | Missing or malformed parameter. Returned directly, not redirected. | | 400 | `unsupported_response_type` | response\_type other than "code". Returned directly. | | 400 | `invalid_scope` | Scope missing "openid" or otherwise unsupported. Returned directly. | | 400 | `invalid_client` | client\_id or redirect\_uri not registered for this client. Returned directly. | | 302 | `access_denied` | User denied consent or cancelled. Delivered on redirect\_uri. | | 302 | `login_required` | prompt=none could not be satisfied silently. Delivered on redirect\_uri. | ### Token Endpoint Errors #### Error Responses | Status | Code | Description | | --- | --- | --- | | 400 | `invalid_request` | Missing required parameter | | 400 | `unsupported_grant_type` | grant\_type must be "authorization\_code" or "refresh\_token" | | 400 | `invalid_grant` | Code is invalid, expired, or already used | | 401 | `invalid_client` | Client authentication failed | | 429 | `rate_limited` | Too many requests | ```json { "error": "invalid_grant", "error_description": "Authorization code is invalid or expired" } ``` ## Security Best Practices - **Always use PKCE**: The S256 code challenge is mandatory. Never skip PKCE, even for confidential clients. - **Validate state parameter**: Compare the returned state to your stored value to prevent CSRF attacks. - **Verify all ID token claims**: Check issuer, audience, expiration, and nonce. Don't trust tokens without full validation. - **Use nonce for replay protection**: Generate a unique nonce per request and verify it in the ID token. - **Keep client\_secret server-side**: Never expose your client secret in frontend code or version control. - **Use HTTPS redirect URIs**: All registered redirect URIs must use HTTPS with no insecure origin exceptions. - **Cache JWKS appropriately**: Cache for 5-10 minutes, but re-fetch if you encounter an unknown key ID. - **Use SVID as the app identity key**: Store your product profile, roles, preferences, uploads, and moderation state under `svid`. Do not use email, phone, or a hash of either as the canonical account link. - **Use a BFF for browser sessions**: Exchange codes server-side and store refresh/session material only in encrypted or signed `HttpOnly`, `Secure`, `SameSite=Lax` cookies. Browser JavaScript should never see refresh tokens. - **Validate locally, introspect selectively**: Validate JWTs with cached JWKS for normal requests. Use `/introspect` for sensitive writes, account settings, admin/moderation actions, suspicious sessions, or short cached hard checks. - **Rotate refresh tokens atomically**: Store only the latest OIDC `refresh_token`. Each successful refresh invalidates the previous token, so clients must persist the replacement before making another refresh attempt. - **Revoke on logout**: Call `/revoke` with the current refresh token, then clear your local session cookies. ## Account Configuration Configure your Identity tenant through the SparkVault dashboard or API: | Setting | Description | | --- | --- | | `redirect_uris` | Allowed callback URLs for OIDC and simple mode. May be empty when only SDK/simple verification is used; OIDC clients still require at least one redirect URI. | | Company Domains | Verified account domains authorize Identity SDK browser origins. Add them under Company / Domains; redirect URIs do not grant SDK origin trust. | | `clients` | Registered client IDs and secrets | | `methods.sparklink` | Enable magic links. Keys: `enabled`, `ttl_minutes` (default 15, range 5-60), `require_ip_binding` (default true), `auto_resend_on_expiry` (default false) | | `methods.otp_email` | Enable email OTP codes (default: true) | | `methods.otp_sms` | Enable SMS OTP codes (default: false) | | `methods.otp_voice` | Enable voice call OTP codes (default: false) | | `methods.passkey` | Enable WebAuthn passkeys (default: true) | | `methods.social.*` | Enable social providers: google, apple, microsoft, github, facebook, linkedin | | `methods.enterprise.*` | Enable enterprise SAML providers: okta, entra, onelogin, ping, jumpcloud | | `methods` | Canonical source for both enabled auth methods and derived email/phone input availability. Passkeys are SVID-scoped and are not tied to an email or phone setting. | | `rate_limits` | Rate limiting config: attempts\_before\_backoff, initial\_backoff\_seconds, etc. | | `tokens.id_token_ttl_seconds` | OIDC ID token lifetime (default 3600). SDK-mode simple tokens use a fixed 300-second TTL. | > **Custom Domains** > > You can CNAME your own domain to `auth.sparkvault.com` for white-label authentication. Contact support to configure custom domain mapping. ## Pricing Identity is included in your SparkVault subscription. There is no per-verification charge, and failed attempts and retries are never billed. Each subscription tier includes a monthly login-attempt allowance; accounts approaching the allowance are warned, and a soft gate applies at 120% of the tier ceiling. [**View Full Pricing →**](/pricing/) ## Try It SparkVault uses its own Identity Product for authentication. You experienced it when you logged in! To integrate Identity into your own application: 1. Configure your redirect URIs in account settings 2. Create a client ID and secret 3. Implement the OIDC flow as shown above 4. Test with the hosted login page [**Configure Identity →**](https://app.sparkvault.com/products/identity) --- # Notify API: SparkVault API Reference > Complete API reference for SparkVault Notify, a secure, identity-gated, multi-channel notification transport. Every notification is sealed per recipient, reaches the right person across the channels they prefer, and produces a portable cryptographic receipt proving they saw, approved, or signed it. Canonical: https://sparkvault.com/api/docs/products/notify/ · OpenAPI: https://sparkvault.com/openapi.yaml ## Overview SparkVault Notify delivers notifications that are **verified, sealed, and provable**, not plaintext blasts. A notification reaches the right person, proves they read/approved/signed it with a portable receipt, keeps replies encrypted, and stays recallable. Notify is composed from the SparkVault primitives rather than bolted onto them: every send is one sealed **Spark** behind a single-use **SparkLink**, opened through an **Identity** verification ceremony, with Notify owning only transport and orchestration. Sealed Secure by Default EdDSA Portable Receipts 12 Delivery Channels Recallable Single-Use Grants ### The Four-Layer Model Notify never touches crypto directly. Each layer owns one job, so confidential content stays sealed end to end and the transport only ever carries an opaque pointer. | Layer | Owns | | --- | --- | | **Spark** | Sealed content + lifecycle (double-zero-trust, view-once, TTL, burn-after-read). Knows nothing about recipients. | | **SparkLink** | The per-recipient verified-access grant: `verification_level` × `interaction`, single-use, revocable. Emits the signed receipt. | | **Identity** | The verification ceremony (auth.sv) producing an EdDSA token, with `action_hash` binding for approve/sign. | | **Notify** | Transport + orchestration: channels, escalation, fan-out, preferences, inbox, receipts, billing. Carries only the SparkLink pointer. | > **Secure by Default** > > Notification content is sealed per recipient as a Spark behind a single-use SparkLink. List and feed responses are **metadata-only** and never carry plaintext. The recipient opens the `sparklink_code` pointer to unseal. A partial or failed send hard-recalls every grant it minted, so a broadcast never leaves orphaned sealed content behind. ## Base URL & Authentication All Notify endpoints are served under a single base path. The tenant is taken from your authenticated account token. There is no account ID in the path. ```text https://api.sparkvault.com/v1/products/notify ``` ### Authentication Every endpoint is account-token authed (a registered user). Pass a session JWT or an API key; both resolve to the calling account, which scopes tenant isolation on every read and write. | Method | Header | Format | | --- | --- | --- | | JWT | `Authorization` | `Bearer {token}` | | API Key | `X-API-Key` | `sv_live_{token}` | ### Response Envelope Every success response wraps its payload in a `data` envelope with a `meta` sibling carrying the deployed API build version and server timing. The field tables on this page describe the contents of `data`. Error responses carry an `error` object with a `meta` sibling carrying `api_version` (see [Error Handling](#error-handling)). ```json { "data": { /* endpoint payload: documented per endpoint below */ }, "meta": { "api_version": "1.2.828", "request_id": "...", "response_ms": 12, "timestamp": 1719446400 } } ``` > **Sender-Side Read Surface (v1)** > > The inbox, status, and receipts read surface is **sender-side**: an account reads and manages the notifications it sent, proxying its recipients' inbox interactions through its own backend. A caller never sees another account's rows. A cross-tenant `send_id` or notification is reported identically to one that never existed (HTTP 404). ## Quick Start Send your first sealed notification with a single POST. Notify seals the content for each recipient, mints a single-use SparkLink, and returns a `send_id` you can poll for delivery status and receipts. #### Send a sealed notification ```curl curl -X POST 'https://api.sparkvault.com/v1/products/notify/send' \ -H 'X-API-Key: sv_live_your_api_key' \ -H 'Content-Type: application/json' \ -d '{ "recipients": [ { "email": "user@example.com" } ], "title": "Your invoice is ready", "category": "transactional", "content": { "payload": "Invoice #1043 for $129.00 is attached.", "content_type": "text/plain" }, "policy": { "verification_level": "identifier", "interaction": "view" }, "channels": ["email", "push"] }' ``` ```node.js const res = await fetch('https://api.sparkvault.com/v1/products/notify/send', { method: 'POST', headers: { 'X-API-Key': process.env.SPARKVAULT_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ recipients: [{ email: 'user@example.com' }], title: 'Your invoice is ready', category: 'transactional', content: { payload: 'Invoice #1043 for $129.00 is attached.', content_type: 'text/plain' }, policy: { verification_level: 'identifier', interaction: 'view' }, channels: ['email', 'push'] }) }); const { data } = await res.json(); console.log(data.send_id, data.recipients, data.status); // "ntsnd_01...", 1, "pending" ``` ```python import os, requests res = requests.post( 'https://api.sparkvault.com/v1/products/notify/send', headers={'X-API-Key': os.environ['SPARKVAULT_API_KEY']}, json={ 'recipients': [{'email': 'user@example.com'}], 'title': 'Your invoice is ready', 'category': 'transactional', 'content': { 'payload': 'Invoice #1043 for $129.00 is attached.', 'content_type': 'text/plain' }, 'policy': {'verification_level': 'identifier', 'interaction': 'view'}, 'channels': ['email', 'push'] } ) res.raise_for_status() data = res.json()['data'] print(data) # { "send_id": "ntsnd_01...", "recipients": 1, "status": "pending" } ``` > **What just happened** > > Notify sealed the body into a Spark, minted a single-use SparkLink for the recipient, and wrote one send row. The recipient receives a pointer on each channel (“tap to view”); when they open it and verify their identifier, the content unseals and a signed receipt is recorded. ## Sending Notifications The send endpoint is the primary entry point. It seals + mints one single-use SparkLink per recipient synchronously from the in-memory payload (so nothing unsealed is ever persisted), then writes the send row whose stream drives fan-out and delivery. #### `POST /products/notify/send` Create a secure send: seal content per recipient, mint a single-use SparkLink each, and write the send row that drives fan-out + delivery. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `recipients` | object\[\] | string\[\] | Required | Non-empty audience. Each entry is a bare identifier string or `{ id?, email?, phone? }`. An SVID id (`ing_...`) enables stored recipient preferences. Max 500 per send; duplicates are collapsed. | | `content` | object | Optional | Content to seal per recipient: `{ payload, content_type?, filename?, ttl_minutes? }`. Provide exactly one of `content` OR `ingot`. | | `ingot` | object | Optional | Existing sealed asset reference `{ ingot_id, vault_id }`. 1:1 sends only (one persistent asset = one grant). Provide exactly one of `content` OR `ingot`. | | `policy` | object | Optional | Access policy for the minted SparkLink: `{ verification_level?, interaction?, reveal_freshness_minutes? }`. See the policy table below. | | `channels` | string\[\] | Optional | Delivery-channel override. When omitted, the company config resolves them (per-category `channel_priority` then `default_channels`). | | `escalation` | object | Optional | `{ delays: number[] }` cumulative per-step delays in seconds. When omitted, derived from the resolved channels + company config. | | `title` | string | Optional | Channel-agnostic display title (metadata; never sealed). | | `category` | string | Optional | The configured category this send is classified as (drives channel resolution, recipient preferences, and the mandatory-seal floor). | | `type` | string | Optional | Optional display type for the recipient feed. | | `history_ttl_days` | integer | Optional | Inbox-row retention in days.Default: `30` | | `send_id` | string | Optional | Caller-supplied idempotency key. Generated (`ntsnd_...`) when absent; a retry with the same id returns the existing send untouched. | #### policy Object | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `verification_level` | string | Optional | "none" | "identifier" | "passkey" | "out\_of\_band" | "dual\_control". How strongly the recipient must prove identity before the content unseals. | | `interaction` | string | Optional | "view" | "acknowledge" | "sign" | "approve" | "reply". The interaction the SparkLink requires; sign/approve bind an `action_hash` into the receipt. | | `reveal_freshness_minutes` | integer | Optional | Require a verification no older than this many minutes before the content reveals. Must be a positive integer (`0` is rejected); omit the field (or pass `null`) for no freshness requirement. | #### Response | Field | Type | Description | | --- | --- | --- | | `send_id` | string | The send identifier (`ntsnd_...`). Use it to query status and receipts. | | `recipients` | integer | Number of distinct recipients sealed for this send. | | `status` | string | Write-once "pending": the send was accepted and fan-out is driven asynchronously. | | `idempotent` | boolean | Present and true when an existing send\_id was returned as-is (no re-seal). | ```json { "data": { "send_id": "ntsnd_01j9z4f6w8m3qk2c7d5h0abxyz", "recipients": 1, "status": "pending" }, "meta": { "api_version": "1.2.828", "request_id": "...", "response_ms": 412, "timestamp": 1719446400 } } ``` > **One Content Source** > > Provide exactly one of `content` or `ingot`. An `ingot` is one persistent sealed asset with a single grant, so it can only target a single recipient; use `content` to fan a fresh per-recipient spark out to many. > **Bounded Audience** > > A single send targets at most **500 recipients**. The audience rides inline on the send row and each recipient is sealed synchronously, so split a larger blast into multiple sends. ## Delivery Model: Sealed vs Plaintext Delivery mode is a property of the message's nature, not a runtime flag. There is one send front door; how the content travels depends on whether it is confidential. DEFAULT #### Sealed Content is sealed into a Spark behind a single-use SparkLink. Channels carry only the pointer; the recipient verifies and unseals. The secure-reveal path for any confidential content. - No plaintext at rest - Works on every channel - Produces a verified receipt #### Plaintext Non-confidential content (an alert, a welcome) is delivered inline in the channel payload: no seal, no SparkLink, no reveal step. A constrained, fail-closed mode for low-stakes transactional messages. - Inline-capable channels only (email) - Max 50 recipients, 7-day TTL - `text/plain` or `text/html` only > **Selecting the Mode** > > The v1 REST send surface is **sealed-only**: `POST /products/notify/send` carries no delivery selector, so every API send travels sealed. Plaintext is the engine's mode for first-party platform flows (for example the `alert` preset's inline pings), and its constraints are enforced fail-closed: `content` only (never an `ingot`), `text/plain` or `text/html`, inline-capable channels only (email today), at most 50 recipients, and the send row's retention capped at 7 days. > **The Mandatory-Seal Guard** > > A hardcoded baseline of confidential categories (`secure`, `conversation`, `approval`, `signature`) **always** seals and can never be delivered plaintext. A tenant's `security.mandatory_seal_categories` may only _expand_ that set, never shrink it, so a confidential category can never be demoted to cleartext by a config edit. A plaintext send that targets a sealing-required category, a pointer-only channel, or too large an audience is rejected (fail-closed), never silently downgraded. ## Presets Presets are five secure-by-default bundles that pair a policy (`verification_level` × `interaction`) with a sensible default channel ladder and a display category. They are server-side helpers used by first-party platform flows (there is no `preset` parameter on the send endpoint), so treat the table below as the recommended bundles to assemble in your own request body. Caller-supplied fields always win over a preset's defaults; an omitted `channels` lets the company config resolve them per category. | Preset | verification\_level | interaction | Default channels | | --- | --- | --- | --- | | `alert` | none | view | config-resolved (plaintext) | | `secureMessage` | identifier | view | push, email | | `conversation` | identifier | reply | in\_app, push, email | | `approval` | passkey | approve | in\_app, push, email | | `signatureRequest` | passkey | sign | in\_app, push, email | > **Preset categories** > > A preset's `category` (e.g. `approval`, `signature`) is an interaction bundle. The high-stakes presets map to the hardcoded confidential categories, so an `approval` or `signatureRequest` always seals regardless of any config edit. `alert` is the one plaintext bundle: it delivers a non-confidential ping inline rather than behind a tap-to-view link; over the REST API an alert-style send travels sealed like everything else. ## Channels & Escalation Every channel is a dumb pointer-carrier behind one provider seam. No channel ever sees plaintext. The durable inbox row is the source of truth; a WebSocket push is only a best-effort realtime nudge. ### Supported Channels in\_app websocket email sms push web\_push voice whatsapp rcs webhook slack teams `push` is APNs/FCM; `web_push` is VAPID. Per-app channel credentials live in the owner-managed config and are never account-writable. ### Verified-Seen-Aware Escalation When `escalation_enabled`, each step fires only if the prior step's notification is still unseen. Escalation is the next-channel delivery enqueued with a delay; the chain stops the moment the recipient's verified `seen_at` is recorded, so a recipient who reads the email is never also called. ```json { "channels": ["push", "email", "voice"], "escalation": { "delays": [0, 120, 600] } } ``` Cumulative per-step delays in seconds: push immediately, email at 2 minutes if still unseen, a voice call at 10 minutes if still unseen. Omit `escalation` to derive delays from the company config's per-channel `realtime_fallback_delay`. > **Schedule Normalization** > > When the config sets `delivery.escalation_enabled: false`, the resolved ladder collapses to the primary channel only: no fallback steps fire. `delays[0]` is always forced to `0` (an override's first entry is ignored), and the stored schedule is normalized to the resolved channel count: extra entries are dropped, a missing tail is derived from the config, and descending values are clamped so the schedule is always non-decreasing. ## Inbox The inbox is a recipient's per-notification feed, metadata only. Each row carries the SparkLink pointer so the client opens the link to unseal; the plaintext and the recipient's raw contact are never echoed here. ### Read a Recipient's Feed #### `GET /products/notify/inbox` A recipient's metadata-only feed, newest first. Account-scoped: returns only notifications THIS account sent to the recipient. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `recipient_id` | string | Required | The inbox owner (the recipient identifier the send addressed). | | `state` | string | Optional | "all" | "unseen" | "unread" | "archived" (archived rows are hidden from "all").Default: `all` | | `limit` | integer | Optional | Page size, clamped to \[1, 100\].Default: `50` | | `cursor` | string | Optional | Opaque next-page cursor from a prior response. | #### Response | Field | Type | Description | | --- | --- | --- | | `notifications` | object\[\] | Metadata-only rows (see below). | | `cursor` | string | null | Next-page cursor, or null when the feed is exhausted. | #### notification row | Field | Type | Description | | --- | --- | --- | | `notification_id` | string | Row id (used with the state endpoint). | | `send_id` | string | The send this row belongs to. | | `created_at` | integer | Row creation epoch (the sort-key timestamp component). | | `title` | string | Display title. | | `category` | string | Display category. | | `type` | string | Display type. | | `seen_at` | integer | null | When the recipient saw the notification. | | `read_at` | integer | null | When the recipient read it. | | `archived_at` | integer | null | When the row was archived. | | `sparklink_code` | string | Opaque pointer the client opens to unseal the content. | | `locked` | boolean | Always true: the content lives behind the SparkLink ("tap to view"). | | `thread_id` | string | Present only for conversation threads. | ```bash curl 'https://api.sparkvault.com/v1/products/notify/inbox?recipient_id=ing_019e66a4...&state=unread&limit=25' \ -H 'X-API-Key: sv_live_your_api_key' ``` ### Mark a Row's State #### `POST /products/notify/inbox/:notificationId/state` Apply a terminal display state to one row. Idempotent. A cross-tenant or missing row is reported as 404. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `recipient_id` | string | Required | The inbox owner. | | `created_at` | integer | Required | The row's created\_at (the sort-key timestamp), as returned by the inbox feed. | | `state` | string | Required | "seen" | "read" | "archived". | #### Response | Field | Type | Description | | --- | --- | --- | | `notification_id` | string | The row that was updated. | | `state` | string | The state that was applied. | | `updated_at` | integer | Update epoch. | ## Send Status The status endpoint is the sender's per-recipient delivery rollup for one send: “who has this reached, and how far did each recipient get?” It is metadata-only: it never returns the SparkLink pointer, the recipient's contact, or any policy internals. #### `GET /products/notify/sends/:sendId/status` Per-recipient delivery + read state for a send, plus aggregate counts. A send owned by another account is reported as 404. #### Response | Field | Type | Description | | --- | --- | --- | | `send_id` | string | The send identifier. | | `status` | string | The send-level status ("pending"). | | `channels` | string\[\] | The resolved channel ladder stored on the send. | | `created_at` | integer | Send creation epoch. | | `title` | string | null | Display title. | | `category` | string | null | Display category. | | `type` | string | null | Display type. | | `recipients` | object\[\] | Per recipient: `{ recipient_id, enqueued, seen_at, read_at, archived_at }`. | | `counts` | object | Aggregate rollup: `{ total, enqueued, seen, read, archived }`. | ```json { "data": { "send_id": "ntsnd_01j9z4f6w8m3qk2c7d5h0abxyz", "status": "pending", "channels": ["email", "push"], "created_at": 1719446400, "title": "Your invoice is ready", "category": "transactional", "type": null, "recipients": [ { "recipient_id": "user@example.com", "enqueued": true, "seen_at": 1719446460, "read_at": null, "archived_at": null } ], "counts": { "total": 1, "enqueued": 1, "seen": 1, "read": 0, "archived": 0 } }, "meta": { "api_version": "1.2.828", "request_id": "...", "response_ms": 34, "timestamp": 1719446400 } } ``` ## Receipts A receipt is a portable, JWKS-verifiable record of a verified recipient interacting with a SparkLink: opening it (accessed), signing for it, approving or denying a bound action, or replying. Receipts are written to the account-scope audit log in plaintext (they are designed to be presented as third-party proof without a vault key), carrying the Identity-signed EdDSA token and the `action_hash` for bound interactions. ### List Account Receipts #### `GET /products/notify/receipts` The account's verified-interaction receipts, newest first. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | Optional | Page size, clamped to \[1, 100\].Default: `50` | | `cursor` | string | Optional | Opaque next-page cursor from a prior response. | | `interaction` | string | Optional | Narrow to one mode: "view" | "acknowledge" | "sign" | "approve" | "reply". | #### Response | Field | Type | Description | | --- | --- | --- | | `receipts` | object\[\] | Receipt rows (see the receipt table below), newest first. | | `cursor` | string | null | Next-page cursor, or null when the list is exhausted. | ### Receipts for One Send #### `GET /products/notify/sends/:sendId/receipts` The signed receipts for one send, each attached to the recipient who produced it. Correlated by the per-grant SparkLink link_code (never the shared asset_id). A foreign send is reported as 404. #### Response | Field | Type | Description | | --- | --- | --- | | `send_id` | string | The send the receipts belong to. | | `receipts` | object\[\] | Receipt rows (see below), each carrying the `recipient_id` that produced it. | #### receipt | Field | Type | Description | | --- | --- | --- | | `event_type` | string | The audit subtype: sparklink\_accessed / signed / approved / denied / replied. | | `occurred_at` | integer | Event epoch parsed from the audit sort key. | | `interaction` | string | null | The interaction mode that produced the receipt. | | `identity` | string | null | The verified identity that completed the ceremony. | | `asset_id` | string | null | The sealed asset behind the grant (the Spark or ingot id). | | `vault_id` | string | null | The vault holding the sealed asset, when applicable. | | `link_code` | string | null | The masked per-grant SparkLink code used for correlation. | | `link_type` | string | null | The SparkLink type that emitted the receipt. | | `verification_level` | string | null | The level the recipient satisfied. | | `action_hash` | string | null | The bound action hash for approve/sign interactions. | | `signed_token` | string | null | The portable, JWKS-verifiable Identity-signed EdDSA proof. | | `decision` | string | null | The approve/deny decision, when applicable. | | `thread_id` | string | null | Present for conversation threads. | | `reply_spark_id` | string | null | The sealed reply Spark, present on reply receipts. | | `recipient_id` | string | Present on the per-send variant: the recipient who produced the receipt. | > **Correlation by grant, not asset** > > Receipts are joined to a send by the per-grant SparkLink `link_code` (unique per recipient), not by `asset_id`. An ingot's `asset_id` is shared across every send and share of that ingot, so correlating on it would cross-attribute receipts from unrelated sends. The per-send correlation scan is bounded (pages of 100 receipt rows, at most 20 pages), so on a very large receipt partition the result can be bounded rather than authoritative-complete. ## Company Configuration An account's effective Notify behavior is the secure-by-default schema deep-merged with the account's stored overrides. The config drives channel resolution, escalation timing, retention, the categories recipients can mute, compliance, and branding. ### Read Config #### `GET /products/notify/config` The account's Notify config as { effective, overrides }: defaults deep-merged with the stored override delta, so an editor can show what is customized vs. default. #### Response | Field | Type | Description | | --- | --- | --- | | `effective` | object | The fully-resolved config (defaults merged with overrides). | | `overrides` | object | The raw stored override delta for this account ({} when none). | ```json { "effective": { "delivery": { "default_channels": ["in_app", "push", "web_push", "email", "sms"], "channel_priority": {}, "realtime_fallback_delay": { "email_minutes": 2, "sms_minutes": 10, "voice_minutes": 30 }, "escalation_enabled": true, "quiet_hours": { "enabled": false, "start": "22:00", "end": "07:00", "tz": "UTC" }, "coalesce_window_seconds": 30 }, "reliability": { "dedup_window_seconds": 60, "rate_limit_per_recipient_per_minute": 0, "digest_frequency": "instant", "dlq_threshold": 8 }, "history": { "history_ttl_days": 30, "max_rows_per_user": 2000, "receipt_retention_days": 365 }, "security": { "default_confidentiality": "encrypted_reveal", "verification_level": "none", "reveal_freshness_minutes": 0, "recall_window_hours": 24, "reply_enabled": false, "mandatory_seal_categories": [] }, "categories": { "security": { "label": "Security", "description": "Sign-in, password, and account-safety alerts.", "required": true }, "account": { "label": "Account", "description": "Changes to your account, team, or plan.", "required": true }, "transactional": { "label": "Transactional", "description": "Receipts, confirmations, and status updates.", "required": false }, "product": { "label": "Product updates", "description": "New features, tips, and announcements.", "required": false }, "marketing": { "label": "Promotions", "description": "Offers and promotional messages.", "required": false } }, "compliance": { "marketing_suppression": true, "signature_required": {} }, "branding": { "logo_url": null, "primary_color": null, "sender_name": null, "support_email": null, "locale": "en" } }, "overrides": {} } ``` ### Update Config #### `PUT /products/notify/config` Validated partial PATCH of the company config, deep-merged onto existing overrides. Returns the new { effective, overrides }. #### Patchable Sections | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `delivery` | object | Optional | default\_channels, channel\_priority, realtime\_fallback\_delay, escalation\_enabled, quiet\_hours, coalesce\_window\_seconds. | | `reliability` | object | Optional | dedup\_window\_seconds, rate\_limit\_per\_recipient\_per\_minute, dlq\_threshold, digest\_frequency. | | `history` | object | Optional | history\_ttl\_days, max\_rows\_per\_user, receipt\_retention\_days. | | `security` | object | Optional | default\_confidentiality, verification\_level, reveal\_freshness\_minutes, recall\_window\_hours, reply\_enabled, mandatory\_seal\_categories. | | `categories` | object | Optional | Open-keyed map of `{ label, description, required }`. Add your own topics (e.g. weekly\_newsletter). The reserved "security" category is always required. | | `compliance` | object | Optional | marketing\_suppression, signature\_required (per-category). | | `branding` | object | Optional | logo\_url, primary\_color, sender\_name, support\_email, locale. | ```bash curl -X PUT 'https://api.sparkvault.com/v1/products/notify/config' \ -H 'X-API-Key: sv_live_your_api_key' \ -H 'Content-Type: application/json' \ -d '{ "delivery": { "default_channels": ["push", "email"] }, "categories": { "weekly_newsletter": { "label": "Weekly newsletter", "description": "Our weekly roundup.", "required": false } } }' ``` > **Strict Validation** > > The PATCH rejects unknown top-level and nested keys, malformed values, and the owner-gated `channel_credentials` (per-app channel secrets are SSM parameter references, never account-writable), naming the offending field path, **before** any write. Only fields actually present are checked; this is a partial PATCH, not a full replace. ## Categories & Recipient Preferences Two tiers decide delivery. The send resolves an offered channel ladder once; fan-out then narrows it per recipient from their stored preferences. Precedence is strict: Compliance floor \> Recipient preference \> Account default - **Categories** are the single source for what a send is classified as and what a recipient can mute. Each entry is `{ label, description, required }`; `required: true` is the compliance floor and can never be muted. `security` is platform-reserved and always required. - **Recipient preferences** are per person, per site (`{ global_off, muted_categories, channels }`), managed on the recipient's auth.sv per-site page. A mandatory category is delivered regardless of any preference. - **Application** is fail-open: a preferences fault delivers as offered rather than dropping a notification. Preferences apply to recipients addressed by SVID (`ing_*`); a suppressed recipient gets no row, no delivery, and is not metered. ## Security & Billing ### Security Properties - **Sealed by default**: confidential content is sealed per recipient; transports carry only an opaque pointer. - **Single-use grants**: each SparkLink burns on first use, so a leaked pointer cannot be replayed. - **Metadata-only reads**: inbox, status, and receipts never echo plaintext or a recipient's raw contact. - **Tenant isolation**: a cross-tenant send or notification is reported identically to one that never existed. - **Atomic cleanup**: a partial or failed send hard-recalls every grant it minted: no orphaned sealed content. - **Provable interactions**: receipts are portable, JWKS-verifiable EdDSA proofs that survive recall and expiry. ### Billing A notification is metered as **one recipient × one logical notification**: a fan-out to N channels still counts as one, idempotent on the send id. The composed Spark + SparkLink + Identity ceremony is included in the Notify unit (it is not double-charged on the standalone usage ledger). Metering is asynchronous and aggregated, with an advisory soft cap that warns and allows overage rather than hard-dropping mid-broadcast. ## Error Handling Errors return a stable shape. Validation errors name the offending field; tenant-isolation failures are deliberately indistinguishable from a missing resource. #### Error Responses | Status | Code | Description | | --- | --- | --- | | 400 | `VALIDATION_ERROR` | Missing/invalid field (e.g. empty recipients\[\], both content and ingot, an ingot send to >1 recipient, >500 recipients, or an unknown config key). | | 401 | `AUTHENTICATION_ERROR` | Missing or invalid account token (JWT or API key). | | 404 | `NOT_FOUND` | The send or notification does not exist OR belongs to another account (cross-tenant rows are reported as not-found). | | 429 | `RATE_LIMIT_EXCEEDED` | Too many requests. Retry after the indicated window. | ```json { "error": { "code": "VALIDATION_ERROR", "message": "Provide exactly one content source: content OR ingot", "details": null }, "meta": { "api_version": "1.2.828" } } ``` ## Related Notify builds directly on the SparkVault primitives. Explore the layers it composes: [ ### Sparks Sealed, lifecycle-bound content. ](/api/docs/sparks/)[ ### SparkLinks Per-recipient verified-access grants. ](/api/docs/sparklinks/)[ ### Identity The verification ceremony + EdDSA tokens. ](/api/docs/products/identity/) [View Full Pricing](/pricing/) --- # Messaging: SparkVault API Reference > Complete API reference for SparkVault Messaging, delivering durable, two-way encrypted conversations between a business account and verified account-less recipients. Every message is a triple-zero-trust vault ingot and carries a SparkVault-verified, portable receipt. Canonical: https://sparkvault.com/api/docs/products/messaging/ · OpenAPI: https://sparkvault.com/openapi.yaml ## Overview SparkVault Messaging gives your business durable, two-way **encrypted** conversations with people who do not have a SparkVault account. A customer is invited to a thread, verifies themselves once through SparkVault Identity, and can then read and reply from any device. Every message your business sends carries a cryptographic **receipt of record** that a third party can independently verify. Message content lives **only as vault ingots**: one immutable, VAK-sealed Forge ingot per message. It is never stored in DynamoDB and never in raw S3. Reads are **server-mediated**: the server brokers a single-use key, decrypts the requested ingot, and returns plaintext to an authorized participant. Posture S Server-Mediated Reads Zero-Trust Triple-ZT At Rest EdDSA Verifiable Receipts Ingot-Only No Content In DB ### What You Get - **Account-less recipients**: invite anyone by email or phone; they verify through SparkVault Identity and become a stable participant. - **Receipts of record**: every send is attested to the account audit trail and can be exported as a portable, JWKS-verifiable token. - **Compliance controls**: per-conversation retention, recall, legal hold, per-participant opt-out, and an immutable audit log of metadata. - **Attachments**: client-direct upload/download of files up to 100 MB, each sealed as its own conversation-tagged ingot. > **Encrypted At Rest, Not End-to-End** > > Posture S means content is **triple zero-trust at rest** and only ever decrypted server-side for an authorized participant. It is never persisted as plaintext. This is not end-to-end encryption; true E2E is a separate v2 tier. The server brokers every read through a system-held key. ## Data Model A conversation is an index-only manifest plus a set of content-addressable ingots. Understanding the four core objects makes the endpoints below self-explanatory. #### Core Objects | Field | Type | Description | | --- | --- | --- | | `Conversation` | object | A thread owned by an account. Its manifest lives in DynamoDB (META row); all content lives in ingots. Addressed by conversation\_id. | | `Participant` | object | A stable svid: an auth.sv principal (business side) or a derived `mid_` principal for an account-less recipient. The durable roster lives on META. | | `Message` | object | One immutable VAK-sealed Forge ingot per message, content-addressable at `ing_`, the SHA-256 truncated to its first 32 hex chars, shaped like a normal `ing_<32hex>` id. text/plain, up to 64 KB; larger payloads use attachments. | | `Page` | object | Lazy compaction seals each run of 25 messages into one page ingot. Read-triggered, no cron. Reads transparently span individual and page ingots. | | `Attachment` | object | A separate, conversation-tagged Forge ingot (up to 100 MB), uploaded and downloaded client-direct, referenced from a message as attachments:\[{ ingot\_id, name }\]. | ### Manifest, Not Mailbox The manifest (`messaging-conversations`) is an **index only**: an atomic message-id counter, `committed_through` (the highest contiguous durable id, the sole authority for what a reader fetches), `compacted_through`, the participant roster, settings, a 25-entry `recent` window, the `recalled` tombstone list, the `legal_hold` flag, and the `closed` flag. A participant's last-read position is `last_read_id` on their per-user inbox row; there are no per-message read receipts. > **Server-Mediated Reads By Predictable ID** > > Reads resolve by id: an id above `compacted_through` reads the individual message ingot; an id at or below it reads page ingot `ceil(id / 25)`. Compaction (seal → conditional advance → delete originals) is race-safe and rides a read, so there is no background job and a reader never sees a half-written tail. ## Vault & Encryption Each account has **one system-flagged "messaging" vault** that holds every conversation's ingots, not a vault per conversation. Conversations are isolated by their deterministic ingot ids plus a membership check, not by separate keys. - The vault's master key (VMK) is generated once and held only via a **system `messaging` DVAK** (Delegated Vault Access Key). - All messaging crypto is server-brokered through that DVAK (`resolveMessagingVak`) for owner and recipient alike. The raw VAK never leaves the server; Forge receives single-use ISTKs. - This is the mechanism for posture S. Triple zero-trust at rest is unchanged: content is sealed, and reads decrypt only transiently for an authorized participant. ## Authentication Messaging has two access surfaces, each authenticated differently. ### Business Side Your application calls the authenticated Core routes under `/v1/products/messaging/*` using a SparkVault API key or a user JWT. The authenticated principal is the conversation participant. #### Business-Side Credentials | Field | Type | Description | | --- | --- | --- | | `X-API-Key` | header | Server-to-server integrations and automation. Create a key on the [API Keys page](https://app.sparkvault.com/api/keys). | | `Authorization: Bearer` | header | A user JWT for user-facing applications and browser sessions. | ### Account-less Recipient A recipient with no SparkVault account authenticates as an **OIDC relying party to auth.sv** using the PKCE authorization-code flow against the company account's Identity tenant. They never hold an API key. The verified `id_token` is their bearer credential on the public `/m/*` routes. > **Recipient Verification Flow** > > 1. The recipient opens `/m/access?account_id&conversation_id` and is redirected (302) to the auth.sv `/authorize` endpoint (PKCE). > 2. They complete account-less verification at the company IdP via a one-time code (OTP) sent to their invited email or phone, or via SparkLink. > 3. auth.sv returns to `/m/callback?code&state`, where the code is exchanged for an EdDSA `id_token`. > 4. The `id_token` is verified with `verifyIdToken` (the SDK's exact JWKS verification) plus a nonce check, and becomes the recipient's bearer credential. > **Membership Is Re-Checked On Every Operation** > > Authorization is always the `META` membership check, re-run before the key is brokered on every operation, so a removed participant is locked out instantly, regardless of token TTL. Recipient routes present the `id_token` as `Authorization: Bearer` (or `x-messaging-token`). ## Business-Side Endpoints Base URL: `https://api.sparkvault.com/v1/products/messaging` All paths below are authenticated with an API key or user JWT. Owner-only operations are noted; every operation re-checks conversation membership. ### Inbox #### `GET /products/messaging/inbox` The caller's conversations, newest-activity-first. Metadata only, no message previews (content is ingot-only). #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | Optional | Maximum conversations to return (default 25) | | `cursor` | string | Optional | URL-encoded JSON pagination cursor from a previous response | #### Response | Field | Type | Description | | --- | --- | --- | | `items` | object\[\] | Inbox rows: conversation\_id, last\_activity\_ts, last\_message\_id, unread\_count, archived | | `cursor` | object | Pagination cursor for the next page (absent on the last page) | ### Create Conversation #### `POST /products/messaging/conversations` Create a conversation. The creator becomes the owner. The system messaging vault is created on first use. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `participants` | object\[\] | Optional | Initial members, each { svid, identity? }. The owner is added automatically and cannot be re-added. | | `settings` | object | Optional | Conversation settings, e.g. { audit\_retention\_seconds }, one of the platform's shared retention durations (0 = disabled; other values are rejected with 400). Unset uses the platform default (3 months). | #### Response | Field | Type | Description | | --- | --- | --- | | `conversation_id` | string | Identifier for the new conversation | | `vault_id` | string | The account messaging vault holding the conversation ingots | | `participants` | object\[\] | The resolved roster, each { svid, role, added\_at, identity? } | | `created_at` | integer | Creation timestamp (Unix epoch seconds) | ### Get Conversation #### `GET /products/messaging/conversations/:id` The conversation manifest (participant-gated). Returns roster, settings, and read pointers, never content. #### Response | Field | Type | Description | | --- | --- | --- | | `conversation_id` | string | Conversation identifier | | `participants` | object\[\] | Roster (identifiers are redacted on the recipient surface) | | `committed_through` | integer | Highest contiguous durable message id | | `compacted_through` | integer | Highest message id sealed into a page ingot | | `settings` | object | Conversation settings, including audit\_retention\_seconds | | `created_at` | integer | Creation timestamp (Unix epoch seconds) | ### Send Message #### `POST /products/messaging/conversations/:id/messages` Send a message. The content is sealed into a deterministic ingot, the message id advances committed_through, and a receipt of record is written to the audit trail and returned. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `content` | string | Required | Message body. `text/plain`, up to 64 KB (larger content is rejected with 413: use attachments). | | `content_type` | string | Optional | Must be `text/plain`, the only supported content type in v1 (no HTML rendering). Any other value is rejected with 400. Defaults to `text/plain`. | | `attachments` | object\[\] | Optional | Previously-uploaded attachment refs, each { ingot\_id, name }. Must belong to this conversation and be fully uploaded. Maximum 20 per message. | #### Response | Field | Type | Description | | --- | --- | --- | | `message_id` | integer | Monotonic conversation-scoped message id | | `conversation_id` | string | Conversation identifier | | `ts` | integer | Server send timestamp (Unix epoch seconds) | | `ingot_id` | string | Deterministic content-addressable id of the sealed message ingot | | `content_hash` | string | SHA-256 (hex) of the canonical sealed message bytes | | `committed_through` | integer | Highest contiguous durable id after this send | | `status` | string | `committed` once landed; `pending` if a concurrent send is still ahead in the contiguous range | | `receipt` | object | The receipt of record: { account\_id, conversation\_id, message\_id, sender\_svid, content\_hash, ts } | ```bash curl -X POST 'https://api.sparkvault.com/v1/products/messaging/conversations/cnv_019aec85fe5972b3c4d5e6f7a8b9c0d1/messages' \ -H 'Authorization: Bearer YOUR_JWT' \ -H 'Content-Type: application/json' \ -d '{ "content": "Your wire transfer was received. Reference #SV-4471.", "content_type": "text/plain" }' ``` ```json { "message_id": 42, "conversation_id": "cnv_019aec85fe5972b3c4d5e6f7a8b9c0d1", "ts": 1703977200, "ingot_id": "ing_8f1c2d4a6b9e0c5f3a7d1e2b4c6f8a09", "content_hash": "9b3e7c1a5f8d2e0b4a6c8f1d3e5b7a9c2d4f6e8a0b1c3d5e7f9a1b3c5d7e9f0a", "committed_through": 42, "status": "committed", "receipt": { "account_id": "acc_example", "conversation_id": "cnv_019aec85fe5972b3c4d5e6f7a8b9c0d1", "message_id": 42, "sender_svid": "ing_019e66a4e27875f2822ede0e4f5d8792", "content_hash": "9b3e7c1a5f8d2e0b4a6c8f1d3e5b7a9c2d4f6e8a0b1c3d5e7f9a1b3c5d7e9f0a", "ts": 1703977200 } } ``` ### Read Messages #### `GET /products/messaging/conversations/:id/messages` Server-mediated read. With from_id + to_id reads that contiguous range; otherwise returns the newest page. Recalled ids return a tombstone, never content. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `from_id` | integer | Optional | Inclusive start message id (pair with to\_id to read a range) | | `to_id` | integer | Optional | Inclusive end message id (pair with from\_id) | | `limit` | integer | Optional | When reading the newest page, the maximum messages to return (default 25, capped at 50) | A `from_id`..`to_id` range spans at most 50 messages (2 pages). A larger range is rejected with 400 ("Read range too large (max 50 messages); paginate"). Without a range, the newest page returns up to 25 messages. #### Response | Field | Type | Description | | --- | --- | --- | | `messages` | object\[\] | Decrypted message objects ordered by message\_id; recalled ids appear as { message\_id, recalled: true } | | `committed_through` | integer | Highest contiguous durable id at read time | | `compacted_through` | integer | Highest id sealed into a page ingot at read time | ```json { "messages": [ { "schema_version": 1, "message_id": 41, "sender_svid": "mid_7c2a9f4b1d8e3a05f6e4d2c0b8a69753", "sender_identity": { "type": "email", "account_less": true }, "ts": 1703970000, "content_type": "text/plain", "content": "Can you confirm the routing number?", "attachments": [] }, { "message_id": 40, "recalled": true } ], "committed_through": 42, "compacted_through": 25 } ``` > **sender\_identity Is A Structured Descriptor** > > `sender_identity` is always an object, never a raw string. A business message carries `{ user_id, email, type: "auth.sv" }`; an account-less recipient's message carries `{ type, account_less: true }`. A message read never exposes a recipient's raw email or phone: the raw identifier lives only on the owner-visible roster. ### Recall Message #### `POST /products/messaging/conversations/:id/messages/:messageId/recall` Soft-delete a message with a tombstone. The owner can recall any message; a sender can recall their own recent message. Blocked while the conversation is under legal hold. #### Response | Field | Type | Description | | --- | --- | --- | | `conversation_id` | string | Conversation identifier | | `message_id` | integer | The recalled message id | | `recalled` | boolean | Always true on success | > **Recall Is A Tombstone, Not Erasure** > > Recall marks the id in `recalled[]` so reads return `{ message_id, recalled: true }`. The sealed content stays in the ingot. Crypto-shred is the owner-gated deletion posture, not recall. ### Fetch Receipt Token #### `GET /products/messaging/conversations/:id/messages/:messageId/receipt` Mint a portable, JWKS-verifiable SparkVault receipt token for one message. Participant-gated. A recalled message or disabled retention yields 403; a message past its retention window yields 404. #### Response | Field | Type | Description | | --- | --- | --- | | `receipt_token` | string | An EdDSA JWT signed by the tenant's Identity key, verifiable by any third party exactly like an id\_token | | `expires_at` | integer | Token expiry (Unix epoch seconds), tracks the conversation's configured retention horizon | | `receipt` | object | The receipt claims: { account\_id, conversation\_id, message\_id, sender\_svid, content\_hash, message\_ts } | The token is re-derived from the **immutable** message: its `content_hash` is re-hashed from the sealed bytes (never caller-supplied), and that hash is also set as the token's `action_hash`. A verifier holding the message content can recompute the hash and confirm the token attests _this exact message_, the same `action_hash` binding the SparkVault SDK uses (`verifyIdToken` with `expectedActionHash`). The token's `exp` tracks `settings.audit_retention_seconds`, so a receipt verifies for as long as the company retains the record. ```bash curl 'https://api.sparkvault.com/v1/products/messaging/conversations/cnv_019aec85fe5972b3c4d5e6f7a8b9c0d1/messages/42/receipt' \ -H 'Authorization: Bearer YOUR_JWT' ``` ```json { "receipt_token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...", "expires_at": 1711753200, "receipt": { "account_id": "acc_example", "conversation_id": "cnv_019aec85fe5972b3c4d5e6f7a8b9c0d1", "message_id": 42, "sender_svid": "ing_019e66a4e27875f2822ede0e4f5d8792", "content_hash": "9b3e7c1a5f8d2e0b4a6c8f1d3e5b7a9c2d4f6e8a0b1c3d5e7f9a1b3c5d7e9f0a", "message_ts": 1703977200 } } ``` ```json { "iss": "https://auth.sparkvault.com/acc_example", "sub": "ing_019e66a4e27875f2822ede0e4f5d8792", "aud": "acc_example", "jti": "b1f0c2d3-4e5f-6a7b-8c9d-0e1f2a3b4c5d", "token_type": "messaging_receipt", "conversation_id": "cnv_019aec85fe5972b3c4d5e6f7a8b9c0d1", "message_id": 42, "content_hash": "9b3e7c1a5f8d2e0b4a6c8f1d3e5b7a9c2d4f6e8a0b1c3d5e7f9a1b3c5d7e9f0a", "message_ts": 1703977200, "svid": "ing_019e66a4e27875f2822ede0e4f5d8792", "action_hash": "9b3e7c1a5f8d2e0b4a6c8f1d3e5b7a9c2d4f6e8a0b1c3d5e7f9a1b3c5d7e9f0a", "iat": 1703977200, "exp": 1711753200 } ``` > **Verify Like Any id\_token** > > The receipt token is signed with the tenant's Ed25519 key. Verify it with the same Identity JWKS endpoint (`https://auth.sparkvault.com/{account_id}/.well-known/jwks.json`), algorithm `EdDSA`, issuer the tenant Identity URL, audience the account id, and assert `action_hash` equals the SHA-256 of the message content you hold. ### Participants #### `POST /products/messaging/conversations/:id/participants` Add a participant by svid (owner only). #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `svid` | string | Required | The participant's stable SparkVault ID | | `identity` | object | Optional | Optional identity descriptor stored on the roster entry | #### `DELETE /products/messaging/conversations/:id/participants/:svid` Remove a participant (owner only). The removed participant loses access instantly on their next operation, regardless of token TTL. ### Invite Account-less Recipient #### `POST /products/messaging/conversations/:id/invites` Invite an account-less recipient by email or phone (owner only). Derives the recipient principal, adds them to the roster, and returns an access link they verify through auth.sv. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `type` | string | Required | `email` or `phone` | | `value` | string | Required | The recipient's email address or E.164 phone number | #### Response | Field | Type | Description | | --- | --- | --- | | `svid` | string | The derived recipient principal (`mid_`) added to the roster | | `access_url` | string | The /m/access link the recipient opens to verify and join | ### Attachments #### `POST /products/messaging/conversations/:id/attachments` Begin an attachment upload. Returns a Forge URL for a client-direct upload of a conversation-tagged ingot (up to 100 MB). Storage-quota gated: returns 402 when the account's vault storage pool is exhausted. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `filename` | string | Required | Original file name | | `size_bytes` | integer | Required | File size in bytes (over 100 MB is rejected with 413) | | `content_type` | string | Optional | MIME type of the file | #### Response | Field | Type | Description | | --- | --- | --- | | `ingot_id` | string | The attachment ingot id: reference it from a message as { ingot\_id, name } | | `forge_url` | string | Presigned client-direct upload URL | | `expires_at` | integer | Upload URL expiry (Unix epoch seconds) | | `max_bytes` | integer | Maximum attachment size (104857600 = 100 MB) | #### `GET /products/messaging/conversations/:id/attachments/:ingotId/download` Get a client-direct download URL for an attachment ingot. Attachment downloads bill bandwidth (real transfers); message reads do not. #### Response | Field | Type | Description | | --- | --- | --- | | `ingot_id` | string | The attachment ingot id | | `name` | string | Original file name | | `size_bytes` | integer | File size in bytes | | `content_type` | string | MIME type of the file | | `forge_url` | string | Presigned client-direct download URL | | `expires_at` | integer | Download URL expiry (Unix epoch seconds) | ### Read, Archive & Opt-out #### `POST /products/messaging/conversations/:id/read` Mark the conversation read for the caller. Sets last_read_id on the inbox row and resets the unread counter. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `last_read_id` | integer | Required | The highest message id the caller has read | #### `PUT /products/messaging/conversations/:id/archive` Archive or unarchive the conversation in the caller's own inbox. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `archived` | boolean | Required | true to archive, false to restore | #### `PUT /products/messaging/conversations/:id/opt-out` Set the caller's per-conversation opt-out. While opted out, the caller receives no alerts; re-checked on every send. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `opted_out` | boolean | Required | true to suppress alerts for this conversation | ### Legal Hold & Close (Owner) #### `PUT /products/messaging/conversations/:id/legal-hold` Set or clear a per-conversation legal hold (owner only). While held, no message can be recalled. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `held` | boolean | Required | true to place the conversation under legal hold | #### `PUT /products/messaging/conversations/:id/close` Close or reopen the conversation (owner only). A closed conversation rejects new sends with 403. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `closed` | boolean | Required | true to close, false to reopen | ## Account-less Recipient Routes The public `/m/*` routes mirror the business-side surface for a verified recipient. They are gated by the auth.sv `id_token` (presented as `Authorization: Bearer` or `x-messaging-token`), and the `META` membership check is re-run on every operation. Identifiers are redacted so one recipient never sees another's email or phone. #### `GET /m/access?account_id&conversation_id` Begin verification: redirects (302) to the auth.sv /authorize endpoint (PKCE). Unauthenticated; rate-limited to 60 requests per hour per IP. #### `GET /m/callback?code&state` OIDC callback: exchanges the code, verifies the id_token and membership, and hands the id_token to the recipient app. #### `GET /m/inbox` The recipient's conversations (id_token bearer). #### `GET /m/conversations/:accountId/:conversationId` Conversation manifest with identifiers redacted. #### `GET /m/conversations/:accountId/:conversationId/messages` Server-mediated read of the recipient's conversation. #### `POST /m/conversations/:accountId/:conversationId/messages` Reply to the conversation. #### `POST /m/conversations/:accountId/:conversationId/messages/:messageId/recall` Recall the recipient's own recent message. #### `POST /m/conversations/:accountId/:conversationId/read` Mark the conversation read for the recipient. #### `POST /m/conversations/:accountId/:conversationId/attachments` Begin an attachment upload. #### `GET /m/conversations/:accountId/:conversationId/attachments/:ingotId/download` Get an attachment download URL. #### `PUT /m/conversations/:accountId/:conversationId/archive` Archive the conversation in the recipient's inbox. #### `PUT /m/conversations/:accountId/:conversationId/opt-out` Set the recipient's opt-out for the conversation. ## Governance & Compliance Messaging is built for regulated, auditable business-to-customer communication. Every governance control is per-conversation and re-checked on the relevant operation. #### Controls | Field | Type | Description | | --- | --- | --- | | `Receipt of record` | audit | Every send records a SparkVault attestation { conversation, message\_id, sender, content\_hash, ts } to the account audit trail and returns it on the send response. Issuing a portable token logs MESSAGING\_RECEIPT\_ISSUED. | | `Retention` | setting | settings.audit\_retention\_seconds governs both the TTL on messaging audit rows and how long a receipt token stays valid. Must be one of the platform's shared retention durations: 24 hours, 7 days, 1 / 2 / 3 / 6 / 9 months, 1 / 2 / 3 years, or 0 = disabled; any other value is rejected with 400. Default 7776000 (3 months). | | `Recall` | action | Soft-delete tombstone (owner any; sender their own recent). Reads return { message\_id, recalled: true }; content stays sealed. | | `Legal hold` | action | Owner sets or clears a per-conversation hold; while held, messages cannot be recalled. | | `Consent` | setting | Per-participant per-conversation opt-out suppresses alerts and is re-checked on send. | | `Audit` | log | MESSAGING\_\* account-scope events: create, participant add/remove, recipient verified, message sent, recall, opt-out, legal hold. Never message content. | | `Close` | action | Owner closes or reopens a conversation; a closed conversation rejects new sends. | ## Alerts Alerts notify the other, non-opted-out participants of new activity. They carry metadata only, never content. - **Real-time nudge**: a best-effort WebSocket notification (reusing the Notify connection registry) when a message lands, conversation and message id only; the client fetches content through the read path. - **Invite email**: a single transactional email delivering the access link when a recipient is invited by email (best-effort). Phone invites return the `access_url` for your application to deliver. ## Billing Messaging bills two dimensions, both automatic. There is no per-message or per-transaction billing. #### Billed Dimensions | Field | Type | Description | | --- | --- | --- | | `Stored bytes` | storage | Messages, pages, and attachments accrue to vault storage automatically via the ingot lifecycle. | | `Bandwidth` | transfer | Attachment downloads bill bandwidth (real transfers). Server-mediated message reads do not bill bandwidth. | [View Full Pricing](/pricing/) ## Error Responses Messaging uses the standard SparkVault error envelope: `{ error: { code, message, details } }`. Common failures: #### Error Responses | Status | Code | Description | | --- | --- | --- | | 400 | `VALIDATION_ERROR` | Missing or invalid body, e.g. empty content, a content\_type other than text/plain, a malformed attachments array (more than 20 refs, or a ref not fully uploaded), an invalid audit\_retention\_seconds, or a from\_id..to\_id read range over 50 messages. | | 401 | `AUTHENTICATION_ERROR` | Missing or invalid credential (API key, user JWT, or recipient id\_token). | | 402 | `PAYMENT_REQUIRED` | Storage capacity is exhausted; cannot upload attachments: attachment uploads are gated on the account's vault storage pool. | | 403 | `FORBIDDEN` | Not a participant, not the owner for an owner-only op, a recall blocked by legal hold, a send to a closed conversation, or no receipt available (message recalled, or audit retention disabled). | | 404 | `NOT_FOUND` | Unknown conversation, message, or attachment id, or a receipt requested for a message past its retention window. | | 413 | `PAYLOAD_TOO_LARGE` | Message content over 64 KB, or an attachment over the 100 MB maximum. | | 429 | `RATE_LIMIT_EXCEEDED` | The per-conversation send ceiling (1,000 messages/hour), or the per-IP limit (60/hour) on the unauthenticated GET /m/access endpoint. | ## Get Started Messaging shares SparkVault's API authentication and Identity verification. To integrate: 1. Create an API key on the [API Keys page](https://app.sparkvault.com/api/keys). 2. Create a conversation, then invite a recipient by email or phone. 3. Send messages and fetch receipts as shown above. 4. For verifiable proof, export a receipt token and validate it against the Identity JWKS. > **Verifying Identity** > > Receipt tokens verify exactly like an OIDC ID token. See the [Identity API reference](/api/docs/products/identity/) for the JWKS verification examples in Node.js, Python, Go, Ruby, PHP, and C#. --- # Structured Ingots API: SparkVault API Reference > Key/value encrypted storage with atomic operations. Store up to 10,000 keys per ingot with server-side encryption and values returned as ephemeral Sparks. Canonical: https://sparkvault.com/api/docs/products/structured-ingots/ · OpenAPI: https://sparkvault.com/openapi.yaml ## Overview Structured Ingots provide encrypted key/value storage within your [Vaults](/api/docs/vaults/). Unlike standard ingots (files), structured ingots store named fields that can be individually retrieved without downloading the entire ingot. 10K Keys per Ingot 1MB Max Value Size AES-256 Encryption Atomic Set/Delete Ops ### Use Cases - **PII Storage**: SSN, phone numbers, addresses per customer - **Credentials**: API keys, passwords, secrets with field-level access - **User Preferences**: Encrypted user settings with individual key access - **Tokenized Data**: Store sensitive data, retrieve as ephemeral Sparks > **Values Returned as Sparks** > > For security, values are never returned inline. When you request keys, you receive Spark IDs that can be read once via the [Sparks API](/api/docs/sparks/). ## Authentication All Structured Ingots endpoints require account authentication (a JWT Bearer token _or_ an API key) plus a Vault Access Token (VAT) obtained by unsealing the vault. #### Headers | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `Authorization` | string | Optional | JWT Bearer token (`Authorization: Bearer `). Provide this or `X-API-Key`. | | `X-API-Key` | string | Optional | Your SparkVault API key. Provide this or `Authorization`. | | `X-Vault-Access-Token` | string | Required | VAT from unsealing the vault. Missing header → `400 VALIDATION_ERROR`; unknown or malformed VAT → `401 AUTHENTICATION_ERROR`; expired VAT → `403 FORBIDDEN`. | ## API Endpoints All JSON responses use the `{"data": ...}` envelope. Successful requests return `201 Created` for POST, `200 OK` for GET and PATCH, and `204 No Content` for DELETE. ### Create Structured Ingot #### `POST /v1/products/structured-ingots/{vault_id}` Create a new structured ingot with initial key/value data. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | Required | Name for the ingot (max 255 characters). A create with a name that already exists in the vault overwrites the existing structured ingot in place: it resets `version` to 1 and replaces every stored key, so the prior contents and keys are destroyed. Two structured ingots in one vault cannot share a name; use a fresh name (or PATCH the existing ingot) unless you intend to replace it. | | `data` | object | Required | Key/value pairs to store (max 10,000 keys) | #### Response | Field | Type | Description | | --- | --- | --- | | `ingot_id` | string | Unique ingot identifier (ing\_...) | | `name` | string | Ingot name | | `version` | integer | Version number (starts at 1) | | `key_count` | integer | Number of keys stored | | `created_at` | integer | Creation timestamp | #### Example Request ```bash curl -X POST https://api.sparkvault.com/v1/products/structured-ingots/vlt_abc123 \ -H "X-API-Key: sv_live_xxx" \ -H "X-Vault-Access-Token: YOUR_VAT" \ -H "Content-Type: application/json" \ -d '{ "name": "customer_12345", "data": { "ssn": "123-45-6789", "phone": "+1-555-123-4567", "dob": "1990-01-15" } }' ``` Response ```json { "data": { "ingot_id": "ing_xyz789...", "name": "customer_12345", "version": 1, "key_count": 3, "created_at": 1702000000 } } ``` ### List Structured Ingots #### `GET /v1/products/structured-ingots/{vault_id}` List structured ingots in a vault as a single, non-paginated page. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | Optional | Maximum number of ingots to fetch before filtering (1-500). This bounds a single fetch across _all_ ingot types in the vault; only structured ingots are then returned, so `count` can be smaller than the true number of structured ingots (even zero) when the vault also holds many file ingots. There is no pagination cursor, so raise the limit if you need more coverage.Default: `100` | #### Response | Field | Type | Description | | --- | --- | --- | | `ingots` | array | Array of structured ingot summaries | | `ingots[].ingot_id` | string | Ingot identifier | | `ingots[].name` | string | Ingot name | | `ingots[].version` | integer|null | Always null in list responses. Versions are only decoded on single-ingot GET | | `ingots[].key_count` | integer|null | Always null in list responses. Key counts are only decoded on single-ingot GET | | `ingots[].created_at` | integer | Creation timestamp | | `ingots[].updated_at` | integer|null | Last update timestamp | | `count` | integer | Number of ingots returned | ### Get Ingot Metadata #### `GET /v1/products/structured-ingots/{vault_id}/{ingot_id}` Get metadata and list of keys (without values). #### Response | Field | Type | Description | | --- | --- | --- | | `ingot_id` | string | Ingot identifier | | `name` | string | Ingot name | | `version` | integer | Current version | | `key_count` | integer | Number of keys | | `keys` | array | List of all key names | | `created_at` | integer | Creation timestamp | | `updated_at` | integer | Last update timestamp | #### Example Request ```bash curl https://api.sparkvault.com/v1/products/structured-ingots/vlt_abc123/ing_xyz789 \ -H "X-API-Key: sv_live_xxx" \ -H "X-Vault-Access-Token: YOUR_VAT" ``` Response ```json { "data": { "ingot_id": "ing_xyz789...", "name": "customer_12345", "version": 3, "key_count": 5, "keys": ["ssn", "phone", "dob", "email", "address"], "created_at": 1702000000, "updated_at": 1702100000 } } ``` ### Get Values as Sparks #### `GET /v1/products/structured-ingots/{vault_id}/{ingot_id}?keys=k1,k2&ttl=900` Retrieve specific values. Values are returned as Spark IDs for secure, auditable access. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `keys` | string | Required | Comma-separated list of keys to retrieve (max 100 keys per request) | | `ttl` | integer | Optional | Spark TTL in seconds (min: 60, max: 86400)Default: `900` | #### Response | Field | Type | Description | | --- | --- | --- | | `ingot_id` | string | Ingot identifier | | `values` | object | Map of key to Spark info | | `values[key].spark_id` | string | Spark ID containing the value | | `values[key].expires_at` | integer | When the Spark expires | | `missing_keys` | array | Keys that were requested but not found | #### Example Request ```bash curl "https://api.sparkvault.com/v1/products/structured-ingots/vlt_abc123/ing_xyz789?keys=ssn,phone&ttl=300" \ -H "X-API-Key: sv_live_xxx" \ -H "X-Vault-Access-Token: YOUR_VAT" ``` Response ```json { "data": { "ingot_id": "ing_xyz789...", "values": { "ssn": { "spark_id": "spk_abc123...", "expires_at": 1702000300 }, "phone": { "spark_id": "spk_def456...", "expires_at": 1702000300 } }, "missing_keys": [] } } ``` > **Reading Spark Values** > > Use the returned Spark IDs with the [Sparks API](/api/docs/sparks/) to read the actual values: > > ``` > GET /v1/sparks/spk_abc123 > ``` ### Update Keys (PATCH) #### `PATCH /v1/products/structured-ingots/{vault_id}/{ingot_id}` Atomically set or delete keys. Operations are applied server-side. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `set` | object | Optional | Keys to add or update (key: value pairs) | | `delete` | array | Optional | Key names to delete | > **At Least One Operation, Optimistic Concurrency** > > A PATCH with neither `set` nor `delete` returns `400 VALIDATION_ERROR` ("At least one set or delete operation required"). Concurrent PATCHes to the same ingot are serialized via optimistic locking: the losing request receives `412 PRECONDITION_FAILED`. Re-read the ingot and retry. #### Response | Field | Type | Description | | --- | --- | --- | | `ingot_id` | string | Ingot identifier | | `version` | integer | New version number | | `key_count` | integer | Updated key count | | `updated_at` | integer | Update timestamp | #### Example Request ```bash curl -X PATCH https://api.sparkvault.com/v1/products/structured-ingots/vlt_abc123/ing_xyz789 \ -H "X-API-Key: sv_live_xxx" \ -H "X-Vault-Access-Token: YOUR_VAT" \ -H "Content-Type: application/json" \ -d '{ "set": { "email": "updated@example.com", "address": "123 New Street" }, "delete": ["old_field"] }' ``` Response ```json { "data": { "ingot_id": "ing_xyz789...", "version": 4, "key_count": 6, "updated_at": 1702100500 } } ``` ### Delete Ingot #### `DELETE /v1/products/structured-ingots/{vault_id}/{ingot_id}` Permanently delete a structured ingot and all its data. #### Response | Field | Type | Description | | --- | --- | --- | | `204` | status | No Content - ingot deleted successfully | ## Name Addressing Structured ingots can be addressed by name using a colon prefix instead of the ingot ID. This allows deterministic addressing based on your own identifiers. | Path | Addressing | | --- | --- | | `/structured-ingots/vlt_abc/ing_xyz789` | By ingot ID | | `/structured-ingots/vlt_abc/:customer_12345` | By name (colon prefix) | > **Idempotent Lookups** > > Name addressing allows you to use your own identifiers (customer IDs, user IDs) without needing to store SparkVault ingot IDs in your database. Because a create reuses an existing same-named ingot, two structured ingots in a vault never share a name, so `:name` addressing is unambiguous for them. A `409 CONFLICT` arises only when a same-named ingot also exists in a different folder (for example a non-structured file ingot), in which case use the ingot ID instead. ## Complete Example ```javascript // Create a structured ingot for a customer const createResponse = await fetch( `https://api.sparkvault.com/v1/products/structured-ingots/${vaultId}`, { method: 'POST', headers: { 'X-API-Key': apiKey, 'X-Vault-Access-Token': vat, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'customer_12345', data: { ssn: '123-45-6789', phone: '+1-555-123-4567', dob: '1990-01-15' } }) } ); const ingot = (await createResponse.json()).data; console.log('Created:', ingot.ingot_id); // Later: Retrieve SSN as a Spark (by name) const readResponse = await fetch( `https://api.sparkvault.com/v1/products/structured-ingots/${vaultId}/:customer_12345?keys=ssn`, { headers: { 'X-API-Key': apiKey, 'X-Vault-Access-Token': vat } } ); const { values } = (await readResponse.json()).data; const ssnSparkId = values.ssn.spark_id; // Read the Spark to get the actual SSN value const sparkResponse = await fetch( `https://api.sparkvault.com/v1/sparks/${ssnSparkId}`, { headers: { 'X-API-Key': apiKey } } ); const ssn = (await sparkResponse.json()).data.payload; console.log('SSN:', ssn); // Update with new fields await fetch( `https://api.sparkvault.com/v1/products/structured-ingots/${vaultId}/:customer_12345`, { method: 'PATCH', headers: { 'X-API-Key': apiKey, 'X-Vault-Access-Token': vat, 'Content-Type': 'application/json' }, body: JSON.stringify({ set: { email: 'new@example.com' }, delete: ['old_field'] }) } ); ``` ## Constraints | Limit | Value | | --- | --- | | Max keys per ingot | 10,000 | | Max key name length | 256 characters | | Max value size (storage) | 1 MB | | Max wrapped value size (Spark retrieval) | 256,000 bytes | | Max keys per retrieval request | 100 | | Max ingot name length | 255 characters | > **Retrieval Size Cap** > > The 1 MB limit applies to _storing_ a value. Retrieval returns values wrapped in Sparks, whose payloads are capped at 256,000 bytes. A value whose base64/JSON-wrapped size exceeds that cap stores fine but fails retrieval with `400 VALIDATION_ERROR`. Keep individual values under ~256 KB wrapped if you need to retrieve them via the API. ## Error Reference #### Error Responses | Status | Code | Description | | --- | --- | --- | | 400 | `VALIDATION_ERROR` | Invalid request parameters, missing X-Vault-Access-Token header, no set/delete operation on PATCH, or the operation would exceed the 10,000-key limit | | 401 | `AUTHENTICATION_ERROR` | Missing or invalid credentials, or the Vault Access Token is invalid | | 403 | `FORBIDDEN` | Expired VAT, VAT issued for a different vault or account, or the ingot is not managed by the Structured Ingots product | | 404 | `NOT_FOUND` | Vault or ingot not found | | 409 | `CONFLICT` | Ambiguous :name reference: a same-named ingot also exists in another folder (for example a non-structured file ingot). Use the ingot ID instead | | 412 | `PRECONDITION_FAILED` | Ingot changed while preparing the update. Re-read and retry the PATCH | | 429 | `RATE_LIMIT_EXCEEDED` | Rate limit exceeded (300 operations per minute per account) | --- # Integrations: SparkVault API Reference > Installable, OAuth-connected, per-account integrations that connect SparkVault to external systems: Slack, HubSpot, and Salesforce. Canonical: https://sparkvault.com/api/docs/integrations/ · OpenAPI: https://sparkvault.com/openapi.yaml ## Overview Integrations are installable, OAuth-connected, per-account additions that connect SparkVault to external systems. They are distinct from global [Products](/api/docs/products/), which are always available and never require installation, and from platform Elements (Entropy, Sparks, SparkLinks, Vaults, Ingots), which are the primitives everything else composes over. [ ### Slack Send self-destructing secrets directly in Slack with the `/secret` command. Post-quantum encrypted, burn-after-read. ](/api/docs/integrations/slack/)[ ### HubSpot Encrypted file storage on HubSpot CRM records: a SparkVault Files tab on every Contact, Company, Deal, and Ticket. ](/api/docs/integrations/hubspot/)[ ### Salesforce Encrypted file storage on Salesforce CRM records: a SparkVault Files panel on every Account, Contact, Opportunity, and Case. ](/api/docs/integrations/salesforce/) > **OAuth required** > > All three integrations connect to an external service and require an OAuth authorization on that service before they are active. Install them from [app.sparkvault.com/integrations](https://app.sparkvault.com/integrations), or see the [OAuth Install Flow](#oauth-install-flow) below. ## Why Use Integrations? #### Zero Cryptographic Expertise Required No key management or protocol work. Slack secrets are burn-after-read Sparks sealed with post-quantum encryption (ML-KEM-1024). HubSpot and Salesforce files are protected with Triple Zero-Trust encryption via [Forge](/api/docs/forge/). Install, connect, and use. #### Tenant and Record Isolation by Design The CRM integrations serve many portal/org users against one shared installation without ever sharing access: vault unseals mint per-user VAT sessions keyed to the verified CRM user, and every download or delete is verified against the requesting record's folder, enforcing per-record isolation within a tenant. #### Predictable Pricing Slack uses standard Spark pricing: $0.001 per secret sent, free to read. HubSpot and Salesforce add no per-operation fees; standard vault storage and transfer pricing applies. #### Fully Audited Every integration operation is logged with timestamp, account, parameters, and result status. Retrieve integration-scoped audit logs with `GET /v1/audit-logs/apps/:app_slug`; Slack additionally exposes usage analytics at `GET /v1/apps/slack/analytics`. ## Authentication Integration endpoints authenticate on three planes: | Plane | Endpoints | Authentication | | --- | --- | --- | | **Management** | List, get, install, update, and uninstall (`/v1/apps`, `/v1/apps/:app_id`) | Standard SparkVault auth: JWT session or `X-API-Key` header. Install, update, and uninstall require an **admin or owner** role. | | **Provider webhooks** | Each integration's webhook routes | Signature-verified per provider: Slack `X-Slack-Signature`, HubSpot signature v3, Salesforce session bearer. | | **CRM proxy** | HubSpot/Salesforce `resolve`, `files`, `unseal`, `download`, `delete-file` (Salesforce also `upload-context`) | Provider context: HubSpot-signed requests resolved by `portal_id` lookup; Salesforce session bearer verified against the installed org. | Installed integrations use **pass-through authentication**: your JWT session or API key flows from user to integration to core API. There is no integration-specific credential binding to manage. > **API Keys** > > Create and manage API keys from the [API Keys](https://app.sparkvault.com/api/keys) page. Use separate keys for different environments and integrations. > **Looking for Secure Entropy?** > > Entropy is a platform Element: FIPS 140-3 validated, HSM-backed randomness available to every account without installation. See the [Entropy API documentation](/api/docs/entropy/). ## Response Format All integration endpoints follow the standard SparkVault response format: ```json { "data": { // Integration-specific response fields }, "meta": { "api_version": "1.2.828", "request_id": "req_a1b2c3d4e5f6", "response_ms": 42, "timestamp": 1783036800 } } ``` Authenticated success responses also carry `meta.quota`: your account's current rate-limit status (see [Rate Limits](#rate-limits)). ```json { "error": { "code": "VALIDATION_ERROR", "message": "Invalid app_id" }, "meta": { "api_version": "1.2.828", "request_id": "req_a1b2c3d4e5f6" } } ``` ## Install Lifecycle API Integrations are installed per account and managed through the `/v1/apps` endpoints. All five require standard SparkVault authentication (JWT or API key); install, update, and uninstall additionally require an **admin or owner** role. #### `GET /v1/apps` List the account's installed integrations plus the ids of every integration available in the catalog. #### Response Fields | Field | Type | Description | | --- | --- | --- | | `installed` | array | Installed integrations, each with `app_id`, `status`, `installed_at` (Unix epoch seconds), and `settings`. | | `available` | array | Ids of all integrations in the catalog: `slack`, `hubspot`, `salesforce`. | #### List integrations Request ```bash curl https://api.sparkvault.com/v1/apps \ -H "X-API-Key: sv_live_abc123xyz789..." ``` Response ```json { "data": { "installed": [ { "app_id": "slack", "status": "active", "installed_at": 1783036800, "settings": {} } ], "available": ["slack", "hubspot", "salesforce"] } } ``` #### `GET /v1/apps/:app_id` Get installation status and catalog details for a single integration. An uninstalled integration returns installed: false with status not_installed, not a 404. #### Response Fields | Field | Type | Description | | --- | --- | --- | | `app_id` | string | Integration id (`slack`, `hubspot`, or `salesforce`). | | `name` | string | Display name from the catalog. | | `description` | string | Catalog description. | | `category` | string | Always `integrations`. | | `features` | array | Catalog feature list. | | `pricing` | object | Pricing summary: per-operation entries (`operations`) and a human-readable `description`. | | `oauth_required` | boolean | Whether the integration requires an OAuth authorization on the external service. `true` for all three. | | `installed` | boolean | Whether the integration is installed for this account. | | `status` | string | `active` when installed; `not_installed` otherwise. | | `settings` | object | The installation's settings. Present when installed. | | `installed_at` | integer | Install time (Unix epoch seconds). Present when installed. | | `account` | object | Account branding for customer-facing surfaces: `organization_name`, `logo_url_light`, `logo_url_dark`. | #### `POST /v1/apps/:app_id` Install an integration for the account. Requires an admin or owner role. Returns 201 with the installation merged over the catalog entry. Reinstalling a previously uninstalled integration reactivates it. #### Body Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `settings` | object | Optional | Initial integration settings.Default: `{}` | | `remote_id` | string | Optional | External workspace binding (e.g. a Slack `team_id`). A remote workspace can be bound to only one active account. Installing with an already-claimed id is rejected. | > **OAuth-connected integrations install via their callback** > > All three catalog integrations set `oauth_required`. In practice they are installed through the [OAuth Install Flow](#oauth-install-flow), which validates the provider grant and then creates the installation. A bare install call does not connect the external service. #### `PUT /v1/apps/:app_id` Update an installed integration's settings. Requires an admin or owner role. Settings are merged into the installation's existing settings object. #### Body Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `settings` | object | Required | Settings to merge into the installation's existing settings. | | `remote_id` | string | Optional | Change the external workspace binding. Subject to the same one-active-account rule as install. | #### `DELETE /v1/apps/:app_id` Uninstall an integration. Requires an admin or owner role. Returns 204 No Content. The installation is soft-deleted and can be reactivated by reinstalling. Blocked while another installed integration depends on it. #### Error Responses | Status | Code | Description | | --- | --- | --- | | 400 | `VALIDATION_ERROR` | Invalid `app_id`, missing `settings` on update, or an unmet install dependency | | 403 | `FORBIDDEN` | Caller is not an admin or owner (install, update, uninstall) | | 404 | `NOT_FOUND` | Unknown integration id, or updating/uninstalling an integration that is not installed | | 409 | `CONFLICT` | Integration is already installed | ## OAuth Install Flow Every integration requires an OAuth authorization on the external service. The flow is the same for all three: 1. **Authorize on the provider**: Start the install from [app.sparkvault.com/integrations](https://app.sparkvault.com/integrations). You are redirected to the provider's consent screen. 2. **Exchange the code**: After the provider redirects back, the frontend posts the authorization code to the integration's OAuth callback under your SparkVault session (JWT): `POST /v1/apps/{slack|hubspot|salesforce}/oauth/callback` with `{ code, redirect_uri }`. The CRM integrations additionally send the selected `vault_id` and the installing user's `sv_refresh_token`; see each integration's page for the full callback body. 3. **Server-side validation**: The backend exchanges the code with the provider and validates the grant. Slack requires the `commands` and `chat:write` bot scopes and is workspace-scoped. Org-wide Enterprise Grid installs are rejected. HubSpot and Salesforce verify the selected vault's ownership server-side with the caller's own token, so an installation can never bind to someone else's vault. 4. **Installation created**: On success the installation is created. Slack and Salesforce re-authorize an existing active install in place with a fresh token set (the recovery path for a revoked or expired connection); HubSpot requires uninstalling before reinstalling. #### Slack OAuth callback Request ```bash curl -X POST https://api.sparkvault.com/v1/apps/slack/oauth/callback \ -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \ -H "Content-Type: application/json" \ -d '{ "code": "slack_oauth_code", "redirect_uri": "https://app.sparkvault.com/apps/slack/oauth/callback" }' ``` Response ```json { "data": { "installed": true, "team_id": "T0123456789", "team_name": "Acme Corp", "account_id": "acc_xyz789..." } } ``` #### Uninstalling Each integration uninstalls two ways: user-initiated via `DELETE /v1/apps/{app_id}/uninstall` (JWT or API key), or automatically via provider webhook: Slack `app_uninstalled`/`tokens_revoked`, HubSpot `app.deauthorize`, Salesforce `app.uninstall`. ## Available Integrations ### Slack Send self-destructing secrets directly in Slack with the `/secret` command. - `/secret` slash command - Send to users, groups, or channels - Post-quantum encryption (ML-KEM-1024) - Burn-after-read guarantee - Configurable expiration (1 minute to 24 hours) - Usage analytics by Slack user **Pricing**: Standard Spark pricing ($0.001 per secret sent, free to read). | Method | Path | Purpose | Auth | | --- | --- | --- | --- | | POST | `/v1/apps/slack/webhooks/commands` | Slash command handler | Slack signature | | POST | `/v1/apps/slack/webhooks/interactions` | Button/modal interactions | Slack signature | | POST | `/v1/apps/slack/webhooks/events` | Slack Events API | Slack signature | | POST | `/v1/apps/slack/oauth/callback` | OAuth install callback | JWT or API key | | DELETE | `/v1/apps/slack/uninstall` | User-initiated uninstall | JWT or API key | | GET | `/v1/apps/slack/analytics` | Usage analytics | JWT or API key | [View full Slack documentation →](/api/docs/integrations/slack/) ### HubSpot Encrypted file storage on HubSpot CRM records. Access vault files directly from Contact, Company, Deal, and Ticket tabs. - SparkVault Files tab on CRM records - Folder-per-record organization (Contacts, Companies, Deals, Tickets) - Triple Zero-Trust encryption via Forge - Vault unseal via VMK, DVAK, or SparkSync mobile - Upload, download, and delete files from HubSpot **Pricing**: Standard vault storage and transfer pricing applies. No additional per-operation fees. | Method | Path | Purpose | Auth | | --- | --- | --- | --- | | POST | `/v1/apps/hubspot/resolve` | Map CRM record to vault + folder | HubSpot v3 signature + portal\_id lookup | | POST | `/v1/apps/hubspot/files` | List files in a CRM record's folder | HubSpot v3 signature + portal\_id lookup | | POST | `/v1/apps/hubspot/unseal` | Unseal vault with VMK/DVAK | HubSpot v3 signature + portal\_id lookup | | POST | `/v1/apps/hubspot/download` | Get Forge URL for file download | HubSpot v3 signature + portal\_id lookup | | POST | `/v1/apps/hubspot/delete-file` | Delete a file from the vault | HubSpot v3 signature + portal\_id lookup | | POST | `/v1/apps/hubspot/oauth/callback` | OAuth install + server-verified vault selection | JWT or API key | | DELETE | `/v1/apps/hubspot/uninstall` | User-initiated uninstall | JWT or API key | | POST | `/v1/apps/hubspot/webhooks` | HubSpot webhook events | HubSpot signature v3 | [View full HubSpot documentation →](/api/docs/integrations/hubspot/) ### Salesforce Encrypted file storage on Salesforce CRM records. Access vault files directly from Account, Contact, Opportunity, and Case pages. - SparkVault Files panel on CRM records - Folder-per-record organization (Accounts, Contacts, Opportunities, Cases) - Triple Zero-Trust encryption via Forge - Vault unseal via VMK, DVAK, or SparkSync mobile - Upload, download, and delete files from Salesforce **Pricing**: Standard vault storage and transfer pricing applies. No additional per-operation fees. | Method | Path | Purpose | Auth | | --- | --- | --- | --- | | POST | `/v1/apps/salesforce/resolve` | Map CRM record to vault + folder | Salesforce session bearer | | POST | `/v1/apps/salesforce/files` | List files in a CRM record's folder | Salesforce session bearer | | POST | `/v1/apps/salesforce/upload-context` | Get upload context (vault + folder + VAT) | Salesforce session bearer | | POST | `/v1/apps/salesforce/unseal` | Unseal vault with VMK/DVAK | Salesforce session bearer | | POST | `/v1/apps/salesforce/download` | Get Forge URL for file download | Salesforce session bearer | | POST | `/v1/apps/salesforce/delete-file` | Delete a file from the vault | Salesforce session bearer | | POST | `/v1/apps/salesforce/oauth/callback` | OAuth install + server-verified vault selection | JWT or API key | | DELETE | `/v1/apps/salesforce/uninstall` | User-initiated uninstall | JWT or API key | | POST | `/v1/apps/salesforce/webhooks` | Salesforce webhook events | Salesforce session bearer | [View full Salesforce documentation →](/api/docs/integrations/salesforce/) ## Common Error Codes #### Error Responses | Status | Code | Description | | --- | --- | --- | | 400 | `VALIDATION_ERROR` | Invalid or missing required parameters | | 401 | `AUTHENTICATION_ERROR` | Missing or invalid API key | | 402 | `PLAN_REQUIRED` | An active subscription is required for this operation | | 429 | `RATE_LIMIT_EXCEEDED` | Too many requests. Retry after the specified time. | | 500 | `INTERNAL_ERROR` | Server error. Contact support if persistent. | ## Rate Limits There are no per-integration rate limits. Rate limiting is **account-wide**: 300 operations per minute in a fixed 60-second window, applied to every authenticated API call. The limiter fails closed: if the rate-limit check itself cannot complete, the request is rejected rather than let through. Authenticated success responses report your current status in `meta.quota`: ```json { "quota": { "limit": 300, "used": 15, "remaining": 285, "resets_at": 1783036860 } } ``` Exceeding the limit returns `429 RATE_LIMIT_EXCEEDED` with a `Retry-After` header and `details` carrying `limit`, `used`, and `resets_at`. > **Need Higher Limits?** > > Enterprise customers can request increased rate limits. Contact your account representative or reach out to support@sparkvault.com to discuss your requirements. ## Best Practices - **Use scoped API keys**: Create separate keys per application and rotate them regularly. - **Handle errors gracefully**: Implement proper error handling and retry logic with exponential backoff for transient failures. - **Monitor usage**: Retrieve integration-scoped audit logs with `GET /v1/audit-logs/apps/:app_slug` and Slack usage analytics with `GET /v1/apps/slack/analytics`. Account-wide usage and costs live in the [Reporting API](/api/docs/reporting/). - **Cache when appropriate**: Some responses can be cached briefly to reduce API calls, but never cache sensitive data or security-critical values. - **Validate inputs client-side**: Reduce API calls by validating parameters before sending requests. --- # Slack Secure Send: SparkVault API Reference > Send self-destructing secrets directly in Slack with the /secret command. Powered by SparkVault's post-quantum encryption and burn-after-read guarantee. Canonical: https://sparkvault.com/api/docs/integrations/slack/ · OpenAPI: https://sparkvault.com/openapi.yaml ## Overview Slack Secure Send integrates SparkVault's Spark system directly into Slack. Users can send encrypted, self-destructing secrets to teammates using the `/secret` slash command. Each secret is encrypted with post-quantum cryptography and automatically destroyed after being read. ML-KEM-1024 Post-Quantum Encryption Burn After Read 24h Max Expiration Window Analytics Per-User Tracking ### Key Features - **/secret command**: Send secrets from any Slack channel or DM. `/secret @user` or `/secret #channel` pre-selects the recipient - **Send to a person or channel**: One conversation per secret: a user DM, public channel, private channel, or group DM - **Post-quantum encryption (ML-KEM-1024)**: Future-proof cryptographic security - **Burn-after-read guarantee**: Secrets are destroyed immediately after viewing - **Configurable expiration**: Set TTL from 1 minute to 24 hours - **Usage analytics**: Track secrets sent by user, team, and time period > **Security Model** > > Secrets sent via Slack Secure Send use the same Spark infrastructure as the web app. The secret content is never stored in Slack. Only the spark ID travels through Slack, carried by the recipient's notification message. Even SparkVault cannot read the encrypted content without the burn-after-read ceremony completing. ## How It Works #### User Flow 1. User types `/secret` in any Slack channel or DM 2. A modal appears to enter the secret and configure options 3. User selects the recipient conversation and expiration time 4. SparkVault creates an encrypted Spark and posts a notification with a **View Secret** button to the recipient; only the spark ID travels through Slack 5. The recipient clicks **View Secret** and confirms in a **View & Destroy** dialog; the secret is displayed in a Slack modal and burns on read 6. The original notification is replaced with a **Secret Burned** notice. The secret is permanently destroyed ### Command Syntax The `/secret` command accepts an optional recipient argument that pre-selects the conversation in the send modal: | Command | Behavior | | --- | --- | | `/secret` | Opens the send modal with the current conversation pre-selected | | `/secret @user` | Pre-selects a direct message to that user | | `/secret #channel` | Pre-selects that channel | ### Modal Options #### Secret Configuration | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `Secret` | text | Required | The sensitive content to send (maximum 250 KB) | | `Send to` | select | Required | A single conversation: a user DM, public channel, private channel, or group DM. Private channels and group DMs require the SparkVault app to already be a member; public channels are joined automatically at delivery. | | `Expires in` | select | Optional | 1 min, 5 min, 15 min, 30 min, 1 hour, 6 hours, 12 hours, or 24 hoursDefault: `1 hour` | > **In-Slack Display Limit** > > Secrets up to 250 KB can be sent, but Slack can only display up to 3,000 characters. A larger secret is refused at view time **before** the burn-read; the secret stays intact and expires at its scheduled TTL if unviewed. ## App Home & Direct Messages The integration also provides a SparkVault App Home inside Slack: - **Home tab**: Opening the SparkVault Home tab publishes an explainer of burn-after-read sharing and how to use `/secret`. When the account's storage or bandwidth capacity is running low or exhausted, the Home tab shows a proactive capacity warning: billing admins get an **Add capacity** button into the billing page, while members are directed to ask their account admin. - **Welcome message**: The first time a user opens the SparkVault Messages tab, a one-time welcome DM explains how to share secrets with `/secret`. - **DM help**: The app is not conversational. Direct messages to the bot receive a help reply pointing at the `/secret` command. This is why the `im:history` scope is requested. ## Installation Slack Secure Send requires OAuth installation from the SparkVault dashboard. The app requests minimal permissions, only what's needed to receive slash commands and post messages. ### Required OAuth Scopes | Scope | Purpose | | --- | --- | | `commands` | Receive /secret slash command | | `chat:write` | Post secret notifications to DMs, channels, and groups | | `chat:write.public` | Post to public channels the app has not joined | | `channels:join` | Join a public channel to deliver a secret when not already a member | | `users:read` | Resolve sender and recipient names | | `channels:read` | Resolve public channel recipients | | `groups:read` | Resolve private channel recipients | | `im:read` | Resolve direct message recipients | | `im:history` | Read direct messages so the app can respond to DMs | | `mpim:read` | Resolve multi-person group DM recipients | ### Installation Steps 1. Navigate to **Integrations → Slack** in the SparkVault dashboard 2. Click **"Add to Slack"** to begin the OAuth flow 3. Select your Slack workspace and authorize the requested permissions 4. You'll be redirected back to SparkVault with installation confirmed 5. The `/secret` command is now available in your workspace [Install Slack App](https://app.sparkvault.com/apps/slack) ## API Endpoints While most Slack integration happens through the slash command, the following API endpoints are available for programmatic access and integration management. ### Analytics #### `GET /v1/apps/slack/analytics` Retrieve usage analytics for your Slack Secure Send integration. Requires standard SparkVault authentication (JWT or API key); the account is derived from your credentials, never from query parameters. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `start_date` | string | Optional | Start date in YYYY-MM-DD format | | `end_date` | string | Optional | End date in YYYY-MM-DD format | | `date` | string | Optional | Single date for daily summary (YYYY-MM-DD) | ```bash curl -X GET 'https://api.sparkvault.com/v1/apps/slack/analytics?start_date=2024-01-01&end_date=2024-01-31' \ -H "Authorization: Bearer YOUR_JWT_TOKEN" ``` ```json { "type": "usage_records", "data": { "records": [ { "date": "2024-01-15", "slack_user_id": "U1234567890", "slack_username": "john.doe", "sparks_created": 12, "sparks_read": 10 }, { "date": "2024-01-16", "slack_user_id": "U1234567890", "slack_username": "john.doe", "sparks_created": 8, "sparks_read": 7 } ], "truncated": false, "totals": { "sparks_created": 20, "sparks_read": 17 }, "installation": { "team_id": "T1234567890", "team_name": "Acme Corp", "installed_at": "2024-01-01T00:00:00.000Z" } } } ``` `truncated` is `true` only when the requested window exceeded the analytics read cap; `totals` would then be partial sums over the returned records. ```json { "type": "daily_summary", "data": { "date": "2024-01-15", "total_sparks_created": 45, "total_sparks_read": 38, "users": [ { "slack_user_id": "U1234567890", "slack_username": "john.doe", "sparks_created": 15, "sparks_read": 12 }, { "slack_user_id": "U0987654321", "slack_username": "jane.smith", "sparks_created": 10, "sparks_read": 9 } ] } } ``` `users` lists per-user counts for the requested day, sorted by `sparks_created` descending. ### Uninstall #### `DELETE /v1/apps/slack/uninstall` Uninstall Slack Secure Send from your SparkVault account. Removes the installation and disconnects your Slack workspace. Requires standard SparkVault authentication. ```bash curl -X DELETE 'https://api.sparkvault.com/v1/apps/slack/uninstall' \ -H "Authorization: Bearer YOUR_JWT_TOKEN" ``` ```json { "uninstalled": true, "account_id": "acc_01hq3vxk9m...", "team_id": "T1234567890" } ``` > **Uninstall Behavior** > > Uninstalling removes the OAuth connection between SparkVault and your Slack workspace, and the `/secret` command will no longer work. Pending secrets are not destroyed, but they become unretrievable via Slack: the **View Secret** button requires the installation's bot token, and interactions from an uninstalled workspace are acknowledged without action. Unviewed secrets simply expire at their scheduled TTL. ## Webhook Endpoints (Internal) The following endpoints support the integration during normal operation. The webhook endpoints are called by Slack's servers; the OAuth callback is called by the SparkVault frontend after Slack redirects back from authorization. They are documented here for transparency but should not be called directly by your application. | Endpoint | Purpose | Auth | | --- | --- | --- | | `POST /v1/apps/slack/webhooks/commands` | Receives the /secret slash command | Slack signature | | `POST /v1/apps/slack/webhooks/interactions` | Modal submissions and button clicks | Slack signature | | `POST /v1/apps/slack/webhooks/events` | Slack Events API: `url_verification`, `app_uninstalled`, `tokens_revoked`, `app_home_opened`, and direct-message help replies | Slack signature | | `POST /v1/apps/slack/oauth/callback` | OAuth code exchange after installation, called by the SparkVault frontend | JWT (account derived from auth, never the request body) | > **Signature Verification** > > All webhook requests from Slack are verified using the `X-Slack-Signature` header and the app's signing secret. This ensures requests originate from Slack, not malicious actors. ## Error Handling #### Error Responses | Status | Code | Description | | --- | --- | --- | | 400 | `VALIDATION_ERROR` | Invalid request parameters. Also returned when Slack is not installed for this account. | | 401 | `AUTHENTICATION_ERROR` | Missing or invalid credentials (session JWT or API key). | | 402 | `PLAN_REQUIRED` | Sending secrets requires an active SparkVault subscription. | | 402 | `QUOTA_EXCEEDED` | The account's storage or bandwidth capacity is exhausted. `details.resource` identifies the pool. | | 429 | `RATE_LIMIT_EXCEEDED` | Too many requests. Retry after the specified time. | An empty analytics window is not an error: the endpoint returns an empty `records` array with zero totals. > **Subscription & Capacity Gates** > > Sending is gated by an active subscription (`PLAN_REQUIRED`) and available account capacity (`QUOTA_EXCEEDED`, with `details.resource` of `storage` or `bandwidth`). When a send is gated, the sender sees an ephemeral Slack message with a role-aware call to action: billing admins get a **Subscribe** or **Add capacity** button linking to the billing page, while members are directed to ask their account admin. ## Billing Slack Secure Send is included with your SparkVault subscription. Billing is license-based. There is no per-secret charge, and no per-spark cost is tracked or billed. | Operation | Requirement | | --- | --- | | Send secret (create Spark) | Active subscription with available storage and bandwidth capacity | | View secret (read Spark) | Free, always included | > **Capacity Blocks** > > If a capacity pool runs low, billing admins can add capacity blocks from the [billing page](https://app.sparkvault.com/company/billing). The SparkVault App Home tab in Slack proactively warns when capacity is running low. ## Security Considerations - **Secrets never stored in Slack**: Only a notification message with a **View Secret** button, carrying the spark ID, is posted to Slack. The encrypted content lives in SparkVault's infrastructure. - **Post-quantum encryption**: All secrets use ML-KEM-1024, providing security against future quantum computer attacks. - **Burn-after-read**: Once a secret is viewed, the encryption key is destroyed. There is no way to recover the content after viewing. - **Time-limited secrets**: Unread secrets automatically expire after the configured TTL (max 24 hours), ensuring secrets don't linger indefinitely. - **In-Slack display cap**: Slack can only display up to 3,000 characters. An oversized secret is refused at view time before the burn-read, leaving the secret intact until it expires on schedule. - **Audit trail**: All secret creation and viewing events are logged with Slack user context for compliance and security monitoring. - **OAuth token security**: OAuth tokens are stored encrypted and can be revoked at any time by uninstalling the app. ## Best Practices - **Use short expiration times**: For highly sensitive secrets, use the shortest practical expiration (1 or 5 minutes) to minimize exposure window. - **Send to specific users**: When possible, send secrets directly to the intended recipient rather than posting to a channel. - **Monitor analytics**: Regularly review usage analytics to identify unusual patterns or potential misuse. - **Train your team**: Ensure team members understand the burn-after-read behavior and save important information before the secret is destroyed. - **Don't screenshot secrets**: While we can't prevent it, encourage users not to screenshot secrets as this defeats the security model. ## Get Started Install Slack Secure Send and start sharing secrets securely with your team. [Install Slack App](https://app.sparkvault.com/apps/slack) [Learn About Sparks](/api/docs/sparks/) --- # HubSpot: SparkVault API Reference > Encrypted file storage on every HubSpot Contact, Company, Deal, and Ticket: files encrypted through Forge and organized into a folder per CRM record. Canonical: https://sparkvault.com/api/docs/integrations/hubspot/ · OpenAPI: https://sparkvault.com/openapi.yaml ## Overview The HubSpot integration adds a **SparkVault Files** sidebar card and a full **SparkVault** tab to every Contact, Company, Deal, and Ticket record in your HubSpot portal. Files uploaded from a record are encrypted through [Forge](/api/docs/forge/) with SparkVault's Triple Zero-Trust model and stored as encrypted ingots in one of your [vaults](/api/docs/vaults/), automatically organized into a folder per CRM record. Nothing sensitive ever lives in HubSpot; the CRM only renders the card. AES-256-GCM Encryption via Forge 4 Record Types Contacts, Companies, Deals, Tickets Zero-Knowledge Triple Zero-Trust Model Per-Record Folder Isolation ### Key Features - **SparkVault Files card on CRM records**: A native card on every Contact, Company, Deal, and Ticket - **Triple Zero-Trust encryption via Forge**: AES-256-GCM with keys derived from your vault's key hierarchy - **Folder-per-record organization**: Files are auto-organized by CRM record type and ID (no manual folder management) - **Vault unseal via VMK or DVAK**: Unseal directly from HubSpot without leaving the record - **Upload, download, and delete from HubSpot**: The full file lifecycle without switching tools > **Security Model** > > File contents are never stored in or streamed through HubSpot: uploads and downloads move directly between your browser and Forge. HubSpot renders the card and relays signed control requests (file listings, unseal, download-link issuance); file contents never transit HubSpot. Once your vault is sealed, only your Vault Master Key can unlock the files; SparkVault employees cannot read them. ## How It Works #### User Flow 1. Open any Contact, Company, Deal, or Ticket record in HubSpot 2. The SparkVault Files card loads on the record page 3. Unseal your vault with your VMK or DVAK 4. The card lists the encrypted files stored for that record 5. Upload, download, or delete files; every operation is encrypted through Forge 6. Your unseal session expires after one hour and the card returns to its sealed state ### Folder-per-Record Organization Every CRM record maps to a deterministic folder in the vault you connected at install time: `{ObjectType}/{RecordID}`. Folders are created lazily on first upload; a record with no files has no folder. | CRM Object | Vault Folder | Example | | --- | --- | --- | | Contact | `Contacts/{record_id}` | `Contacts/501` | | Company | `Companies/{record_id}` | `Companies/9021` | | Deal | `Deals/{record_id}` | `Deals/3307` | | Ticket | `Tickets/{record_id}` | `Tickets/118` | > **Per-User Unseal Sessions** > > Unsealing is scoped to the individual HubSpot user who entered the key. Each teammate in your portal must unseal the vault themselves; one user's unseal never grants vault access to the rest of the portal. ## Installation The HubSpot integration is installed via OAuth from the SparkVault dashboard. During installation you choose which vault stores your CRM files; SparkVault verifies server-side that you own the selected vault before completing the connection. ### Installation Steps 1. Sign in to SparkVault and open [Integrations](https://app.sparkvault.com/integrations) 2. Select **HubSpot** and click **Connect HubSpot** 3. Authorize the requested permissions in HubSpot and choose your portal 4. Back in SparkVault, choose the vault where CRM files will be stored 5. SparkVault verifies your ownership of the selected vault and confirms the installation > **Installation Invariants** > > Each SparkVault account can have **one active HubSpot installation**, and a HubSpot portal can be bound to **at most one SparkVault account**. The vault you select in the dashboard is verified on the server with your own credentials; an installation can never bind to a vault you don't own (`Vault not found or access denied`). ### Requested HubSpot OAuth Scopes | Scope | Purpose | | --- | --- | | `oauth` | OAuth token exchange and identifying the connected portal | | `crm.objects.contacts.read` | Record context for the Files card on Contact records | | `crm.objects.companies.read` | Record context for the Files card on Company records | | `crm.objects.deals.read` | Record context for the Files card on Deal records | | `tickets` | Record context for the Files card on Ticket records | ### Add the Card in HubSpot After connecting, add the SparkVault Files card to your record layouts: 1. Open any Contact, Company, Deal, or Ticket in HubSpot 2. Click **Customize** in the top-right of the record page 3. In the sidebar section, click **\+ Add card** and select **SparkVault Files** 4. Click **Save**; the card now appears on all records of that type 5. Enter your Vault Master Key (VMK) or DVAK to unseal and start uploading files [Connect HubSpot](https://app.sparkvault.com/integrations) ## Using the Files Card The card has two states. While the vault is **sealed**, it prompts for your Vault Master Key (VMK) or a Device Vault Access Key (DVAK). Once **unsealed**, the card shows the encrypted files attached to the record you are viewing: - **List**: The card shows only the files in the viewed record's folder, never the rest of the vault. - **Upload**: Uploads open the SparkVault vault view embedded in HubSpot, scoped to the record's folder. File bytes are encrypted and travel directly between your browser and Forge. - **Download**: Downloads open a signed, short-lived Forge URL in an embedded download window: the decrypted stream goes directly from Forge to your browser, never through HubSpot. - **Delete**: Removes a file from the record's folder. Deletion is scoped to the record you are viewing. > **Reconnect Prompt** > > If the stored SparkVault connection behind the installation expires or is revoked, the card shows a reconnect prompt instead of an error. Reconnect from [Integrations](https://app.sparkvault.com/integrations) to restore it. ## Security Model - **Triple Zero-Trust encryption via Forge**: Files are encrypted with AES-256-GCM using keys derived from your vault's key hierarchy. Once the vault is sealed, only your Vault Master Key can unlock them; SparkVault employees cannot read your files. - **File bytes never transit HubSpot**: Uploads and downloads move directly between your browser and Forge. HubSpot only renders the card and relays signed control requests. - **HubSpot v3 request signatures**: Every request the card makes to SparkVault is signed by HubSpot with the app's client secret (HMAC-SHA256 over method, URI, body, and timestamp) via the `X-HubSpot-Signature-v3` and `X-HubSpot-Request-Timestamp` headers. Requests older than five minutes are rejected, and signatures are compared in constant time. The signature, not the portal ID, is the caller proof. - **Per-user unseal sessions**: Unsealing mints a vault access token stored for the verified HubSpot user who entered the key, valid for one hour. Other users in the portal must unseal themselves. - **Per-record authorization**: Downloads and deletions verify the requested file actually lives in the viewed record's folder. A file ID from elsewhere in the vault is rejected, enforcing isolation between records even within your own portal. - **Vault ownership verified at install**: The OAuth callback re-checks the selected vault with the installing user's own credentials, so an installation can never be bound to a vault the installer doesn't own. - **Audit trail**: Every operation runs through the SparkVault Core API and is recorded in the unified audit log. ## API Endpoints The HubSpot integration is driven entirely from HubSpot's UI; there is no customer-facing file API surface specific to this integration. The one endpoint you can call directly is uninstall. #### `DELETE /v1/apps/hubspot/uninstall` Disconnect HubSpot from your SparkVault account. Removes the installation and its stored OAuth connection. Requires standard SparkVault authentication. ```bash curl -X DELETE https://api.sparkvault.com/v1/apps/hubspot/uninstall \ -H "Authorization: Bearer YOUR_SESSION_JWT" ``` ```json { "data": { "uninstalled": true, "account_id": "acc_a1b2c3d4", "portal_id": "244567890" }, "meta": { "api_version": "1.2.828", "response_ms": 84, "request_id": "3f9d2b7c-8a1e-4c5b-9d0f-6e2a4b8c1d3e", "timestamp": 1782864000 } } ``` > **Authentication** > > This endpoint uses standard SparkVault authentication: a session JWT (`Authorization: Bearer …`) or an API key (`X-API-Key`). > **Uninstall Behavior** > > Uninstalling removes the OAuth connection between SparkVault and your HubSpot portal; the SparkVault Files card stops working on CRM records. **Your files are not deleted**: they remain encrypted in your vault and stay accessible from the SparkVault app. Removing the app from the HubSpot side instead triggers the same cleanup via the `app.deauthorize` webhook. Reinstalling requires running the OAuth flow again. #### Error Responses | Status | Code | Description | | --- | --- | --- | | 400 | `VALIDATION_ERROR` | HubSpot is not installed for this account. | | 401 | `AUTHENTICATION_ERROR` | Missing or invalid session JWT. | ## Integration Endpoints (Internal) The following endpoints are called by HubSpot's servers (as signed `hubspot.fetch()` requests from the Files card) or by the SparkVault dashboard during installation. They are documented for transparency and should not be called directly by your application. | Endpoint | Purpose | Auth | | --- | --- | --- | | `POST /v1/apps/hubspot/resolve` | Maps a CRM record to its vault and folder so the card can render | HubSpot v3 signature | | `POST /v1/apps/hubspot/files` | Lists the encrypted files for the record being viewed | HubSpot v3 signature | | `POST /v1/apps/hubspot/unseal` | Unseals the vault with the VMK or DVAK entered in the card | HubSpot v3 signature | | `POST /v1/apps/hubspot/download` | Issues a signed Forge URL for a file on the viewed record | HubSpot v3 signature | | `POST /v1/apps/hubspot/delete-file` | Deletes a file from the viewed record's folder | HubSpot v3 signature | | `POST /v1/apps/hubspot/oauth/callback` | OAuth code exchange and vault binding during installation | SparkVault session JWT | | `POST /v1/apps/hubspot/webhooks` | HubSpot lifecycle events (`app.deauthorize`) | HubSpot v3 signature | > **Signature Verification** > > All proxy and webhook requests are verified with HubSpot's v3 request signature: an HMAC-SHA256 of the method, full URI, raw body, and timestamp, keyed with the app's client secret. This proves requests originate from HubSpot on behalf of a signed-in portal user, not from an arbitrary caller who knows a portal ID. ## Pricing The HubSpot integration is **free to install**. Files stored through the integration are billed exactly like files uploaded in the SparkVault app; standard vault storage and transfer pricing applies. There is no per-operation charge for the integration itself. ## Get Started Connect HubSpot and bring encrypted file storage to every record in your CRM. [Connect HubSpot](https://app.sparkvault.com/integrations) [Learn About Vaults](/api/docs/vaults/) --- # Salesforce: SparkVault API Reference > Encrypted file storage on every Salesforce Account, Contact, Opportunity, and Case: files encrypted via Forge, organized into a folder per CRM record, and readable only after unsealing your vault. Canonical: https://sparkvault.com/api/docs/integrations/salesforce/ · OpenAPI: https://sparkvault.com/openapi.yaml ## Overview The Salesforce integration adds a native **SparkVault Files** panel to every Account, Contact, Opportunity, and Case record page in your org. Files uploaded from a record are encrypted via Forge with AES-256-GCM, stored as ingots in the SparkVault vault you choose at install time, and organized automatically into a dedicated folder per CRM record. Salesforce never holds the file contents, only the panel. AES-256-GCM Forge Encryption 4 CRM Record Types openid Only OAuth Scope Requested Zero-Knowledge Vault-Sealed Storage ### Key Features - **SparkVault Files panel on CRM records**: native Lightning panel on Account, Contact, Opportunity, and Case pages - **Triple Zero-Trust encryption via Forge**: files encrypted with AES-256-GCM using keys derived from your vault's key hierarchy - **Folder-per-record organization**: files auto-organized by CRM object type and record ID, no manual folder management - **Vault unseal via VMK or DVAK**: each user unlocks the vault with their own key - **Upload, download, and delete from Salesforce**: manage record files without leaving the CRM > **Zero-Knowledge Storage** > > Your vault is sealed with a key that only you hold. The panel uses it to unlock the vault for the current session, then discards it. The key is never written to disk or stored on SparkVault servers. Once the vault is sealed, not even SparkVault can read your files, and Salesforce never stores the file bytes at all. ## How It Works #### User Flow 1. A user opens an Account, Contact, Opportunity, or Case, and the SparkVault Files panel loads on the record page 2. The panel resolves the record to its vault and deterministic folder (e.g. `Accounts/001xx000003DGbzAAG`) 3. If the vault is sealed for this user, the panel prompts them to unlock it with their VMK or DVAK 4. Once unsealed, files attached to the record are listed in a native Lightning datatable with download and delete actions 5. Uploads open the embedded SparkVault upload widget in an in-panel modal; files are encrypted via Forge before storage 6. Downloads open a signed Forge URL; the file streams directly from Forge to the user's browser ### Folder Organization Every record maps to one vault folder, named by object type and record ID. Folders are created lazily on first upload, so records without files add nothing to your vault. | Record type | Vault folder | | --- | --- | | Account | `Accounts/{recordId}` | | Contact | `Contacts/{recordId}` | | Opportunity | `Opportunities/{recordId}` | | Case | `Cases/{recordId}` | ## Installation Installation has two halves: the SparkVault managed package inside your Salesforce org, and the OAuth connection that binds the org to your SparkVault account and a vault you own. ### In Salesforce 1. Install the **SparkVault** managed package in your org 2. Assign the **SparkVault User** permission set to users who need the Files panel 3. Add the **SparkVault Files** panel to Account, Contact, Opportunity, and Case record pages via Lightning App Builder ### In SparkVault 1. Sign in and open [Integrations](https://app.sparkvault.com/integrations) in the SparkVault dashboard 2. Click **"Connect Salesforce"**; you are redirected to Salesforce to authorize the connection (only the `openid` scope is requested) 3. Choose which vault Salesforce CRM files will be stored in; the vault must belong to you, which SparkVault verifies server-side 4. You are redirected back to SparkVault with the installation confirmed, and the panel becomes active for your org > **Minimal OAuth Footprint** > > The integration requests only the `openid` OAuth scope. SparkVault uses OAuth solely to bind your Salesforce org ID and instance URL to your account. Salesforce access and refresh tokens are not retained after installation. Every subsequent panel request is authorized by the calling user's own Salesforce session instead. ### Refreshing the Connection If the connection needs to be re-authorized (for example after the panel reports "Reconnect SparkVault"), use **Refresh Connection** on the [Integrations](https://app.sparkvault.com/integrations) page. Re-authorization keeps the bound vault and must target the same Salesforce org; connecting a different org is rejected with _"Disconnect first to connect a new org"_ so the original org-to-account mapping is never silently orphaned. [Connect Salesforce](https://app.sparkvault.com/integrations) ## Using the Files Panel The panel is a native Lightning Web Component: no iframes for browsing, no third-party UI. It lists each file's name, size, and upload date with per-row **Download** and **Delete** actions. ### Unsealing the Vault A sealed vault shows an unlock prompt. The user enters their Vault Master Key (VMK) or a Delegated Vault Access Key (DVAK); the key unlocks the vault for the session and is then discarded. It is never saved in the browser or stored on SparkVault servers. Each Salesforce user unseals for themselves: one user's unlocked session is never shared with the rest of the org, and vault sessions expire after one hour, after which the panel prompts for the key again. ### Uploading Upload opens the embedded SparkVault upload widget in an in-panel modal. The vault and folder ride in the widget URL, while the short-lived vault access token is handed to the widget via `postMessage`, never in a URL. Files stream to Forge and are encrypted before storage; when the upload completes, the panel refreshes the file list automatically. ### Panel States | State | Meaning | Resolution | | --- | --- | --- | | Vault locked | No live vault session for this Salesforce user | Enter your VMK or DVAK to unseal | | Reconnect SparkVault | The stored SparkVault connection can no longer be refreshed | A SparkVault admin uses **Refresh Connection** on the [Integrations](https://app.sparkvault.com/integrations) page | | Subscription required | The SparkVault account has no active plan | Subscribe in SparkVault billing | | Capacity exhausted | A pooled limit (storage, bandwidth, seats, or identity) is used up, blocking the gated operation: storage exhaustion blocks uploads | Add capacity in SparkVault billing | > **Billing Actions Are Admin-Gated** > > The subscription and capacity states show an actionable billing link only to Salesforce administrators (users with the Customize Application permission). Standard users are never shown an action they cannot take. ## Security Model - **Caller authentication (session bearer verified against the installed org)**: every panel request carries the current Salesforce user's session bearer token. SparkVault validates that token against the installed org's own `/services/oauth2/userinfo` endpoint and rejects the request unless the returned organization ID matches the installation. The org ID in the request body identifies the tenant but is never trusted as authentication. - **Record-level access checks**: before listing, uploading, downloading, or deleting, SparkVault confirms the calling user can actually see the record by querying Salesforce with the user's own session. Object types are allow-listed (Account, Contact, Opportunity, Case) and record IDs must match the strict 15/18-character Salesforce ID format, re-validated at the query site. The packaged Apex controller additionally performs user-mode record checks before any callout. - **Per-user vault sessions**: unsealing mints a Vault Access Token stored per verified Salesforce user. A user who has not unsealed the vault cannot reuse another user's unlocked session. - **Per-record isolation**: download and delete verify that the requested file actually lives in that record's folder. A file ID alone is never sufficient to reach a file attached to a different record. - **Vault ownership verified at install**: the OAuth callback re-checks the selected vault against the installing user's own SparkVault session, so an installation can never bind to someone else's vault. - **Keys are never stored**: the VMK or DVAK is used to unseal and then discarded: not written to disk, not stored on SparkVault servers, not saved in the browser. - **No Salesforce tokens retained**: after the `openid`\-scoped OAuth exchange, SparkVault keeps the org ID, instance URL, bound vault, and its own SparkVault credential for the account: no Salesforce access or refresh tokens. - **Direct Forge streaming**: file bytes stream between the browser and Forge. They are never proxied through the panel, the Apex controller, or Salesforce. - **Explicit CSP allow list**: the managed package trusts exactly `api.sparkvault.com`, `app.sparkvault.com`, and `files.sv`, no wildcard domains. ## API Endpoints The integration is installed and managed from the SparkVault dashboard, and the Files panel talks to SparkVault on your behalf. The one endpoint you may want to call programmatically is uninstall. #### `DELETE /v1/apps/salesforce/uninstall` Disconnect Salesforce from your SparkVault account. Removes the installation; the Files panel stops working in your org. Authenticate with either a session JWT (Authorization: Bearer) or an API key (X-API-Key). ```bash # Authenticate with a session JWT... curl -X DELETE 'https://api.sparkvault.com/v1/apps/salesforce/uninstall' \ -H "Authorization: Bearer YOUR_JWT_TOKEN" # ...or with an API key curl -X DELETE 'https://api.sparkvault.com/v1/apps/salesforce/uninstall' \ -H "X-API-Key: YOUR_API_KEY" ``` ```json { "uninstalled": true, "account_id": "acc_1234567890", "org_id": "00Dxx0000001gPLEAY" } ``` #### Error Responses | Status | Code | Description | | --- | --- | --- | | 400 | `VALIDATION_ERROR` | Salesforce is not installed for this account. | | 401 | `AUTHENTICATION_ERROR` | Missing or invalid credentials. | ## Internal Endpoints The following endpoints are called by the SparkVault Files panel (through its Apex controller) and by the SparkVault dashboard during installation. They are documented here for transparency but are not intended to be called directly by your application. | Endpoint | Purpose | Auth | | --- | --- | --- | | `POST /v1/apps/salesforce/resolve` | Map a CRM record to its vault and folder path | Salesforce session bearer + record access | | `POST /v1/apps/salesforce/files` | List files attached to a CRM record | Salesforce session bearer + record access | | `POST /v1/apps/salesforce/upload-context` | Vault, folder, and vault access token for the embedded upload widget | Salesforce session bearer + record access | | `POST /v1/apps/salesforce/unseal` | Unseal the bound vault with a VMK or DVAK | Salesforce session bearer | | `POST /v1/apps/salesforce/download` | Mint a signed Forge URL for direct file download | Salesforce session bearer + record access | | `POST /v1/apps/salesforce/delete-file` | Delete a record-bound file from the vault | Salesforce session bearer + record access | | `POST /v1/apps/salesforce/oauth/callback` | OAuth code exchange and vault selection during install | SparkVault JWT | | `POST /v1/apps/salesforce/webhooks` | Salesforce lifecycle events (`app.uninstall`) | Salesforce session bearer | > **Session Verification, Not Signatures** > > Salesforce does not sign outbound calls with an HMAC the way Slack or HubSpot do. Panel calls and webhooks instead authenticate with a Salesforce session bearer token, which SparkVault validates against the installed org before acting. See the Security Model above. ## Uninstall Behavior There are two ways to disconnect, and both end at the same place: - **From SparkVault**: click **Disconnect** on the [Integrations](https://app.sparkvault.com/integrations) page, or call `DELETE /v1/apps/salesforce/uninstall`. - **From Salesforce**: uninstalling the managed package notifies SparkVault via a session-verified webhook, which removes the installation. > **What Uninstalling Does (and Does Not) Do** > > Uninstalling removes the connection between your Salesforce org and your SparkVault account, and the SparkVault Files panel disappears from CRM records. **Your files are not deleted**. They remain encrypted in your vault under their per-record folders and stay accessible from the SparkVault app. The Salesforce-side uninstall notification is best-effort: if Salesforce cannot deliver it during package removal, disconnect from the SparkVault Integrations page instead. ## Pricing Free to install. Standard vault storage and transfer pricing applies. The integration itself adds no per-file or per-seat charge. ## Best Practices - **Use a dedicated vault for CRM files**: binding the integration to its own vault keeps access, capacity, and audit review simple, and disconnecting never touches your other vaults. - **Issue DVAKs instead of sharing the VMK**: give each team member a Delegated Vault Access Key so they unseal with their own revocable credential rather than the vault's master key. - **Expect re-unseal after an hour**: vault sessions are deliberately short-lived; the panel will prompt for the key again once a session expires. - **Scope the permission set**: assign the SparkVault User permission set only to users who need the Files panel. Record-level checks still apply, but least privilege starts in Salesforce. ## Get Started Connect your Salesforce org and put encrypted file storage on every CRM record. [Connect Salesforce](https://app.sparkvault.com/integrations) [API Authentication](/api/docs/authentication/) --- # Reporting API: SparkVault API Reference > Access analytics, usage metrics, and activity logs for your account. Build dashboards and monitor usage patterns. Usage is tracked but never billed. Subscriptions are billed per license. Canonical: https://sparkvault.com/api/docs/reporting/ · OpenAPI: https://sparkvault.com/openapi.yaml ## Overview The Reporting API provides comprehensive analytics and audit data for your SparkVault account. Use it to monitor usage, track consumption, and build custom dashboards. > **Authentication** > > All Reporting endpoints accept standard SparkVault authentication: a session JWT via `Authorization: Bearer …` or an API key via the `X-API-Key` header (API keys are prefixed `sv_live_`). Vault- and ingot-scope audit logs additionally require an unsealed vault session (VAT). ### Available Data - **Dashboard Overview**: High-level account metrics at a glance - **Usage Over Time**: Time-series data for trend analysis - **Activity Feed**: Rolling 24-hour feed of recent operations - **Audit Logs**: Durable audit trail across account, integration, vault, and ingot scopes (see the [Audit Logs reference](/api/docs/audit-logs/)) - **Vault Statistics**: Per-vault usage and storage metrics - **API Key Statistics**: Usage metadata per API key - **Usage Ledger**: Tracked consumption records (usage is tracked, never billed) ## Dashboard Overview #### `GET /v1/analytics/overview` Get high-level account metrics for the dashboard. Returns summary statistics for Sparks, vaults, ingots, transfers, and activity (usage tracked, never billed). #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `period` | string | Optional | Reporting window: `day`, `week`, `month` (30 days), or `quarter` (90 days). Omitted or unrecognized values fall back to a 30-day window.Default: `30 days` | #### Response Fields | Field | Type | Description | | --- | --- | --- | | `period` | string | Echo of the requested period; `all` when omitted. | | `period_start` | integer | Window start (epoch seconds). | | `period_end` | integer | Window end (epoch seconds). | | `period_days` | integer | Number of days in the window. | | `sparks.total` | integer | Total Sparks, all time. | | `sparks.active` | integer | Live, unread Sparks. | | `sparks.burned` | integer | Sparks read and destroyed, all time. | | `sparks.expired` | integer | Sparks that expired unread, all time. | | `sparks.created_this_period` | integer | Sparks created in the window. | | `sparks.rate_per_day` | number | Average Sparks created per day in the window. | | `vaults.total` | integer | Total vaults. | | `vaults.active` | integer | Active vaults. | | `vaults.created_this_period` | integer | Vaults created in the window. | | `vaults.rate_per_day` | number | Average vaults created per day in the window. | | `ingots.total` | integer | Total ingots across all vaults. | | `ingots.storage_bytes` | integer | Total storage used across all vaults. | | `ingots.storage_mb` | number | Total storage in MB. | | `ingots.storage_gb` | number | Total storage in GB. | | `transfers.total_access_count` | integer | Total ingot downloads. | | `transfers.total_bytes` | integer | Bandwidth consumed (uploads + downloads). | | `transfers.total_mb` | number | Bandwidth consumed in MB. | | `transfers.total_gb` | number | Bandwidth consumed in GB. | | `activity.total_operations` | integer | Operations recorded in the window. | | `activity.operations_per_day` | number | Average operations per day in the window. | #### Example Request ```bash curl "https://api.sparkvault.com/v1/analytics/overview?period=month" \ -H "X-API-Key: sv_live_YOUR_API_KEY" ``` Response ```json { "data": { "period": "month", "period_start": 1780358400, "period_end": 1782950400, "period_days": 30, "sparks": { "total": 214, "active": 12, "burned": 187, "expired": 15, "created_this_period": 42, "rate_per_day": 1.4 }, "vaults": { "total": 5, "active": 5, "created_this_period": 1, "rate_per_day": 0.03 }, "ingots": { "total": 128, "storage_bytes": 10485760, "storage_mb": 10, "storage_gb": 0.01 }, "transfers": { "total_access_count": 356, "total_bytes": 2576980378, "total_mb": 2457.6, "total_gb": 2.4 }, "activity": { "total_operations": 134, "operations_per_day": 4.47 } }, "meta": { "api_version": "1.2.828" } } ``` ## Usage Over Time #### `GET /v1/analytics/usage-over-time` Get time-series usage data for charts and trend analysis. Returns hourly buckets for the day period and daily buckets otherwise. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `period` | string | Optional | Time window: `day` (hourly buckets), `week`, `month` (30 days), `bimonth` (60 days), or `quarter` (90 days).Default: `month` | | `metric` | string | Optional | Echoed back in the response for chart labeling; it does not filter or change the data. Every data point always includes all four metrics.Default: `operations` | #### Response Fields | Field | Type | Description | | --- | --- | --- | | `period` | string | Requested period. | | `metric` | string | Echo of the `metric` parameter. | | `granularity` | integer | Bucket size in seconds: 3600 (hourly) or 86400 (daily). | | `granularity_label` | string | `hourly` or `daily`. | | `start_time` | integer | Window start, aligned to the UTC bucket grid (epoch seconds). | | `end_time` | integer | Window end (epoch seconds). | | `data_points` | array | One entry per bucket, oldest first. The first and last buckets are partial: they cover only the slice of the window that overlaps them. | | `data_points[].timestamp` | integer | Bucket start (epoch seconds, UTC-aligned). | | `data_points[].datetime` | string | ISO 8601 timestamp of the bucket start. | | `data_points[].operations` | integer | Operations recorded in the bucket. | | `data_points[].sparks_created` | integer | Sparks created in the bucket. | | `data_points[].vaults_created` | integer | Vaults created in the bucket. | | `data_points[].ingots_created` | integer | Ingots created in the bucket. | #### Example: Sparks Created Per Day Request ```bash curl "https://api.sparkvault.com/v1/analytics/usage-over-time?period=week" \ -H "X-API-Key: sv_live_YOUR_API_KEY" ``` Response ```json { "data": { "period": "week", "metric": "operations", "granularity": 86400, "granularity_label": "daily", "start_time": 1782345600, "end_time": 1782950400, "data_points": [ { "timestamp": 1782345600, "datetime": "2026-06-25T00:00:00.000Z", "operations": 18, "sparks_created": 15, "vaults_created": 0, "ingots_created": 3 }, { "timestamp": 1782432000, "datetime": "2026-06-26T00:00:00.000Z", "operations": 27, "sparks_created": 23, "vaults_created": 1, "ingots_created": 3 }, { "timestamp": 1782518400, "datetime": "2026-06-27T00:00:00.000Z", "operations": 20, "sparks_created": 18, "vaults_created": 0, "ingots_created": 2 }, { "timestamp": 1782604800, "datetime": "2026-06-28T00:00:00.000Z", "operations": 35, "sparks_created": 31, "vaults_created": 0, "ingots_created": 4 }, { "timestamp": 1782691200, "datetime": "2026-06-29T00:00:00.000Z", "operations": 30, "sparks_created": 27, "vaults_created": 0, "ingots_created": 3 }, { "timestamp": 1782777600, "datetime": "2026-06-30T00:00:00.000Z", "operations": 14, "sparks_created": 12, "vaults_created": 0, "ingots_created": 2 }, { "timestamp": 1782864000, "datetime": "2026-07-01T00:00:00.000Z", "operations": 10, "sparks_created": 8, "vaults_created": 0, "ingots_created": 2 }, { "timestamp": 1782950400, "datetime": "2026-07-02T00:00:00.000Z", "operations": 3, "sparks_created": 2, "vaults_created": 0, "ingots_created": 1 } ] }, "meta": { "api_version": "1.2.828" } } ``` ## Activity Feed #### `GET /v1/analytics/activity` Get a rolling feed of recent account activity: usage-ledger operations combined with analytics events (such as Spark reads) from the last 24 hours. > **24-Hour Window, Not an Audit Log** > > The feed always covers the last 24 hours and is assembled from the usage ledger plus analytics events. This is a monitoring convenience, not a compliance-grade audit trail. For the durable, queryable audit trail, use the [Audit Logs API](/api/docs/audit-logs/). #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | Optional | Maximum events to return (1-200). Values above 200 are clamped; invalid values fall back to 50.Default: `50` | | `event_type` | string | Optional | Return only events whose `event_type` exactly matches this value. | | `period` | string | Optional | Echoed back in the response as-is; the feed window is always the last 24 hours.Default: `day` | #### Response Fields | Field | Type | Description | | --- | --- | --- | | `events` | array | Activity events, newest first. | | `events[].event_id` | string | Unique event identifier. | | `events[].event_type` | string | Event type (see Event Types below). | | `events[].description` | string | Human-readable description of the event. | | `events[].icon` | string | Display icon for the event. | | `events[].amount` | string? | Informational usage amount from the ledger entry (dollars string). Present on ledger-backed events only. Usage is tracked, never billed. | | `events[].reference_id` | string | Related resource reference (e.g. Spark or ingot ID). | | `events[].vault_id` | string? | Related vault ID on ledger-backed events; null or absent when not applicable. | | `events[].timestamp` | integer | When the event occurred (epoch seconds). | | `events[].datetime` | string | ISO 8601 timestamp of the event. | | `count` | integer | Number of events returned. | | `period` | string | Echo of the `period` parameter. | | `filtered_by` | string? | The `event_type` filter that was applied, or null. | ### Event Types | Type | Description | | --- | --- | | `spark_created` | Spark created | | `vault_created` | Vault created | | `ingot_created` | Ingot created | | `ingot_updated` | Ingot updated | Ledger entries of any other type pass through with their entry type verbatim (e.g. `sparklink_create`, `entropy_generate`), and events sourced from the analytics store (such as Spark reads) carry their stored `event_type` verbatim. #### Example Request ```bash curl "https://api.sparkvault.com/v1/analytics/activity?limit=5" \ -H "X-API-Key: sv_live_YOUR_API_KEY" ``` Response ```json { "data": { "events": [ { "event_id": "TX#1782942000#spk_9f2c4b7e", "event_type": "spark_created", "description": "Spark created", "icon": "⚡", "amount": "0.00", "reference_id": "spk_9f2c4b7e", "vault_id": null, "timestamp": 1782942000, "datetime": "2026-07-01T21:40:00.000Z" }, { "event_id": "TX#1782938400#ing_5d8a1c3f", "event_type": "ingot_created", "description": "Ingot created", "icon": "📦", "amount": "0.00", "reference_id": "ing_5d8a1c3f", "vault_id": "vlt_7b2e9d4a", "timestamp": 1782938400, "datetime": "2026-07-01T20:40:00.000Z" } ], "count": 2, "period": "day", "filtered_by": null }, "meta": { "api_version": "1.2.828" } } ``` ## Audit Logs The durable, queryable audit trail is the Audit Logs API. It records events at four scopes (account, integration, vault, and ingot) with signed cursor pagination and event-type filtering. Full parameters, response shapes, and event types are documented in the [Audit Logs reference](/api/docs/audit-logs/). #### `GET /v1/audit-logs` List account-scope audit logs. #### `GET /v1/audit-logs/apps/{app_slug}` List audit logs for a specific integration. #### `POST /v1/audit-logs/apps` Record an integration-scope audit event. #### `GET /v1/vaults/{vault_id}/audit-logs` List vault-scope audit logs. Requires an unsealed vault session (VAT). #### `GET /v1/vaults/{vault_id}/ingots/{ingot_id}/audit-logs` List per-ingot audit logs. Requires an unsealed vault session (VAT). All list endpoints accept `limit` (1-100, default 25), `cursor` (the signed `next_cursor` from the previous page, bound to your account and audit scope), and `event_types` (comma-separated filter). Entries include a resolved `actor_type` (`user` or `api_key`) and `actor_display` (email or key name), and the account, vault, and ingot list responses include the valid event types for their scope. `POST /v1/audit-logs/apps` takes `app_slug`, `event_type`, and optional `metadata` in the request body. ## Spark Activity #### `GET /v1/analytics/spark-activity` Get Spark creation counts as a time series. Counts are derived from the usage ledger, so they remain accurate after Spark records expire. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `days` | integer | Optional | Window size in days (1-365). `days=1` returns hourly buckets; anything larger returns daily buckets. Invalid values fall back to 7.Default: `7` | #### Response Fields | Field | Type | Description | | --- | --- | --- | | `days` | integer | Number of days covered. | | `type` | string | `hourly` (when `days=1`) or `daily`. | | `total_sparks` | integer | Total Sparks created in the window. | | `hourly_activity` | array | Hourly buckets of `{hour, count}`, oldest first (`hour` 23 is the current hour). Present when type is `hourly`. | | `daily_activity` | array | Daily buckets of `{date, count}` with `date` as YYYY-MM-DD, oldest first. Includes today, so a 7-day request returns 8 entries. Present when type is `daily`. | | `start_date` | string | First date in the window (daily responses only). | | `end_date` | string | Last date in the window (daily responses only). | ## Vault Statistics #### `GET /v1/analytics/vaults/{vault_id}` Get detailed statistics for a specific vault, including storage usage and ingot metrics. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `period` | string | Optional | Activity window: `day`, `week`, or `month`. Any other value widens the window to the vault's full history.Default: `month` | #### Response Fields | Field | Type | Description | | --- | --- | --- | | `vault.vault_id` | string | Vault identifier. | | `vault.name` | string | Vault name. | | `vault.status` | string | Vault status. | | `vault.created_at` | integer | Creation timestamp (epoch seconds). | | `statistics.total_ingots` | integer | Number of ingots in the vault. | | `statistics.ingots_created_this_period` | integer | Ingots created in the window. | | `statistics.total_storage_bytes` | integer | Total storage used by the vault. | | `statistics.total_storage_gb` | string | Total storage in GB, two decimal places. | | `statistics.average_ingot_size_bytes` | integer | Average ingot size. | | `statistics.large_ingots_count` | integer | Ingots held in object storage. | | `statistics.small_ingots_count` | integer | Ingots stored inline. | | `statistics.last_unsealed_at` | integer? | Last unseal timestamp; null if the vault has never been unsealed. | | `statistics.unseal_count` | integer | Number of times the vault has been unsealed. | | `activity.period` | string | Echo of the `period` parameter. | | `activity.ingots_created` | integer | Ingots created in the window. | | `activity.storage_added_bytes` | integer | Storage added in the window. | ## API Key Statistics #### `GET /v1/analytics/api-keys/{api_key_id}` Get usage statistics for a specific API key, derived from the key record. > **Request-Level Metrics** > > Per-request counts and top-endpoint breakdowns are not part of this endpoint. Statistics are derived from the key record's creation and last-used timestamps, and the response carries a fixed `note` saying so. #### Response Fields | Field | Type | Description | | --- | --- | --- | | `api_key.api_key_id` | string | API key identifier. | | `api_key.name` | string | API key name. | | `api_key.key_preview` | string | Redacted key preview (e.g. `sv_live_***`). | | `api_key.status` | string | Key status. | | `api_key.created_at` | integer | Creation timestamp (epoch seconds). | | `usage.last_used_at` | integer? | Last usage timestamp; null if the key has never been used. | | `usage.days_since_last_used` | integer? | Days since last use; null if the key has never been used. | | `usage.days_since_creation` | integer | Days since the key was created. | | `usage.is_active` | boolean | True when the key was used within the last 30 days. | | `note` | string | Fixed notice that per-request logging is not included. | ## Usage Ledger The usage ledger records consumption events (operations and transfers) for your account. Usage is tracked, never billed. Subscriptions are billed per license via Stripe invoices. Use this to attribute usage to vaults and operations. #### `GET /v1/billing/transactions` Get the usage ledger for your account. Each entry records a tracked operation or transfer. Individual entries that were rolled up into aggregate summaries are hidden by default. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | Optional | Maximum results (1-1000). Invalid values fall back to 1000.Default: `1000` | | `type` | string | Optional | Filter by ledger entry type (e.g. `spark_create`). Ignored when `start_date` is a YYYY-MM-DD date. Combine it with epoch `start_date` filtering or no date filter. | | `category` | string | Optional | Filter by category (maps to entry types, e.g. `Sparks Created`). | | `start_date` | string | Optional | Start of range: epoch timestamp or YYYY-MM-DD. | | `end_date` | string | Optional | End of range (YYYY-MM-DD). Honored only when `start_date` is also a YYYY-MM-DD date; ignored with epoch filtering. Defaults to the same day as `start_date`. | | `include_aggregated` | string | Optional | Set to `true` to include individual entries that were rolled up into aggregate summaries; they are filtered out by default. | #### Response Fields | Field | Type | Description | | --- | --- | --- | | `transactions` | array | Array of usage ledger entries. | | `transactions[].type` | string | Ledger entry type (e.g. `spark_create`, `entropy_generate`, `Ingot Transfer - Forge Encryption`). | | `transactions[].amount` | string | Informational cost recorded for the entry (dollars string, zero or negative). Usage is tracked, never billed. | | `transactions[].name` | string? | Resource name, if applicable. | | `transactions[].description` | string? | Human-readable description, if set. | | `transactions[].vault_id` | string? | Related vault ID, if applicable. | | `transactions[].reference_id` | string | Related resource reference (also the entry's idempotency key). | | `transactions[].created_at` | integer | Entry timestamp (epoch seconds). | | `transactions[].operation_count` | integer? | Number of operations rolled into an aggregated summary entry; null on individual entries. | | `transactions[].period_start` | integer? | Start of the window covered by an aggregated summary entry; null on individual entries. | | `transactions[].period_end` | integer? | End of the window covered by an aggregated summary entry; null on individual entries. | | `count` | integer | Number of entries returned. | #### Example Request ```bash curl "https://api.sparkvault.com/v1/billing/transactions?limit=5" \ -H "X-API-Key: sv_live_YOUR_API_KEY" ``` Response ```json { "data": { "transactions": [ { "type": "spark_create", "amount": "0.00", "reference_id": "spk_9f2c4b7e", "vault_id": null, "name": null, "description": null, "created_at": 1782942000, "operation_count": null, "period_start": null, "period_end": null }, { "type": "entropy_generate", "amount": "0.00", "reference_id": "ent_a1b2c3d4", "vault_id": null, "name": null, "description": null, "created_at": 1782938400, "operation_count": null, "period_start": null, "period_end": null } ], "count": 2 }, "meta": { "api_version": "1.2.828" } } ``` ### Related Endpoints #### `GET /v1/billing/summaries` List pre-aggregated daily usage summaries. Accepts `start_date` / `end_date` (YYYY-MM-DD, defaults to the last 30 days) and `category`. Returns one row per day and category (`date`, `category`, `total_charges`, `transaction_count`, `total_bytes`) plus period totals. #### `GET /v1/billing/usage/breakdown` Per-member storage and per-vault bandwidth attribution, derived from the account's ingot records. Admin-only. Returns `by_member` (storage attributed to the member who uploaded each ingot) and `by_vault` (transfer bandwidth attributed to the vault itself), plus a `truncated` flag when a very large account exceeds the scan cap. ## Error Reference #### Error Responses | Status | Code | Description | | --- | --- | --- | | 400 | `VALIDATION_ERROR` | Invalid query parameters | | 401 | `AUTHENTICATION_ERROR` | Invalid or missing authentication | | 403 | `FORBIDDEN` | Insufficient permissions for this resource | | 404 | `NOT_FOUND` | Resource not found | --- # Audit Logs: SparkVault API Reference > Query the unified audit trail for your account, integrations, vaults, and ingots: cursor-paged event streams with per-vault retention controls. Canonical: https://sparkvault.com/api/docs/audit-logs/ · OpenAPI: https://sparkvault.com/openapi.yaml ## Overview SparkVault records security-relevant activity in a unified audit log. Every entry captures what happened (`event_type`), when (`timestamp`), who did it (`actor_id`), and from where (`ip_address`, `user_agent`), plus event-specific `metadata`. Events are partitioned into four scopes, each with its own list endpoint: | Scope | What it records | Endpoint | Auth | | --- | --- | --- | --- | | Account | Logins, user and license management, billing, API keys, custom domains, vault create/delete, SparkLink receipts, Messaging governance | `GET /v1/audit-logs` | API key or JWT | | Integration / Product | Per-integration and per-product event streams (Slack, HubSpot, Identity, Structured Ingots, …), called `apps` in the API | `GET /v1/audit-logs/apps/{app_slug}` | API key or JWT | | Vault | Vault settings, seal/unseal, sharing, upload portals, DVAKs, folder operations | `GET /v1/vaults/{vault_id}/audit-logs` | API key or JWT + `X-Vault-Access-Token` | | Ingot | Per-file activity: uploads, downloads, accesses, renames, sharing | `GET /v1/vaults/{vault_id}/ingots/{ingot_id}/audit-logs` | API key or JWT + `X-Vault-Access-Token` | Entries are returned newest-first with signed cursor pagination. Account and integration entries are stored in plaintext. Vault and ingot entry metadata is encrypted at rest with AES-256-GCM under a key derived (HKDF) from the vault's Vault Access Key (VAK) when the event is recorded through an unsealed session. It can only be decrypted with a valid [Vault Access Token](/api/docs/vaults/). Events recorded outside a vault session (such as `retention_changed`) store their metadata in plaintext. ### Entry Fields #### Audit Log Entry | Field | Type | Description | | --- | --- | --- | | `timestamp` | integer | Unix timestamp of the event in epoch **seconds** | | `event_type` | string | Event identifier (see Event Types) | | `actor_id` | string? | Who performed the action: a user (`usr_...`) or an API key (`key_...`). `null` for unattributed events; omitted on integration-scope entries. | | `actor_type` | string? | `user` or `api_key`, present when the actor still exists in your account and could be resolved | | `actor_display` | string? | Human-readable actor: the user's email or the API key's name, present when resolved | | `ip_address` | string? | Client IP address that triggered the event | | `user_agent` | string? | Client user agent | | `metadata` | object? | Event-specific details. Decrypted transparently on vault/ingot endpoints using your VAT session; `null` when the event carries none. | | `vault_id` | string? | Present on vault- and ingot-scope entries | | `ingot_id` | string? | Present on ingot-scope entries | | `app_slug` | string? | Present on integration-scope entries | Audit logs answer _who did what, and when_. For usage aggregates and analytics dashboards, see [Reporting](/api/docs/reporting/). ## Event Types Each scope has its own set of event types. The account, vault, and ingot list endpoints return the full valid set for their scope in the response's `event_types` field, so you can build filters without hardcoding the lists below. ### Account Events | Category | Event Types | | --- | --- | | User management | `user_invited`, `user_removed`, `user_role_changed` | | License management | `user_seat_type_set`, `user_license_cancel_scheduled`, `user_license_cancel_undone`, `user_license_released`, `license_migration_applied` | | Authentication | `login`, `logout`, `login_failed` | | Identity verification | `auth_attempt`, `auth_success`, `auth_failure` | | Account settings | `email_changed`, `name_changed`, `organization_name_changed`, `logo_updated` | | Custom domains | `custom_domain_added`, `custom_domain_removed`, `custom_domain_verified` | | Vault lifecycle | `vault_created`, `vault_deleted` | | Billing | `payment_added`, `payment_failed`, `credit_card_added`, `credit_card_removed` | | API keys | `api_key_created`, `api_key_revoked` | | SparkLink lifecycle | `sparklink_created`, `sparklink_deleted`, `sparklink_revoked` | | Verified-interaction receipts | `sparklink_accessed`, `sparklink_signed`, `sparklink_approved`, `sparklink_denied`, `sparklink_replied` | | Messaging governance | `messaging_conversation_created`, `messaging_participant_added`, `messaging_participant_removed`, `messaging_recipient_verified`, `messaging_message_sent`, `messaging_message_recalled`, `messaging_opted_out`, `messaging_opted_in`, `messaging_legal_hold_set`, `messaging_legal_hold_cleared`, `messaging_receipt_issued` | The account-scope `event_types` response set also includes platform-admin event types (`admin_impersonate`, `admin_account_deleted`, `admin_status_changed`, `admin_pricing_updated`, `admin_blocklist_source_toggled`, `admin_blocklist_domain_added`, `admin_blocklist_domain_removed`). These are recorded only under the SparkVault platform admin account, so your own audit logs will never contain them. > **Portable Receipts Live at the Account Scope** > > Verified-interaction receipt events (`sparklink_accessed`, `sparklink_signed`, `sparklink_approved`, `sparklink_denied`, `sparklink_replied`) are recorded at the **account** scope in plaintext so they can be presented as third-party proof without a vault key: the signed EdDSA receipt token and `action_hash` ride in `metadata` and are JWKS-verifiable. Messaging events never contain message content: only IDs, the verified sender, and the SparkVault-signed receipt token. ### Vault Events | Category | Event Types | | --- | --- | | Vault operations | `vault_renamed`, `vault_settings_changed`, `vault_unsealed`, `vault_sealed` | | Sharing | `sharing_enabled`, `sharing_disabled`, `sharing_config_changed` | | Upload portal and widget | `upload_portal_enabled`, `upload_portal_disabled`, `upload_portal_repaired`, `upload_widget_enabled`, `upload_widget_disabled`, `upload_widget_repaired` | | VMK hosting | `vmk_hosted`, `vmk_removed`, `hvmk_key_created` | | Delegated Vault Access Keys | `dvak_created`, `dvak_revoked`, `dvak_used`, `dvak_auto_revoked` | | Folders | `folder_created`, `folder_renamed`, `folder_moved`, `folder_deleted`, `folder_updated` | | Retention | `retention_changed` | ### Ingot Events | Category | Event Types | | --- | --- | | Lifecycle | `ingot_created`, `ingot_renamed`, `ingot_deleted`, `ingot_moved` | | Sharing | `ingot_shared`, `ingot_unshared`, `invite_created`, `invite_revoked` | | Access | `ingot_accessed`, `ingot_downloaded`, `ingot_uploaded` | | SparkLink access | `sparklink_accessed` | | Errors | `upload_error`, `download_error` | ### Integration and Product Events Integration-scope event types are **free-form strings**: integrations and products define their own. Common patterns: | Category | Event Types | | --- | --- | | Installation | `app_installed`, `app_uninstalled`, `app_config_changed` | | Identity Product | `passkey_created`, `passkey_removed` | | Slack Integration | `slack_connected`, `slack_disconnected`, `slack_message_sent` | | Structured Ingots Product | `structure_created`, `structure_updated`, `key_accessed` | ## Pagination and Filtering All list endpoints share the same cursor-based pagination and filtering contract: #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | Optional | Maximum entries per page. Values outside 1-100 are clamped into range.Default: `25` | | `cursor` | string | Optional | Opaque signed pagination cursor: pass back the `next_cursor` from the previous responseDefault: `null` | | `event_types` | string | Optional | Comma-separated event type filter, e.g. `?event_types=login,login_failed`Default: `none (all events)` | - **Ordering**: entries are returned newest-first; `timestamp` is epoch seconds. - **`has_more` / `next_cursor`**: `next_cursor` is present only when `has_more` is `true`. Keep requesting with `?cursor=` until `has_more` is `false`. - **Signed cursors**: cursors are HMAC-signed per account and bound to the exact audit scope they were minted for. A tampered cursor, a cursor from another account, or a cursor replayed against a different scope returns `400 VALIDATION_ERROR`. - **Filtered pages can run short**: with an `event_types` filter, a page may contain fewer than `limit` entries (even zero) while `has_more` is still `true`. Always page by `has_more`, not by page size. ## Account Audit Logs #### `GET /v1/audit-logs` List account-scope audit logs: authentication, user and license management, billing, API keys, custom domains, vault lifecycle, SparkLink receipts, and Messaging governance events. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | Optional | Maximum entries per page (1-100)Default: `25` | | `cursor` | string | Optional | Signed pagination cursor from a previous responseDefault: `null` | | `event_types` | string | Optional | Comma-separated filter of account event typesDefault: `none (all events)` | #### Response | Field | Type | Description | | --- | --- | --- | | `entries` | array | Audit log entries, newest first (see Entry Fields in the Overview) | | `count` | integer | Number of entries in this page | | `has_more` | boolean | Whether more pages exist | | `next_cursor` | string? | Signed cursor for the next page (present when `has_more` is true) | | `event_types` | string\[\] | Every valid account-scope event type, useful for building filter UIs | #### List Recent Logins Request ```bash curl "https://api.sparkvault.com/v1/audit-logs?limit=2&event_types=login,login_failed" \ -H "X-API-Key: sv_live_xxx" ``` Response ```json { "data": { "entries": [ { "timestamp": 1702000600, "event_type": "login", "actor_id": "usr_abc123...", "actor_type": "user", "actor_display": "admin@example.com", "ip_address": "203.0.113.7", "user_agent": "Mozilla/5.0...", "metadata": null }, { "timestamp": 1702000000, "event_type": "login_failed", "actor_id": null, "ip_address": "198.51.100.23", "user_agent": "Mozilla/5.0...", "metadata": null } ], "count": 2, "has_more": true, "next_cursor": "eyJrZXkiOnsi...", "event_types": ["user_invited", "user_removed", "user_role_changed", "..."] } } ``` ## Integration and Product Audit Logs Every installed [integration](/api/docs/integrations/) and every product keeps its own event stream, addressed by its slug. The API calls this scope `apps`. Integration-scope entries record `timestamp`, `event_type`, `metadata`, and `app_slug`. They carry no actor or IP fields. #### `GET /v1/audit-logs/apps/{app_slug}` List the audit log for one integration or product stream. #### Path Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `app_slug` | string | Required | Slug of the integration or product stream, e.g. `slack` | #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | Optional | Maximum entries per page (1-100)Default: `25` | | `cursor` | string | Optional | Signed pagination cursor from a previous responseDefault: `null` | | `event_types` | string | Optional | Comma-separated event type filterDefault: `none (all events)` | #### Response | Field | Type | Description | | --- | --- | --- | | `entries` | array | Audit log entries, newest first | | `count` | integer | Number of entries in this page | | `has_more` | boolean | Whether more pages exist | | `next_cursor` | string? | Signed cursor for the next page (present when `has_more` is true) | | `app_slug` | string | The requested stream slug | #### List Slack Integration Events Request ```bash curl "https://api.sparkvault.com/v1/audit-logs/apps/slack?limit=25" \ -H "X-API-Key: sv_live_xxx" ``` Response ```json { "data": { "entries": [ { "timestamp": 1702000000, "event_type": "slack_connected", "metadata": null, "app_slug": "slack" } ], "count": 1, "has_more": false, "app_slug": "slack" } } ``` #### `POST /v1/audit-logs/apps` Append an event to an integration or product stream. Event types are free-form, so your own automations can write their own audit trail. Returns 201 Created. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `app_slug` | string | Required | Stream to log under, e.g. `slack` | | `event_type` | string | Required | Free-form event identifier | | `metadata` | object | Optional | Arbitrary event details stored with the entry (plaintext; never put secrets here)Default: `null` | #### Response | Field | Type | Description | | --- | --- | --- | | `logged` | boolean | `true`: the entry was written | | `app_slug` | string | Stream the entry was written to | | `event_type` | string | The recorded event type | #### Log a Custom Event Request ```bash curl -X POST https://api.sparkvault.com/v1/audit-logs/apps \ -H "X-API-Key: sv_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "app_slug": "hubspot", "event_type": "export_completed", "metadata": { "record_count": 42 } }' ``` Response ```json { "data": { "logged": true, "app_slug": "hubspot", "event_type": "export_completed" } } ``` > **Append-Only** > > Audit streams are append-only: entries cannot be edited or deleted through the API, and each `POST` writes a new entry. SparkVault's own integrations and products log to these streams via internal service calls. ## Vault Audit Logs #### `GET /v1/vaults/{vault_id}/audit-logs` List vault-scope audit logs: settings, seal/unseal, sharing, upload portals, DVAKs, and folder operations. Requires an unsealed vault session (VAT). This endpoint requires the `X-Vault-Access-Token` header with a valid VAT obtained by [unsealing the vault](/api/docs/vaults/). Entry metadata is stored encrypted and is decrypted transparently with the VAK held by your VAT session. Vault-scope events cover the vault itself. Per-file events live on the [per-ingot endpoint](/api/docs/ingots/). #### Path Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `vault_id` | string | Required | Vault identifier (`vlt_...`) | #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | Optional | Maximum entries per page (1-100)Default: `25` | | `cursor` | string | Optional | Signed pagination cursor from a previous responseDefault: `null` | | `event_types` | string | Optional | Comma-separated filter of vault event typesDefault: `none (all events)` | #### Response | Field | Type | Description | | --- | --- | --- | | `entries` | array | Audit log entries, newest first; each includes `vault_id` | | `count` | integer | Number of entries in this page | | `has_more` | boolean | Whether more pages exist | | `next_cursor` | string? | Signed cursor for the next page (present when `has_more` is true) | | `vault_id` | string | The requested vault | | `event_types` | string\[\] | Every valid vault-scope event type | #### List Vault Events Request ```bash curl "https://api.sparkvault.com/v1/vaults/vlt_abc123/audit-logs?limit=25" \ -H "X-API-Key: sv_live_xxx" \ -H "X-Vault-Access-Token: vat_xyz789..." ``` Response ```json { "data": { "entries": [ { "timestamp": 1702000000, "event_type": "vault_unsealed", "actor_id": "usr_abc123...", "actor_type": "user", "actor_display": "admin@example.com", "ip_address": "203.0.113.7", "user_agent": "Mozilla/5.0...", "metadata": { "ttl_seconds": 3600 }, "vault_id": "vlt_abc123..." } ], "count": 1, "has_more": false, "vault_id": "vlt_abc123...", "event_types": ["vault_renamed", "vault_settings_changed", "..."] } } ``` > **VAT Context Checks** > > The VAT must belong to the requested vault and to your account: a mismatch, or a token that has expired, returns `403 FORBIDDEN`. A missing, malformed, or unknown token returns `401 AUTHENTICATION_ERROR`, and a vault that does not exist (or is not owned by your account) returns `404 NOT_FOUND`. ## Ingot Audit Logs #### `GET /v1/vaults/{vault_id}/ingots/{ingot_id}/audit-logs` List per-ingot audit logs: uploads, downloads, accesses, renames, moves, and sharing on a single file. Requires an unsealed vault session (VAT). The per-ingot endpoint follows the same auth, pagination, and filtering contract as the vault endpoint above: `X-Vault-Access-Token` required, encrypted metadata decrypted via your VAT session, and a response carrying `entries` (each with `vault_id` and `ingot_id`), `count`, `has_more`, `next_cursor`, and the valid ingot-scope `event_types`. Full documentation lives on the [Ingots page](/api/docs/ingots/). ## Retention Every audit entry is written with an expiry and is removed automatically when it lapses. The default retention window is **90 days** (7,776,000 seconds). Each vault additionally carries a configurable **access-log retention** setting (`GET`/`PUT /v1/vaults/{vault_id}/access-log-retention`, see the [Vaults docs](/api/docs/vaults/)) that governs how long ingot access events recorded through public sharing surfaces (public portal uploads and SparkLink downloads) are kept. Setting it to `0` (Disabled) turns that logging off entirely: no entries are written. The change itself is always recorded as a `retention_changed` vault event under the default window, so the configuration trail survives even when access logging is disabled. | Value (seconds) | Window | | --- | --- | | `0` | Disabled: no access-log entries are written | | `86400` | 24 hours | | `604800` | 7 days | | `2592000` | 1 month | | `5184000` | 2 months | | `7776000` | 3 months (default) | | `15552000` | 6 months | | `23328000` | 9 months | | `31536000` | 1 year | | `63072000` | 2 years | | `94608000` | 3 years | > **Deletion Cascades to Audit History** > > Audit logs are owned by the entity they describe. Deleting an ingot, a vault, an integration installation, or your account **permanently deletes** the audit history recorded under it. Export anything you need for compliance before deleting. ## Error Reference #### Error Responses | Status | Code | Description | | --- | --- | --- | | 400 | `VALIDATION_ERROR` | Invalid or tampered pagination cursor (wrong account or scope), or missing app\_slug/event\_type when creating an entry | | 401 | `AUTHENTICATION_ERROR` | Missing or invalid API key/JWT; missing, malformed, or unknown X-Vault-Access-Token on vault and ingot endpoints | | 403 | `FORBIDDEN` | The VAT belongs to a different vault or account than the one requested, or the token has expired | | 404 | `NOT_FOUND` | Vault does not exist or is not owned by your account | | 429 | `RATE_LIMIT_EXCEEDED` | Too many requests |