Notify v1

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, from an event in your system to a receipt you can verify 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/<link_code>, never the content. It walks each person's channels in order and stops at the first one that lands. 3 · It arrives where they already look Push Their phone or browser Email A link, not the content Text message When it cannot wait Your own app The Notify feed, via the SDK 4 · They open it They tap the link Any channel, the same grant They prove who they are A passkey, or the level you set The content renders Once. Then the grant is spent. 5 · You get proof, not a guess notify.delivered, .signed, .approved, .denied and .replied reach your webhook the moment they happen. Each ceremony receipt carries an EdDSA proof that verifies against your JWKS. back into your workflow
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
SparkSealed 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.
SparkLinkThe per-recipient verified-access grant: verification_level × interaction, single-use, revocable. Emits the signed receipt.
IdentityThe verification ceremony (auth.sv) producing an EdDSA token, with action_hash binding for approve/sign.
NotifyTransport + orchestration: channels, escalation, fan-out, preferences, inbox, receipts, billing. Carries only the SparkLink pointer.
The four layers and what each one owns THE MESSAGE WHAT THE CHANNELS CARRY Spark Seals the content. TTL and burn rules. SparkLink One single-use grant per recipient. Identity The verification ceremony on auth.sv. Notify Channels, fan-out, escalation, inbox. Nothing The sealed content never leaves the vault. https://x.sv/<link_code> An opaque pointer. Useless without the ceremony. An EdDSA proof Who opened it, and what they did. The pointer, on every channel email · sms · push · in_app · 8 more
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
JWTAuthorizationBearer {token}
API KeyX-API-Keysv_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).

json Envelope shape
{
  "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

// 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 -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"]
  }'
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"
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 approval.mjs
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 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, from the API call to the recipient SYNCHRONOUS POST /v1/products/notify/send Validate Recipients, category, source Resolve policy Channels and delays Seal and mint One Spark, one SparkLink each Write one send row It carries every minted code The API returns here: 200, data { send_id, recipients, status: "pending" } Nothing has been delivered yet. Everything below runs after the caller already has their answer. ASYNCHRONOUS The send row's stream drives it Change stream The row insert starts fan-out Fan-out 200 per chunk, then a cursor Resolve each ladder Prune, then apply preferences Write inbox rows One per recipient, idempotent Enqueue step 0 In batches of 10, no delay Realtime queue in_app · websocket Standard queue email · sms · push · 7 more Sender Claims, then dispatches Channel adapter The pointer, to the recipient 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

ParameterTypeRequiredDescription
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

ParameterTypeRequiredDescription
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

