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.
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.
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.
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
messagingDVAK (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. |
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.
- The recipient opens
/m/access?account_id&conversation_idand is redirected (302) to the auth.sv/authorizeendpoint (PKCE). - 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.
- auth.sv returns to
/m/callback?code&state, where the code is exchanged for an EdDSAid_token. - The
id_tokenis verified withverifyIdToken(the SDK's exact JWKS verification) plus a nonce check, and becomes the recipient's bearer credential.
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
/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
/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
/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
/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 } |
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"
}'
{
"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
/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 |
{
"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 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
/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 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
/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.
curl 'https://api.sparkvault.com/v1/products/messaging/conversations/cnv_019aec85fe5972b3c4d5e6f7a8b9c0d1/messages/42/receipt' \
-H 'Authorization: Bearer YOUR_JWT'
{
"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
}
}
{
"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
}
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
/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 |
/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
/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
/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) |
/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
/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 |
/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 |
/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)
/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 |
/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.
/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.
/m/callback?code&state
OIDC callback: exchanges the code, verifies the id_token and membership, and hands the id_token to the recipient app.
/m/inbox
The recipient's conversations (id_token bearer).
/m/conversations/:accountId/:conversationId
Conversation manifest with identifiers redacted.
/m/conversations/:accountId/:conversationId/messages
Server-mediated read of the recipient's conversation.
/m/conversations/:accountId/:conversationId/messages
Reply to the conversation.
/m/conversations/:accountId/:conversationId/messages/:messageId/recall
Recall the recipient's own recent message.
/m/conversations/:accountId/:conversationId/read
Mark the conversation read for the recipient.
/m/conversations/:accountId/:conversationId/attachments
Begin an attachment upload.
/m/conversations/:accountId/:conversationId/attachments/:ingotId/download
Get an attachment download URL.
/m/conversations/:accountId/:conversationId/archive
Archive the conversation in the recipient's inbox.
/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_urlfor 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. |
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:
- Create an API key on the API Keys page.
- Create a conversation, then invite a recipient by email or phone.
- Send messages and fetch receipts as shown above.
- For verifiable proof, export a receipt token and validate it against the Identity JWKS.
Receipt tokens verify exactly like an OIDC ID token. See the Identity API reference for the JWKS verification examples in Node.js, Python, Go, Ruby, PHP, and C#.