# Messaging: SparkVault API Reference

> Complete API reference for SparkVault Messaging, delivering durable, two-way encrypted conversations between a business account and verified account-less recipients. Every message is a triple-zero-trust vault ingot and carries a SparkVault-verified, portable receipt.

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

## Overview

SparkVault Messaging gives your business durable, two-way **encrypted** conversations with people who do not have a SparkVault account. A customer is invited to a thread, verifies themselves once through SparkVault Identity, and can then read and reply from any device. Every message your business sends carries a cryptographic **receipt of record** that a third party can independently verify.

Message content lives **only as vault ingots**: one immutable, VAK-sealed Forge ingot per message. It is never stored in DynamoDB and never in raw S3. Reads are **server-mediated**: the server brokers a single-use key, decrypts the requested ingot, and returns plaintext to an authorized participant.

Posture S

Server-Mediated Reads

Zero-Trust

Triple-ZT At Rest

EdDSA

Verifiable Receipts

Ingot-Only

No Content In DB

### What You Get

-   **Account-less recipients**: invite anyone by email or phone; they verify through SparkVault Identity and become a stable participant.
-   **Receipts of record**: every send is attested to the account audit trail and can be exported as a portable, JWKS-verifiable token.
-   **Compliance controls**: per-conversation retention, recall, legal hold, per-participant opt-out, and an immutable audit log of metadata.
-   **Attachments**: client-direct upload/download of files up to 100 MB, each sealed as its own conversation-tagged ingot.

> **Encrypted At Rest, Not End-to-End**
>
> Posture S means content is **triple zero-trust at rest** and only ever decrypted server-side for an authorized participant. It is never persisted as plaintext. This is not end-to-end encryption; true E2E is a separate v2 tier. The server brokers every read through a system-held key.

## Data Model

A conversation is an index-only manifest plus a set of content-addressable ingots. Understanding the four core objects makes the endpoints below self-explanatory.

#### Core Objects

| Field | Type | Description |
| --- | --- | --- |
| `Conversation` | object | A thread owned by an account. Its manifest lives in DynamoDB (META row); all content lives in ingots. Addressed by conversation\_id. |
| `Participant` | object | A stable svid: an auth.sv principal (business side) or a derived `mid_<hash>` principal for an account-less recipient. The durable roster lives on META. |
| `Message` | object | One immutable VAK-sealed Forge ingot per message, content-addressable at `ing_<sha256(conversationId|m|messageId)[0:32]>`, the SHA-256 truncated to its first 32 hex chars, shaped like a normal `ing_<32hex>` id. text/plain, up to 64 KB; larger payloads use attachments. |
| `Page` | object | Lazy compaction seals each run of 25 messages into one page ingot. Read-triggered, no cron. Reads transparently span individual and page ingots. |
| `Attachment` | object | A separate, conversation-tagged Forge ingot (up to 100 MB), uploaded and downloaded client-direct, referenced from a message as attachments:\[{ ingot\_id, name }\]. |

### Manifest, Not Mailbox

The manifest (`messaging-conversations`) is an **index only**: an atomic message-id counter, `committed_through` (the highest contiguous durable id, the sole authority for what a reader fetches), `compacted_through`, the participant roster, settings, a 25-entry `recent` window, the `recalled` tombstone list, the `legal_hold` flag, and the `closed` flag. A participant's last-read position is `last_read_id` on their per-user inbox row; there are no per-message read receipts.

> **Server-Mediated Reads By Predictable ID**
>
> Reads resolve by id: an id above `compacted_through` reads the individual message ingot; an id at or below it reads page ingot `ceil(id / 25)`. Compaction (seal → conditional advance → delete originals) is race-safe and rides a read, so there is no background job and a reader never sees a half-written tail.

## Vault & Encryption

Each account has **one system-flagged "messaging" vault** that holds every conversation's ingots, not a vault per conversation. Conversations are isolated by their deterministic ingot ids plus a membership check, not by separate keys.

-   The vault's master key (VMK) is generated once and held only via a **system `messaging` DVAK** (Delegated Vault Access Key).
-   All messaging crypto is server-brokered through that DVAK (`resolveMessagingVak`) for owner and recipient alike. The raw VAK never leaves the server; Forge receives single-use ISTKs.
-   This is the mechanism for posture S. Triple zero-trust at rest is unchanged: content is sealed, and reads decrypt only transiently for an authorized participant.

## Authentication

Messaging has two access surfaces, each authenticated differently.

### Business Side

