Messaging v1

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

FieldTypeDescription
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

FieldTypeDescription
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.

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

ParameterTypeRequiredDescription
limit integer Optional Maximum conversations to return (default 25)
cursor string Optional URL-encoded JSON pagination cursor from a previous response

Response

FieldTypeDescription
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

ParameterTypeRequiredDescription
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

FieldTypeDescription
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

FieldTypeDescription
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

ParameterTypeRequiredDescription
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

FieldTypeDescription
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 Send a message
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 Response
{
  "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

ParameterTypeRequiredDescription
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

FieldTypeDescription
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 Example Response
{
  "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

FieldTypeDescription
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

FieldTypeDescription
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 Fetch a receipt token
curl 'https://api.sparkvault.com/v1/products/messaging/conversations/cnv_019aec85fe5972b3c4d5e6f7a8b9c0d1/messages/42/receipt' \
  -H 'Authorization: Bearer YOUR_JWT'
json Response
{
  "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 Decoded receipt_token claims
{
  "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

ParameterTypeRequiredDescription
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

ParameterTypeRequiredDescription
type string Required email or phone
value string Required The recipient's email address or E.164 phone number

Response

FieldTypeDescription
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

ParameterTypeRequiredDescription
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

FieldTypeDescription
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

FieldTypeDescription
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

ParameterTypeRequiredDescription
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

ParameterTypeRequiredDescription
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

ParameterTypeRequiredDescription
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

ParameterTypeRequiredDescription
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

ParameterTypeRequiredDescription
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

FieldTypeDescription
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

FieldTypeDescription
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

Error Responses

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

Error Responses

StatusCodeDescription
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.
  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 for the JWKS verification examples in Node.js, Python, Go, Ruby, PHP, and C#.