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.
The Four-Layer Model
Notify never touches crypto directly. Each layer owns one job, so confidential content stays sealed end to end and the transport only ever carries an opaque pointer.
| Layer | Owns |
|---|---|
| Spark | Sealed content + lifecycle (double-zero-trust, TTL, burn-after-read on a view grant; on an interactive grant the answer spends it, not the read). Knows nothing about recipients. |
| SparkLink | The per-recipient verified-access grant: verification_level × interaction, single-use, revocable. Emits the signed receipt. |
| Identity | The verification ceremony (auth.sv) producing an EdDSA token, with action_hash binding for approve/sign. |
| Notify | Transport + orchestration: channels, escalation, fan-out, preferences, inbox, receipts, billing. Carries only the SparkLink pointer. |
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.
https://api.sparkvault.com/v1/products/notify
Authentication
Every endpoint is account-token authed (a registered user). Pass a session JWT or an API key; both resolve to the calling account, which scopes tenant isolation on every read and write.
| Method | Header | Format |
|---|---|---|
| JWT | Authorization | Bearer {token} |
| API Key | X-API-Key | sv_live_{token} |
Response Envelope
Every success response wraps its payload in a data envelope with a meta sibling
carrying the deployed API build version and server timing. The field tables on this page describe the
contents of data. Error responses carry an error object with a
meta sibling carrying api_version (see Error Handling).
{
"data": { /* endpoint payload: documented per endpoint below */ },
"meta": { "api_version": "1.2.828", "request_id": "...", "response_ms": 12, "timestamp": 1719446400 }
}
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.
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" }
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.
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...
}
/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.
/products/notify/send
Create a secure send: seal content per recipient, mint a single-use SparkLink each, and write the send row that drives fan-out + delivery.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
recipients |
object[] | string[] | Required | Non-empty audience. Each entry is a bare identifier string or { id?, email?, phone? }. Addressing someone as { id, email } is the strongest form: the id reaches their in-app inbox and device tokens, and the address is what an identifier policy makes them prove and what the email channel delivers to. A bare string must itself be reachable — an email, an E.164 phone, or a SparkVault id (usr_... / ing_...) — anything else is rejected 400 rather than accepted as a send every channel would skip. Max 500 per send; duplicates are collapsed by resolved identity. |
content |
object | Optional | Content to seal per recipient: { payload, content_type?, filename?, ttl_minutes? }. Provide exactly one of content OR ingot. Sealed: payload is at most 256000 bytes (250 KB) of UTF-8 — point at an ingot for anything larger; ttl_minutes is 1–1440, default 1440 (24 h), and the grant never outlives the content. Plaintext: at most 16384 bytes, and content_type must be text/plain or text/html. |
ingot |
object | Optional | Existing sealed asset reference { ingot_id, vault_id }. 1:1 sends only (one persistent asset = one grant). Provide exactly one of content OR ingot. |
policy |
object | Optional | Access policy for the minted SparkLink: { verification_level?, interaction?, reveal_freshness_minutes?, action_hash? }. See the policy table below. |
channels |
string[] | Optional | Delivery-channel override. Order is the escalation order, and duplicates collapse to their first position. An entry that is not a channel is rejected 400 VALIDATION_ERROR quoting the bad value and the valid set — naming channels states an intent, so a typo is heard about rather than dropped into a ladder you never asked for. When omitted, the company config resolves them (per-category channel_priority, then default_channels) and an invalid entry there is filtered silently instead: standing defaults are validated on write and must never break a live send. |
escalation |
object | Optional | { delays: number[] } cumulative per-step delays in seconds. When omitted, derived from the resolved channels + company config. |
title |
string | Optional | Channel-agnostic display title (metadata; never sealed). Truncated to 200 characters. |
category |
string | Required | The category this send is classified as. It decides whether the recipient may mute it, whether it is on the compliance floor, whether it must seal, which channel ladder resolves, and whether its email may offer a one-click unsubscribe — so it cannot be defaulted. Must be a key of your configured categories or one of the five preset categories (alert, secure, conversation, approval, signature); a missing or unknown value is rejected 400 naming the categories your account accepts. |
type |
string | Optional | Optional display type for the recipient feed. |
instructions |
string | Optional | Non-secret display text (≤500 chars) shown alongside a sealed send — e.g. how to open it — on the inbox row and the email/push payload. NEVER the sealed content; allowed on all categories. |
delivery |
string | Optional | "sealed" (default) or "plaintext". Plaintext renders the body inline on the inline-capable channels with no seal and no reveal step; see Delivery Modes below.Default: sealed |
history_ttl_days |
integer | Optional | Inbox-row retention in days. Defaults to your config's history.history_ttl_days.Default: 30 |
send_id |
string | Optional | Caller-supplied idempotency key, scoped to your account (1..128 chars of A-Z a-z 0-9 _ . : -). Generated (ntsnd_...) when absent; a retry with the same id returns the existing send untouched. |
policy Object
| Parameter | Type | Required | Description |
|---|---|---|---|
verification_level |
string | Optional | "none" | "identifier" | "passkey". How strongly the recipient must prove identity before the content unseals. none opens to whoever holds the link; identifier makes them prove control of the address you addressed; passkey binds the reveal to a device they physically hold. Enforced on the method the recipient ACHIEVED, so a passkey-level grant is never satisfied by an identifier session. Defaults to your config's security.verification_level, which ships as identifier — see the note below. A non-view interaction requires at least identifier: { "verification_level": "none", "interaction": "approve" } is rejected 400 VALIDATION_ERROR, because a grant that renders an action bar the identity gate then refuses is dead on arrival. out_of_band and dual_control are not creatable and are rejected both here and on security.verification_level; they stay in the enforcement vocabulary, so a stored grant naming one is still held to that strength and a receipt may report it. |
interaction |
string | Optional | "view" | "acknowledge" | "sign" | "approve" | "reply". The interaction the SparkLink requires. Anything past view needs a verification level of at least identifier. sign and approve bind an action_hash into the receipt. |
reveal_freshness_minutes |
integer | Optional | Require a verification no older than this many minutes before the content reveals. Must be a positive integer (0 is rejected); omit the field (or pass null) for no freshness requirement. |
action_hash |
string | Optional | 64 lowercase hex (SHA-256) of the canonical bytes of the thing being approved or signed. It is stamped onto the grant and lands verbatim in the receipt's action_hash claim — this is what turns “Ada verified” into “Ada approved THIS wire”. Supply it on every approve and sign send. Omit it and the ceremony still completes, but the receipt carries a server-derived digest of the grant itself rather than of your document, so a hash recomputed from your own copy can never match. REST only: the JS SDK does not forward this field. |
Compute the hash the same way both times — once when you send, once when you verify — over a canonical serialization you control.
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.
Your config's security.verification_level ships as identifier, not
none, and it is the default for any sealed send whose policy omits the field. A
sealed send hands a named recipient a pointer, so with no verification that pointer is a bearer
token: a forwarded mail, a shared screen, or a mailbox someone else reads opens the content.
identifier asks the opener to prove the address the message was already addressed to.
The consequence is deliberate. A sealed send to a recipient with no email or phone —
{ "id": "usr_..." } alone — and no explicit policy is rejected
400 VALIDATION_ERROR, naming the recipient by index. There is no address to scope the grant to,
so any verified identity would open it. Two honest fixes:
- Address them by both handles:
{ "id": "usr_...", "email": "user@example.com" }. This is the right answer nearly always — the id still reaches their in-app inbox and devices. - Say
policy: { "verification_level": "none" }deliberately, when an open pointer really is what you want.
Plaintext sends are unaffected: they carry no seal, mint no grant, and must leave the policy trivial anyway.
Response
| Field | Type | Description |
|---|---|---|
send_id |
string | The send identifier (ntsnd_...). Use it to query status and receipts. |
recipients |
integer | Number of distinct recipients sealed for this send. |
status |
string | Write-once "pending": the send was accepted and fan-out is driven asynchronously. |
idempotent |
boolean | Present and true when an existing send_id was returned as-is (no re-seal). |
{
"data": {
"send_id": "ntsnd_01j9z4f6w8m3qk2c7d5h0abxyz",
"recipients": 1,
"status": "pending"
},
"meta": { "api_version": "1.2.828", "request_id": "...", "response_ms": 412, "timestamp": 1719446400 }
}
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.
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.
// 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.
}
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, andinstructions— the display text the recipient actually reads; two replays differing only there are not the same message to them- your
policy—verification_level,interaction,reveal_freshness_minutes - your
channelsandescalation.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
ingotreference
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
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/plainortext/htmlonly
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.
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.
One grant is minted per recipient and every channel carries that same pointer, so whatever spends it is shared across channels. What spends it depends on what you asked the recipient to do:
-
view— the read spends it. The first channel the recipient opens consumes the grant, and a later tap on another channel reports the link as already used. -
acknowledge/approve/sign/reply— the answer spends it, not the read. The request stays openable inside the recipient's verification window, so a reload, a closed tab, or a second look still finds it. Only the recorded action closes it. This is deliberate: an approval that vanished because someone's phone discarded the tab would be unanswerable, and the escalation ladder would keep chasing a link that no longer worked.
Either way the grant expires with its sealed Spark, and a tap after that reports an expired link.
Presets
Presets are five secure-by-default bundles that pair a policy (verification_level ×
interaction) with a sensible default channel ladder and a display category. There is no
preset parameter on the send endpoint: a preset is a merge over the one send
contract, applied before the request goes out, producing an ordinary send. Over REST, assemble the
bundle yourself from the table below. In the JS SDK, one call does it. Caller-supplied fields always win over
a preset's defaults; an omitted channels lets the company config resolve them per category.
| Preset | verification_level | interaction | Default channels |
|---|---|---|---|
alert | none | view | config-resolved (plaintext) |
secureMessage | identifier | view | push, web_push, email |
conversation | identifier | reply | in_app, push, web_push, email |
approval | identifier | approve | in_app, push, web_push, email |
signatureRequest | identifier | sign | in_app, push, web_push, email |
// 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);
A preset's category (alert, secure, conversation,
approval, signature) describes the ceremony rather than your subject matter, but it
is seeded into config.categories like any other topic. A category absent from
that map derives nothing: it cannot sit on the compliance floor and cannot be offered to the recipient as a
toggle.
| Category | Seeded as | What that means |
|---|---|---|
secure, approval, signature | required: true | These sends are critical: no recipient mute silences them, and the email adapter delivers them through an unsubscribe suppression. A sealed message someone is waiting on, an approval blocking a colleague, or a signature request is stranded by a mute. |
conversation, alert | required: false | Ordinary mutable topics. |
Seeded is not reserved. Only security is locked; you may tombstone or demote any
preset category, and the compliance floor follows the map you actually have. The send endpoint accepts a
preset category unconditionally, so tombstoning one narrows what it derives — never what you are
allowed to send.
The high-stakes presets map to the confidential baseline, so an approval or
signatureRequest always seals regardless of any config edit. alert is the one
plaintext bundle: it delivers a non-confidential ping inline rather than behind a tap-to-view link. To send
alert-style over the REST API, pass delivery: "plaintext" with category: "alert".
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:
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.
Supported Channels
Deliverable to your recipients
Platform-internal (SparkVault's own account only)
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.
{
"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.
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.
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
/products/notify/inbox
A recipient's metadata-only feed, newest first. Account-scoped: returns only notifications THIS account sent to the recipient.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
recipient_id |
string | Required | The inbox owner (the recipient identifier the send addressed). |
state |
string | Optional | "all" | "unseen" | "unread" | "archived" (archived rows are hidden from "all").Default: all |
limit |
integer | Optional | Page size, clamped to [1, 100].Default: 50 |
cursor |
string | Optional | Opaque next-page cursor from a prior response. |
Response
| Field | Type | Description |
|---|---|---|
notifications |
object[] | Metadata-only rows (see below). |
cursor |
string | null | Next-page cursor. null is the only end-of-feed signal. The archived/active split is applied after the page is read, so a short — or entirely empty — page with a live cursor means keep paging, not end of list. |
notification row
| Field | Type | Description |
|---|---|---|
notification_id |
string | Row id (used with the state endpoint). |
send_id |
string | The send this row belongs to. |
created_at |
integer | Row creation time as Unix epoch seconds (the sort-key timestamp component). |
title |
string | Display title. |
category |
string | Display category. |
type |
string | Display type. |
seen_at |
integer | null | When the recipient saw the notification. |
read_at |
integer | null | When the recipient read it. |
archived_at |
integer | null | When the row was archived. |
body |
string | Present when there is visible text: a plaintext send's inline body, or a sealed send's non-secret instructions. Never sealed content. |
sparklink_code |
string | Opaque pointer the client opens to unseal the content (sealed sends only). Absent once the send is recalled: the grant behind it is revoked, so the pointer opens nothing. |
locked |
boolean | True when the content lives behind the SparkLink ("tap to view"). False for a cleartext row, and false once recalled is true, because there is nothing left to unlock. |
recalled |
boolean | True when the sender took this notification back. The reveal is revoked and the pointer is withheld, so render “no longer available” rather than a tap-to-reveal that dead ends. The display metadata (title, body, timestamps) stays readable. |
thread_id |
string | Present only for conversation threads. |
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
/products/notify/inbox/:notificationId/state
Apply a terminal display state to one row. Idempotent. A cross-tenant or missing row is reported as 404.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
recipient_id |
string | Required | The inbox owner. |
created_at |
integer | Required | The row's created_at as Unix epoch seconds (the sort-key timestamp), exactly as returned by the inbox feed. |
state |
string | Required | "seen" | "read" | "archived". |
Response
| Field | Type | Description |
|---|---|---|
notification_id |
string | The row that was updated. |
state |
string | The state that was applied. |
updated_at |
integer | Update epoch. |
Mark All (bulk)
/products/notify/inbox/state
Bulk-mark a recipient's inbox seen or read, one bounded page per call. Loop on the returned cursor until it is null. Archived is per-row only (no bulk drain).
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
recipient_id |
string | Required | The inbox owner. |
state |
string | Required | "seen" | "read". |
cursor |
string | Optional | Opaque continuation cursor from a prior call. Omit for the first page. |
Response
| Field | Type | Description |
|---|---|---|
updated |
integer | Rows marked in this page (can be 0 with a non-null cursor when a page is filtered — keep looping). |
cursor |
string | null | Next-page cursor; null when the inbox is fully drained. |
Send Status
The status endpoint is the sender's per-recipient delivery rollup for one send: “who has this reached, and how far did each recipient get?” It is metadata-only: it never returns the SparkLink pointer, the recipient's contact, or any policy internals.
/products/notify/sends/:sendId/status
Per-recipient delivery + read state for a send, plus aggregate counts. A send owned by another account is reported as 404.
Response
| Field | Type | Description |
|---|---|---|
send_id |
string | The send identifier. |
status |
string | The DERIVED send-level state: unknown | pending | sending | delivered | partial | failed. Computed at read from the per-recipient rows by the SAME function the send-history list uses, so the two can never disagree about what a state means. It is not the write-once pending the send row stores. |
channels |
string[] | The resolved channel ladder stored on the send. |
created_at |
integer | Send creation time as Unix epoch seconds. |
title |
string | null | Display title. |
category |
string | null | Display category. |
type |
string | null | Display type. |
recipients |
object[] | Per recipient (see the table below). |
counts |
object | Aggregate rollup: { total, enqueued, seen, read, archived, pending, delivered, undeliverable, failed, suppressed, rate_limited, recalled }. The seven STATE counters PARTITION the audience — pending + delivered + undeliverable + failed + suppressed + rate_limited + recalled === total — so a shrinking pending is real progress. enqueued/seen/read/archived are independent progress markers and partition nothing. total is the send's audience size, not the number of rows returned. |
recipient
| Field | Type | Description |
|---|---|---|
recipient_id |
string | The recipient this row is about. |
state |
string | The one answer for this recipient, derived from their row. One of the seven terminal-or-pending states in the table below. Read this rather than assembling a verdict from the booleans — they do not cover every outcome on their own. |
enqueued |
boolean | Fan-out accepted this recipient and enqueued the first delivery step. |
seen_at |
integer | null | When the recipient saw the notification. |
read_at |
integer | null | When the recipient read it. |
archived_at |
integer | null | When the row was archived. |
suppressed_at |
integer | null | When the recipient was taken out of the ladder. NOT always an opt-out — read suppressed_reason to tell an opt-out from an unreachable address, or just read state, which already separates them. |
suppressed_reason |
string | null | Why, as a fixed token (never a transport string, so nothing recipient-identifying rides it): no_channels (the send offered no channel at all) and unreachable (every offered channel lacks a handle for them) both roll up to state: "undeliverable"; global_off, category_muted, all_channels_opted_out and identifier_unsubscribed all roll up to state: "suppressed". |
rate_limited_at |
integer | null | When the recipient was skipped by the per-recipient ceiling. Cleared if a later fan-out legitimately drives them. |
channel_outcomes |
object | Map of channel name to { outcome, at, step }. outcome is delivered, retryable, skipped, not_implemented, failed (the step exhausted its retries), or bounced (the provider later repudiated a delivery it had accepted — this REPLACES the optimistic delivered entry). Keyed once per channel: a later attempt OVERWRITES the earlier entry, so this is the latest state per channel, not a history. Provider detail strings are withheld — they can echo the recipient's contact. |
delivered |
boolean | At least one channel's transport ACCEPTED the message. See the semantics note below — it is a transport fact, not proof a human saw anything. |
undeliverable |
boolean | Something was attempted, nothing delivered, and every recorded outcome declined (skipped, not_implemented, or bounced). A recipient with a retryable step still in flight is neither delivered nor undeliverable. |
failed |
boolean | Every attempt is terminal, nothing delivered, and at least one channel exhausted its retries. OUR transport gave up; the address may be perfectly good. |
The seven per-recipient states, and what each one asks of you:
state | What happened | What to do |
|---|---|---|
pending | Still in flight, or not yet driven. | Wait. Poll again. |
delivered | A channel accepted it and no provider has repudiated that. | Nothing. |
undeliverable | Every channel attempted declined — no handle, bounced, or not implemented. The recipient cannot be reached as addressed. | Fix the address, or offer a channel they have a handle for. |
failed | A channel exhausted its retries. Our transport gave up; the address may be fine. | Retry with a fresh send_id, or escalate to us. |
suppressed | The person chose silence: global off, category mute, every offered channel opted out, or they unsubscribed the address the send was made to. | Nothing. This is not a fault, and it is not something to work around. |
rate_limited | Skipped by the per-recipient ceiling for that minute. | Slow down, or raise reliability.rate_limit_per_recipient_per_minute. |
recalled | You took it back before a channel reached them. The reveal is revoked and nothing further will be attempted. A recipient a channel had already reached stays delivered: recall closes the reveal, it does not un-send the notification. | Nothing. If they still need it, send again with a fresh send_id. |
{
"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 }
}
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.
/products/notify/sends
The account's send history, newest first. Metadata + counters only.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
limit |
integer | Optional | Page size, clamped to [1, 100].Default: 50 |
cursor |
string | Optional | Opaque next-page cursor from a prior response. |
Response
| Field | Type | Description |
|---|---|---|
sends |
object[] | Send summaries (see below), newest first. |
cursor |
string | null | Next-page cursor, or null when the history is exhausted. |
send summary
| Field | Type | Description |
|---|---|---|
send_id |
string | null | The send identifier. |
created_at |
integer | null | Send creation time as Unix epoch seconds. |
title |
string | null | Display title. |
category |
string | null | Display category. |
delivery |
string | null | "sealed" or "plaintext". |
display_sender_name |
string | null | The “from” the recipient saw. Display only; nothing routes on it. |
recipient_count |
integer | null | Audience size. null on a row written before the counters existed. |
enqueued_count |
integer | null | Recipients handed to delivery. |
delivered_count |
integer | null | Recipients where at least one channel accepted. |
failed_count |
integer | null | Recipients whose every attempted channel declined. |
state |
string | Derived from the counters, never stored: delivered (every recipient reached an accepting channel), failed (every recipient resolved, none delivered), partial (every recipient resolved, some delivered), sending (still in flight), pending (accepted, nothing handed to delivery yet), or unknown (the row predates the counters — no result is claimed rather than a wrong one). |
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);
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.
/products/notify/sends/:sendId/recall
Revoke the access grants a sealed send minted, or destroy its sealed content. Idempotent. A foreign send is reported as 404.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
recipient_ids |
string[] | Optional | Recall only these recipients. Omit the field entirely to recall the whole audience; an EMPTY array is rejected 400 rather than silently meaning “none”. Ids that are not on this send come back in not_found instead of failing the call. |
mode |
string | Optional | "grant" revokes the access grants and leaves the sealed content intact for any other share of it. "content" destroys the sealed content itself.Default: grant |
Response
| Field | Type | Description |
|---|---|---|
send_id |
string | The send that was recalled. |
mode |
string | The mode that ran. |
recalled |
integer | Grants revoked. Idempotent: a grant already revoked (or already consumed and cleaned up) counts as recalled, so re-running a recall is safe. |
failed |
integer | Recipients whose recall raised an error. |
not_found |
string[] | Requested recipient ids that are not on this send. Reported rather than dropped, so a typo can never read as success. |
failures |
object[] | One { recipient_id, error } per failure. Recall is deliberately partial: one recipient failing never stops the rest, because stopping would leave the remaining grants live. |
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" }'
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.
List Account Receipts
/products/notify/receipts
The account's verified-interaction receipts, newest first.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
limit |
integer | Optional | Page size, clamped to [1, 100].Default: 50 |
cursor |
string | Optional | Opaque next-page cursor from a prior response. |
interaction |
string | Optional | Narrow to one mode: "view" | "acknowledge" | "sign" | "approve" | "reply". |
Response
| Field | Type | Description |
|---|---|---|
receipts |
object[] | Receipt rows (see the receipt table below), newest first. |
cursor |
string | null | Next-page cursor, or null when the list is exhausted. |
source |
string | Which store answered: receipt_index or legacy_scan. It changes what an empty page MEANS — see below. |
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 nullcursoris 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
/products/notify/sends/:sendId/receipts
The signed receipts for one send, each attached to the recipient who produced it. Correlated by the per-grant SparkLink grant (never the shared asset_id). A foreign send is reported as 404.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
cursor |
string | Optional | Resume point from a prior truncated response. |
Response
| Field | Type | Description |
|---|---|---|
send_id |
string | The send the receipts belong to. |
receipts |
object[] | Receipt rows (see below), each carrying the recipient_id that produced it. |
truncated |
boolean | True when the bounded correlation walk stopped before exhausting the partition. Re-call with the returned cursor until false. |
cursor |
string | null | Resume point for a truncated walk; null when complete. |
receipt
| Field | Type | Description |
|---|---|---|
event_type |
string | The audit subtype: sparklink_accessed / signed / approved / denied / replied. |
occurred_at |
integer | Event epoch parsed from the audit sort key. |
interaction |
string | null | The interaction mode that produced the receipt. |
identity |
string | null | The verified identity that completed the ceremony. |
asset_id |
string | null | The sealed asset behind the grant (the Spark or ingot id). |
vault_id |
string | null | The vault holding the sealed asset, when applicable. |
link_code |
string | null | The masked per-grant SparkLink code (a 6-character display prefix, which can collide across sends). Display only. |
link_code_hash |
string | null | The correlation key: 32 hex chars, 128 bits of SHA-256 over the full grant code. This is what an event webhook quotes — match an interaction event to its receipt on this, never on link_code. Never the usable grant. |
link_type |
string | null | The SparkLink type that emitted the receipt. |
verification_level |
string | null | The level the recipient satisfied. This can be wider than the set a send may ASK for — a grant minted under a retired level is still enforced at the strength it names, so a receipt can report one. |
action_hash |
string | null | The bound action hash. Always present on an approve or sign receipt, null on every other interaction. It is the policy.action_hash you sent; supply none and it is a server-derived digest of the grant, which no hash of your own document can match. |
signed_token |
string | null | The portable, JWKS-verifiable Identity-signed EdDSA proof. Minted on a ceremony receipt (acknowledge, sign, approve, reply). A plain view receipt may carry null: opening a link is an access record, not an attestation. Verify it as described below. |
decision |
string | null | On an approve ceremony: "approved" or "denied" (the reveal page shows Approve and Decline). Null on every other interaction. |
thread_id |
string | null | Present for conversation threads. |
reply_spark_id |
string | null | The sealed reply Spark, present on reply receipts. Read it with GET /v1/sparks/:id — it is burn-on-read, so the first read is the only one, and it lives no longer than the message it answers. |
send_id |
string | null | The send whose grant produced this receipt, stamped at mint time. Null for a standalone SparkLink, and on the legacy source. |
title |
string | null | The send's display title, under the same conditions as send_id. |
receipt_id |
string | null | The receipt row id. Null on the legacy source, whose rows carry none. |
recipient_id |
string | Present on the per-send variant: the recipient who produced the receipt. |
Both sources produce the same keys: a field only one of them can fill comes back as
null on the other, so a consumer never has to know which partition answered to know the shape.
Receipts are joined to a send by the per-grant SparkLink grant (unique per recipient) via a full-strength
link_code_hash stored on the receipt, not by asset_id. An ingot's
asset_id is shared across every send and share of that ingot, so correlating on it would
cross-attribute receipts from unrelated sends. The per-send correlation scan is bounded (pages of 100
receipt rows, at most 20 pages per call); a bounded stop returns truncated: true with a
cursor: re-call with it until truncated is false.
Verifying a receipt
A receipt's signed_token is a compact JWS you can verify yourself, with no SparkVault involvement
and no SparkVault credential. That is the point: it is evidence you can hand to an auditor, a counterparty, or
a court, and they can check it against a public key we publish.
| Property | Value |
|---|---|
| Algorithm | EdDSA (Ed25519). Reject any token whose header names a different alg. |
| Key type | OKP / crv: Ed25519, use: sig |
| JWKS URL | https://auth.sparkvault.com/{account_id}/.well-known/jwks.json |
| Key selection | The token header carries a kid; match it against the JWKS entry. |
The JWKS is per tenant: {account_id} is your acc_..., the account that sent
the notification. The same key signs Identity verification tokens, so one verification path covers both.
signed_token claims (normative)
| Parameter | Type | Required | Description |
|---|---|---|---|
iss |
string | Required | The tenant account id (acc_...) whose JWKS verifies this token. Present so the receipt is self-describing: a verifier handed only the token can find the key set and pin the issuer, with no out-of-band hint about which account issued it. |
link_code_hash |
string | Required | SHA-256 of the per-recipient SparkLink grant code. The correlation key: it binds the proof to one grant on one send without ever carrying the grant itself. |
identity |
string | Required | The verified identity that performed the ceremony. |
interaction |
string | Required | "view" | "acknowledge" | "sign" | "approve" | "reply" — the ceremony that was completed. |
decision |
string | Optional | Present on an approve ceremony: "approved" or "denied". Absent otherwise. |
action_hash |
string | Optional | SHA-256 (64 lowercase hex) of the action bound to the ceremony. Present on every approve and sign receipt; absent on view, acknowledge, and reply. It is the policy.action_hash you supplied at send time — and that is what turns “Ada verified” into “Ada approved THIS document”. Supply none and it is instead a server-derived digest of the grant, which no hash of your own document can match. |
iat |
integer | Required | Issued-at, Unix epoch seconds — when the ceremony completed. |
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');
}
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
/products/notify/config
The account's Notify config as { effective, overrides }: defaults deep-merged with the stored override delta, so an editor can show what is customized vs. default.
Response
| Field | Type | Description |
|---|---|---|
effective |
object | The fully-resolved config (defaults merged with overrides). |
overrides |
object | The raw stored override delta for this account ({} when none). |
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.
{
"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
/products/notify/config
Validated partial PATCH of the company config, deep-merged onto existing overrides. Returns the new { effective, overrides }.
Patchable Sections
| Parameter | Type | Required | Description |
|---|---|---|---|
delivery |
object | Optional | default_channels, channel_priority, realtime_fallback_delay, escalation_enabled. |
reliability |
object | Optional | rate_limit_per_recipient_per_minute (integer 1..600; the per-recipient backstop cannot be disabled). |
history |
object | Optional | history_ttl_days, receipt_retention_days. |
security |
object | Optional | verification_level ("none" | "identifier" | "passkey") and reveal_freshness_minutes — the DEFAULT sealed-send policy applied when a send omits them; a send that states either always wins. Ships as identifier, which is why a sealed send to an id-only recipient with no explicit policy is rejected 400. Plus mandatory_seal_categories (expand-only: it adds to the hardcoded confidential baseline and can never shrink it). |
events |
object | Optional | { webhook_url, secret } — the outbound event webhook. See Event Webhooks below. |
categories |
object | Optional | Open-keyed map of { label, description, required } (label ≤ 60 chars, description ≤ 200, ≤ 50 categories, new keys [a-z0-9_]). Add your own topics (e.g. weekly_newsletter). Set a key to null to delete that category, seeded defaults included. The reserved "security" category is always required and cannot be deleted. |
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.
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 }
}
}'
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
curl -X PUT 'https://api.sparkvault.com/v1/products/notify/config' \
-H 'X-API-Key: sv_live_your_api_key' \
-H 'Content-Type: application/json' \
-d '{
"events": {
"webhook_url": "https://hooks.example.com/sparkvault/notify",
"secret": "a-long-random-string-you-generate"
}
}'
events Object
| Parameter | Type | Required | Description |
|---|---|---|---|
webhook_url |
string | Optional | Where events are POSTed. https only, on a public host (≤2048 chars): loopback, private, link-local, and CGNAT addresses are rejected 400. This is the one config field that points SparkVault's compute at an address you choose, so it is validated as a security control. |
secret |
string | Optional | The HMAC signing secret, 16–256 characters. It is your receiver's only proof an event is genuine, so a short one is a broken one and is rejected. Never returned on a read — a config GET reports events.secret_set: true instead, and a PATCH never has to resend it. |
Both fields are optional in a patch, but nothing is sent until both are stored: an unsigned event is not something SparkVault emits.
Verify the signature
Each event arrives as a JSON POST with an X-SparkVault-Signature header carrying the
hex HMAC-SHA256 of the raw request body under your secret. Sign the bytes exactly as
received — re-serializing parsed JSON will not match.
import { createHmac, timingSafeEqual } from 'node:crypto';
// Capture the RAW body — a re-serialized object will not produce the same digest.
app.post('/sparkvault/notify', express.raw({ type: 'application/json' }), (req, res) => {
const expected = createHmac('sha256', process.env.SPARKVAULT_NOTIFY_SECRET)
.update(req.body)
.digest('hex');
const received = req.get('X-SparkVault-Signature') || '';
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(received, 'utf8');
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Answer fast. Queue the work; do not process inline.
res.sendStatus(200);
queue.push(event);
});
Event types
Seven types, all in one notify. namespace. Anything else is not an event SparkVault emits.
type | Fires when | Carries |
|---|---|---|
notify.delivered | A recipient's first successful delivery — a channel transport accepted the message. | send_id, recipient_id |
notify.failed | A recipient's ladder ran out with nothing delivered. | send_id, recipient_id |
notify.bounced | A provider later repudiated a delivery it had already accepted (a hard bounce). | send_id, recipient_id |
notify.signed | The recipient completed a sign — or an acknowledge, which produces the same receipt subtype. | link_code_hash, interaction, send_id* |
notify.approved | The recipient approved a bound action. | link_code_hash, interaction, decision, send_id* |
notify.denied | The recipient declined a bound action. | link_code_hash, interaction, decision, send_id* |
notify.replied | The recipient sent an encrypted reply back into the thread. | link_code_hash, interaction, send_id* |
* send_id rides an interaction event only when the grant knows which send minted it — a
standalone SparkLink has no send. A plain view emits no event: opening a sealed
message is recorded as a receipt, not pushed. Poll /products/notify/receipts for opens.
event payload
| Field | Type | Description |
|---|---|---|
event_id |
string | Unique id for this event (nev_...). Use it to deduplicate. |
type |
string | What happened — one of the seven above. |
account_id |
string | Your account. Check it matches before acting. |
occurred_at |
integer | Unix epoch seconds. |
send_id |
string | The send this concerns. Always on a delivery-lifecycle event; on an interaction event only when the grant carries it. |
recipient_id |
string | Present on delivery-lifecycle events. |
link_code_hash |
string | Present on interaction events: the SHA-256 correlation key, matching the receipt's link_code_hash. Never the usable grant code. |
interaction |
string | Present on interaction events: the ceremony performed (acknowledge | sign | approve | reply). |
decision |
string | Present on an approve ceremony: "approved" or "denied". |
Optional fields are omitted when absent rather than sent as null, so presence is
a usable signal. An event body never carries sealed content, the SparkLink grant code, or the recipient's raw
contact.
{
"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"
}
{
"event_id": "nev_01j9z4f6w8m3qk2c7d5h0abcde",
"type": "notify.delivered",
"account_id": "acc_01hq...",
"occurred_at": 1719446405,
"send_id": "invoice-1043-usr_01hq8yv2k3",
"recipient_id": "usr_01hq8yv2k3"
}
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 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: trueis the compliance floor and can never be muted.securityis platform-reserved and always required; the seeded preset categoriessecure,approval, andsignaturealso 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_atorrate_limited_atand 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.
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
/products/notify/suppressions
The addresses that opted out of THIS account's mail, newest first. Account-scoped rows only — global suppressions are structurally unreachable here.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
limit |
integer | Optional | Page size, clamped to 100. A missing, zero, negative, or non-integer value means “you did not choose one” and falls back to the default rather than to a 1-row page.Default: 50 |
cursor |
string | Optional | Opaque continuation token from a prior page's cursor. |
Response
| Field | Type | Description |
|---|---|---|
suppressions |
object[] | Suppression rows, newest first. |
cursor |
string | null | Opaque continuation token; null when the listing is exhausted. |
suppression
| Field | Type | Description |
|---|---|---|
email |
string | The suppressed address. The stored row is keyed by a composite of your account and the address; that internal key never leaves the API. |
type |
string | What kind of suppression this is. |
source |
string | What recorded it. |
reason |
string | null | Free-form detail, when the recording path supplied one. |
created_at |
integer | null | Unix epoch seconds the row was written. |
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.
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
/products/notify/suppressions/:email
Re-enable one address for THIS account's mail. URL-encode the address in the path.
Response
| Field | Type | Description |
|---|---|---|
cleared |
boolean | True when one of your rows was removed. False when there was nothing of yours to clear. |
email |
string | The normalized (lower-cased, trimmed) address the call acted on. |
A missing row is not an error. If the only suppression on that address is global, the
honest answer is “there was nothing of yours to clear” — and a 404 would tell you whether an
address is globally suppressed, which is another tenant's business and the person's own. So both cases return
200 with cleared: false, and the two are deliberately indistinguishable.
Clearing a row is a statement that you have the person's renewed consent. The endpoint cannot check that; you are the party who can.
The unsubscribe endpoint
Every non-critical email SparkVault sends on your behalf carries an unsubscribe URL, in the footer and in the RFC 8058 headers. It is a public endpoint with two verbs, and the difference is load-bearing:
| Verb | What it does |
|---|---|
GET /v1/unsubscribe?token=… |
Asks. Writes nothing. Renders a confirmation page with a single button and the line “Nothing has changed yet.” |
POST /v1/unsubscribe |
Performs the opt-out. Reached from that page's form, and from a mail provider's one-click control. The token is read from the form body or the query string. |
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:
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.
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.
The honest boundary, rather than a blanket claim:
recipient_idon a status or history response is the handle you addressed, echoed back. Address someone by{ email: "user@example.com" }and that email is theirrecipient_id. Address them by{ id: "usr_..." }and only the opaque id ever appears. Notify never introduces a contact you did not supply.identityon 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_codeon 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 /sendstill 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/statusthat recipient readsenqueued: falsewith an emptychannel_outcomes— the same shape as a recipient suppressed by their own preferences. - If the counter store itself fails, this check fails open: the notification is delivered rather than lost.
So a 200 from the send endpoint means “accepted”, never “everyone will receive it”. If per-recipient delivery matters, read the status endpoint rather than trusting the send response.
3. The recipient-side link ceilings — 120 per hour each
These apply to your recipient, not to your backend. Three independent hourly buckets, all 120:
| Bucket | Counts | Keyed on |
|---|---|---|
| Open | The metadata read and the reveal itself — one ceremony costs two. | Client IP and the grant. A stranger sharing your recipient's public IP cannot spend their budget, and the grant they were sent is theirs alone. |
| Verify | Verification sessions started on a protected link. | Client IP. |
| Answer | acknowledge / sign / approve / reply submissions. | Client IP. |
Exceeding one returns 429 RATE_LIMIT_EXCEEDED to the recipient with details of
{ limit, used, resets_at }, where resets_at is the top of the next hour.
It exists because an interactive grant's reveal is deliberately repeatable — the answer spends it, not the read, which is what lets a reload find the request still there. That same property would make an unbounded reveal an amplifier: every open resolves the proof through Identity and writes an access receipt, so anyone holding a live link could inflate your receipt partition by reloading. A real recipient opens a link a handful of times; this bounds the rest, and it is not a limit an ordinary recipient will meet.
See Rate Limiting on the API overview page for the account limit across every product.
Error Handling
Errors return a stable shape. Validation errors name the offending field; tenant-isolation failures are deliberately indistinguishable from a missing resource.
Silent failures — the ones that return 200
Every row below is a successful API call. Read /sends/:sendId/status, never the send response.
| What you see | What happened | Remedy |
|---|---|---|
state: "rate_limited", enqueued: false | The per-recipient ceiling for that minute dropped them at fan-out. | Slow down, or raise reliability.rate_limit_per_recipient_per_minute (1–600; it cannot be disabled). |
state: "suppressed" with suppressed_reason global_off / category_muted / all_channels_opted_out / identifier_unsubscribed | The person chose silence. A non-critical ladder ends outright. | Nothing. If it must reach them, it belongs in a required: true category. |
state: "undeliverable" with suppressed_reason unreachable / no_channels | No offered channel has a handle for this recipient. | Address them by { id, email }, or offer a channel they have a handle for. |
state: "recalled", enqueued: false | The send was recalled before delivery reached them. Withdrawing the remaining waves is what recall is for, so this is the intended outcome, not a fault. | Nothing. If they still need it, send again under a fresh send_id. |
channel_outcomes.slack / teams / webhook = { "outcome": "skipped", "detail": "no_destination_configured" } | These three resolve a platform-internal destination only SparkVault's own account can reach. | Drop them from the ladder. |
channel_outcomes.sms / voice / whatsapp / rcs = "skipped" | Not E.164, outside the +1 allowlist, opted out, or over the account volume ceiling. | Store E.164; use email or a device channel outside North America. |
channel_outcomes.push / web_push = "skipped", or the channel missing from the map entirely | No registered device handle. Where the recipient's device capability is known the step is pruned from the ladder before it burns its delay; where it is unknown the step dispatches and skips. | Register a device token, or lead with email. |
channel_outcomes.email = "bounced", later steps still ran | A bounce is a fact about the address, not the person, so the rest of the ladder is deliberately untouched. Only a consent decline speaks for the person. | Fix the address. Do not read a bounce as an opt-out. |
state: "failed" | A channel exhausted its retries. Our transport gave up; the address may be fine. | Retry under a fresh send_id — the original key returns the original send. |
channel_outcomes: {} on a delivered send | Outcome recording is best-effort telemetry and never blocks a delivery. | Never read an empty map as proof nothing was sent. |
| Receipts stay empty | An interactive grant is spent by the ANSWER, not the read. The recipient has not answered. | Keep polling, or subscribe to the notify.* interaction events. |
Error codes
Error Responses
| Status | Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR |
Missing/invalid field: empty recipients[], both content and ingot, an ingot send to >1 recipient, >500 recipients, a plaintext send that breaks a plaintext constraint, a recall of a plaintext send, an empty recipient_ids array, an unknown config key, or a plaintext send_id replayed with different content. |
| 400 | INVALID_CURSOR |
GET /products/notify/suppressions: the cursor decoded but is not a key of this index — a hand-made cursor, or one issued by a different list surface. Cursors are never portable between surfaces; start the listing again without one. |
| 401 | AUTHENTICATION_ERROR |
Missing or invalid account token (JWT or API key). |
| 403 | NOTIFY_SUBSCRIPTION_REQUIRED |
The account holds no Notify tier. Purchase one from the console's Billing page; reads are never gated. |
| 404 | NOT_FOUND |
The send or notification does not exist OR belongs to another account (cross-tenant rows are reported as not-found). |
| 429 | RATE_LIMIT_EXCEEDED |
The account exceeded 300 requests this minute. Honour Retry-After; details carries { limit, used, resets_at }. Retry with the same send_id. |
| 503 | SEND_HISTORY_PROVISIONING |
GET /products/notify/sends only: the send-history index is not queryable on this deployment. Temporary; sending and delivery are unaffected. |
| 503 | SUPPRESSIONS_PROVISIONING |
GET /products/notify/suppressions only: the suppression index is not queryable on this deployment. Temporary; sending, delivery, and DELETE are unaffected. |
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Provide exactly one content source: content OR ingot",
"details": null
},
"meta": { "api_version": "1.2.828" }
}