Your application calls the authenticated Core routes under `/v1/products/messaging/*` using a SparkVault API key or a user JWT. The authenticated principal is the conversation participant.

#### Business-Side Credentials

| Field | Type | Description |
| --- | --- | --- |
| `X-API-Key` | header | Server-to-server integrations and automation. Create a key on the [API Keys page](https://app.sparkvault.com/api/keys). |
| `Authorization: Bearer` | header | A user JWT for user-facing applications and browser sessions. |

### Account-less Recipient

A recipient with no SparkVault account authenticates as an **OIDC relying party to auth.sv** using the PKCE authorization-code flow against the company account's Identity tenant. They never hold an API key. The verified `id_token` is their bearer credential on the public `/m/*` routes.

> **Recipient Verification Flow**
>
> 1.  The recipient opens `/m/access?account_id&conversation_id` and is redirected (302) to the auth.sv `/authorize` endpoint (PKCE).
> 2.  They complete account-less verification at the company IdP via a one-time code (OTP) sent to their invited email or phone, or via SparkLink.
> 3.  auth.sv returns to `/m/callback?code&state`, where the code is exchanged for an EdDSA `id_token`.
> 4.  The `id_token` is verified with `verifyIdToken` (the SDK's exact JWKS verification) plus a nonce check, and becomes the recipient's bearer credential.

> **Membership Is Re-Checked On Every Operation**
>
> Authorization is always the `META` membership check, re-run before the key is brokered on every operation, so a removed participant is locked out instantly, regardless of token TTL. Recipient routes present the `id_token` as `Authorization: Bearer` (or `x-messaging-token`).

## Business-Side Endpoints

Base URL: `https://api.sparkvault.com/v1/products/messaging`

All paths below are authenticated with an API key or user JWT. Owner-only operations are noted; every operation re-checks conversation membership.

### Inbox

#### `GET /products/messaging/inbox`

The caller's conversations, newest-activity-first. Metadata only, no message previews (content is ingot-only).

#### Query Parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | integer | Optional | Maximum conversations to return (default 25) |
| `cursor` | string | Optional | URL-encoded JSON pagination cursor from a previous response |

#### Response

| Field | Type | Description |
| --- | --- | --- |
| `items` | object\[\] | Inbox rows: conversation\_id, last\_activity\_ts, last\_message\_id, unread\_count, archived |
| `cursor` | object | Pagination cursor for the next page (absent on the last page) |

### Create Conversation

#### `POST /products/messaging/conversations`

Create a conversation. The creator becomes the owner. The system messaging vault is created on first use.

#### Request Body

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `participants` | object\[\] | Optional | Initial members, each { svid, identity? }. The owner is added automatically and cannot be re-added. |
| `settings` | object | Optional | Conversation settings, e.g. { audit\_retention\_seconds }, one of the platform's shared retention durations (0 = disabled; other values are rejected with 400). Unset uses the platform default (3 months). |

#### Response

| Field | Type | Description |
| --- | --- | --- |
| `conversation_id` | string | Identifier for the new conversation |
| `vault_id` | string | The account messaging vault holding the conversation ingots |
| `participants` | object\[\] | The resolved roster, each { svid, role, added\_at, identity? } |
| `created_at` | integer | Creation timestamp (Unix epoch seconds) |

### Get Conversation

#### `GET /products/messaging/conversations/:id`

The conversation manifest (participant-gated). Returns roster, settings, and read pointers, never content.

#### Response

| Field | Type | Description |
| --- | --- | --- |
| `conversation_id` | string | Conversation identifier |
| `participants` | object\[\] | Roster (identifiers are redacted on the recipient surface) |
| `committed_through` | integer | Highest contiguous durable message id |
| `compacted_through` | integer | Highest message id sealed into a page ingot |
| `settings` | object | Conversation settings, including audit\_retention\_seconds |
| `created_at` | integer | Creation timestamp (Unix epoch seconds) |

### Send Message

#### `POST /products/messaging/conversations/:id/messages`

Send a message. The content is sealed into a deterministic ingot, the message id advances committed_through, and a receipt of record is written to the audit trail and returned.

#### Request Body

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `content` | string | Required | Message body. `text/plain`, up to 64 KB (larger content is rejected with 413: use attachments). |
| `content_type` | string | Optional | Must be `text/plain`, the only supported content type in v1 (no HTML rendering). Any other value is rejected with 400. Defaults to `text/plain`. |
| `attachments` | object\[\] | Optional | Previously-uploaded attachment refs, each { ingot\_id, name }. Must belong to this conversation and be fully uploaded. Maximum 20 per message. |

#### Response

| Field | Type | Description |
| --- | --- | --- |
| `message_id` | integer | Monotonic conversation-scoped message id |
| `conversation_id` | string | Conversation identifier |
| `ts` | integer | Server send timestamp (Unix epoch seconds) |
| `ingot_id` | string | Deterministic content-addressable id of the sealed message ingot |
| `content_hash` | string | SHA-256 (hex) of the canonical sealed message bytes |
| `committed_through` | integer | Highest contiguous durable id after this send |
| `status` | string | `committed` once landed; `pending` if a concurrent send is still ahead in the contiguous range |
| `receipt` | object | The receipt of record: { account\_id, conversation\_id, message\_id, sender\_svid, content\_hash, ts } |

```bash
curl -X POST 'https://api.sparkvault.com/v1/products/messaging/conversations/cnv_019aec85fe5972b3c4d5e6f7a8b9c0d1/messages' \
  -H 'Authorization: Bearer YOUR_JWT' \
  -H 'Content-Type: application/json' \
  -d '{
    "content": "Your wire transfer was received. Reference #SV-4471.",
    "content_type": "text/plain"
  }'
```

```json
{
  "message_id": 42,
  "conversation_id": "cnv_019aec85fe5972b3c4d5e6f7a8b9c0d1",
  "ts": 1703977200,
  "ingot_id": "ing_8f1c2d4a6b9e0c5f3a7d1e2b4c6f8a09",
  "content_hash": "9b3e7c1a5f8d2e0b4a6c8f1d3e5b7a9c2d4f6e8a0b1c3d5e7f9a1b3c5d7e9f0a",
  "committed_through": 42,
  "status": "committed",
  "receipt": {
    "account_id": "acc_example",
    "conversation_id": "cnv_019aec85fe5972b3c4d5e6f7a8b9c0d1",
    "message_id": 42,
    "sender_svid": "ing_019e66a4e27875f2822ede0e4f5d8792",
    "content_hash": "9b3e7c1a5f8d2e0b4a6c8f1d3e5b7a9c2d4f6e8a0b1c3d5e7f9a1b3c5d7e9f0a",
    "ts": 1703977200
  }
}
```

### Read Messages

#### `GET /products/messaging/conversations/:id/messages`

Server-mediated read. With from_id + to_id reads that contiguous range; otherwise returns the newest page. Recalled ids return a tombstone, never content.

#### Query Parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `from_id` | integer | Optional | Inclusive start message id (pair with to\_id to read a range) |
| `to_id` | integer | Optional | Inclusive end message id (pair with from\_id) |
| `limit` | integer | Optional | When reading the newest page, the maximum messages to return (default 25, capped at 50) |

A `from_id`..`to_id` range spans at most 50 messages (2 pages). A larger range is rejected with 400 ("Read range too large (max 50 messages); paginate"). Without a range, the newest page returns up to 25 messages.

#### Response

| Field | Type | Description |
| --- | --- | --- |
| `messages` | object\[\] | Decrypted message objects ordered by message\_id; recalled ids appear as { message\_id, recalled: true } |
| `committed_through` | integer | Highest contiguous durable id at read time |
| `compacted_through` | integer | Highest id sealed into a page ingot at read time |

```json
{
  "messages": [
    {
      "schema_version": 1,
      "message_id": 41,
      "sender_svid": "mid_7c2a9f4b1d8e3a05f6e4d2c0b8a69753",
      "sender_identity": { "type": "email", "account_less": true },
      "ts": 1703970000,
      "content_type": "text/plain",
      "content": "Can you confirm the routing number?",
      "attachments": []
    },
    { "message_id": 40, "recalled": true }
  ],
  "committed_through": 42,
  "compacted_through": 25
}
```

> **sender\_identity Is A Structured Descriptor**
>
> `sender_identity` is always an object, never a raw string. A business message carries `{ user_id, email, type: "auth.sv" }`; an account-less recipient's message carries `{ type, account_less: true }`. A message read never exposes a recipient's raw email or phone: the raw identifier lives only on the owner-visible roster.

### Recall Message

#### `POST /products/messaging/conversations/:id/messages/:messageId/recall`

Soft-delete a message with a tombstone. The owner can recall any message; a sender can recall their own recent message. Blocked while the conversation is under legal hold.

#### Response

| Field | Type | Description |
| --- | --- | --- |
| `conversation_id` | string | Conversation identifier |
| `message_id` | integer | The recalled message id |
| `recalled` | boolean | Always true on success |

> **Recall Is A Tombstone, Not Erasure**
>
> Recall marks the id in `recalled[]` so reads return `{ message_id, recalled: true }`. The sealed content stays in the ingot. Crypto-shred is the owner-gated deletion posture, not recall.

### Fetch Receipt Token

#### `GET /products/messaging/conversations/:id/messages/:messageId/receipt`

Mint a portable, JWKS-verifiable SparkVault receipt token for one message. Participant-gated. A recalled message or disabled retention yields 403; a message past its retention window yields 404.

#### Response

| Field | Type | Description |
| --- | --- | --- |
| `receipt_token` | string | An EdDSA JWT signed by the tenant's Identity key, verifiable by any third party exactly like an id\_token |
| `expires_at` | integer | Token expiry (Unix epoch seconds), tracks the conversation's configured retention horizon |
| `receipt` | object | The receipt claims: { account\_id, conversation\_id, message\_id, sender\_svid, content\_hash, message\_ts } |

The token is re-derived from the **immutable** message: its `content_hash` is re-hashed from the sealed bytes (never caller-supplied), and that hash is also set as the token's `action_hash`. A verifier holding the message content can recompute the hash and confirm the token attests _this exact message_, the same `action_hash` binding the SparkVault SDK uses (`verifyIdToken` with `expectedActionHash`). The token's `exp` tracks `settings.audit_retention_seconds`, so a receipt verifies for as long as the company retains the record.

```bash
curl 'https://api.sparkvault.com/v1/products/messaging/conversations/cnv_019aec85fe5972b3c4d5e6f7a8b9c0d1/messages/42/receipt' \
  -H 'Authorization: Bearer YOUR_JWT'
```

```json
{
  "receipt_token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...",
  "expires_at": 1711753200,
  "receipt": {
    "account_id": "acc_example",
    "conversation_id": "cnv_019aec85fe5972b3c4d5e6f7a8b9c0d1",
    "message_id": 42,
    "sender_svid": "ing_019e66a4e27875f2822ede0e4f5d8792",
    "content_hash": "9b3e7c1a5f8d2e0b4a6c8f1d3e5b7a9c2d4f6e8a0b1c3d5e7f9a1b3c5d7e9f0a",
    "message_ts": 1703977200
  }
}
```

```json
{
  "iss": "https://auth.sparkvault.com/acc_example",
  "sub": "ing_019e66a4e27875f2822ede0e4f5d8792",
  "aud": "acc_example",
  "jti": "b1f0c2d3-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
  "token_type": "messaging_receipt",
  "conversation_id": "cnv_019aec85fe5972b3c4d5e6f7a8b9c0d1",
  "message_id": 42,
  "content_hash": "9b3e7c1a5f8d2e0b4a6c8f1d3e5b7a9c2d4f6e8a0b1c3d5e7f9a1b3c5d7e9f0a",
  "message_ts": 1703977200,
  "svid": "ing_019e66a4e27875f2822ede0e4f5d8792",
  "action_hash": "9b3e7c1a5f8d2e0b4a6c8f1d3e5b7a9c2d4f6e8a0b1c3d5e7f9a1b3c5d7e9f0a",
  "iat": 1703977200,
  "exp": 1711753200
}
```

> **Verify Like Any id\_token**
>
> The receipt token is signed with the tenant's Ed25519 key. Verify it with the same Identity JWKS endpoint (`https://auth.sparkvault.com/{account_id}/.well-known/jwks.json`), algorithm `EdDSA`, issuer the tenant Identity URL, audience the account id, and assert `action_hash` equals the SHA-256 of the message content you hold.

### Participants

#### `POST /products/messaging/conversations/:id/participants`

Add a participant by svid (owner only).

#### Request Body

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `svid` | string | Required | The participant's stable SparkVault ID |
| `identity` | object | Optional | Optional identity descriptor stored on the roster entry |

#### `DELETE /products/messaging/conversations/:id/participants/:svid`

Remove a participant (owner only). The removed participant loses access instantly on their next operation, regardless of token TTL.

### Invite Account-less Recipient

#### `POST /products/messaging/conversations/:id/invites`

Invite an account-less recipient by email or phone (owner only). Derives the recipient principal, adds them to the roster, and returns an access link they verify through auth.sv.

#### Request Body

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | string | Required | `email` or `phone` |
| `value` | string | Required | The recipient's email address or E.164 phone number |

#### Response

| Field | Type | Description |
| --- | --- | --- |
| `svid` | string | The derived recipient principal (`mid_<hash>`) added to the roster |
| `access_url` | string | The /m/access link the recipient opens to verify and join |

### Attachments

#### `POST /products/messaging/conversations/:id/attachments`

Begin an attachment upload. Returns a Forge URL for a client-direct upload of a conversation-tagged ingot (up to 100 MB). Storage-quota gated: returns 402 when the account's vault storage pool is exhausted.

#### Request Body

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `filename` | string | Required | Original file name |
| `size_bytes` | integer | Required | File size in bytes (over 100 MB is rejected with 413) |
| `content_type` | string | Optional | MIME type of the file |

#### Response

| Field | Type | Description |
| --- | --- | --- |
| `ingot_id` | string | The attachment ingot id: reference it from a message as { ingot\_id, name } |
| `forge_url` | string | Presigned client-direct upload URL |
| `expires_at` | integer | Upload URL expiry (Unix epoch seconds) |
| `max_bytes` | integer | Maximum attachment size (104857600 = 100 MB) |

#### `GET /products/messaging/conversations/:id/attachments/:ingotId/download`

Get a client-direct download URL for an attachment ingot. Attachment downloads bill bandwidth (real transfers); message reads do not.

#### Response

| Field | Type | Description |
| --- | --- | --- |
| `ingot_id` | string | The attachment ingot id |
| `name` | string | Original file name |
| `size_bytes` | integer | File size in bytes |
| `content_type` | string | MIME type of the file |
| `forge_url` | string | Presigned client-direct download URL |
| `expires_at` | integer | Download URL expiry (Unix epoch seconds) |

### Read, Archive & Opt-out

#### `POST /products/messaging/conversations/:id/read`

Mark the conversation read for the caller. Sets last_read_id on the inbox row and resets the unread counter.

#### Request Body

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `last_read_id` | integer | Required | The highest message id the caller has read |

#### `PUT /products/messaging/conversations/:id/archive`

Archive or unarchive the conversation in the caller's own inbox.

#### Request Body

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `archived` | boolean | Required | true to archive, false to restore |

#### `PUT /products/messaging/conversations/:id/opt-out`

Set the caller's per-conversation opt-out. While opted out, the caller receives no alerts; re-checked on every send.

#### Request Body

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `opted_out` | boolean | Required | true to suppress alerts for this conversation |

### Legal Hold & Close (Owner)

#### `PUT /products/messaging/conversations/:id/legal-hold`

Set or clear a per-conversation legal hold (owner only). While held, no message can be recalled.

#### Request Body

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `held` | boolean | Required | true to place the conversation under legal hold |

#### `PUT /products/messaging/conversations/:id/close`

Close or reopen the conversation (owner only). A closed conversation rejects new sends with 403.

#### Request Body

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `closed` | boolean | Required | true to close, false to reopen |

## Account-less Recipient Routes

The public `/m/*` routes mirror the business-side surface for a verified recipient. They are gated by the auth.sv `id_token` (presented as `Authorization: Bearer` or `x-messaging-token`), and the `META` membership check is re-run on every operation. Identifiers are redacted so one recipient never sees another's email or phone.

#### `GET /m/access?account_id&conversation_id`

Begin verification: redirects (302) to the auth.sv /authorize endpoint (PKCE). Unauthenticated; rate-limited to 60 requests per hour per IP.

#### `GET /m/callback?code&state`

OIDC callback: exchanges the code, verifies the id_token and membership, and hands the id_token to the recipient app.

#### `GET /m/inbox`

The recipient's conversations (id_token bearer).

#### `GET /m/conversations/:accountId/:conversationId`

Conversation manifest with identifiers redacted.

#### `GET /m/conversations/:accountId/:conversationId/messages`

Server-mediated read of the recipient's conversation.

#### `POST /m/conversations/:accountId/:conversationId/messages`

Reply to the conversation.

#### `POST /m/conversations/:accountId/:conversationId/messages/:messageId/recall`

Recall the recipient's own recent message.

#### `POST /m/conversations/:accountId/:conversationId/read`

Mark the conversation read for the recipient.

#### `POST /m/conversations/:accountId/:conversationId/attachments`

Begin an attachment upload.

#### `GET /m/conversations/:accountId/:conversationId/attachments/:ingotId/download`

Get an attachment download URL.

#### `PUT /m/conversations/:accountId/:conversationId/archive`

Archive the conversation in the recipient's inbox.

#### `PUT /m/conversations/:accountId/:conversationId/opt-out`

Set the recipient's opt-out for the conversation.

## Governance & Compliance

Messaging is built for regulated, auditable business-to-customer communication. Every governance control is per-conversation and re-checked on the relevant operation.

#### Controls

| Field | Type | Description |
| --- | --- | --- |
| `Receipt of record` | audit | Every send records a SparkVault attestation { conversation, message\_id, sender, content\_hash, ts } to the account audit trail and returns it on the send response. Issuing a portable token logs MESSAGING\_RECEIPT\_ISSUED. |
| `Retention` | setting | settings.audit\_retention\_seconds governs both the TTL on messaging audit rows and how long a receipt token stays valid. Must be one of the platform's shared retention durations: 24 hours, 7 days, 1 / 2 / 3 / 6 / 9 months, 1 / 2 / 3 years, or 0 = disabled; any other value is rejected with 400. Default 7776000 (3 months). |
| `Recall` | action | Soft-delete tombstone (owner any; sender their own recent). Reads return { message\_id, recalled: true }; content stays sealed. |
| `Legal hold` | action | Owner sets or clears a per-conversation hold; while held, messages cannot be recalled. |
| `Consent` | setting | Per-participant per-conversation opt-out suppresses alerts and is re-checked on send. |
| `Audit` | log | MESSAGING\_\* account-scope events: create, participant add/remove, recipient verified, message sent, recall, opt-out, legal hold. Never message content. |
| `Close` | action | Owner closes or reopens a conversation; a closed conversation rejects new sends. |

## Alerts

Alerts notify the other, non-opted-out participants of new activity. They carry metadata only, never content.

-   **Real-time nudge**: a best-effort WebSocket notification (reusing the Notify connection registry) when a message lands, conversation and message id only; the client fetches content through the read path.
-   **Invite email**: a single transactional email delivering the access link when a recipient is invited by email (best-effort). Phone invites return the `access_url` for your application to deliver.

## Billing

Messaging bills two dimensions, both automatic. There is no per-message or per-transaction billing.

#### Billed Dimensions

| Field | Type | Description |
| --- | --- | --- |
| `Stored bytes` | storage | Messages, pages, and attachments accrue to vault storage automatically via the ingot lifecycle. |
| `Bandwidth` | transfer | Attachment downloads bill bandwidth (real transfers). Server-mediated message reads do not bill bandwidth. |

[View Full Pricing](/pricing/)

## Error Responses

Messaging uses the standard SparkVault error envelope: `{ error: { code, message, details } }`. Common failures:

#### Error Responses

| Status | Code | Description |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Missing or invalid body, e.g. empty content, a content\_type other than text/plain, a malformed attachments array (more than 20 refs, or a ref not fully uploaded), an invalid audit\_retention\_seconds, or a from\_id..to\_id read range over 50 messages. |
| 401 | `AUTHENTICATION_ERROR` | Missing or invalid credential (API key, user JWT, or recipient id\_token). |
| 402 | `PAYMENT_REQUIRED` | Storage capacity is exhausted; cannot upload attachments: attachment uploads are gated on the account's vault storage pool. |
| 403 | `FORBIDDEN` | Not a participant, not the owner for an owner-only op, a recall blocked by legal hold, a send to a closed conversation, or no receipt available (message recalled, or audit retention disabled). |
| 404 | `NOT_FOUND` | Unknown conversation, message, or attachment id, or a receipt requested for a message past its retention window. |
| 413 | `PAYLOAD_TOO_LARGE` | Message content over 64 KB, or an attachment over the 100 MB maximum. |
| 429 | `RATE_LIMIT_EXCEEDED` | The per-conversation send ceiling (1,000 messages/hour), or the per-IP limit (60/hour) on the unauthenticated GET /m/access endpoint. |

## Get Started

Messaging shares SparkVault's API authentication and Identity verification. To integrate:

1.  Create an API key on the [API Keys page](https://app.sparkvault.com/api/keys).
2.  Create a conversation, then invite a recipient by email or phone.
3.  Send messages and fetch receipts as shown above.
4.  For verifiable proof, export a receipt token and validate it against the Identity JWKS.

> **Verifying Identity**
>
> Receipt tokens verify exactly like an OIDC ID token. See the [Identity API reference](/api/docs/products/identity/) for the JWKS verification examples in Node.js, Python, Go, Ruby, PHP, and C#.