FieldTypeDescription
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 Example Response
{
  "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 Retry safely on 429 and on a timeout
// 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 policyverification_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, stage by stage SEALED (THE DEFAULT) PLAINTEXT (OPT IN) delivery: "sealed" delivery: "plaintext" the request content, or one Ingot (Ingot sends are 1:1) Up to 500 recipients. Policy defaults to identifier. content only, text/plain or text/html Up to 50 recipients. Payload at most 16 KiB. what is created A Spark and a single-use SparkLink One per recipient. Nothing unsealed is ever stored. Nothing. No seal, no grant. The body rides the send row for at most 7 days. what travels The pointer only https://x.sv/<link_code>, on any of the 12 channels. The body itself in_app, push, web_push and email only. the recipient Verifies, then the content reveals View, reply, approve or sign. Reads it. There is no reveal step. verification_level none, interaction view. what remains A signed receipt EdDSA proof of who opened it. Recall revokes the grant. No receipt No ceremony to prove, and no grant for recall to revoke. 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
alertnoneviewconfig-resolved (plaintext)
secureMessageidentifierviewpush, web_push, email
conversationidentifierreplyin_app, push, web_push, email
approvalidentifierapprovein_app, push, web_push, email
signatureRequestidentifiersignin_app, push, web_push, email
javascript The SDK preset helpers
// 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.

CategorySeeded asWhat that means
secure, approval, signaturerequired: trueThese 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, alertrequired: falseOrdinary 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.

One recipient escalation ladder over time, and what stops it OFFERED LADDER, RESOLVED ONCE PER SEND in_app 0s push No handle. Pruned. web_push No handle. Pruned. email 240s sms 600s 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 HOW THE CHAIN ENDS t = 0s t = 120s t = 600s in_app Enqueued with no delay. email realtime_fallback_delay.email_minutes: 2 sms realtime_fallback_delay.sms_minutes: 10 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). When a step exhausts its retries The next step is scheduled before the message dead letters, and that step is recorded as failed, not retryable. When the ladder runs out The recipient settles exactly once, delivered or failed. Nothing stays pending. 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 Per-send escalation override
{
  "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

ParameterTypeRequiredDescription
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

FieldTypeDescription
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

FieldTypeDescription
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 Example Request
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

ParameterTypeRequiredDescription
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

FieldTypeDescription
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

ParameterTypeRequiredDescription
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

FieldTypeDescription
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 six per-recipient delivery states and how they roll up to the send 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 pending + delivered + undeliverable + failed + suppressed + rate_limited = total The six state counters partition the audience exactly. enqueued, seen, read and archived are independent markers and 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. Both run the 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

FieldTypeDescription
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

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

stateWhat happenedWhat to do
pendingStill in flight, or not yet driven.Wait. Poll again.
deliveredA channel accepted it and no provider has repudiated that.Nothing.
undeliverableEvery 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.
failedA channel exhausted its retries. Our transport gave up; the address may be fine.Retry with a fresh send_id, or escalate to us.
suppressedThe 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_limitedSkipped by the per-recipient ceiling for that minute.Slow down, or raise reliability.rate_limit_per_recipient_per_minute.
recalledYou 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 Example Response
{
  "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

ParameterTypeRequiredDescription
limit integer Optional Page size, clamped to [1, 100].Default: 50
cursor string Optional Opaque next-page cursor from a prior response.

Response

FieldTypeDescription
sends object[] Send summaries (see below), newest first.
cursor string | null Next-page cursor, or null when the history is exhausted.

send summary

FieldTypeDescription
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 Page through your send history
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

ParameterTypeRequiredDescription
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

FieldTypeDescription
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 Recall one recipient's grant
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, from the tap to the receipt FROM THE TAP TO THE RECEIPT The recipient opens the pointer GET https://x.sv/<link_code>. The page names the sender and what is being asked. 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. The content reveals POST https://x.sv/<link_code>. What this spends depends on the interaction the grant asks for. A view grant: the read spends it The reveal is the whole ceremony. The grant flips from active to consumed at the reveal. The sealed Spark burns on read. Receipt: sparklink_accessed. It records the open. Notify stops escalating: the recipient engaged. An interactive grant: the answer spends it Acknowledge, sign, approve, decline, or reply. The reveal spends neither the grant nor the content. A reload, a second tab, or a failed submit finds the request still open inside the verification window. Notify keeps escalating until the answer lands. The answer POST https://x.sv/<link_code>/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

ParameterTypeRequiredDescription
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

FieldTypeDescription
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

ParameterTypeRequiredDescription
cursor string Optional Resume point from a prior truncated response.

Response

FieldTypeDescription
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

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

PropertyValue
AlgorithmEdDSA (Ed25519). Reject any token whose header names a different alg.
Key typeOKP / crv: Ed25519, use: sig
JWKS URLhttps://auth.sparkvault.com/{account_id}/.well-known/jwks.json
Key selectionThe 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)

ParameterTypeRequiredDescription
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 Verify a receipt against the published JWKS
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

FieldTypeDescription
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 Example effective config (abridged)
{
  "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

ParameterTypeRequiredDescription
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 Add a category and tighten escalation
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 Point Notify at your receiver
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

ParameterTypeRequiredDescription
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 Verify an incoming event (Express)
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.

typeFires whenCarries
notify.deliveredA recipient's first successful delivery — a channel transport accepted the message.send_id, recipient_id
notify.failedA recipient's ladder ran out with nothing delivered.send_id, recipient_id
notify.bouncedA provider later repudiated a delivery it had already accepted (a hard bounce).send_id, recipient_id
notify.signedThe recipient completed a sign — or an acknowledge, which produces the same receipt subtype.link_code_hash, interaction, send_id*
notify.approvedThe recipient approved a bound action.link_code_hash, interaction, decision, send_id*
notify.deniedThe recipient declined a bound action.link_code_hash, interaction, decision, send_id*
notify.repliedThe 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

FieldTypeDescription
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 Example interaction event
{
  "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 Example delivery event
{
  "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

ParameterTypeRequiredDescription
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

FieldTypeDescription
suppressions object[] Suppression rows, newest first.
cursor string | null Opaque continuation token; null when the listing is exhausted.

suppression

FieldTypeDescription
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 Page to the end of your list
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

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

VerbWhat 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: <https://api.sparkvault.com/v1/unsubscribe?token=...>
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 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:

BucketCountsKeyed on
OpenThe 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.
VerifyVerification sessions started on a protected link.Client IP.
Answeracknowledge / 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 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 seeWhat happenedRemedy
state: "rate_limited", enqueued: falseThe 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_unsubscribedThe 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_channelsNo 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: falseThe 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 entirelyNo 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 ranA 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 sendOutcome recording is best-effort telemetry and never blocks a delivery.Never read an empty map as proof nothing was sent.
Receipts stay emptyAn 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

StatusCodeDescription
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 Validation Error Response
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Provide exactly one content source: content OR ingot",
    "details": null
  },
  "meta": { "api_version": "1.2.828" }
}