# 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_live_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 - [auth.sv Avatars](/api/docs/auth-sv/): Public avatar URLs you embed in an img tag (`my.auth.sv`) - [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_`. - **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 (epoch seconds) 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) All timestamps in API responses (`created_at`, `seen_at`, `resets_at`, `meta.timestamp`, and every other `*_at` field) are Unix epoch seconds, never milliseconds. ## 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 limit is reached (`details.resource` is `storage` or `bandwidth`). `details.action_required` indicates which capacity block 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 **5000 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 a hardened managed secret store - **Account Master Key (AMK)**: Your account's unique HMAC key managed in a FIPS 140-3 validated key management service - **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 envelope-wrapped by the key management service (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 > **Browser and native transports** > > Browser dashboard requests send `X-SV-Client: web` with credentials enabled. SparkVault stores the refresh credential only in the host-locked `__Host-sv_refresh` HttpOnly cookie and omits it from JSON. Native and server clients omit the browser marker, receive `refresh_token` in JSON, and send it in the refresh/logout body. Browser access tokens are memory-only. #### `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 | Native/server only: JWT refresh token (30 day lifetime). Browser responses set the HttpOnly cookie instead. | | `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 | Native/server only: identity-only refresh token. Browser responses set the HttpOnly cookie instead. | | `user` | null | null indicates registration required | | `account` | null | null indicates registration required | #### Example Native/server 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" }' ``` Native/server 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 | Native/server only: JWT refresh token (30 day lifetime). Browser responses set the HttpOnly cookie instead. | | `user` | object | User profile (null if registration required) | | `account` | object | Account information (null if registration required) | #### Example Native/server request ```bash curl -X POST https://api.sparkvault.com/v1/auth/identity/verify \ -H "Content-Type: application/json" \ -d '{"token": "eyJhbGciOiJFZDI1NTE5..."}' ``` Native/server 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 | Native/server only: full JWT refresh token. Browser responses set the HttpOnly cookie instead. | | `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` Rotate a refresh session and obtain a new access token. Browser callers send an empty body and authenticate with the HttpOnly cookie; native/server callers send the body token. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `refresh_token` | string | Optional | Required for native/server callers. Browser dashboard callers use the HttpOnly cookie and must omit this field. | #### Response | Field | Type | Description | | --- | --- | --- | | `access_token` | string | New JWT access token | | `refresh_token` | string | Native/server only: new rotated refresh token. Replace the prior value atomically. | | `token_type` | string | Always "Bearer" | | `expires_in` | integer | Access-token lifetime in seconds | | `session_type` | string | Authoritative session shape: full or identity\_only | | `user` | object|null | Current user for a full session; null for identity-only registration | | `account` | object|null | Current account for a full session; null for identity-only registration | #### Example Native/server request ```bash curl -X POST https://api.sparkvault.com/v1/auth/refresh \ -H "Content-Type: application/json" \ -d '{"refresh_token": "eyJhbGciOiJSUzI1NiIs..."}' ``` Native/server response ```json { "data": { "access_token": "eyJhbGciOiJSUzI1NiIs...", "refresh_token": "eyJhbGciOiJSUzI1NiIs...", "token_type": "Bearer", "expires_in": 3600, "session_type": "full", "user": { "user_id": "usr_abc123..." }, "account": { "account_id": "acc_xyz789..." } } } ``` > **Refresh Tokens Rotate on Every Use** > > Refresh tokens are **one-time-use**. Every successful call advances the session family's generation. Native/server clients must atomically replace the body token; the browser receives a rotated cookie automatically. Reusing a superseded generation returns `401 TOKEN_REPLAY` and revokes that session family, so the client must sign in again. > > One narrow exception makes a lost response recoverable: the token a rotation supersedes stays exchangeable for **30 seconds** from that rotation, so a call that timed out or dropped its response can be retried with the same token and will be issued the next generation. Retrying repeatedly inside those 30 seconds is safe; the window is measured from the rotation that superseded the token and is never extended by these retries. After it closes, the same token is treated as replay. > > A session family is also bound, when it is issued, to the delivery path it was issued on. A credential issued in the JSON body must always be presented in the body, and a browser cookie session must always refresh from the cookie. Presenting one on the other path returns `401` without revoking anything. Never copy a credential out of a browser session into a script or server job: give that job its own session, or the two holders will rotate independently and revoke each other. > > If a refresh credential is ever copied or leaked, destroy it by POSTing it to `/v1/auth/logout`. Revocation accepts the credential from any client holding it, on either delivery path, and kills the whole session family immediately. #### `POST /v1/auth/logout` Revoke the session authorized by the supplied refresh credential. No access-token bearer is required. Returns 204 No Content on success. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `refresh_token` | string | Optional | Required for native/server callers. Browser dashboard callers use the HttpOnly cookie and send an empty body. | #### Example Request ```bash curl -X POST https://api.sparkvault.com/v1/auth/logout \ -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` | A superseded refresh token was presented outside the 30-second grace window, or one more than a generation old. The session family is revoked; 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 | | 503 | `TOKEN_GENERATION_FAILED` | Signing infrastructure is temporarily unavailable; retry without discarding a valid refresh credential | ## 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 - Dashboard browsers keep access tokens in memory and refresh only through the host-locked HttpOnly cookie - Native and server clients keep refresh credentials in their platform secure store, never localStorage - Implement automatic token refresh before expiry - Native and server clients atomically persist each rotated response token before using it again - Clear the browser access token immediately when logout starts; the API revokes and clears the refresh cookie - Retry transient 503 responses; redirect to login only when refresh returns an authentication failure #### 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 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 a hardware security module, 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 properties of the validated HSM platform). For defense-in-depth, the hardware 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 hardware 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 hardware DRBG passes NIST statistical randomness tests and provides prediction resistance (guarantees of the validated HSM 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 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 Sparks are free on the SparkVault API. Every endpoint below requires no subscription, draws no storage or bandwidth pool, and carries no per-operation charges. (The Slack integration is the one exception: sending from `/secret` is gated on an active subscription. See [Slack](/api/docs/integrations/slack/).) For subscription tiers and capacity blocks on other products, see the [pricing page](/pricing/). | Operation | Usage | | --- | --- | | Create Spark | Free on every account | | Read Spark (burn) | Free on every account | | List Sparks | Free on every account | | Delete Spark | Free on every account | ## 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 a hardened managed secret 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 service. The API calls it `GenerateMac` (HMAC-SHA512) to produce a per-Spark salt; the key itself never leaves the key management service. The per-Spark encryption key is derived with HKDF from the ML-KEM-1024 shared secret and the hardware 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 | | 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 secure shared links for sparks and ingots, including durable ingot shares and single-use SparkLinks. Canonical: https://sparkvault.com/api/docs/sparklinks/ · OpenAPI: https://sparkvault.com/openapi.yaml ## Overview SparkVault uses one grant system for sparks and ingots with configurable visibility and expiration. Spark and one-time ingot grants use `x.sv` and are consumed after one access. Durable ingot grants use `files.sv` and remain available until the owner disables sharing. ### Key Features - **Unified Sharing**: One system for sparks and ingots - **Visibility Controls**: Public, authenticated, or invite-only access - **Purpose-Built Lifecycles**: Single-use links for sparks and one-time ingot delivery; durable links for ongoing ingot sharing - **Expiration**: Automatic expiration for single-use grants - **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 a vault file through a durable link or an independent one-time link | `PUT /v1/vaults/{vault_id}/ingots/{ingot_id}/sharing` (durable), or `POST /v1/vaults/{vault_id}/ingots/{ingot_id}/sparklinks` (one-time) | ### 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 Sparks and one-time ingot links use `x.sv/{link_code}`. Durable ingot shares use `files.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} for sparks and one-time ingot links; https://files.sv/{link\_code} for durable ingot shares | | `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 | Lifecycle status: active, consumed (single-use grants after access), or 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} for sparks and one-time ingot links; https://files.sv/{link\_code} for durable ingot shares | | `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 | Lifecycle status: active, consumed (single-use grants after access), or 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 Spark** > > Each spark has at most one SparkLink. An ingot can have one durable files.sv share plus independent, expiring one-time SparkLinks. 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 through one permanent `files.sv` URL. Public, authenticated, and invite-only policies update that durable URL in place. One-time SparkLinks are independent, expiring `x.sv` grants that are consumed when their decrypt stream is issued. #### `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? | Permanent share URL: https://files.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://files.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 to the durable share (body: `identity`) > - `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 > - `POST /v1/vaults/{vault_id}/ingots/{ingot_id}/sparklinks`: Mint an independent one-time x.sv URL (body: `expires_in_seconds`, 300–86400) > - `POST /v1/vaults/{vault_id}/ingots/{ingot_id}/sparklinks/{code}/deliver`: Send an existing one-time URL to an email or E.164 phone (body: `identity`) ## 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. Shares remain on the platform-owned `x.sv` and `files.sv` domains, so there is no link-hosting step here. A domain is only ever stored **verified**: request the TXT record from the challenge endpoint, publish it at your DNS host, then claim the domain — the claim runs the DNS check and stores the domain only when it passes. There is no pending or unverified state. Listing domains works with any authenticated caller (JWT or API key); the challenge, claim, and delete endpoints are **admin-only** (JWT admin session). #### `GET /v1/domains` List the account's verified domains. #### Response | Field | Type | Description | | --- | --- | --- | | `domains` | array | Array of domain objects | | `domains[].domain` | string | The hostname | | `domains[].verification_method` | string | Always dns\_txt | | `domains[].txt_name` | string | TXT record name (\_sparkvault.{domain}) — keep it published so ownership stays verifiable | | `domains[].txt_value` | string | TXT record value (sparkvault-domain-verification=svdv\_...) | | `domains[].created_at` | integer | Claim timestamp | | `domains[].verified_at` | integer | Ownership verification timestamp (equal to created\_at — a domain is stored only once verified) | #### `POST /v1/domains/challenge` Get the TXT record to publish for a domain (admin only). Stores nothing — the record is derived, so you can request it now and claim the domain whenever DNS has propagated. Rejections DNS cannot fix (a domain claimed by another organization, an invalid hostname, the 10-domain account limit) are raised here, before you touch your DNS host. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `domain` | string | Required | Hostname to claim (e.g. app.example.com). SparkVault-owned domains are rejected. | #### Response | Field | Type | Description | | --- | --- | --- | | `domain` | string | The normalized hostname | | `txt_name` | string | TXT record name to create (\_sparkvault.{domain}) | | `txt_value` | string | TXT record value (sparkvault-domain-verification=svdv\_...) | #### Get the TXT Record Request ```bash curl -X POST https://api.sparkvault.com/v1/domains/challenge \ -H "Authorization: Bearer ADMIN_JWT" \ -H "Content-Type: application/json" \ -d '{ "domain": "app.acme.com" }' ``` Response ```json { "data": { "domain": "app.acme.com", "txt_name": "_sparkvault.app.acme.com", "txt_value": "sparkvault-domain-verification=svdv_..." } } ``` #### `POST /v1/domains` Claim a domain (admin only). Runs the DNS TXT check and stores the domain only if it passes — a failed check stores nothing, and the error names the exact record to fix. Maximum 10 domains per account. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `domain` | string | Required | Hostname whose TXT record (from the challenge endpoint) is already published. | #### Response | Field | Type | Description | | --- | --- | --- | | `domain` | object | The stored, verified domain object (see GET /v1/domains fields) | | `message` | string | Confirmation | #### Verify & Claim a 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", "verification_method": "dns_txt", "txt_name": "_sparkvault.app.acme.com", "txt_value": "sparkvault-domain-verification=svdv_...", "created_at": 1702000600, "verified_at": 1702000600 }, "message": "app.acme.com is verified and ready to use." } } ``` #### `DELETE /v1/domains/{domain}` Remove a domain (admin only) and release its global claim. #### Response | Field | Type | Description | | --- | --- | --- | | `deleted` | boolean | Always true on success | | `domain` | string | The removed hostname | > **Global Domain Claims** > > A successful claim reserves the domain globally: a domain verified by one organization cannot be claimed by another (the attempt fails with `400 VALIDATION_ERROR`). Keep the TXT record published — it is the standing proof of ownership behind cross-domain SSO and Identity SDK browser origins. ## 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 A SparkLink backed by a Spark is free, like the Spark itself. A SparkLink backed by an ingot draws the bandwidth pool included with your plan when the file is downloaded. There are no per-operation charges either way. For subscription tiers and capacity blocks, see the [pricing page](/pricing/). | Operation | Usage | | --- | --- | | Create SparkLink | Free on every account | | Access SparkLink | Counts against pooled bandwidth (included) | | List SparkLinks | Free on every account | | Delete SparkLink | Free on every account | ## 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 | | 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. Forge requires every non-final // chunk to be exactly X-Chunk-Size (52,428,800 bytes); tus-js-client cannot // read that header, so the value is pinned. const forgeUrl = new URL(forge_url); const istk = forgeUrl.searchParams.get('istk'); const upload = new tus.Upload(file, { endpoint: `${forgeUrl.origin}/encrypt`, chunkSize: 52428800, headers: { 'X-ISTK': istk }, retryDelays: [0, 1000, 3000, 5000, 10000] }); upload.start(); // Status will change: uploading → active ``` ### List Ingots #### `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`); `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'); // Every non-final chunk must be exactly Forge's X-Chunk-Size (52,428,800 bytes). const upload = new tus.Upload(file, { endpoint: `${forgeUrl.origin}/encrypt`, chunkSize: 52428800, headers: { 'X-ISTK': istk }, metadata: { filename: file.name, filetype: file.type || 'application/octet-stream' } }); upload.start(); ``` ```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 | PATCH chunk size: 52,428,800 bytes (50 MB). Every non-final chunk must be exactly this size; read it from this response rather than assuming it. | ```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 | The session's PATCH chunk size (same value as the create response); every non-final chunk must be exactly this size | ### Cancel Upload #### `DELETE /encrypt/{upload_id}` Cancel an in-progress upload. Deletes the staged encrypted chunks (the partial transfer counts against your bandwidth pool) and returns 204 No Content. 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'; // Forge fixes every session's chunk geometry: each non-final PATCH must be // exactly X-Chunk-Size (52,428,800 bytes). tus-js-client cannot size its // PATCHes from a response header, so pin the value here. const FORGE_CHUNK_SIZE = 52428800; function uploadWithForge(file, forgeUrl) { const url = new URL(forgeUrl); const istk = url.searchParams.get('istk'); const upload = new tus.Upload(file, { endpoint: `${url.origin}/encrypt`, chunkSize: FORGE_CHUNK_SIZE, // tus-js-client sends headers on every request; Forge only requires // X-ISTK on session creation and ignores it elsewhere. headers: { 'X-ISTK': istk }, 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 def upload_file(forge_url: str, input_path: str): """Upload a file through Forge with TUS.""" parsed = urlparse(forge_url) istk = parse_qs(parsed.query)['istk'][0] endpoint = f'{parsed.scheme}://{parsed.netloc}/encrypt' size = os.path.getsize(input_path) # X-ISTK is only needed here, on session creation create = requests.post( endpoint, headers={ 'Tus-Resumable': '1.0.0', 'Upload-Length': str(size), 'X-ISTK': istk, }, ) create.raise_for_status() upload_url = urljoin(endpoint, create.headers['Location']) # Forge dictates the chunk geometry: every non-final PATCH must be # exactly this many bytes, so never substitute a value of your own. chunk_size = int(create.headers['X-Chunk-Size']) offset = 0 with open(input_path, 'rb') as f: while offset < size: chunk = f.read(chunk_size) patch = requests.patch( upload_url, headers={ 'Tus-Resumable': '1.0.0', 'Upload-Offset': str(offset), 'Content-Type': 'application/offset+octet-stream', }, data=chunk, ) patch.raise_for_status() offset = int(patch.headers['Upload-Offset']) def decrypt_file(download_url: str, output_path: str): """Decrypt a file through Forge. The download_url is provided by the Ingot download endpoint and includes the ISTK as a query parameter. Default download URLs are single-use. """ response = requests.get(download_url, stream=True) response.raise_for_status() with open(output_path, 'wb') as out: for chunk in response.iter_content(chunk_size=8192): out.write(chunk) print(f'Decrypted: {output_path}') # Usage upload_file(forge_url, 'report.xlsx') decrypt_file(download_url, 'report-restored.xlsx') ``` ## Common Use Cases #### Database Backups Encrypt database dumps before storing them as ingots, using the same resumable upload flow as every other file. #### Video Content Protection Encrypt video files of any size for DRM or content protection before distribution. #### Log Encryption Encrypt log files containing sensitive data before archival to meet compliance requirements. #### File Transfer Security Add end-to-end encryption to file transfers without modifying client applications. #### IoT Data Encryption Encrypt sensor data and telemetry from devices that lack cryptographic capabilities. #### Medical Imaging Encrypt DICOM files and medical images for HIPAA-compliant storage. ## Security Details ### Encryption Algorithm Forge uses AES-256-GCM (Galois/Counter Mode) for authenticated chunk encryption: - **Key Size**: 256 bits (32 bytes), the vault-level Vault Access Key (VAK) - **IV/Nonce**: a random 96-bit base nonce per upload; each chunk's nonce is derived deterministically as base nonce XOR chunk index - **Authentication Tag**: 128 bits (16 bytes) per chunk for integrity verification - **Additional Authenticated Data**: every chunk's AAD binds the account, vault, ingot, and chunk index, so ciphertext cannot be replayed across ingots or reordered within one - **Mode**: Galois/Counter Mode provides both confidentiality and authenticity ### Key Handling Forge never derives or stores long-lived keys of its own. The key arrives with the ISTK: 1. The Core API envelope-encrypts the vault's VAK into the ISTK record: an HSM-derived key-encryption key wraps a random data key, which wraps the VAK 2. Forge validates the ISTK, derives the same KEK from the HSM, 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) | | [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 | Identity also publishes one surface that is not an API at all: [auth.sv public avatars](/api/docs/auth-sv/), a plain image URL on `my.auth.sv` that needs no account, no key, and no SDK. > **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 Default, RS256 Opt-In ### 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 // npm install jose import * as jose from 'jose'; const ISSUER = 'https://auth.sparkvault.com/acc_xxx'; // Caches the JWKS and picks the key by the token's `kid`. const JWKS = jose.createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`)); const { payload } = await jose.jwtVerify(token, JWKS, { issuer: ISSUER, audience: 'acc_xxx', algorithms: ['EdDSA'] // signIn() tokens are always EdDSA }); const svid = payload.svid || payload.sub; ``` ```python # pip install "pyjwt[crypto]" — EdDSA needs the `cryptography` extra import jwt from jwt import PyJWKClient ISSUER = "https://auth.sparkvault.com/acc_xxx" # Caches the JWKS and picks the key by the token's `kid`. jwks_client = PyJWKClient(f"{ISSUER}/.well-known/jwks.json") signing_key = jwks_client.get_signing_key_from_jwt(token) decoded = jwt.decode( token, signing_key.key, algorithms=["EdDSA"], # signIn() tokens are always EdDSA issuer=ISSUER, audience="acc_xxx" ) svid = decoded.get("svid") or decoded["sub"] ``` ```go // go get github.com/lestrrat-go/jwx/v2 import ( "context" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" ) const issuer = "https://auth.sparkvault.com/acc_xxx" const jwksURL = issuer + "/.well-known/jwks.json" // Caches the JWKS; jwx picks the key by `kid` and honours the JWK's `alg`, // so the same code verifies an EdDSA or an RS256 token. cache := jwk.NewCache(context.Background()) cache.Register(jwksURL) tok, err := jwt.ParseString(tokenString, jwt.WithKeySet(jwk.NewCachedSet(cache, jwksURL)), jwt.WithIssuer(issuer), jwt.WithAudience("acc_xxx"), ) if err != nil { // Reject the request: signature, issuer, audience, or expiry failed. } svid := tok.Subject() if v, ok := tok.Get("svid"); ok { if s, isString := v.(string); isString && s != "" { svid = s } } ``` ```ruby # Gemfile: gem 'jwt' + gem 'jwt-eddsa' # # ruby-jwt 3.x ships EdDSA and its OKP JWK class in the separate jwt-eddsa gem. # Without it, JWT::JWK::Set silently drops the Ed25519 key and decode fails with # "Could not find public key for kid" — which reads like a server-side bug. require 'jwt' require 'jwt/eddsa' require 'json' require 'net/http' ISSUER = 'https://auth.sparkvault.com/acc_xxx' # JWT::JWK::Set picks the key by the token's `kid`. jwks = JWT::JWK::Set.new( JSON.parse(Net::HTTP.get(URI("#{ISSUER}/.well-known/jwks.json"))) ) decoded, = JWT.decode(token, nil, true, { algorithms: ['EdDSA'], # signIn() tokens are always EdDSA jwks: jwks, iss: ISSUER, aud: 'acc_xxx', verify_iss: true, verify_aud: true }) svid = decoded['svid'] || decoded['sub'] ``` ```php // composer require firebase/php-jwt use Firebase\JWT\JWT; use Firebase\JWT\JWK; $issuer = 'https://auth.sparkvault.com/acc_xxx'; // parseKeySet returns a kid => Key map and carries each JWK's `alg`, // so decode() selects the right key and algorithm on its own. $jwks = json_decode(file_get_contents("{$issuer}/.well-known/jwks.json"), true); $keys = JWK::parseKeySet($jwks); $decoded = JWT::decode($token, $keys); // php-jwt checks the signature and expiry only — check iss/aud yourself. if ($decoded->iss !== $issuer) { throw new Exception('Invalid issuer'); } if ($decoded->aud !== 'acc_xxx') { throw new Exception('Invalid audience'); } $svid = $decoded->svid ?? $decoded->sub; ``` ```c# // The token signIn() returns is Ed25519 (EdDSA). Microsoft.IdentityModel does // not implement OKP/Ed25519 keys, so it cannot verify this token on any // algorithm setting — JsonWebKeySet skips the OKP entry and no signing key // resolves. Two ways forward on .NET: // // 1. Verify EdDSA with a library that supports it (BouncyCastle exposes // Ed25519Signer, over the `x` member of the OKP JWK). // 2. Use the OIDC authorization-code flow instead of the widget, and register // that client with id_token_signed_response_alg: "RS256". Its id_token is // then RSA-signed and Microsoft.IdentityModel verifies it natively. // See "Quick Start (OIDC)" below. // // Option 2, for reference. NOTE the audience: an OIDC id_token is issued TO A // CLIENT, so `aud` is the client_id — not the account id the widget token uses. using System.IdentityModel.Tokens.Jwt; using System.Threading; using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; const string issuer = "https://auth.sparkvault.com/acc_xxx"; const string clientId = "your_registered_client_id"; // Keep `sub` as `sub` instead of remapping it to ClaimTypes.NameIdentifier. JwtSecurityTokenHandler.DefaultMapInboundClaims = false; // Caches the discovery document and the JWKS, refreshing on an unknown kid. var configManager = new ConfigurationManager( $"{issuer}/.well-known/openid-configuration", new OpenIdConnectConfigurationRetriever()); var oidcConfig = await configManager.GetConfigurationAsync(CancellationToken.None); var handler = new JwtSecurityTokenHandler(); var principal = handler.ValidateToken(idToken, new TokenValidationParameters { IssuerSigningKeys = oidcConfig.SigningKeys, ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 }, ValidateIssuer = true, ValidIssuer = issuer, ValidateAudience = true, ValidAudience = clientId }, out _); var svid = principal.FindFirst("svid")?.Value ?? principal.FindFirst("sub")?.Value; ``` > **Signing algorithm** > > Tokens are **EdDSA (Ed25519)** signed by default. A registered OIDC client that sets `id_token_signed_response_alg: "RS256"` at creation gets both its `id_token` and its access token signed RS256 instead — the escape hatch for stacks whose JWT library has no EdDSA support (.NET being the common one). The opt-in is scoped to that client's OIDC tokens: SDK/simple-mode tokens and simple-redirect callback signatures are always EdDSA. The JWKS publishes both keys, so verifiers select by `kid` either way. > > A client that registers no algorithm gets **EdDSA**, not RS256. That is a deliberate deviation from OIDC Dynamic Registration §2, which names RS256 as the default for an omitted `id_token_signed_response_alg`: every SparkVault integration verifies EdDSA, and applying the spec default would re-sign their tokens with a key their verifiers have never fetched. Set the field explicitly to opt in. > **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. `/introspect` is confidential-clients-only: it authenticates with `client_secret_basic` or `client_secret_post`, so a public (PKCE, no-secret) client cannot use it. 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. > **Subject stability** > > A SVID is minted once and never rotates — no re-verification, recovery, or credential change ever alters it. Two user-initiated lifecycle events can _end_ a SVID: (1) **identity merge** — when a person proves control of an identifier already linked to a second identity (step-up-verified in the auth.sv portal), the two records become one; the surviving SVID is kept and the other is permanently deleted, so the person's future logins present the surviving SVID as `sub`. (2) **identity deletion** — permanently ends a SVID; a later re-signup is a new identity with a new SVID. If a login presents a verified identifier you already have bound to a different SVID, treat it as a possible post-merge return of the same person and re-bind per your own policy. > **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 }); }); ``` > **SAML metadata URL requirements** > > A configured `metadata_url` must be a direct public `https://` endpoint. URLs with embedded credentials and all redirects are rejected. SparkVault validates public DNS answers during the HTTPS connection, pins the validated destination, applies a 10-second total timeout, and accepts at most 1 MiB of metadata. ## Cross-Domain SSO One SparkVault Identity signs a person into every site your account runs. Each successful sign-in through the first-party popup (or hosted page) also establishes a 30-day SSO session on the Identity origin — a first-party `HttpOnly` cookie set in a top-level window, never a third-party context, so it is unaffected by Safari ITP or Chrome's third-party-cookie phase-out. ### What the person sees A person who signed in on one of your domains and later visits another clicks your sign-in button, and the popup offers a one-click **“Continue as …”** — no OTP, passkey ceremony, or typing. With several SparkVault accounts signed in on the browser (up to five), the popup instead lists them in an account chooser, most recent first: picking a row continues as that account, each row can be removed (which ends that account's SSO session in that browser), and **“Use another account”** starts a fresh interactive sign-in whose account joins the list. The result your page receives is the same shape as any other sign-in (the token's `method`/`amr` value is `sso`): verify it server-side and mint your own session exactly as usual. Pass `signIn({ prompt: 'select_account' })` to always show the chooser — the affordance for a “switch account” button — even when only one account is signed in. Sign-in is always an explicit gesture. The SDK never signs a visitor in silently on page load, and landing on a sibling domain does not by itself create a session — the silent re-mint inside `handleRedirectResult()` only completes a sign-in the person already started. For a browser-native one-click without the popup, add FedCM (`signInWithFedcm()` — see the [JavaScript SDK](/api/docs/sdk-js/)); FedCM is also the one surface that can sign a person into a brand-new site in a single gesture, since the browser's account chooser carries the consent. Feature-detect it and fall back to `signIn()`. ### Requirements - **One account, every domain verified.** Cross-domain SSO connects the verified domains of a _single_ account: fetch each apex's TXT record via `POST /v1/domains/challenge`, publish it, then claim the domain with `POST /v1/domains` — the claim verifies DNS and stores the domain only on success (an apex covers its subdomains, and a domain can be verified by only one account). Sites on separate SparkVault accounts do not share SSO. - **A prior sign-in to your account.** The one-click and silent paths only ever resume an existing relationship — the person's first sign-in on any of your domains establishes it. A silent path never creates a new relationship. - **Account posture.** The `sso.enabled` setting (on by default) controls participation; opting out makes every sign-in fully interactive even with a live SSO session. > **Requiring a fresh authentication** > > A silent or one-click continuation reuses an authentication that may be up to 30 days old. When an entry point needs a recent one, send `max_age` (seconds) on the OIDC authorize request: a candidate authenticated longer ago than that is never minted silently, and `max_age=0` forces an active re-authentication. The ID token's `auth_time` reports the real authentication time — the SSO session's creation on a silent mint — so it is a claim you can hold the flow to. > **Sign-out expectations** > > Ending your own application session does not end the person's SparkVault SSO session — like any federated identity provider, the next sign-in click is a one-click “Continue as …” rather than a full re-verification. The person can end an SSO session themselves — by removing that account from the sign-in chooser, or from their auth.sv portal (deep-link there with `portalUrl()`), which also manages and revokes per-site access. If your application needs SparkVault-owned revocation — where ending the SparkVault session invalidates your app's sessions too — use the OIDC/BFF integration with `/introspect` and `/revoke`. > **One brand per account** > > The sign-in popup, hosted page, and portal show your account's organization branding. All domains of an account share that one brand surface — weigh this when deciding whether differently-branded properties should share an account for SSO. ## 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()`) | | `prompt` | string | Optional | `select_account` — always show the sign-in page's account chooser instead of silently continuing as the current account (the affordance for a "switch account" button). Any other value is ignored. | | `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) | | `picture` | string? | Public avatar URL — the stable `https://my.auth.sv/{svid}/avatar` — present only while the person publishes a visible photo. Embed it directly in an ``. Its _presence_ is the supported signal that a real photo exists, since the URL itself always renders. See [auth.sv Public Avatars](/api/docs/auth-sv/). | | `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 * as jose from 'jose'; // Module scope: createRemoteJWKSet caches the key set and re-fetches on an // unknown kid. The JWKS carries an Ed25519 and an RSA key; jose selects by kid. const jwksCache = new Map(); const jwksFor = (accountId) => { if (!jwksCache.has(accountId)) { jwksCache.set(accountId, jose.createRemoteJWKSet( new URL(`https://auth.sparkvault.com/${accountId}/.well-known/jwks.json`) )); } return jwksCache.get(accountId); }; async function verifyIdToken(idToken, accountId, clientId, nonce) { const { payload } = await jose.jwtVerify(idToken, jwksFor(accountId), { issuer: `https://auth.sparkvault.com/${accountId}`, audience: clientId, algorithms: ['EdDSA'] // ['RS256'] when the client registers id_token_signed_response_alg }); // jose checks signature, issuer, audience, and expiry. The nonce is yours. if (payload.nonce !== nonce) { throw new Error('Invalid nonce'); } 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", "end_session_endpoint": "https://auth.sparkvault.com/acc_example/end_session", "userinfo_endpoint": "https://auth.sparkvault.com/acc_example/userinfo", "jwks_uri": "https://auth.sparkvault.com/acc_example/.well-known/jwks.json", "response_types_supported": ["code"], "response_modes_supported": ["query"], "prompt_values_supported": ["none", "login", "consent", "select_account"], "grant_types_supported": ["authorization_code", "refresh_token"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["EdDSA", "RS256"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic", "none"], "revocation_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic", "none"], "introspection_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], "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", "picture", "name", "given_name", "middle_name", "family_name"], "code_challenge_methods_supported": ["S256"], "scopes_supported": ["openid", "email", "phone", "profile"] } ``` ### JWKS (Public Keys) #### `GET /{account_id}/.well-known/jwks.json` Returns the JSON Web Key Set containing the tenant's Ed25519 and RSA-2048 public keys for verifying token signatures. ```json { "keys": [ { "kty": "OKP", "crv": "Ed25519", "use": "sig", "alg": "EdDSA", "kid": "ed25519-key-id", "x": "base64url-encoded-public-key" }, { "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "rsa-key-id", "n": "base64url-encoded-modulus", "e": "AQAB" } ] } ``` > **Two keys, select by kid** > > Every tenant publishes both keys: the Ed25519 key that signs by default, and the RSA-2048 key that signs for clients registered with `id_token_signed_response_alg: "RS256"`. Each JWK carries its own `alg`, so select the key by the `kid` in the JWT header — never by array position. Cache the response for 5-10 minutes and re-fetch on an unknown `kid`. Standard JWKS clients (jose, PyJWT's `PyJWKClient`, php-jwt's `parseKeySet`, jwx's key set) do this for you. Two need a caveat: Ruby's `JWT::JWK::Set` resolves the Ed25519 key only with the `jwt-eddsa` gem loaded, and Microsoft.IdentityModel resolves the **RSA key only** — it has no EdDSA support at all, which is exactly what `id_token_signed_response_alg: "RS256"` exists for. ### 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. | | `prompt` | string | Optional | Values per `prompt_values_supported`: `none`, `login`, `consent`, `select_account`. `none` never shows UI — silent success, or `login_required` / `account_selection_required` on your redirect\_uri; it cannot be combined with another value (`invalid_request`). `select_account` always shows the account chooser when at least one account is signed in. | | `max_age` | integer | Optional | Maximum acceptable age, in seconds, of the person's authentication. Must be a non-negative integer; any other value expresses no enforceable constraint and is read as absent rather than rejected, so a malformed `max_age` silently applies no freshness gate. `max_age=0` forces an active re-authentication on every request. The returned ID token's `auth_time` is the value it is judged against. | > **max\_age and freshness** > > A candidate whose authentication is older than `max_age` — or whose authentication time cannot be established at all — is refused at the mint. It is dropped before selection at `/authorize`, and refused again when the person picks an account, so it can never produce a credential; a stale chip clicked in the account chooser falls through to interactive login rather than returning one. Combined with `prompt=none`, a too-old session returns `login_required` rather than a stale credential. ### 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 | Signed JWT containing user claims (authorization\_code grant). EdDSA by default; RS256 when the client is registered with `id_token_signed_response_alg: "RS256"` | | `access_token` | string | Short-lived bearer token containing svid and session\_id, signed with the same algorithm as the client's id\_token | | `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. ### UserInfo Endpoint #### `GET /{account_id}/userinfo` Returns claims about the person the access token was issued for. Also accepts POST. Present the access token as a Bearer credential: `Authorization: Bearer ` on either verb, or as an `access_token` form field on `POST` with `Content-Type: application/x-www-form-urlencoded`. The token is the authorization, so no client authentication is required. A token in the query string is not accepted, because URLs reach logs, referrers and browser history. #### Response | Field | Type | Description | | --- | --- | --- | | `sub` | string | Always returned. The same value as the `sub` claim of the id\_token issued for this session | | `name` | string | `profile` scope. The person's full name, composed from the parts below | | `given_name` | string | `profile` scope | | `middle_name` | string | `profile` scope | | `family_name` | string | `profile` scope | | `email` | string | `email` scope | | `email_verified` | boolean | `email` scope | | `phone_number` | string | `phone` scope | | `phone_number_verified` | boolean | `phone` scope | A claim is present only when the grant carried the scope that releases it and the person has a value for it. Claims are read when you call, not when the token was issued, so a name the person changed after signing in is reflected here rather than in the id\_token they already hold. > **What this endpoint does not return** > > A person may also record a title, a suffix, and any number of alternative names. OpenID Connect defines no standard claim for those, so they are never placed in a token or returned here. `picture` is likewise not a UserInfo claim: read it from the `id_token`. > **Errors follow RFC 6750** > > A missing, malformed, expired or revoked token returns `401` with a `WWW-Authenticate: Bearer` challenge. A token whose grant lacks the `openid` scope returns `403` with `error="insufficient_scope"`. The response never reveals whether a subject exists. ### Introspection Endpoint #### `POST /{account_id}/introspect` Checks whether an access token is still active for its managed Identity session. Confidential clients only. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `token` | string | Required | Access token returned by the token endpoint | | `client_id` | string | Required | Your registered client ID | | `client_secret` | string | Optional | Your client secret, when authenticating with `client_secret_post`. Use the `Authorization: Basic` header instead for `client_secret_basic`. One of the two is required. | > **Confidential clients only** > > `/introspect` accepts only `client_secret_basic` or `client_secret_post` (as advertised in `introspection_endpoint_auth_methods_supported`). A public client — one registered with `token_endpoint_auth_method: "none"`, such as a browser SPA using PKCE — is rejected with `401 invalid_client`. Per RFC 7662 an unauthenticated introspection endpoint is a token-scanning oracle, so there is no public-client path. #### 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. | ### End Session (RP-Initiated Logout) #### `GET /{account_id}/end_session` Browser-navigated sign-out (OpenID Connect RP-Initiated Logout 1.0). Ends the RP session your id_token identifies, then returns the browser to your registered post-logout URI — after a brief interstitial where the person chooses whether to also end SparkVault-wide SSO. Also accepts POST. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `id_token_hint` | string | Optional | The ID token issued to your client. An expired token is accepted as long as its signature verifies. Required for post\_logout\_redirect\_uri to be honored and for targeted session revocation (via its session\_id claim). | | `post_logout_redirect_uri` | string | Optional | Where to send the browser afterwards. Honored only when it exactly matches one of the client's registered post\_logout\_redirect\_uris (registered when the client is created). An unregistered or absent value is ignored — the sign-out still completes and the browser lands on the SparkVault signed-out page. | | `state` | string | Optional | Opaque value echoed back on the post-logout redirect | > **SparkVault-wide sign-out is the person's choice** > > `end_session` ends _your site's_ session immediately. Whether to also end the person's SparkVault single sign-on on that browser is confirmed with the person on an interstitial page — a customer site cannot silently sign someone out of every SparkVault-connected site. Navigate the browser top-level (not fetch/XHR): the sign-out state lives in cookies on the identity origin. ## 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) by default, or RS256 when the client is registered with `id_token_signed_response_alg: "RS256"`. 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 | The real time the person authenticated (Unix timestamp): the completion of the sign-in ceremony for an interactive login, or the creation of the SparkVault SSO session for a silent SSO mint — never the time this token was issued. Compare it against your own freshness policy, or state that policy up front with `max_age` on the authorize request. | | `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 | | `session_id` | string | The managed Identity session this token was minted against — what `end_session` revokes when the token is presented as `id_token_hint` | | `picture` | string? | Public avatar URL — the stable `https://my.auth.sv/{svid}/avatar` — present only while the person publishes a visible photo. The standard OIDC claim name (OIDC Core 1.0 §5.1), so client libraries that map `picture` to a profile image need no custom wiring. Its _presence_ is the supported signal that a real photo exists, since the URL itself always renders. See [auth.sv Public Avatars](/api/docs/auth-sv/). | ```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", "session_id": "sess_1f4a9c2d7b3e48a0", "identity": "user@example.com", "identity_type": "email", "email": "user@example.com", "email_verified": true, "amr": ["passkey"], "acr": "urn:sparkvault:identity:1" } ``` ## Public Avatar Lookup Every person has one stable, unauthenticated avatar URL — the same URL the `picture` claim carries — and it always renders an image. You can also resolve an avatar from an email address or phone number you already hold, with no sign-in involved. It is a plain image URL: no SDK, no API key, no CORS. ```html ``` The selector is either an SVID (`ing_` plus 32 lowercase hex characters) or exactly 64 lowercase hex characters, the SHA-256 of the normalized identifier. The full reference — the hashing rules, framework snippets, the CSP line, response and caching contracts, what the person controls, and how to debug a broken embed — lives on its own page: [**auth.sv Public Avatars →**](/api/docs/auth-sv/) ## 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.js'; async function verifyCallback(params, accountId) { // Fetch the JWKS. It carries an Ed25519 key and an RSA key; the callback // signature is always Ed25519, and it carries no header to name a kid, so // select the key by its type rather than by array position. const jwksResponse = await fetch( `https://auth.sparkvault.com/${accountId}/.well-known/jwks.json` ); const jwks = await jwksResponse.json(); const jwk = jwks.keys.find(k => k.kty === 'OKP' && k.crv === 'Ed25519'); if (!jwk) throw new Error('No Ed25519 key in JWKS'); const publicKeyBytes = base64urlDecode(jwk.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` reports the account's cross-domain SSO posture (see the Cross-Domain SSO section): `enabled` — on by default — governs whether the account participates in silent SSO and the sign-in popup's one-click “Continue as …” and account chooser, and is enforced server-side (it is account configuration, not an SDK setting); `forced` is reserved for accounts where the hosted page is the only sign-in surface. 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.js'; 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. The set carries an Ed25519 key and an RSA key, so always // select by the kid in the token header — never by array position. let jwksCache = null; let jwksCacheExpiry = 0; async function getJwks(forceRefresh = false) { if (!forceRefresh && jwksCache && Date.now() < jwksCacheExpiry) { return jwksCache; } const res = await fetch(`${IDENTITY_URL}/.well-known/jwks.json`); jwksCache = (await res.json()).keys; jwksCacheExpiry = Date.now() + 5 * 60 * 1000; // 5 min return jwksCache; } async function getPublicKey(kid) { let keys = await getJwks(); let jwk = keys.find(k => k.kid === kid); if (!jwk) { keys = await getJwks(true); // unknown kid: re-fetch once jwk = keys.find(k => k.kid === kid); } if (!jwk) throw new Error(`No JWKS key for kid ${kid}`); // This example verifies the default EdDSA signing. A client registered with // id_token_signed_response_alg: "RS256" gets the RSA key here instead. if (jwk.alg !== 'EdDSA') throw new Error(`Unexpected signing algorithm ${jwk.alg}`); return base64urlDecode(jwk.x); } // 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 header = JSON.parse(Buffer.from(headerB64, 'base64url').toString()); const publicKey = await getPublicKey(header.kid); // 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. The set carries an Ed25519 key and an RSA key, so always # select by the kid in the token header — never by list position. _jwks_cache = {'keys': None, 'expires': 0} def get_jwks(force_refresh=False): import time if not force_refresh and _jwks_cache['keys'] and time.time() < _jwks_cache['expires']: return _jwks_cache['keys'] response = requests.get(f'{IDENTITY_URL}/.well-known/jwks.json', timeout=5) response.raise_for_status() _jwks_cache['keys'] = response.json()['keys'] _jwks_cache['expires'] = time.time() + 300 # 5 min return _jwks_cache['keys'] def get_public_key(kid): jwk = next((k for k in get_jwks() if k.get('kid') == kid), None) if jwk is None: # Unknown kid: re-fetch once before giving up. jwk = next((k for k in get_jwks(force_refresh=True) if k.get('kid') == kid), None) if jwk is None: raise ValueError(f'No JWKS key for kid {kid}') # This example verifies the default EdDSA signing. A client registered with # id_token_signed_response_alg: "RS256" gets the RSA key here instead. if jwk.get('alg') != 'EdDSA': raise ValueError(f"Unexpected signing algorithm {jwk.get('alg')}") key_b64 = jwk['x'] # Add padding if needed padding = 4 - (len(key_b64) % 4) if padding != 4: key_b64 += '=' * padding return base64.urlsafe_b64decode(key_b64) 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 header (for the kid) and payload def _b64(segment): padding = 4 - (len(segment) % 4) if padding != 4: segment += '=' * padding return base64.urlsafe_b64decode(segment) header = json.loads(_b64(header_b64)) payload = json.loads(_b64(payload_b64)) # Verify signature with the key the header names public_key = get_public_key(header['kid']) verify_key = VerifyKey(public_key) signed_data = f'{parts[0]}.{parts[1]}'.encode() signature = _b64(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. | | 302 | `account_selection_required` | prompt=none with several accounts signed in — silent selection needs a single unambiguous candidate. Delivered on redirect\_uri. | | 302 | `invalid_request` | prompt=none combined with any other prompt value — the combination asks for both no UI and a UI. 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. It requires a confidential client, so call it from your backend — never from a browser or a public client. - **State your freshness policy with `max_age`**: For step-up-sensitive entry points, send `max_age` on the authorize request rather than inspecting `auth_time` after the fact — SparkVault then refuses to mint a silent SSO credential from an older authentication instead of handing you one to reject. - **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. - **Sign out through the front door**: For browser sign-out, navigate top-level to `/end_session` with your `id_token_hint` and a registered `post_logout_redirect_uri` — it ends the managed session and returns the browser to you. For server-side/BFF teardown without a navigation, call `/revoke` with the current refresh token instead. Either way, 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 | | `clients[].id_token_signed_response_alg` | Per-client signing algorithm, set at client creation. Omit it for the default `EdDSA`; set `"RS256"` to have both the client's `id_token` and its access token signed with the tenant's RSA-2048 key instead. Choose RS256 only when your JWT library has no EdDSA support. Changing it later means deleting and re-creating the client. | | `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. A `metadata_url` must be a direct public HTTPS URL with no credentials or redirects; fetches enforce public DNS, a 10-second timeout, and a 1 MiB limit. | | `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) --- # auth.sv Public Avatars: SparkVault API Reference > Show a SparkVault person's avatar with one img tag. The public avatar URL at my.auth.sv needs no SDK, no API key, no account, and no CORS - resolve it from an SVID or from the SHA-256 of an email or phone number. Canonical: https://sparkvault.com/api/docs/auth-sv/ · OpenAPI: https://sparkvault.com/openapi.yaml ## Overview **auth.sv** is where a person manages their own SparkVault identity: their verified emails and phones, passkeys, active sessions, the sites they have signed into, and the one photo they choose to publish. It belongs to the person, not to your account. That published photo is the only part of auth.sv you integrate against, and it is a plain image URL on a second host, `my.auth.sv`. Point an `` at it and you are done: ```html ``` ### No SDK A URL in an image tag ### No Key Unauthenticated, no account needed ### No CORS Embed it, never fetch it ### Always 200 A valid URL always renders ### The Hosts | Host | What it is | Who talks to it | | --- | --- | --- | | `my.auth.sv` | The public avatar edge. Static images, served from the edge. | Your pages, via `` | | `auth.sv` | The person's own portal: identifiers, passkeys, sessions, connected sites, and the avatar they publish. | The person, in a browser | | `auth.sparkvault.com` | The [Identity](/api/docs/products/identity/) OIDC issuer and sign-in ceremony origin. | Your app's auth flow | > **This page stands alone** > > Nothing here requires a SparkVault account, an API key, the [JavaScript SDK](/api/docs/sdk-js/), or any prior SparkVault integration. If all you want is avatars, this page is the whole manual. If you also sign people in with SparkVault, the [picture claim](#the-picture-claim) hands you the same URL already resolved. ## The Avatar URL #### `GET https://my.auth.sv/{selector}/avatar` Resolve a person's public avatar. A well-formed selector always answers 200 with an image: their photo when one is published and visible, otherwise a neutral placeholder. #### Path Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `selector` | string | Required | Either an **SVID** — `ing_` followed by exactly 32 lowercase hex characters — or an **identifier hash**: exactly 64 lowercase hex characters, the SHA-256 of a normalized email or phone number. Anything else returns `404` | The path is case-sensitive and the hex must be lowercase. Query strings are ignored, not honored: there is no `?s=` size parameter and no `?d=` default-image parameter. ### Which Selector Do You Have? | What you hold | Selector to use | How to get it | | --- | --- | --- | | An ID token from SparkVault sign-in | Use the `picture` claim verbatim | It is already this URL. See [The picture Claim](#the-picture-claim) | | An SVID you stored at sign-up | `{svid}/avatar` | The `sub` / `svid` claim from any SparkVault token | | Only an email address or phone number | `{sha256hex}/avatar` | Hash it yourself. See [Hashing an Email or Phone](#hashing-an-email-or-phone) | | Neither | Do not embed | There is no search, listing, or reverse lookup | > **A well-formed selector never 404s** > > Every reason a photo can be absent — the selector matches nobody, the person turned their avatar off, that identifier's lookup is switched off, they never uploaded one — answers identically: `200` with the same placeholder bytes, the same `ETag`, the same headers. A `404` means your _selector_ is malformed (uppercase hex, wrong length, a stray prefix), never that the person is unknown. ## Hashing an Email or Phone The hash is a plain lowercase-hex SHA-256 of the normalized identifier. No salt, no prefix, no type tag. Normalize exactly as described — SparkVault applies the same two rules and nothing more: - **Email** — trim surrounding whitespace, then lowercase. `"  Ada@Example.COM "` becomes `ada@example.com`. - **Phone** — E.164 including the leading `+`, with whitespace removed. `"+1 415 555 0123"` becomes `+14155550123`. ### Test Vectors Check your implementation against these before you wire it to real data. If your digest does not match, your normalization is wrong — and a wrong hash fails silently, returning a perfectly valid placeholder rather than an error. ```text ada@example.com b5fc85e55755f9e0d030a10ab4429b6b2944855f9a0d60077fe832becbc41d72 +14155550123 36a2cef4ff9bf7a1abd2a93359136b870393be4106e6c5d19b72ff564f9deca4 ``` > **Normalize exactly this much, and no more** > > Every extra canonicalization step produces a different hash and therefore a silent miss. Do **not** strip `+tags`, do **not** remove dots from Gmail addresses, do **not** apply Unicode folding or punycode conversion, and do **not** reformat the phone number beyond deleting whitespace — a number without its leading `+`, or with dashes or parentheses left in, hashes to something else. > > Uppercase hex is the other silent killer. `Convert.ToHexString` in .NET and `%X` formatters emit uppercase; lowercase the digest before building the URL or the request `404`s. ```javascript async function avatarUrl(email) { const normalized = email.trim().toLowerCase(); const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(normalized)); const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); return `https://my.auth.sv/${hex}/avatar`; } ``` ```node.js import { createHash } from 'node:crypto'; export function avatarUrl(email) { const hex = createHash('sha256').update(email.trim().toLowerCase()).digest('hex'); return `https://my.auth.sv/${hex}/avatar`; } // Phone: E.164 with whitespace removed, the leading + kept. export function avatarUrlForPhone(phone) { const hex = createHash('sha256').update(phone.replace(/\s+/g, '')).digest('hex'); return `https://my.auth.sv/${hex}/avatar`; } ``` ```python import hashlib def avatar_url(email: str) -> str: normalized = email.strip().lower() digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest() return f"https://my.auth.sv/{digest}/avatar" ``` ```ruby require "digest" def avatar_url(email) digest = Digest::SHA256.hexdigest(email.strip.downcase) "https://my.auth.sv/#{digest}/avatar" end ``` ```php function avatar_url(string $email): string { // mb_strtolower, not strtolower: strtolower is ASCII-only and leaves a // non-ASCII capital untouched, which hashes to a different digest. $digest = hash('sha256', mb_strtolower(trim($email), 'UTF-8')); return "https://my.auth.sv/{$digest}/avatar"; } ``` ```go import ( "crypto/sha256" "encoding/hex" "strings" ) func AvatarURL(email string) string { sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(email)))) return "https://my.auth.sv/" + hex.EncodeToString(sum[:]) + "/avatar" } ``` ```java import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.HexFormat; import java.util.Locale; static String avatarUrl(String email) throws Exception { // Locale.ROOT matters: the Turkish locale lowercases "I" to a dotless i. String normalized = email.strip().toLowerCase(Locale.ROOT); byte[] digest = MessageDigest.getInstance("SHA-256") .digest(normalized.getBytes(StandardCharsets.UTF_8)); return "https://my.auth.sv/" + HexFormat.of().formatHex(digest) + "/avatar"; } ``` ```c# using System.Security.Cryptography; using System.Text; static string AvatarUrl(string email) { var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(email.Trim().ToLowerInvariant())); // Convert.ToHexString returns UPPERCASE; the URL requires lowercase. return $"https://my.auth.sv/{Convert.ToHexString(bytes).ToLowerInvariant()}/avatar"; } ``` ```shell # Normalize first (trim + lowercase), then hash. Use sha256sum on Linux. hex=$(printf '%s' 'ada@example.com' | shasum -a 256 | cut -d' ' -f1) echo "https://my.auth.sv/$hex/avatar" ``` ## Embedding This is an image URL and nothing else. There is no redirect to follow, no API call to make, no token to attach, no CORS preflight to satisfy, and no `onerror` fallback to write — the URL always renders. Hotlinking is the intended usage: serve it straight from `my.auth.sv`. ```html Ada Lovelace Ada Lovelace ``` ```react export function Avatar({ selector, name, size = 40 }) { return ( {name ); } ``` ```next.js import Image from 'next/image'; // `unoptimized` is required: without it next/image proxies every avatar // through your server, which breaks revocation and concentrates every // fetch on one IP. See "Common Mistakes" below. export function Avatar({ selector, name, size = 40 }) { return ( {name ); } ``` ```vue ``` ### Sizing There are no URL sizing parameters. Set `width` and `height` (which also reserves layout space and avoids a shift) and let CSS do the rest. Images are always square, so `border-radius:50%` gives you a circle with no cropping surprises. ### Content Security Policy If your pages set a CSP, allow the host in `img-src`. Nothing else is needed — no `connect-src` entry, because nothing is ever fetched: ```http Content-Security-Policy: img-src 'self' data: https://my.auth.sv ``` ### Referrer `referrerpolicy="no-referrer"` is recommended on every avatar embed. Without it the browser sends the URL of the page doing the embedding to `my.auth.sv` on each load. Nothing depends on that header, so suppressing it costs you nothing. ## Common Mistakes Each of these is code an integration writes by default, and each one breaks something: | Do not | Why it breaks | Instead | | --- | --- | --- | | `fetch()` the URL from a browser | The response carries no `Access-Control-Allow-Origin`, so the browser blocks it and the promise rejects. A plain `GET` never preflights, so there is no `OPTIONS` to fix; anything that does preflight is answered `405` | Embed it in an `` | | Add `crossorigin` to the tag | Opts the image into a CORS check that has no headers to satisfy it, so the load fails | Omit the attribute | | Proxy it through an image optimizer | `next/image`, Nuxt Image, Gatsby, Cloudinary and imgix fetch URLs all re-host the bytes: revocation stops working and every fetch collapses onto one server IP | Render the URL directly (`unoptimized` in Next.js) | | Copy the bytes to your own CDN or database | You will keep publishing a photo the person has already withdrawn | Hotlink it; that is what it is for | | Write an `onerror` fallback for a missing photo | The URL always renders, so the handler never fires and your fallback is dead code | Branch on the [`picture` claim](#the-picture-claim) before you embed | | Uppercase the hex, or store the SVID case-folded | The path is case-sensitive; `B5FC…` is a `404` | Lowercase the digest at the point you build the URL | | Fetch many avatars from one server | Server-side bulk fetching trips the edge rate limit on this host | Let each visitor's browser load them | | Branch application logic on `X-SV-Avatar` | It is a debugging aid, not a contract, and its values are not stable | Use the `picture` claim as the presence signal | ## The picture Claim If you sign people in with [SparkVault Identity](/api/docs/products/identity/), you never need to build an avatar URL: the token already carries one. The `picture` claim is the standard OIDC name (OIDC Core 1.0 §5.1), so libraries that map `picture` to a profile image work with no custom wiring. | Property | Value | | --- | --- | | Where | The OIDC `id_token` and the SDK / simple-mode token. `picture` is not a UserInfo claim, so read it from the token rather than from `/userinfo` | | Value | `https://my.auth.sv/{svid}/avatar` — the same URL you would build from the `sub` claim | | Present when | The person has published a photo and their avatar is switched on. Otherwise the key is absent from the payload, never `null` | | Freshness | Resolved once per sign-in. A long-lived session keeps the value it was issued with; it refreshes at the person's next sign-in | > **Its presence is the only supported way to know there is a real photo** > > The URL itself cannot tell you: it renders the placeholder just as happily as a photo, deliberately and indistinguishably. So to show **your own** fallback — initials, a brand glyph, a team default — branch on the claim being absent and skip the embed entirely. Waiting on an image load that never fails will not work. > > There is no equivalent signal for hash lookups, by design. With only an email address you either embed and accept the silhouette, or you ask the person. ```javascript // Verify the signature before trusting a claim: an unverified decode reads the // payload as given, so `picture` would be whatever the token's holder wrote. const claims = await verifyIdToken(idToken); if (claims.picture) { // Set as a property, never interpolated into markup. const img = document.createElement('img'); img.src = claims.picture; img.alt = ''; img.width = img.height = 40; img.loading = 'lazy'; img.referrerPolicy = 'no-referrer'; img.style.borderRadius = '50%'; slot.replaceChildren(img); } else { slot.replaceChildren(renderInitials(claims.identity)); // your own placeholder, as a node } ``` ```javascript import { createHash } from 'node:crypto'; const avatarUrl = (email) => `https://my.auth.sv/${createHash('sha256').update(email.trim().toLowerCase()).digest('hex')}/avatar`; const rows = contacts.map((c) => `
  • ${escapeHtml(c.name)}
  • `).join(''); ``` ## Response Contract ### Status Codes | Request | Response | | --- | --- | | Well-formed selector, photo published and visible | `200` with the photo, `X-SV-Avatar: stored` | | Well-formed selector, no visible photo for any reason | `200` with the placeholder, `X-SV-Avatar: default` | | Well-formed selector, storage read failing | `200` with the placeholder, `X-SV-Avatar: unavailable`, `Cache-Control: no-store` | | Malformed selector or unknown path | `404`, empty body, no `X-SV-Avatar` | | `If-None-Match` carrying the current `ETag` | `304`, no body, every header the `200` carries except `Content-Length` | | `If-None-Match: *` | `304` only when a photo is stored. The placeholder answers `200`, so a revalidating cache is never told a withdrawn photo is still current | | Any method other than `GET` or `HEAD` | `405` with `Allow: GET, HEAD` | ### What You Get Back | Property | Value | | --- | --- | | Format | A photo is `image/png` or `image/jpeg`; the placeholder is always `image/png`. Read `Content-Type`, do not assume | | Shape | Always square. A photo is 512×512 when uploaded through the portal, and is never smaller than 64 or larger than 1024. The placeholder is 512×512 | | Size | A photo is 600 KB at most, usually far less. The placeholder is about 5 KB | | Caching | `Cache-Control: public, max-age=300` for a photo, `public, max-age=60` for the placeholder. Both carry an `ETag` and answer `If-None-Match` with a `304` | | Methods | `GET` and `HEAD`. A `HEAD` reports the `Content-Length` its `GET` would send | | Diagnostics | `X-SV-Avatar`: `stored`, `default`, or `unavailable` | | Hardening | `X-Content-Type-Options: nosniff` and `X-Robots-Tag: noindex` on every response. Every upload is structurally rebuilt server-side, dropping EXIF, color profiles, text chunks and any non-image payload | > **The placeholder** > > When there is no visible photo the URL serves a neutral grey head-and-shoulders silhouette — achromatic and deliberately unbranded, so it does not plant SparkVault's identity on your page. Its bytes are a constant: identical for every selector, never derived from it. There is no parameter that turns it off. > **X-SV-Avatar is diagnostics, not a contract** > > It exists so you can tell a broken integration apart from a person who simply has no visible photo. Do not branch application logic on it and do not treat its values as stable. The [`picture` claim](#the-picture-claim) is the supported presence signal. ## Caching and Revocation **Please honor the cache headers.** Revocation works by the object ceasing to exist: a withdrawn photo stops being served at the bucket immediately and clears everywhere, edge caches included, within the stored copy's 300-second TTL, after which the URL serves the placeholder. There is no purge path. If you mirror the bytes onto your own storage, or cache them indefinitely, you will keep publishing a photo the person has already withdrawn. Publication runs the same clock in reverse. The placeholder is cacheable for 60 seconds at the URL a photo will occupy, so a newly published avatar can take up to a minute to appear at a URL that was requested moments earlier. Neither the placeholder nor a `404` is ever stored in the edge cache, which is what keeps that window short. Embedded images are fetched by your visitors' browsers, so ordinary usage never approaches the edge rate limit on this host. Bulk-fetching many avatars from a single server address will trip it. ## Privacy and Consent The photo belongs to the person, and they hold two switches in their auth.sv portal. Both are on by default, and either one can go off at any moment with no notice to you: | Control | Where | Effect | | --- | --- | --- | | Public avatar | `auth.sv/public` | The master switch. Off means every avatar URL for that person — SVID and hashes alike — serves the placeholder, and the `picture` claim stops being issued | | Per-identifier lookup | `auth.sv/identity` | One toggle beside each verified email and phone. Off means that identifier's hash URL serves the placeholder. The SVID URL and the `picture` claim are unaffected | > **The hash lookup reveals one bit, on purpose** > > Answering `200` everywhere buys no privacy: the placeholder is a fixed length with a fixed `ETag`, so a single request still answers "does this address have a public SparkVault avatar?". That disclosure is the feature — it is what makes lookup by email work at all — and the person can switch it off per identifier, or turn the whole surface off, whenever they like. > > Resolving avatars for addresses already in your own contact list is the intended use. Sweeping the host with addresses you do not hold, to discover who has a SparkVault account, is not. Treat the URL as a live pointer, never as a fact you have learned. Do not persist the image, and do not persist a derived "this person has an avatar" flag — both go stale the moment the person changes their mind, and the URL is stable enough that you never need to. ## Verify Your Integration One request tells you which of the three responses you are getting, without opening a browser: ```bash curl -sI https://my.auth.sv/ing_019e6f58f7d27c6ebe93cf08b1e6c19b/avatar \ | grep -iE '^HTTP/|^content-type:|^cache-control:|^x-sv-avatar:' # HTTP/2 200 # content-type: image/jpeg # cache-control: public, max-age=300 # x-sv-avatar: stored ``` | Symptom | Likely cause | | --- | --- | | Broken image on the page | A `404`: uppercase hex, wrong digest length, a mistyped SVID prefix. Or a CSP blocking `my.auth.sv` in `img-src`, or a stray `crossorigin` attribute | | `x-sv-avatar: default` for someone you know has a photo | Your hash is wrong (over-normalized input, or the phone lost its leading `+`), or they switched that identifier's lookup off, or their master switch is off | | A CORS error in the console | You are fetching the URL instead of embedding it | | The old photo is still showing | Something on your side cached it past 300 seconds: an image proxy, a CDN, or a copy in your own storage | | A new photo has not appeared yet | Publication latency. Wait 60 seconds | | Requests suddenly blocked | Server-side bulk fetching tripped the edge rate limit. Move the loads into visitors' browsers | ## The auth.sv Portal `auth.sv` is the person's own account page, not yours. Anyone who has verified with SparkVault Identity can sign in there and manage: - Verified emails and phone numbers, and the per-identifier avatar lookup toggles - The public avatar itself: upload, replace, remove, and the master visibility switch - Passkeys, two-factor authentication, and backup identifiers - Active sessions, with the ability to end them individually or everywhere - Connected sites: which applications they have signed into, and revoking that access You do not proxy or wrap the portal. To send someone to their photo, link `https://auth.sv/public` directly. The SDK's `portalUrl()` builds deep links to the other screens — `home`, `identity`, `security`, `sessions`, and `sites` — and has no `public` section, so asking it for one throws. See [JavaScript SDK](/api/docs/sdk-js/). > **Avatars are included** > > The public avatar surface carries no charge and no quota. It is part of every SparkVault identity, and embedding it costs the embedding site nothing. --- # 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 Sending a secret from Slack requires an active subscription, but carries no per-secret charge. 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`. Their SparkVault credential is minted between trusted services and is never exposed to browser JavaScript. 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**: Included with your subscription. No per-secret charge. | 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. | | 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 Gate** > > Sending is gated by an active subscription (`PLAN_REQUIRED`). Sparks draw no storage or bandwidth pool, so capacity never blocks a send. The `/secret` command itself always opens the compose modal; the gate is enforced on submit, and the sender then sees an ephemeral Slack message with a role-aware call to action: billing admins get a **Subscribe** 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. Sends are recorded in your usage ledger for reporting, never billed per secret. | Operation | Requirement | | --- | --- | | Send secret (create Spark) | Active subscription. Sparks draw no storage or bandwidth capacity. | | View secret (read Spark) | Free, always included | ## 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[].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": "⚡", "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": "📦", "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[].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", "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", "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`, `transaction_count`, `total_bytes`) plus a period transaction total. Consumption only — capacity is sold as pooled blocks, so usage never carries a dollar amount. #### `GET /v1/billing/usage/breakdown` Per-vault storage and bandwidth attribution, derived from the account's ingot records. Admin-only. Returns `by_vault` (per vault: `storage_bytes`, `bandwidth_bytes`, `ingot_count`) plus a `truncated` flag when a very large account exceeds the scan cap. Capacity is pooled account-wide, so the vault is the only attribution axis — no byte belongs to a member. ## 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 | `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_requested` | | 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` | 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. ### 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, and SparkLink receipts. #### 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 | --- # 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. **How a notification travels** ```text 1 SOMETHING HAPPENS IN YOUR SYSTEM A payment is flagged. A contract is ready. A login needs approval. │ │ sv.products.notify.send() ▼ 2 NOTIFY SEALS IT AND PICKS THE CHANNELS One single-use grant per recipient. Every channel carries only https://x.sv/, never the content. Notify walks each person's channels in order and stops at the first one that lands. │ │ │ │ ▼ ▼ ▼ ▼ 3 IT ARRIVES WHERE THEY ALREADY LOOK ┌──────────┐ ┌───────────┐ ┌────────────┐ ┌──────────────┐ │ Push │ │ Email │ │ Text │ │ Your own app │ │ phone or │ │ a link, │ │ when it │ │ the Notify │ │ browser │ │ not the │ │ cannot │ │ feed, via │ │ │ │ content │ │ wait │ │ the SDK │ └──────────┘ └───────────┘ └────────────┘ └──────────────┘ │ ▼ 4 THEY OPEN IT tap the link ──▶ prove who they are ──▶ the content renders any channel, a passkey, or the once, then the grant the same grant level you required is spent │ ▼ 5 YOU GET PROOF, NOT A GUESS notify.delivered / .signed / .approved / .denied / .replied reach your webhook the moment they happen, and each ceremony receipt carries an EdDSA proof that verifies against your JWKS. │ └──▶ back into your workflow (step 1) ``` One call turns an event in your system into a proven interaction. Notify seals the content and mints a single-use grant per recipient, so every channel carries an opaque pointer rather than the message. The recipient opens it, proves who they are, and their answer comes back to you as a webhook event and a verifiable receipt. This is the sealed path, which is the default. 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, TTL, burn-after-read on a `view` grant; on an interactive grant the answer spends it, not the 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. | **What each layer owns** ```text the message what the channels carry ─────────── ──────────────────────── ┌──────────────────────────────┐ │ Spark sealed content │ ──▶ nothing (never leaves) │ TTL, burn rules │ ├──────────────────────────────┤ │ SparkLink one single-use │ ──▶ https://x.sv/ │ grant per person │ (an opaque pointer) ├──────────────────────────────┤ │ Identity the verification │ ──▶ EdDSA proof of WHO opened it │ ceremony │ ├──────────────────────────────┤ │ Notify channels, fan-out │ ──▶ the pointer, on every channel │ escalation, inbox │ email · sms · push · in_app └──────────────────────────────┘ Notify never touches crypto. Every channel is a dumb pointer carrier. ``` Notify orchestrates delivery and never handles plaintext. Content is sealed by Spark, access is granted per recipient by SparkLink, the recipient is proven by Identity, and the channels carry only an opaque 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. > **Sending needs a Notify subscription** > > Every call below returns `403 NOTIFY_SUBSCRIPTION_REQUIRED` until your account holds a Notify tier (buy one on the console's Billing page). Reads — inbox, status, receipts, config — are never gated. #### Send a sealed notification ```sdk (node) // npm install @sparkvault/sdk-js import SparkVault from '@sparkvault/sdk-js'; // CommonJS: const { SparkVault } = require('@sparkvault/sdk-js'); const sv = SparkVault.init({ accountId: process.env.SPARKVAULT_ACCOUNT_ID, // acc_... apiKey: process.env.SPARKVAULT_API_KEY // sv_live_... }); const invoiceId = '1043'; const recipient = { id: 'usr_01hq8yv2k3', email: 'user@example.com' }; const result = await sv.products.notify.send({ // Idempotency key — safe to retry with the same value. See Idempotency below. sendId: `invoice-${invoiceId}-${recipient.id}`, // BOTH handles: the id gets them the in-app row, the email is what the // 'identifier' policy makes them prove and what the email channel delivers to. recipients: [{ id: recipient.id, email: recipient.email }], title: 'Your invoice is ready', category: 'transactional', content: { payload: `Invoice #${invoiceId} for $129.00 is attached.`, contentType: 'text/plain' }, policy: { verificationLevel: 'identifier', interaction: 'view' }, channels: ['email', 'push'] }); console.log(result.send_id, result.recipients, result.status); // "invoice-1043-usr_01hq8yv2k3", 1, "pending" ``` ```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 '{ "send_id": "invoice-1043-usr_01hq8yv2k3", "recipients": [ { "id": "usr_01hq8yv2k3", "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({ send_id: 'invoice-1043-usr_01hq8yv2k3', recipients: [{ id: 'usr_01hq8yv2k3', 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); // "invoice-1043-usr_01hq8yv2k3", 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={ 'send_id': 'invoice-1043-usr_01hq8yv2k3', 'recipients': [{'id': 'usr_01hq8yv2k3', '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": "invoice-1043-usr_01hq8yv2k3", "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. ## End to End: an approval, start to finish One file, six steps, no fragments. Configure the category once at deploy time; the rest runs per approval. ```javascript import { createHash } from 'node:crypto'; import { createRemoteJWKSet, jwtVerify } from 'jose'; const API = 'https://api.sparkvault.com/v1'; const H = { 'X-API-Key': process.env.SPARKVAULT_API_KEY, 'Content-Type': 'application/json' }; const call = async (method, path, body) => { const res = await fetch(`${API}${path}`, { method, headers: H, body: body && JSON.stringify(body) }); const json = await res.json(); if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`); return json.data; }; // 1. ONCE, at deploy time. `required: true` puts it on the compliance floor: // no recipient mute silences it and its mail carries no unsubscribe. await call('PUT', '/products/notify/config', { categories: { wire_approval: { label: 'Wire approvals', description: 'Approve or decline an outgoing wire.', required: true } } }); // 2. Bind the receipt to THIS wire. Same canonical string at send and at verify. const wire = { id: 'wr_1043', amount: 'USD 25,000.00', payee: 'Northwind Ltd' }; const canonical = JSON.stringify({ wire_id: wire.id, amount: wire.amount, payee: wire.payee }); const actionHash = createHash('sha256').update(canonical).digest('hex'); // 3. Send. send_id is the idempotency key: derived from the wire, never a clock. const send = await call('POST', '/products/notify/send', { send_id: `wire-approval-${wire.id}`, recipients: [{ id: approver.userId, email: approver.email }], // id reaches their inbox, email is what they prove title: 'Approve a wire transfer', category: 'wire_approval', content: { payload: `Approve ${wire.amount} to ${wire.payee}?` }, policy: { verification_level: 'identifier', interaction: 'approve', action_hash: actionHash }, channels: ['in_app', 'push', 'email'], escalation: { delays: [0, 300, 900] } // in-app now, push at 5 min, email at 15 min, each only if still unseen }); console.log(send.send_id, send.recipients, send.status); // "wire-approval-wr_1043", 1, "pending" // 4. Poll DELIVERY. This answers "did a transport accept it", never "did a human act". let status; do { await new Promise((r) => setTimeout(r, 5000)); status = await call('GET', `/products/notify/sends/${send.send_id}/status`); } while (status.status === 'pending' || status.status === 'sending'); for (const r of status.recipients) { console.log(r.recipient_id, r.state, r.suppressed_reason ?? '', r.channel_outcomes); } // 5. Poll the RECEIPT. It appears only once the recipient ANSWERS. The answer is // what spends an interactive grant, so this can legitimately stay empty for hours. let receipts = [], cursor = null, truncated = true; while (truncated) { const page = await call('GET', `/products/notify/sends/${send.send_id}/receipts${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`); receipts = receipts.concat(page.receipts); ({ truncated, cursor } = page); } // 6. Verify one, with no SparkVault credential and no SparkVault call. const JWKS = createRemoteJWKSet(new URL(`https://auth.sparkvault.com/${accountId}/.well-known/jwks.json`)); for (const receipt of receipts) { if (!receipt.signed_token) continue; // a plain `view` receipt carries none const { payload } = await jwtVerify(receipt.signed_token, JWKS, { algorithms: ['EdDSA'], issuer: accountId }); if (payload.action_hash !== actionHash) throw new Error('This receipt attests a different action'); console.log(payload.identity, payload.interaction, payload.decision, new Date(payload.iat * 1000)); // "ada@example.com" "approve" "approved" 2026-08-27T... } ``` > **A receipt is not late, it is unanswered** > > `/sends/:sendId/receipts` comes back empty until the recipient completes the ceremony. Delivery and an answer are different events on different clocks: poll status to learn the message landed, and either poll receipts on a slow cadence or subscribe to [`notify.approved` / `notify.denied`](#event-types) and reconcile. ## 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. **The send pipeline** ```text SYNCHRONOUS, inside POST /v1/products/notify/send ───────────────────────────────────────────────────────────────────── 1. Validate 1 to 500 recipients, a required category, and │ exactly one source (content or ingot). 2. Resolve policy The channel ladder plus its cumulative escalation │ delays, from the account config and the per-send │ overrides. Stored on the row, read back verbatim. 3. Seal and mint One Spark sealed per recipient, one single-use │ SparkLink minted for each, from the in-memory │ payload. Nothing unsealed is ever persisted. 4. Write one row ONE send row, status "pending". Its audience │ carries every recipient's pre-minted entry │ { recipient_id, sparklink_code, contact }. ▼ ═════════════════════════════════════════════════════════════════════ THE API RETURNS HERE 200, data { "send_id": "...", "recipients": 3, "status": "pending" } Nothing has been delivered yet. Every step below runs after the caller already has their answer. ═════════════════════════════════════════════════════════════════════ │ ASYNCHRONOUS, driven by the send row's stream ───────────────────────────────────────────────────────────────────── 5. Change stream The row's insert starts fan-out. No cron, no poll. │ 6. Fan-out 200 recipients per invocation. A larger audience │ self-continues on a recipient cursor. 7. Resolve each Prune the channels this recipient cannot receive, │ ladder then apply their preferences. A suppressed one │ gets a terminal row and no delivery. 8. Inbox rows One row per recipient, idempotent per │ (recipient, send). The inbox feed and the status │ endpoint read these rows. 9. Enqueue step 0 SendMessageBatch, 10 per call, no delay, onto: │ ├──▶ realtime queue in_app · websocket └──▶ standard queue email · sms · push · 7 more │ ▼ 10. Sender Stop if seen_at is set or the grant is consumed, │ claim the (notification, step), then dispatch │ through the channel adapter. ▼ 11. Channel adapter The pointer reaches the recipient on that channel. Still unseen? The sender enqueues the next channel on its queue with its own delay. The ladder stops the moment the recipient is seen or the grant is consumed. ``` The send call seals a Spark and mints a SparkLink per recipient, writes one send row, and returns. Delivery starts after that, driven by the row's stream, so a 200 means accepted and never delivered. A plaintext send runs the same pipeline without the seal and the mint. #### `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? }`. Addressing someone as `{ id, email }` is the strongest form: the id reaches their in-app inbox and device tokens, and the address is what an `identifier` policy makes them prove and what the email channel delivers to. A bare string must itself be reachable — an email, an E.164 phone, or a SparkVault id (`usr_...` / `ing_...`) — anything else is rejected 400 rather than accepted as a send every channel would skip. Max 500 per send; duplicates are collapsed by resolved identity. | | `content` | object | Optional | Content to seal per recipient: `{ payload, content_type?, filename?, ttl_minutes? }`. Provide exactly one of `content` OR `ingot`. **Sealed:** `payload` is at most **256000 bytes** (250 KB) of UTF-8 — point at an `ingot` for anything larger; `ttl_minutes` is 1–1440, default 1440 (24 h), and the grant never outlives the content. **Plaintext:** at most 16384 bytes, and `content_type` must be `text/plain` or `text/html`. | | `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?, action_hash? }`. See the policy table below. | | `channels` | string\[\] | Optional | Delivery-channel override. **Order is the escalation order**, and duplicates collapse to their first position. An entry that is not a channel is rejected `400 VALIDATION_ERROR` quoting the bad value and the valid set — naming channels states an intent, so a typo is heard about rather than dropped into a ladder you never asked for. When omitted, the company config resolves them (per-category `channel_priority`, then `default_channels`) and an invalid entry there is filtered silently instead: standing defaults are validated on write and must never break a live send. | | `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). Truncated to 200 characters. | | `category` | string | Required | The category this send is classified as. It decides whether the recipient may mute it, whether it is on the compliance floor, whether it must seal, which channel ladder resolves, and whether its email may offer a one-click unsubscribe — so it cannot be defaulted. Must be a key of your configured `categories` or one of the five preset categories (alert, secure, conversation, approval, signature); a missing or unknown value is rejected 400 naming the categories your account accepts. | | `type` | string | Optional | Optional display type for the recipient feed. | | `instructions` | string | Optional | Non-secret display text (≤500 chars) shown alongside a sealed send — e.g. how to open it — on the inbox row and the email/push payload. NEVER the sealed content; allowed on all categories. | | `delivery` | string | Optional | "sealed" (default) or "plaintext". Plaintext renders the body inline on the inline-capable channels with no seal and no reveal step; see Delivery Modes below.Default: `sealed` | | `history_ttl_days` | integer | Optional | Inbox-row retention in days. Defaults to your config's `history.history_ttl_days`.Default: `30` | | `send_id` | string | Optional | Caller-supplied idempotency key, scoped to your account (1..128 chars of `A-Z a-z 0-9 _ . : -`). 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". How strongly the recipient must prove identity before the content unseals. `none` opens to whoever holds the link; `identifier` makes them prove control of the address you addressed; `passkey` binds the reveal to a device they physically hold. Enforced on the method the recipient ACHIEVED, so a passkey-level grant is never satisfied by an identifier session. Defaults to your config's `security.verification_level`, which ships as `identifier` — see the note below. A non-`view` interaction requires at least `identifier`: `{ "verification_level": "none", "interaction": "approve" }` is rejected `400 VALIDATION_ERROR`, because a grant that renders an action bar the identity gate then refuses is dead on arrival. `out_of_band` and `dual_control` are not creatable and are rejected both here and on `security.verification_level`; they stay in the enforcement vocabulary, so a stored grant naming one is still held to that strength and a receipt may report it. | | `interaction` | string | Optional | "view" | "acknowledge" | "sign" | "approve" | "reply". The interaction the SparkLink requires. Anything past `view` needs a verification level of at least `identifier`. `sign` and `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. | | `action_hash` | string | Optional | 64 lowercase hex (SHA-256) of the canonical bytes of the thing being approved or signed. It is stamped onto the grant and lands verbatim in the receipt's `action_hash` claim — this is what turns “Ada verified” into “Ada approved THIS wire”. **Supply it on every `approve` and `sign` send.** Omit it and the ceremony still completes, but the receipt carries a server-derived digest of the grant itself rather than of your document, so a hash recomputed from your own copy can never match. REST only: the JS SDK does not forward this field. | > **Bind approve and sign to your own document** > > Compute the hash the same way both times — once when you send, once when you verify — over a canonical serialization you control. > > ```javascript > import { createHash } from 'node:crypto'; > > const canonical = JSON.stringify({ wire_id: wire.id, amount: wire.amount, payee: wire.payee }); > const actionHash = createHash('sha256').update(canonical).digest('hex'); > > // REST only: policy.action_hash is not on the SDK send options. > 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({ > send_id: `wire-approval-${wire.id}`, > recipients: [{ id: approver.id, email: approver.email }], > title: 'Approve a wire transfer', > category: 'approval', > content: { payload: `Approve ${wire.amount} to ${wire.payee}?` }, > policy: { verification_level: 'identifier', interaction: 'approve', action_hash: actionHash } > }) > }); > ``` > > Verify later with the SAME canonical string. A receipt whose `action_hash` you did not supply attests that an identity performed a ceremony, never _what_ they were looking at. > **Sealed sends verify by default — an id-only recipient is rejected** > > Your config's `security.verification_level` ships as **`identifier`**, not `none`, and it is the default for any sealed send whose `policy` omits the field. A sealed send hands a named recipient a pointer, so with no verification that pointer _is_ a bearer token: a forwarded mail, a shared screen, or a mailbox someone else reads opens the content. `identifier` asks the opener to prove the address the message was already addressed to. > > The consequence is deliberate. A sealed send to a recipient with **no email or phone** — `{ "id": "usr_..." }` alone — and no explicit `policy` is rejected `400 VALIDATION_ERROR`, naming the recipient by index. There is no address to scope the grant to, so any verified identity would open it. Two honest fixes: > > - Address them by both handles: `{ "id": "usr_...", "email": "user@example.com" }`. This is the right answer nearly always — the id still reaches their in-app inbox and devices. > - Say `policy: { "verification_level": "none" }` deliberately, when an open pointer really is what you want. > > Plaintext sends are unaffected: they carry no seal, mint no grant, and must leave the policy trivial anyway. #### 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. ### Idempotency: always send a `send_id` A send is not free to repeat. Retrying without an idempotency key seals a second Spark, mints a second SparkLink per recipient, delivers a duplicate on every channel, and meters a second notification. Supply your own `send_id` and the retry is a no-op instead: Notify writes the send row under a conditional put keyed on `send_id`, so the second attempt returns the original send with `idempotent: true` and fans out nothing. Derive it from the thing you are notifying about, not from a clock or a random value — a key that changes between attempts is not an idempotency key. `invoice-1043-usr_01hq` is a good one; `Date.now()` is not. It must be 1–128 characters of `A-Z a-z 0-9 _ . : -` and is unique within your account. ```javascript // The SAME sendId on every attempt is what makes this safe to retry. const sendId = `invoice-${invoice.id}-${recipient.userId}`; async function sendInvoiceNotification(attempt = 0) { try { return await sv.products.notify.send({ sendId, // A sealed send defaults to verification_level 'identifier', so the // recipient needs an address to prove. An id on its own has none. recipients: [{ id: recipient.userId, email: recipient.email }], title: 'Your invoice is ready', category: 'transactional', content: { payload: `Invoice #${invoice.id} for ${invoice.total}.` } }); } catch (err) { // 429: you exceeded 300 requests this minute — details carries resets_at. // Timeout / network fault: the send may well have SUCCEEDED. Never assume it did not; // the sendId is what makes retrying that unknown state safe. const retryable = ['RATE_LIMIT_EXCEEDED', 'timeout_error', 'network_error'].includes(err.code); if (!retryable || attempt >= 4) throw err; const retryAfterMs = (err.details?.resets_at) ? Math.max(0, err.details.resets_at * 1000 - Date.now()) : Math.min(30000, 2 ** attempt * 1000); // exponential backoff, capped await new Promise((r) => setTimeout(r, retryAfterMs)); return sendInvoiceNotification(attempt + 1); } } const result = await sendInvoiceNotification(); if (result.idempotent) { // This exact send already existed. Nothing was re-sealed, re-sent, or re-metered. } ``` > **What a replay checks** > > A replay returns the stored send as-is: it does not re-seal, re-deliver, or re-meter. **Every** send is pinned by a fingerprint, so a replay of one `send_id` carrying a _different_ request is rejected with `400 VALIDATION_ERROR` rather than answered with a success for a message that never went out. Reusing one key for two different messages is a bug, not an optimization. > > What each mode pins is what it can. A **plaintext** send holds its body in the clear, so it fingerprints the title, category, content type, and body. A **sealed** send stores no content anywhere — that is the point of sealing — so it fingerprints the **request identity** instead: > > - `title`, `category`, and `instructions` — the display text the recipient actually reads; two replays differing only there are not the same message to them > - your `policy` — `verification_level`, `interaction`, `reveal_freshness_minutes` > - your `channels` and `escalation.delays`, in the **order you gave them** — a ladder is ordered intent, so `["sms","email"]` and `["email","sms"]` are different sends. Name neither and nothing is hashed for them: your send is defined by your standing config, which is deliberately not part of the fingerprint. > - the recipient ids, **sorted**, so re-ordering the same audience _is_ the same send > - a digest of the content (type, filename, TTL, payload) or the `ingot` reference > > Two consequences worth knowing. It is your _request_ that is hashed, never the resolved policy, so changing your config's security defaults between two otherwise identical retries does not turn the second one into a 400. And switching `delivery` mode between replays mismatches, which is correct — the same key cannot mean both a sealed and a plaintext message. ## 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. **Sealed versus plaintext** ```text SEALED (the default) PLAINTEXT (opt in) delivery: "sealed" delivery: "plaintext" ────────────────────────────────── ────────────────────────────────── the request content, up to 500 recipients content only or one Ingot (Ingot sends are 1:1) text/plain or text/html policy defaults to identifier up to 50 recipients payload at most 16 KiB │ │ ▼ ▼ what is created a Spark and a single-use SparkLink nothing. no seal, no grant one per recipient, at accept the body rides the send row nothing unsealed is ever stored for at most 7 days │ │ ▼ ▼ what travels the pointer only the body itself https://x.sv/ in_app, push, web_push and email on any of the 12 channels only. any other channel is a 400 │ │ ▼ ▼ the recipient verifies, then the content reveals reads it. there is no reveal step view, reply, approve or sign verification_level must be none interaction must be view │ │ ▼ ▼ what remains a signed EdDSA receipt no receipt recall revokes the grant recall returns 400 A confidential category can never be sent in the clear: secure, conversation, approval and signature always seal. An account's config can add categories to that set, never remove them. ``` Sealed is the default. The content is sealed into a Spark, each recipient gets one single-use SparkLink, the channels carry only that pointer, and the reveal produces a signed receipt. Plaintext puts the body in the channel payload itself, so it is for short alerts whose words are not the secret. A confidential category always seals. 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 (in\_app, push, web\_push, email) - Max 50 recipients, 7-day TTL, 16 KiB payload - `text/plain` or `text/html` only > **Selecting the Mode** > > Pass `delivery: "plaintext"` on `POST /products/notify/send`; anything else (or an omitted field) travels sealed. Plaintext constraints are enforced fail-closed: `content` only (never an `ingot`), `text/plain` or `text/html`, at most 50 recipients, a 16 KiB payload, retention capped at 7 days, and a trivial policy (a `verification_level` other than `none` or an interaction other than `view` is rejected: with no seal there is no ceremony to enforce it). On a device channel the inline body renders as a native visible notification; `text/html` is flattened to text there. Channels you name explicitly must all be inline-capable; a config-resolved ladder is filtered to the inline-capable channels automatically. > **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. > **What “single-use” spends, per interaction** > > One grant is minted per recipient and every channel carries that same pointer, so whatever spends it is shared across channels. What spends it depends on what you asked the recipient to do: > > - **`view`** — the _read_ spends it. The first channel the recipient opens consumes the grant, and a later tap on another channel reports the link as already used. > - **`acknowledge` / `approve` / `sign` / `reply`** — the _answer_ spends it, not the read. The request stays openable inside the recipient's verification window, so a reload, a closed tab, or a second look still finds it. Only the recorded action closes it. This is deliberate: an approval that vanished because someone's phone discarded the tab would be unanswerable, and the escalation ladder would keep chasing a link that no longer worked. > > Either way the grant expires with its sealed Spark, and a tap after that reports an expired link. ## 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. There is no `preset` parameter on the send endpoint: a preset is a **merge over the one send contract**, applied before the request goes out, producing an ordinary send. Over REST, assemble the bundle yourself from the table below. In the JS SDK, one call does it. 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, web\_push, email | | `conversation` | identifier | reply | in\_app, push, web\_push, email | | `approval` | identifier | approve | in\_app, push, web\_push, email | | `signatureRequest` | identifier | sign | in\_app, push, web\_push, email | ```javascript // One helper per bundle. Each is `send()` with the preset merged underneath, so // every send option still applies — and anything you pass wins over the bundle. await sv.products.notify.sendAlert({ recipients: [{ id: user.id }], // plaintext: no seal, so no address needed content: { payload: 'Your export finished.' }, title: 'Export complete' }); await sv.products.notify.sendSecureMessage({ sendId: `payslip-${period}-${user.id}`, recipients: [{ id: user.id, email: user.email }], // sealed → needs a verifiable address content: { payload: payslipPdfBase64, contentType: 'application/pdf' }, title: 'Your payslip' }); await sv.products.notify.sendApproval({ sendId: `wire-${transfer.id}`, recipients: [{ id: approver.id, email: approver.email }], content: { payload: `Approve a wire of ${transfer.amount} to ${transfer.payee}?` }, title: 'Wire approval' }); // Also: sendConversation(...) and sendSignatureRequest(...) // Inspect (or adjust) what a preset would send, without sending it: import { buildNotifyPresetSend, NOTIFY_PRESETS } from '@sparkvault/sdk-js'; const options = buildNotifyPresetSend('approval', { recipients: [{ id: approver.id, email: approver.email }], content: { payload: '...' }, policy: { verificationLevel: 'passkey' } // your field wins over the bundle's default }); await sv.products.notify.send(options); ``` > **Preset categories are seeded into your config — and three of them are required** > > A preset's `category` (`alert`, `secure`, `conversation`, `approval`, `signature`) describes the ceremony rather than your subject matter, but it is **seeded into `config.categories`** like any other topic. A category absent from that map derives nothing: it cannot sit on the compliance floor and cannot be offered to the recipient as a toggle. > > | Category | Seeded as | What that means | > | --- | --- | --- | > | `secure`, `approval`, `signature` | `required: true` | These sends are **critical**: no recipient mute silences them, and the email adapter delivers them through an unsubscribe suppression. A sealed message someone is waiting on, an approval blocking a colleague, or a signature request is stranded by a mute. | > | `conversation`, `alert` | `required: false` | Ordinary mutable topics. | > > **Seeded is not reserved.** Only `security` is locked; you may tombstone or demote any preset category, and the compliance floor follows the map you actually have. The send endpoint accepts a preset category unconditionally, so tombstoning one narrows what it _derives_ — never what you are allowed to send. > > The high-stakes presets map to the confidential baseline, 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. To send alert-style over the REST API, pass `delivery: "plaintext"` with `category: "alert"`. > **Passkey is opt-in, and it is a prerequisite — not a preference** > > No preset defaults to `verification_level: "passkey"`, including `approval` and `signatureRequest`. A passkey-level grant can only be satisfied by someone who **already has a passkey registered with SparkVault**, and the recipient ceremony cannot enrol one mid-flight — so a passkey default would strand every first-time, raw-email recipient on a request they can never answer. The four sealed presets verify at `identifier`: the recipient proves control of the address you addressed, opens immediately, and still produces a signed receipt. > > Ask for a passkey **explicitly**, when you know the audience is enrolled: > > ```javascript > await sv.products.notify.sendApproval({ > recipients: [{ id: approver.id, email: approver.email }], > content: { payload: `Approve a wire of ${transfer.amount}?` }, > policy: { verificationLevel: 'passkey' } // your field wins over the bundle > }); > ``` > > - **Employees, contractors, and repeat approvers** — worth asking for `passkey`. They enrol once and every subsequent approval is a fingerprint or a face, which buys you the strongest proof SparkVault can issue. > - **Consumers and one-off recipients** — leave the preset alone. Demanding a passkey enrolment from someone who will never come back is how a notification goes unread. > > Presets are bundles, not a fixed contract. The `interaction` shapes the ceremony and the verification level is how hard you make them prove who they are; the only coupling is that anything past `view` needs at least `identifier`. ## 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. **The escalation ladder over time** ```text Offered ladder, resolved once per send ┌───────────┬───────────┬───────────┬───────────┬───────────┐ │ in_app │ push │ web_push │ email │ sms │ │ 0s │ no handle │ no handle │ 240s │ 600s │ │ │ pruned │ pruned │ │ │ └───────────┴───────────┴───────────┴───────────┴───────────┘ │ Fan-out prunes every channel this recipient has no endpoint for, then recomputes the schedule. The email hop leaves the 240s slot it inherited and fires at the configured email_minutes: 2. ▼ Effective ladder for this recipient ─────────────────────────────────── t = 0s in_app enqueued with no delay │ t = 120s email realtime_fallback_delay.email_minutes: 2 │ t = 600s sms realtime_fallback_delay.sms_minutes: 10 How the chain ends ────────────────── Before every step the chain stops if the recipient's row already has seen_at, or if the grant is consumed or recalled (sealed sends only). A step out of retries the next step is scheduled before the message dead letters, and that step is recorded as failed, not retryable. The ladder runs out the recipient settles exactly once, delivered or failed. A schedule the caller states on escalation.delays is kept per channel instead of recomputed. ``` Escalation is a chain of delayed queue messages, one recipient at a time. The account default ladder is in_app, push, web_push, email, sms; a recipient with no device handle gets in_app, email, sms, with the schedule recomputed for what is left. ### Supported Channels #### Deliverable to your recipients in\_app websocket email sms push web\_push voice whatsapp rcs #### Platform-internal (SparkVault's own account only) webhook slack teams `push` covers Expo, APNs, and FCM device tokens; `web_push` is VAPID. Every phone channel — `sms`, `voice`, `whatsapp`, `rcs` — runs one shared guard: E.164 validation, a `+1` (North American) country allowlist, the opt-out register, and a per-account volume ceiling. A recipient outside the allowlist skips that channel and escalation advances to the next one; no phone channel is a way around any of the four checks. Per-app channel credentials live in the owner-managed config and are never account-writable. `webhook`, `slack` and `teams` resolve their destination from SparkVault's own platform credentials, and that destination is reachable only by SparkVault's account. Naming one on a tenant send is accepted with a `200` and then recorded as `{ "outcome": "skipped", "detail": "no_destination_configured" }` for every recipient. Do not put them in a ladder you rely on. ### 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. > **What stops a ladder early — consent, not deliverability** > > Only **consent** ends the remaining ladder: a recipient who has opted out on that identifier stops a non-critical send outright, because escalating an unsubscribe onto SMS and then a voice call is exactly the abuse an opt-out exists to prevent. A mandatory (compliance-floor) send is exempt and delivers through the suppression. > > A **deliverability** failure does not. A hard bounce or a complaint is a fact about one _address_, not about the person, and a dead mailbox says nothing about their phone or their device — so the email step is marked `bounced` and **the rest of the ladder still runs** (SMS, push, and the remaining channels). Reading those two signals as one is what would let a single bounce cancel a compliance notice's entire fallback. ## 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. A recalled row carries no pointer at all: read `recalled` and show the recipient that the content is gone, instead of a reveal that no longer opens. ### 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. **`null` is the only end-of-feed signal.** The archived/active split is applied after the page is read, so a short — or entirely empty — page with a live cursor means keep paging, not end of list. | #### 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 time as Unix epoch seconds (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. | | `body` | string | Present when there is visible text: a plaintext send's inline body, or a sealed send's non-secret instructions. Never sealed content. | | `sparklink_code` | string | Opaque pointer the client opens to unseal the content (sealed sends only). **Absent once the send is recalled**: the grant behind it is revoked, so the pointer opens nothing. | | `locked` | boolean | True when the content lives behind the SparkLink ("tap to view"). False for a cleartext row, and false once `recalled` is true, because there is nothing left to unlock. | | `recalled` | boolean | True when the sender took this notification back. The reveal is revoked and the pointer is withheld, so render **“no longer available”** rather than a tap-to-reveal that dead ends. The display metadata (`title`, `body`, timestamps) stays readable. | | `thread_id` | string | Present only for conversation threads. | ```bash curl 'https://api.sparkvault.com/v1/products/notify/inbox?recipient_id=usr_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 as Unix epoch seconds (the sort-key timestamp), exactly 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. | ### Mark All (bulk) #### `POST /products/notify/inbox/state` Bulk-mark a recipient's inbox seen or read, one bounded page per call. Loop on the returned cursor until it is null. Archived is per-row only (no bulk drain). #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `recipient_id` | string | Required | The inbox owner. | | `state` | string | Required | "seen" | "read". | | `cursor` | string | Optional | Opaque continuation cursor from a prior call. Omit for the first page. | #### Response | Field | Type | Description | | --- | --- | --- | | `updated` | integer | Rows marked in this page (can be 0 with a non-null cursor when a page is filtered — keep looping). | | `cursor` | string | null | Next-page cursor; null when the inbox is fully drained. | ## 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. **The delivery state of one recipient** ```text A recipient's state is DERIVED at read time from their row. Nothing stores it. STILL CHANGING. POLL AGAIN. ┌────────────────┐ │ pending │ Fan-out queued their first step and the ladder has not settled, └────────────────┘ or no row exists for them yet. ▲ │ a later fan-out of the same send drives them, clears the marker and │ gives the send back the count ┌────────────────┐ │ rate_limited │ The per-recipient per-minute ceiling skipped them before any └────────────────┘ channel ran, and no fan-out has driven them since. SETTLED. THIS IS THE ANSWER. ┌────────────────┐ │ delivered │ One channel's transport accepted the message. It outranks every └────────────────┘ other marker on the row. │ │ a hard bounce replaces that outcome with bounced, so the row leaves ▼ delivered ┌────────────────┐ │ undeliverable │ Every attempted channel declined (skipped, not_implemented or └────────────────┘ bounced), or fan-out found no channel with a handle for them (suppressed_reason no_channels or unreachable), or the ladder ran out with nothing delivered and nothing recorded. ┌────────────────┐ │ failed │ A channel exhausted its retries, every attempt is terminal, and └────────────────┘ none delivered. Our transport gave up; the address may be fine. ┌────────────────┐ │ suppressed │ The person chose silence: global_off, category_muted or └────────────────┘ all_channels_opted_out stamped before the ladder, or identifier_unsubscribed stamped mid ladder by the sender. ── HOW IT ROLLS UP ─────────────────────────────────────────────────────────── counts partitions the audience exactly: pending + delivered + undeliverable + failed + suppressed + rate_limited = total enqueued, seen, read and archived are independent markers. They partition nothing. settled = every recipient that is neither pending nor delivered. The send level state is the FIRST rule that matches: 1 no recipient_count on the row, or zero recipients -> unknown 2 every recipient delivered -> delivered 3 every recipient settled, none delivered -> failed 4 every recipient settled, some delivered -> partial 5 some enqueued or settled, some pending -> sending 6 nothing driven yet -> pending GET /products/notify/sends/:sendId/status recomputes the counts from the recipient rows; GET /products/notify/sends reads the send row's own counters. Same derivation. ``` Every recipient of a send sits in one of six states, derived at read time from their row rather than stored. Two of them are still changing and two transitions can move a recipient back out of a settled state. The six counters partition the audience exactly, and the send level state is derived from the same numbers. #### `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 DERIVED send-level state: `unknown` | `pending` | `sending` | `delivered` | `partial` | `failed`. Computed at read from the per-recipient rows by the SAME function the send-history list uses, so the two can never disagree about what a state means. It is not the write-once `pending` the send row stores. | | `channels` | string\[\] | The resolved channel ladder stored on the send. | | `created_at` | integer | Send creation time as Unix epoch seconds. | | `title` | string | null | Display title. | | `category` | string | null | Display category. | | `type` | string | null | Display type. | | `recipients` | object\[\] | Per recipient (see the table below). | | `counts` | object | Aggregate rollup: `{ total, enqueued, seen, read, archived, pending, delivered, undeliverable, failed, suppressed, rate_limited, recalled }`. The seven STATE counters PARTITION the audience — `pending + delivered + undeliverable + failed + suppressed + rate_limited + recalled === total` — so a shrinking `pending` is real progress. `enqueued`/`seen`/`read`/`archived` are independent progress markers and partition nothing. `total` is the send's audience size, not the number of rows returned. | #### recipient | Field | Type | Description | | --- | --- | --- | | `recipient_id` | string | The recipient this row is about. | | `state` | string | **The one answer for this recipient**, derived from their row. One of the seven terminal-or-pending states in the table below. Read this rather than assembling a verdict from the booleans — they do not cover every outcome on their own. | | `enqueued` | boolean | Fan-out accepted this recipient and enqueued the first delivery step. | | `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. | | `suppressed_at` | integer | null | When the recipient was taken out of the ladder. NOT always an opt-out — read `suppressed_reason` to tell an opt-out from an unreachable address, or just read `state`, which already separates them. | | `suppressed_reason` | string | null | Why, as a fixed token (never a transport string, so nothing recipient-identifying rides it): `no_channels` (the send offered no channel at all) and `unreachable` (every offered channel lacks a handle for them) both roll up to `state: "undeliverable"`; `global_off`, `category_muted`, `all_channels_opted_out` and `identifier_unsubscribed` all roll up to `state: "suppressed"`. | | `rate_limited_at` | integer | null | When the recipient was skipped by the per-recipient ceiling. Cleared if a later fan-out legitimately drives them. | | `channel_outcomes` | object | Map of channel name to `{ outcome, at, step }`. `outcome` is `delivered`, `retryable`, `skipped`, `not_implemented`, `failed` (the step exhausted its retries), or `bounced` (the provider later repudiated a delivery it had accepted — this REPLACES the optimistic `delivered` entry). Keyed once per channel: a later attempt OVERWRITES the earlier entry, so this is the latest state per channel, not a history. Provider detail strings are withheld — they can echo the recipient's contact. | | `delivered` | boolean | At least one channel's transport ACCEPTED the message. See the semantics note below — it is a transport fact, not proof a human saw anything. | | `undeliverable` | boolean | Something was attempted, nothing delivered, and every recorded outcome declined (`skipped`, `not_implemented`, or `bounced`). A recipient with a `retryable` step still in flight is neither delivered nor undeliverable. | | `failed` | boolean | Every attempt is terminal, nothing delivered, and at least one channel exhausted its retries. OUR transport gave up; the address may be perfectly good. | The seven per-recipient states, and what each one asks of you: | `state` | What happened | What to do | | --- | --- | --- | | `pending` | Still in flight, or not yet driven. | Wait. Poll again. | | `delivered` | A channel accepted it and no provider has repudiated that. | Nothing. | | `undeliverable` | Every channel attempted declined — no handle, bounced, or not implemented. The recipient cannot be reached _as addressed_. | Fix the address, or offer a channel they have a handle for. | | `failed` | A channel exhausted its retries. Our transport gave up; the address may be fine. | Retry with a fresh `send_id`, or escalate to us. | | `suppressed` | The **person** chose silence: global off, category mute, every offered channel opted out, or they unsubscribed the address the send was made to. | Nothing. This is not a fault, and it is not something to work around. | | `rate_limited` | Skipped by the per-recipient ceiling for that minute. | Slow down, or raise `reliability.rate_limit_per_recipient_per_minute`. | | `recalled` | **You** took it back before a channel reached them. The reveal is revoked and nothing further will be attempted. A recipient a channel had already reached stays `delivered`: recall closes the reveal, it does not un-send the notification. | Nothing. If they still need it, send again with a fresh `send_id`. | ```json { "data": { "send_id": "ntsnd_01j9z4f6w8m3qk2c7d5h0abxyz", "status": "partial", "channels": ["email", "push"], "created_at": 1719446400, "title": "Your invoice is ready", "category": "transactional", "type": null, "recipients": [ { "recipient_id": "usr_01hq8yv2k3", "state": "delivered", "enqueued": true, "seen_at": 1719446460, "read_at": null, "archived_at": null, "suppressed_at": null, "suppressed_reason": null, "rate_limited_at": null, "channel_outcomes": { "email": { "outcome": "delivered", "at": 1719446405, "step": 0 }, "push": { "outcome": "skipped", "at": 1719446405, "step": 1 } }, "delivered": true, "undeliverable": false, "failed": false }, { "recipient_id": "usr_01hq8yv2k4", "state": "suppressed", "enqueued": false, "seen_at": null, "read_at": null, "archived_at": null, "suppressed_at": 1719446401, "suppressed_reason": "category_muted", "rate_limited_at": null, "channel_outcomes": {}, "delivered": false, "undeliverable": false, "failed": false } ], "counts": { "total": 2, "enqueued": 1, "seen": 1, "read": 0, "archived": 0, "pending": 0, "delivered": 1, "undeliverable": 0, "failed": 0, "suppressed": 1, "rate_limited": 0, "recalled": 0 } }, "meta": { "api_version": "1.2.828", "request_id": "...", "response_ms": 34, "timestamp": 1719446400 } } ``` > **What delivered actually means** > > `delivered` means **a channel's transport accepted the message** — the mail provider took the envelope, the push service took the token, the webhook receiver answered 2xx. That is the strongest claim a sender-side transport can honestly make, and it is _not_ a claim about a human. > > Escalate your certainty deliberately: > > - `delivered` — a transport accepted it. Nothing about a person. > - `seen_at` / `read_at` — the recipient's client reported the row seen or read. > - A **receipt** — the recipient completed a verified ceremony. This is the only proof that a specific identity opened, approved, or signed a specific thing, and it is the only one that is portable and independently verifiable. > > Outcome recording is best-effort telemetry that never blocks a delivery, so `channel_outcomes` can legitimately under-report a send that went out. Never treat an empty map as proof nothing was sent. ## Send History Every send you made, newest first. Metadata and counters only: the index this reads projects display fields and delivery counts and **not** the audience, so this surface cannot leak a recipient's contact, the SparkLink pointer, or a plaintext body. Take the deliberate second step to `/sends/:sendId/status` for per-recipient detail. #### `GET /products/notify/sends` The account's send history, newest first. Metadata + counters only. #### 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. | #### Response | Field | Type | Description | | --- | --- | --- | | `sends` | object\[\] | Send summaries (see below), newest first. | | `cursor` | string | null | Next-page cursor, or null when the history is exhausted. | #### send summary | Field | Type | Description | | --- | --- | --- | | `send_id` | string | null | The send identifier. | | `created_at` | integer | null | Send creation time as Unix epoch seconds. | | `title` | string | null | Display title. | | `category` | string | null | Display category. | | `delivery` | string | null | "sealed" or "plaintext". | | `display_sender_name` | string | null | The “from” the recipient saw. Display only; nothing routes on it. | | `recipient_count` | integer | null | Audience size. `null` on a row written before the counters existed. | | `enqueued_count` | integer | null | Recipients handed to delivery. | | `delivered_count` | integer | null | Recipients where at least one channel accepted. | | `failed_count` | integer | null | Recipients whose every attempted channel declined. | | `state` | string | Derived from the counters, never stored: `delivered` (every recipient reached an accepting channel), `failed` (every recipient resolved, none delivered), `partial` (every recipient resolved, some delivered), `sending` (still in flight), `pending` (accepted, nothing handed to delivery yet), or `unknown` (the row predates the counters — no result is claimed rather than a wrong one). | ```javascript let cursor = null; do { const page = await sv.products.notify.listSends({ limit: 100, cursor }); for (const send of page.sends) { console.log(send.created_at, send.state, send.title, `${send.delivered_count}/${send.recipient_count}`); } cursor = page.cursor; } while (cursor); ``` > **503 SEND\_HISTORY\_PROVISIONING** > > Send history is served by a secondary index. In the rare window where a deployment's index is not yet queryable, this endpoint answers `503 SEND_HISTORY_PROVISIONING` rather than a 500 — explicit and unmistakably temporary. Retry shortly. Sending and delivery are unaffected either way. ## Recall A sealed send can be taken back. Recall closes the reveal: it revokes the per-recipient access grants (or destroys the sealed content outright), so a recipient who has not yet opened the pointer never can. Receipts already produced survive — a recall cannot un-prove something that happened. **A whole-audience recall also stops delivery that is still in flight.** A large send goes out in waves, and recalling it withdraws the waves that have not gone yet: no further inbox rows, no further notifications, and **nothing further billed** against your allowance. Recipients the send had not reached settle as `recalled` on the status endpoint, so the send finishes instead of reporting `pending` forever, and every recipient who already has a row sees their reveal withdrawn in their feed (`recalled: true`, no pointer) rather than a tap that dead ends. A **targeted** recall (`recipient_ids`) is narrower on purpose: it takes back the recipients you name and leaves the send going out to everyone else. #### `POST /products/notify/sends/:sendId/recall` Revoke the access grants a sealed send minted, or destroy its sealed content. Idempotent. A foreign send is reported as 404. #### Request Body | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `recipient_ids` | string\[\] | Optional | Recall only these recipients. Omit the field entirely to recall the whole audience; an EMPTY array is rejected 400 rather than silently meaning “none”. Ids that are not on this send come back in `not_found` instead of failing the call. | | `mode` | string | Optional | `"grant"` revokes the access grants and leaves the sealed content intact for any other share of it. `"content"` destroys the sealed content itself.Default: `grant` | #### Response | Field | Type | Description | | --- | --- | --- | | `send_id` | string | The send that was recalled. | | `mode` | string | The mode that ran. | | `recalled` | integer | Grants revoked. Idempotent: a grant already revoked (or already consumed and cleaned up) counts as recalled, so re-running a recall is safe. | | `failed` | integer | Recipients whose recall raised an error. | | `not_found` | string\[\] | Requested recipient ids that are not on this send. Reported rather than dropped, so a typo can never read as success. | | `failures` | object\[\] | One `{ recipient_id, error }` per failure. Recall is deliberately partial: one recipient failing never stops the rest, because stopping would leave the remaining grants live. | ```bash curl -X POST 'https://api.sparkvault.com/v1/products/notify/sends/ntsnd_01j9z4.../recall' \ -H 'X-API-Key: sv_live_your_api_key' \ -H 'Content-Type: application/json' \ -d '{ "recipient_ids": ["usr_01hq..."], "mode": "grant" }' ``` > **A plaintext send cannot be recalled** > > Plaintext delivery puts the body inline in the channel payload: it mints no grant, so there is no reveal to close and the message is already in the recipient's mailbox or notification tray. Calling recall on one is rejected with `400 VALIDATION_ERROR` rather than reporting a success that would be a lie. If a message might ever need taking back, send it sealed. ## 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. **The recipient ceremony** ```text ┌───────────────────────────────────────────────────────────────────────────┐ │ 1 The recipient opens the pointer │ │ GET https://x.sv/ │ │ The page names the sender and what is being asked. │ └───────────────────────────────────────────────────────────────────────────┘ │ ▼ ┌───────────────────────────────────────────────────────────────────────────┐ │ 2 Identity runs the ceremony at the level the grant requires │ │ identifier or passkey. Invites or an interaction floor it at │ │ identifier. │ └───────────────────────────────────────────────────────────────────────────┘ │ A proof that is too weak or too old is not a denial. ▼ The page offers the ceremony again. ┌───────────────────────────────────────────────────────────────────────────┐ │ 3 The content reveals │ │ POST https://x.sv/ │ │ What this spends depends on the interaction the grant asks for. │ └───────────────────────────────────────────────────────────────────────────┘ │ ┌──────────────┴───────────────┐ ▼ ▼ A VIEW GRANT AN INTERACTIVE GRANT the READ spends it the ANSWER spends it the reveal is the whole acknowledge, sign, approve, ceremony decline, or reply active becomes consumed the reveal spends neither the at the reveal grant nor the content the sealed Spark burns a reload, a second tab, or a on read failed submit finds the request receipt: sparklink_accessed, still open inside the which records the open verification window Notify stops escalating: Notify keeps escalating until the recipient engaged the answer lands │ ▼ ┌──────────────────────────────────────────────┐ │ 4 The answer │ │ POST https://x.sv//interaction │ └──────────────────────────────────────────────┘ the grant is consumed, the EdDSA proof is signed, the receipt is written only then does the revealed content burn receipt: sparklink_signed, sparklink_approved, sparklink_denied or sparklink_replied, verifiable against the tenant JWKS │ ▼ ┌──────────────────────────────────────────────┐ │ All or nothing │ │ No receipt, no spend: the grant reopens and │ │ nothing is burned. │ └──────────────────────────────────────────────┘ ``` The reveal and the answer are separate steps. A view grant is spent by the read, so it ends at the reveal. An interactive grant is spent by the answer, so the request survives a reload inside the verification window, and the ceremony lands only when its signed receipt is written. ### 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. | | `source` | string | Which store answered: `receipt_index` or `legacy_scan`. It changes what an empty page MEANS — see below. | > **Two sources, and only one of them can hand you an empty page mid-walk** > > `source` tells you which store answered, and it is not cosmetic: > > - **`receipt_index`** — the normal path. A real key query with real pagination: a page is a page, and a null `cursor` is the end. > - **`legacy_scan`** — served to an account whose receipts all predate the receipt index. It filters the account audit log, so it returns legitimately **EMPTY** pages that are nowhere near the end of the data. An empty page carrying a cursor is a scan that has not finished, not an account with no receipts. > > On _either_ source: follow the cursor until it is null before concluding there are none. The two are never merged — a merged page would either double-count the overlap or hide it — and a cursor always resumes on the source that issued it. ### 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 grant (never the shared asset_id). A foreign send is reported as 404. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `cursor` | string | Optional | Resume point from a prior truncated response. | #### 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. | | `truncated` | boolean | True when the bounded correlation walk stopped before exhausting the partition. Re-call with the returned cursor until false. | | `cursor` | string | null | Resume point for a truncated walk; null when complete. | #### 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 (a 6-character display prefix, which can collide across sends). Display only. | | `link_code_hash` | string | null | The correlation key: 32 hex chars, 128 bits of SHA-256 over the full grant code. This is what an **event webhook quotes** — match an interaction event to its receipt on this, never on `link_code`. Never the usable grant. | | `link_type` | string | null | The SparkLink type that emitted the receipt. | | `verification_level` | string | null | The level the recipient satisfied. This can be wider than the set a send may ASK for — a grant minted under a retired level is still enforced at the strength it names, so a receipt can report one. | | `action_hash` | string | null | The bound action hash. Always present on an `approve` or `sign` receipt, null on every other interaction. It is the `policy.action_hash` you sent; supply none and it is a server-derived digest of the grant, which no hash of your own document can match. | | `signed_token` | string | null | The portable, JWKS-verifiable Identity-signed EdDSA proof. Minted on a **ceremony** receipt (acknowledge, sign, approve, reply). A plain `view` receipt may carry `null`: opening a link is an access record, not an attestation. Verify it as described below. | | `decision` | string | null | On an `approve` ceremony: `"approved"` or `"denied"` (the reveal page shows Approve and Decline). Null on every other interaction. | | `thread_id` | string | null | Present for conversation threads. | | `reply_spark_id` | string | null | The sealed reply Spark, present on reply receipts. Read it with `GET /v1/sparks/:id` — it is **burn-on-read**, so the first read is the only one, and it lives no longer than the message it answers. | | `send_id` | string | null | The send whose grant produced this receipt, stamped at mint time. Null for a standalone SparkLink, and on the legacy source. | | `title` | string | null | The send's display title, under the same conditions as `send_id`. | | `receipt_id` | string | null | The receipt row id. Null on the legacy source, whose rows carry none. | | `recipient_id` | string | Present on the per-send variant: the recipient who produced the receipt. | Both sources produce the **same keys**: a field only one of them can fill comes back as `null` on the other, so a consumer never has to know which partition answered to know the shape. > **Correlation by grant, not asset** > > Receipts are joined to a send by the per-grant SparkLink grant (unique per recipient) via a full-strength `link_code_hash` stored on the receipt, 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 per call); a bounded stop returns `truncated: true` with a `cursor`: re-call with it until `truncated` is false. ### Verifying a receipt A receipt's `signed_token` is a compact JWS you can verify yourself, with no SparkVault involvement and no SparkVault credential. That is the point: it is evidence you can hand to an auditor, a counterparty, or a court, and they can check it against a public key we publish. | Property | Value | | --- | --- | | Algorithm | `EdDSA` (Ed25519). Reject any token whose header names a different `alg`. | | Key type | `OKP` / `crv: Ed25519`, `use: sig` | | JWKS URL | `https://auth.sparkvault.com/{account_id}/.well-known/jwks.json` | | Key selection | The token header carries a `kid`; match it against the JWKS entry. | The JWKS is per tenant: `{account_id}` is _your_ `acc_...`, the account that sent the notification. The same key signs Identity verification tokens, so one verification path covers both. #### signed\_token claims (normative) | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `iss` | string | Required | The tenant account id (`acc_...`) whose JWKS verifies this token. Present so the receipt is self-describing: a verifier handed only the token can find the key set and pin the issuer, with no out-of-band hint about which account issued it. | | `link_code_hash` | string | Required | SHA-256 of the per-recipient SparkLink grant code. The correlation key: it binds the proof to one grant on one send without ever carrying the grant itself. | | `identity` | string | Required | The verified identity that performed the ceremony. | | `interaction` | string | Required | "view" | "acknowledge" | "sign" | "approve" | "reply" — the ceremony that was completed. | | `decision` | string | Optional | Present on an approve ceremony: "approved" or "denied". Absent otherwise. | | `action_hash` | string | Optional | SHA-256 (64 lowercase hex) of the action bound to the ceremony. Present on every `approve` and `sign` receipt; absent on `view`, `acknowledge`, and `reply`. It is the `policy.action_hash` you supplied at send time — and that is what turns “Ada verified” into “Ada approved THIS document”. Supply none and it is instead a server-derived digest of the grant, which no hash of your own document can match. | | `iat` | integer | Required | Issued-at, Unix epoch seconds — when the ceremony completed. | ```javascript import { createRemoteJWKSet, jwtVerify } from 'jose'; import { createHash } from 'node:crypto'; const JWKS = createRemoteJWKSet( new URL(`https://auth.sparkvault.com/${accountId}/.well-known/jwks.json`) ); // EdDSA only — never let the token choose its own algorithm. The token names its // own issuer, so pin it: a receipt signed by another tenant must not verify here. const { payload, protectedHeader } = await jwtVerify(receipt.signed_token, JWKS, { algorithms: ['EdDSA'], issuer: accountId }); console.log(protectedHeader.kid, payload.identity, payload.interaction, payload.decision); // Bind the proof to the thing you care about, or you have only proved a signature exists. const expectedActionHash = createHash('sha256').update(canonicalDocumentBytes).digest('hex'); if (payload.action_hash !== expectedActionHash) { throw new Error('This receipt attests a different action'); } ``` > **A signature alone proves nothing useful** > > Verifying the token proves SparkVault signed it. To make it evidence, check the claims against what you expected: compare `action_hash` to a hash you recompute from your own copy of the document or transaction, confirm `identity` is the party you meant, and confirm `interaction` (and `decision`) is the ceremony you required. > > That comparison only works when **you** set the binding. Send `policy.action_hash` on every `approve` and `sign`. A receipt whose binding you did not supply carries a server-derived digest of the grant instead, so it attests that an identity performed a ceremony — never _what_ they were looking at — and the comparison above can never succeed. ## 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 default sealed-send policy, and the categories recipients can mute. ### 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). | > **One field is never read back** > > `events.secret` is redacted on both `effective` and `overrides`, replaced by `events.secret_set: true|false`. It is the only value the config withholds: echoing a signing secret on every GET would park it in browser devtools and proxy logs for a value you already hold. A PUT is a partial patch merged server-side, so nothing has to round-trip it. ```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 }, "reliability": { "rate_limit_per_recipient_per_minute": 60 }, "history": { "history_ttl_days": 30, "receipt_retention_days": 365 }, "security": { "verification_level": "identifier", "reveal_freshness_minutes": 0, "mandatory_seal_categories": [] }, "events": { "webhook_url": "https://hooks.example.com/sparkvault/notify", "secret_set": true }, "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 } } }, "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. | | `reliability` | object | Optional | rate\_limit\_per\_recipient\_per\_minute (integer 1..600; the per-recipient backstop cannot be disabled). | | `history` | object | Optional | history\_ttl\_days, receipt\_retention\_days. | | `security` | object | Optional | `verification_level` ("none" | "identifier" | "passkey") and `reveal_freshness_minutes` — the DEFAULT sealed-send policy applied when a send omits them; a send that states either always wins. Ships as `identifier`, which is why a sealed send to an id-only recipient with no explicit policy is rejected 400. Plus `mandatory_seal_categories` (expand-only: it adds to the hardcoded confidential baseline and can never shrink it). | | `events` | object | Optional | `{ webhook_url, secret }` — the outbound event webhook. See Event Webhooks below. | | `categories` | object | Optional | Open-keyed map of `{ label, description, required }` (label ≤ 60 chars, description ≤ 200, ≤ 50 categories, new keys `[a-z0-9_]`). Add your own topics (e.g. weekly\_newsletter). Set a key to `null` to delete that category, seeded defaults included. The reserved "security" category is always required and cannot be deleted. | > **Two sections are admin only** > > `security` and `events` set your tenant's security posture: one is the default seal policy applied to every send that states none, the other is where your delivery-event stream is sent. Patching either requires an account **admin** or **owner**; any other member is refused `403` and **nothing in the patch is written**, including the sections they were allowed to change. Every other section is patchable by any member. > > An API key carries the role of **the person who created it**, read fresh on each request, so a server-to-server integration can still patch these as long as its key was created by an admin or owner. If that person's role is later reduced, the key loses the sections with it. A role that cannot be resolved holds nothing: the check fails closed. ```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 managed secret-store 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. ## Event Webhooks Without this, the only way to learn that a notification reached someone — or that they signed, approved, denied, or replied to it — is to poll. Configure an `events` endpoint and Notify pushes those moments to you, signed so you can prove the request came from us. ### Configure it ```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 '{ "events": { "webhook_url": "https://hooks.example.com/sparkvault/notify", "secret": "a-long-random-string-you-generate" } }' ``` #### events Object | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `webhook_url` | string | Optional | Where events are POSTed. **https only, on a public host** (≤2048 chars): loopback, private, link-local, and CGNAT addresses are rejected 400. This is the one config field that points SparkVault's compute at an address you choose, so it is validated as a security control. | | `secret` | string | Optional | The HMAC signing secret, 16–256 characters. It is your receiver's only proof an event is genuine, so a short one is a broken one and is rejected. **Never returned on a read** — a config GET reports `events.secret_set: true` instead, and a PATCH never has to resend it. | Both fields are optional in a patch, but **nothing is sent until both are stored**: an unsigned event is not something SparkVault emits. ### Verify the signature Each event arrives as a JSON POST with an `X-SparkVault-Signature` header carrying the **hex HMAC-SHA256 of the raw request body** under your secret. Sign the bytes exactly as received — re-serializing parsed JSON will not match. ```javascript import { createHmac, timingSafeEqual } from 'node:crypto'; // Capture the RAW body — a re-serialized object will not produce the same digest. app.post('/sparkvault/notify', express.raw({ type: 'application/json' }), (req, res) => { const expected = createHmac('sha256', process.env.SPARKVAULT_NOTIFY_SECRET) .update(req.body) .digest('hex'); const received = req.get('X-SparkVault-Signature') || ''; const a = Buffer.from(expected, 'utf8'); const b = Buffer.from(received, 'utf8'); if (a.length !== b.length || !timingSafeEqual(a, b)) { return res.sendStatus(401); } const event = JSON.parse(req.body.toString('utf8')); // Answer fast. Queue the work; do not process inline. res.sendStatus(200); queue.push(event); }); ``` ### Event types Seven types, all in one `notify.` namespace. Anything else is not an event SparkVault emits. | `type` | Fires when | Carries | | --- | --- | --- | | `notify.delivered` | A recipient's first successful delivery — a channel transport accepted the message. | `send_id`, `recipient_id` | | `notify.failed` | A recipient's ladder ran out with nothing delivered. | `send_id`, `recipient_id` | | `notify.bounced` | A provider later repudiated a delivery it had already accepted (a hard bounce). | `send_id`, `recipient_id` | | `notify.signed` | The recipient completed a `sign` — or an `acknowledge`, which produces the same receipt subtype. | `link_code_hash`, `interaction`, `send_id`\* | | `notify.approved` | The recipient approved a bound action. | `link_code_hash`, `interaction`, `decision`, `send_id`\* | | `notify.denied` | The recipient declined a bound action. | `link_code_hash`, `interaction`, `decision`, `send_id`\* | | `notify.replied` | The recipient sent an encrypted reply back into the thread. | `link_code_hash`, `interaction`, `send_id`\* | \* `send_id` rides an interaction event only when the grant knows which send minted it — a standalone SparkLink has no send. **A plain `view` emits no event**: opening a sealed message is recorded as a receipt, not pushed. Poll `/products/notify/receipts` for opens. #### event payload | Field | Type | Description | | --- | --- | --- | | `event_id` | string | Unique id for this event (`nev_...`). Use it to deduplicate. | | `type` | string | What happened — one of the seven above. | | `account_id` | string | Your account. Check it matches before acting. | | `occurred_at` | integer | Unix epoch seconds. | | `send_id` | string | The send this concerns. Always on a delivery-lifecycle event; on an interaction event only when the grant carries it. | | `recipient_id` | string | Present on delivery-lifecycle events. | | `link_code_hash` | string | Present on interaction events: the SHA-256 correlation key, matching the receipt's `link_code_hash`. Never the usable grant code. | | `interaction` | string | Present on interaction events: the ceremony performed (`acknowledge` | `sign` | `approve` | `reply`). | | `decision` | string | Present on an approve ceremony: "approved" or "denied". | Optional fields are **omitted** when absent rather than sent as `null`, so presence is a usable signal. An event body never carries sealed content, the SparkLink grant code, or the recipient's raw contact. ```json { "event_id": "nev_01j9z4f6w8m3qk2c7d5h0abxyz", "type": "notify.approved", "account_id": "acc_01hq...", "occurred_at": 1719446512, "send_id": "invoice-1043-usr_01hq8yv2k3", "link_code_hash": "9f2c1a...", "interaction": "approve", "decision": "approved" } ``` ```json { "event_id": "nev_01j9z4f6w8m3qk2c7d5h0abcde", "type": "notify.delivered", "account_id": "acc_01hq...", "occurred_at": 1719446405, "send_id": "invoice-1043-usr_01hq8yv2k3", "recipient_id": "usr_01hq8yv2k3" } ``` > **At most once. Polling remains the source of truth.** > > Event delivery is a **best-effort nudge, not a guarantee**. There is one retry, then the event is dropped — no durable queue, no dead-letter, no replay, and no way to ask for it again. Events are emitted on the request path of the work they describe, and that work is never allowed to fail because your endpoint is down. > > So: never build a system whose correctness depends on receiving every event. Reconcile against `GET /products/notify/sends`, `/sends/:sendId/status`, and `/products/notify/receipts`. The webhook exists to make that reconciliation _rare_, never to replace it. > > Practically: answer 2xx quickly (each attempt is bounded to 5 seconds), do the real work off the request, deduplicate on `event_id`, and treat any event as possibly stale relative to a poll. ## Categories & Recipient Preferences Two tiers decide delivery. The send resolves an offered channel ladder once; fan-out then narrows it per recipient — first by what they can actually receive, then by what they asked for. Precedence is strict: Capability \> Compliance floor \> Recipient preference \> Account default - **Capability comes first**, before any preference and before the compliance floor: a channel this recipient has no way to receive on is pruned from their ladder, in three sweeps — **device** channels (`push`, `web_push`) with no registered handle, **inbox** channels (`in_app`, `websocket`) for a recipient with no first-party SparkVault inbox, and **identifier** channels (`email`, `sms`, `voice`, `whatsapp`, `rcs`) whose handle the send never carried. A pruned step does not sit in the ladder burning its delay: the surviving channels are re-based so they fire at their configured cadence rather than waiting behind a hop that could never land. Pruning is fail-open — an absent or malformed contact prunes nothing and the dead channel simply skips at dispatch. - **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; the seeded preset categories `secure`, `approval`, and `signature` also ship required (see Presets). - **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. - **Preferences belong to the PERSON**, not to however a send addressed them. They are keyed by the recipient's SparkVault identity (SVID), so a recipient you address by **email or phone is resolved to their SVID** through the Identity directory and their stored mutes are applied. You do not have to know someone's SVID for their unsubscribe to be honoured — email is the form of address an unsubscribing recipient is most likely to have been reached at, and it is honoured. A recipient with no SparkVault identity resolves to none and keeps identifier-level suppression (unsubscribe / bounce suppression). - **Application** is fail-open: a preferences fault, or a directory lookup that faults, delivers as offered rather than dropping a notification. A suppressed or rate-limited recipient still gets a **terminal row** — carrying `suppressed_at` or `rate_limited_at` and an empty ladder — so a status read can tell an opt-out from a delivery still in flight. They are never driven and never metered. ## Suppressions & Unsubscribe An opt-out is not a one-way door. These two endpoints let you see who opted out of **your** mail and put someone back on the list once they ask — without a support ticket about somebody else's recipients. > **Your list, and only your list** > > SparkVault records two kinds of suppression, and only one of them is yours: > > - **Account-scoped** — someone unsubscribed from _your_ mail. These are the rows these endpoints read and clear. > - **Global** — a hard bounce, a spam complaint, or a platform-wide opt-out. A bounce is a fact about the _address_; a platform opt-out is a person telling SparkVault to stop. Neither is one tenant's to overrule, and resuming mail to a mailbox that already refused burns the sending reputation every tenant on the platform shares. > > This is **structural, not a filter**. Global rows carry no `account_id`, so the sparse index the list reads does not contain them, and the clear builds its key from your own account id. There is no parameter on either endpoint that reaches a global row or another tenant's. ### List your suppressions #### `GET /products/notify/suppressions` The addresses that opted out of THIS account's mail, newest first. Account-scoped rows only — global suppressions are structurally unreachable here. #### Query Parameters | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | Optional | Page size, clamped to 100. A missing, zero, negative, or non-integer value means “you did not choose one” and falls back to the default rather than to a 1-row page.Default: `50` | | `cursor` | string | Optional | Opaque continuation token from a prior page's `cursor`. | #### Response | Field | Type | Description | | --- | --- | --- | | `suppressions` | object\[\] | Suppression rows, newest first. | | `cursor` | string | null | Opaque continuation token; `null` when the listing is exhausted. | #### suppression | Field | Type | Description | | --- | --- | --- | | `email` | string | The suppressed address. The stored row is keyed by a composite of your account and the address; that internal key never leaves the API. | | `type` | string | What kind of suppression this is. | | `source` | string | What recorded it. | | `reason` | string | null | Free-form detail, when the recording path supplied one. | | `created_at` | integer | null | Unix epoch seconds the row was written. | ```javascript let cursor = null; do { const params = new URLSearchParams({ limit: '100' }); if (cursor) params.set('cursor', cursor); const res = await fetch( `https://api.sparkvault.com/v1/products/notify/suppressions?${params}`, { headers: { 'X-API-Key': process.env.SPARKVAULT_API_KEY } } ); const { data } = await res.json(); for (const row of data.suppressions) { console.log(row.email, row.type, row.created_at); } cursor = data.cursor; // loop until it comes back null } while (cursor); ``` The cursor is the same opaque contract `/sends` and `/receipts` use: it carries the whole page pointer, so never assemble one from a field on a row, and never treat a short page as the end — `cursor` being `null` is the only end-of-list signal. > **503 SUPPRESSIONS\_PROVISIONING** > > This listing is served by a secondary index. In the rare window where a deployment's index is not yet queryable, the read answers **`503 SUPPRESSIONS_PROVISIONING`** rather than a 500. Retry shortly; sending and delivery are unaffected. > > Clearing a suppression needs no index, so `DELETE` always works. ### Clear one suppression #### `DELETE /products/notify/suppressions/:email` Re-enable one address for THIS account's mail. URL-encode the address in the path. #### Response | Field | Type | Description | | --- | --- | --- | | `cleared` | boolean | True when one of your rows was removed. False when there was nothing of yours to clear. | | `email` | string | The normalized (lower-cased, trimmed) address the call acted on. | > **Why this reports cleared: false instead of 404** > > A missing row is **not** an error. If the only suppression on that address is _global_, the honest answer is “there was nothing of yours to clear” — and a 404 would tell you whether an address is globally suppressed, which is another tenant's business and the person's own. So both cases return `200` with `cleared: false`, and the two are deliberately indistinguishable. > > Clearing a row is a statement that **you have the person's renewed consent**. The endpoint cannot check that; you are the party who can. ### The unsubscribe endpoint Every non-critical email SparkVault sends on your behalf carries an unsubscribe URL, in the footer and in the RFC 8058 headers. It is a public endpoint with **two verbs, and the difference is load-bearing**: | Verb | What it does | | --- | --- | | `GET /v1/unsubscribe?token=…` | **Asks. Writes nothing.** Renders a confirmation page with a single button and the line “Nothing has changed yet.” | | `POST /v1/unsubscribe` | **Performs the opt-out.** Reached from that page's form, and from a mail provider's one-click control. The token is read from the form body _or_ the query string. | > **Why the GET cannot be the one that writes** > > An unsubscribe link travels through corporate mail security. Microsoft Defender, Proofpoint and Mimecast all fetch every URL in an inbound message to see where it leads — and they fetch with `GET`. If the GET performed the opt-out, a scanner would silently unsubscribe your recipient before that person had even opened the message: permanently, invisibly, and with an audit trail saying they asked for it. > > The same split is what makes one-click work properly. `List-Unsubscribe-Post` tells Gmail and Outlook to `POST`, so their native control opts someone out in a single action, while a scanner following the identical URL with `GET` changes nothing. ### One-click headers Outgoing **non-critical** mail carries both RFC 8058 headers, pointing at the same endpoint and the same signed token as the visible footer link: ```text List-Unsubscribe: List-Unsubscribe-Post: List-Unsubscribe=One-Click ``` `List-Unsubscribe` alone is a 20-year-old convention that clients render as a link; the `-Post` companion is what makes Gmail and Outlook show their own Unsubscribe control and honour it without a click-through — which is what keeps a frustrated recipient from pressing “Report spam” instead. Both providers have required the pair on bulk mail since February 2024, and a complaint costs the whole platform's sending reputation, not just yours. > **Critical mail carries neither header, and no footer link** > > Mail in a **mandatory** (compliance-floor) category — a login code, a payment-failure notice, a security alert — ships with no `List-Unsubscribe` pair and no footer unsubscribe link at all. That is deliberate on both counts: there is no list to leave, and offering one would opt the recipient out of the optional mail they _did_ want. The unsubscribe token is never minted for a critical send, so there is no URL to put in either place. > > The opt-out is also **scoped to the sender whose mail carried the link**. One bank's customer unsubscribing must not stop their broker's statements. ## Security & Billing ### Security Properties - **Sealed by default**: confidential content is sealed per recipient; transports carry only an opaque pointer. - **Single-use grants**: each SparkLink is spent once, so a leaked pointer cannot be replayed. For a view-only grant that is the first open. For an **interactive** grant (acknowledge / sign / approve / reply) the _answer_ spends it, not the reveal — reading an approval request is not answering it, so a reload or a restored tab cannot lock the recipient out of a request they have not yet responded to. - **Metadata-only reads**: inbox, status, send history, and receipts never carry sealed content. What they do carry about a person is bounded to what you already hold — the exact guarantee is spelled out below. - **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. > **What the read surface does and does not reveal about a person** > > The honest boundary, rather than a blanket claim: > > - `recipient_id` on a status or history response is **the handle you addressed**, echoed back. Address someone by `{ email: "user@example.com" }` and that email is their `recipient_id`. Address them by `{ id: "usr_..." }` and only the opaque id ever appears. Notify never _introduces_ a contact you did not supply. > - `identity` on a receipt is the identifier the recipient **proved control of** during the ceremony. That is the substance of the proof; it is deliberately there. > - A provider's response detail (an SMS gateway's error body, which can echo a phone number) is **stripped** from the status response — it is stored, never returned. > - `link_code` on a receipt is **masked**. The usable grant code is never returned on any read surface. > - Inbox rows carry no contact field at all, and validation errors mask any email or phone they name. > > If you need a read surface that carries no contact under any circumstance, address recipients by `usr_` id. ### Billing Notify is a subscription product: sending requires an active Notify tier (a monthly notification allowance, purchased from the console's Billing page). Without one, `POST /products/notify/send` returns `403 NOTIFY_SUBSCRIPTION_REQUIRED`; every read surface (inbox, status, receipts, config) stays open. 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, and the allowance resets monthly. 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; your current cycle's usage rides the `notify` block of `GET /v1/billing/subscription`. ## Rate Limits Two independent limits apply, and they fail in completely different ways. One rejects your API call; the other silently drops a recipient. Know both. ### 1\. The account request limit — 300 requests per minute Every authenticated call to any SparkVault endpoint, Notify included, draws from one account-wide budget of **300 requests per minute** in fixed 60-second windows. A single send counts as one request no matter how many recipients it carries, so a 500-recipient fan-out costs the same as a one-recipient one. Your position in the current window rides on `meta.quota` of every authenticated response as `{ limit, used, remaining, resets_at }`, so you can back off before you are refused. Exceeding it returns `429 RATE_LIMIT_EXCEEDED` with a `Retry-After` header (seconds until the window resets) and `details` of `{ limit, used, resets_at }` (`resets_at` is Unix epoch seconds). Retry with the _same_ [`send_id`](#idempotency) and the retry is free. This limiter fails **closed**: if its own store is unreachable, calls are refused with a 429 rather than let through unmetered. ### 2\. The per-recipient delivery ceiling — 60 per recipient per minute Separately, Notify caps how many notifications one recipient can be delivered per minute. It defaults to **60**, is configurable through `reliability.rate_limit_per_recipient_per_minute` (integer 1–600), and **cannot be disabled** — it is the backstop that stops a loop in your code from turning into a notification flood for one person. This one is enforced at fan-out, not at the API boundary, which is the part that surprises people: - Your `POST /send` still returns **200**. There is no error, no warning field, and no code to catch. - An over-limit recipient is **dropped from the fan-out**. They get no inbox row and no delivery. - On `/sends/:sendId/status` that recipient reads `enqueued: false` with an empty `channel_outcomes` — the same shape as a recipient suppressed by their own preferences. - If the counter store itself fails, this check fails **open**: the notification is delivered rather than lost. So a 200 from the send endpoint means “accepted”, never “everyone will receive it”. If per-recipient delivery matters, read the status endpoint rather than trusting the send response. ### 3\. The recipient-side link ceilings — 120 per hour each These apply to your **recipient**, not to your backend. Three independent hourly buckets, all 120: | Bucket | Counts | Keyed on | | --- | --- | --- | | Open | The metadata read and the reveal itself — one ceremony costs two. | **Client IP _and_ the grant.** A stranger sharing your recipient's public IP cannot spend their budget, and the grant they were sent is theirs alone. | | Verify | Verification sessions started on a protected link. | Client IP. | | Answer | `acknowledge` / `sign` / `approve` / `reply` submissions. | Client IP. | Exceeding one returns `429 RATE_LIMIT_EXCEEDED` to the recipient with `details` of `{ limit, used, resets_at }`, where `resets_at` is the top of the next hour. It exists because an interactive grant's reveal is deliberately repeatable — the answer spends it, not the read, which is what lets a reload find the request still there. That same property would make an unbounded reveal an amplifier: every open resolves the proof through Identity and writes an access receipt, so anyone holding a live link could inflate your receipt partition by reloading. A real recipient opens a link a handful of times; this bounds the rest, and it is not a limit an ordinary recipient will meet. See [Rate Limiting](/api/docs/#rate-limiting) on the API overview page for the account limit across every product. ## Error Handling Errors return a stable shape. Validation errors name the offending field; tenant-isolation failures are deliberately indistinguishable from a missing resource. ### Silent failures — the ones that return 200 Every row below is a successful API call. Read `/sends/:sendId/status`, never the send response. | What you see | What happened | Remedy | | --- | --- | --- | | `state: "rate_limited"`, `enqueued: false` | The per-recipient ceiling for that minute dropped them at fan-out. | Slow down, or raise `reliability.rate_limit_per_recipient_per_minute` (1–600; it cannot be disabled). | | `state: "suppressed"` with `suppressed_reason` `global_off` / `category_muted` / `all_channels_opted_out` / `identifier_unsubscribed` | The person chose silence. A non-critical ladder ends outright. | Nothing. If it must reach them, it belongs in a `required: true` category. | | `state: "undeliverable"` with `suppressed_reason` `unreachable` / `no_channels` | No offered channel has a handle for this recipient. | Address them by `{ id, email }`, or offer a channel they have a handle for. | | `state: "recalled"`, `enqueued: false` | The send was recalled before delivery reached them. Withdrawing the remaining waves is what recall is for, so this is the intended outcome, not a fault. | Nothing. If they still need it, send again under a **fresh** `send_id`. | | `channel_outcomes.slack / teams / webhook = { "outcome": "skipped", "detail": "no_destination_configured" }` | These three resolve a platform-internal destination only SparkVault's own account can reach. | Drop them from the ladder. | | `channel_outcomes.sms / voice / whatsapp / rcs = "skipped"` | Not E.164, outside the `+1` allowlist, opted out, or over the account volume ceiling. | Store E.164; use email or a device channel outside North America. | | `channel_outcomes.push / web_push = "skipped"`, or the channel missing from the map entirely | No registered device handle. Where the recipient's device capability is known the step is pruned from the ladder before it burns its delay; where it is unknown the step dispatches and skips. | Register a device token, or lead with `email`. | | `channel_outcomes.email = "bounced"`, later steps still ran | A bounce is a fact about the address, not the person, so the rest of the ladder is deliberately untouched. Only a consent decline speaks for the person. | Fix the address. Do not read a bounce as an opt-out. | | `state: "failed"` | A channel exhausted its retries. Our transport gave up; the address may be fine. | Retry under a **fresh** `send_id` — the original key returns the original send. | | `channel_outcomes: {}` on a delivered send | Outcome recording is best-effort telemetry and never blocks a delivery. | Never read an empty map as proof nothing was sent. | | Receipts stay empty | An interactive grant is spent by the ANSWER, not the read. The recipient has not answered. | Keep polling, or subscribe to the `notify.*` interaction events. | ### Error codes #### Error Responses | Status | Code | Description | | --- | --- | --- | | 400 | `VALIDATION_ERROR` | Missing/invalid field: empty `recipients[]`, both `content` and `ingot`, an ingot send to >1 recipient, >500 recipients, a plaintext send that breaks a plaintext constraint, a recall of a plaintext send, an empty `recipient_ids` array, an unknown config key, or a plaintext `send_id` replayed with different content. | | 400 | `INVALID_CURSOR` | `GET /products/notify/suppressions`: the `cursor` decoded but is not a key of this index — a hand-made cursor, or one issued by a different list surface. Cursors are never portable between surfaces; start the listing again without one. | | 401 | `AUTHENTICATION_ERROR` | Missing or invalid account token (JWT or API key). | | 403 | `NOTIFY_SUBSCRIPTION_REQUIRED` | The account holds no Notify tier. Purchase one from the console's Billing page; reads are never gated. | | 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` | The account exceeded 300 requests this minute. Honour `Retry-After`; `details` carries `{ limit, used, resets_at }`. Retry with the same `send_id`. | | 503 | `SEND_HISTORY_PROVISIONING` | `GET /products/notify/sends` only: the send-history index is not queryable on this deployment. Temporary; sending and delivery are unaffected. | | 503 | `SUPPRESSIONS_PROVISIONING` | `GET /products/notify/suppressions` only: the suppression index is not queryable on this deployment. Temporary; sending, delivery, and `DELETE` are unaffected. | ```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/)