Notify v1

Overview

SparkVault Notify delivers notifications that are verified, sealed, and provable, not plaintext blasts. A notification reaches the right person, proves they read/approved/signed it with a portable receipt, keeps replies encrypted, and stays recallable. Notify is composed from the SparkVault primitives rather than bolted onto them: every send is one sealed Spark behind a single-use SparkLink, opened through an Identity verification ceremony, with Notify owning only transport and orchestration.

Sealed
Secure by Default
EdDSA
Portable Receipts
12
Delivery Channels
Recallable
Single-Use Grants

The Four-Layer Model

Notify never touches crypto directly. Each layer owns one job, so confidential content stays sealed end to end and the transport only ever carries an opaque pointer.

Layer Owns
SparkSealed content + lifecycle (double-zero-trust, view-once, TTL, burn-after-read). Knows nothing about recipients.
SparkLinkThe per-recipient verified-access grant: verification_level × interaction, single-use, revocable. Emits the signed receipt.
IdentityThe verification ceremony (auth.sv) producing an EdDSA token, with action_hash binding for approve/sign.
NotifyTransport + orchestration: channels, escalation, fan-out, preferences, inbox, receipts, billing. Carries only the SparkLink pointer.
Secure by Default

Notification content is sealed per recipient as a Spark behind a single-use SparkLink. List and feed responses are metadata-only and never carry plaintext. The recipient opens the sparklink_code pointer to unseal. A partial or failed send hard-recalls every grant it minted, so a broadcast never leaves orphaned sealed content behind.

Base URL & Authentication

All Notify endpoints are served under a single base path. The tenant is taken from your authenticated account token. There is no account ID in the path.

text
https://api.sparkvault.com/v1/products/notify

Authentication

Every endpoint is account-token authed (a registered user). Pass a session JWT or an API key; both resolve to the calling account, which scopes tenant isolation on every read and write.

Method Header Format
JWTAuthorizationBearer {token}
API KeyX-API-Keysv_live_{token}

Response Envelope

Every success response wraps its payload in a data envelope with a meta sibling carrying the deployed API build version and server timing. The field tables on this page describe the contents of data. Error responses carry an error object with a meta sibling carrying api_version (see Error Handling).

json Envelope shape
{
  "data": { /* endpoint payload: documented per endpoint below */ },
  "meta": { "api_version": "1.2.828", "request_id": "...", "response_ms": 12, "timestamp": 1719446400 }
}
Sender-Side Read Surface (v1)

The inbox, status, and receipts read surface is sender-side: an account reads and manages the notifications it sent, proxying its recipients' inbox interactions through its own backend. A caller never sees another account's rows. A cross-tenant send_id or notification is reported identically to one that never existed (HTTP 404).

Quick Start

Send your first sealed notification with a single POST. Notify seals the content for each recipient, mints a single-use SparkLink, and returns a send_id you can poll for delivery status and receipts.

Send a sealed notification

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 '{
    "recipients": [
      { "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({
    recipients: [{ 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); // "ntsnd_01...", 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={
        'recipients': [{'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": "ntsnd_01...", "recipients": 1, "status": "pending" }
What just happened

Notify sealed the body into a Spark, minted a single-use SparkLink for the recipient, and wrote one send row. The recipient receives a pointer on each channel (“tap to view”); when they open it and verify their identifier, the content unseals and a signed receipt is recorded.

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.

POST /products/notify/send

Create a secure send: seal content per recipient, mint a single-use SparkLink each, and write the send row that drives fan-out + delivery.

Request Body

ParameterTypeRequiredDescription
recipients object[] | string[] Required Non-empty audience. Each entry is a bare identifier string or { id?, email?, phone? }. An SVID id (ing_...) enables stored recipient preferences. Max 500 per send; duplicates are collapsed.
content object Optional Content to seal per recipient: { payload, content_type?, filename?, ttl_minutes? }. Provide exactly one of content OR ingot.
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? }. See the policy table below.
channels string[] Optional Delivery-channel override. When omitted, the company config resolves them (per-category channel_priority then default_channels).
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).
category string Optional The configured category this send is classified as (drives channel resolution, recipient preferences, and the mandatory-seal floor).
type string Optional Optional display type for the recipient feed.
history_ttl_days integer Optional Inbox-row retention in days.Default: 30
send_id string Optional Caller-supplied idempotency key. Generated (ntsnd_...) when absent; a retry with the same id returns the existing send untouched.

policy Object

ParameterTypeRequiredDescription
verification_level string Optional "none" | "identifier" | "passkey" | "out_of_band" | "dual_control". How strongly the recipient must prove identity before the content unseals.
interaction string Optional "view" | "acknowledge" | "sign" | "approve" | "reply". The interaction the SparkLink requires; sign/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.

Response

FieldTypeDescription
send_id string The send identifier (ntsnd_...). Use it to query status and receipts.
recipients integer Number of distinct recipients sealed for this send.
status string Write-once "pending": the send was accepted and fan-out is driven asynchronously.
idempotent boolean Present and true when an existing send_id was returned as-is (no re-seal).
json Example Response
{
  "data": {
    "send_id": "ntsnd_01j9z4f6w8m3qk2c7d5h0abxyz",
    "recipients": 1,
    "status": "pending"
  },
  "meta": { "api_version": "1.2.828", "request_id": "...", "response_ms": 412, "timestamp": 1719446400 }
}
One Content Source

Provide exactly one of content or ingot. An ingot is one persistent sealed asset with a single grant, so it can only target a single recipient; use content to fan a fresh per-recipient spark out to many.

Bounded Audience

A single send targets at most 500 recipients. The audience rides inline on the send row and each recipient is sealed synchronously, so split a larger blast into multiple sends.

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.

DEFAULT

Sealed

Content is sealed into a Spark behind a single-use SparkLink. Channels carry only the pointer; the recipient verifies and unseals. The secure-reveal path for any confidential content.

  • No plaintext at rest
  • Works on every channel
  • Produces a verified receipt

Plaintext

Non-confidential content (an alert, a welcome) is delivered inline in the channel payload: no seal, no SparkLink, no reveal step. A constrained, fail-closed mode for low-stakes transactional messages.

  • Inline-capable channels only (email)
  • Max 50 recipients, 7-day TTL
  • text/plain or text/html only
Selecting the Mode

The v1 REST send surface is sealed-only: POST /products/notify/send carries no delivery selector, so every API send travels sealed. Plaintext is the engine's mode for first-party platform flows (for example the alert preset's inline pings), and its constraints are enforced fail-closed: content only (never an ingot), text/plain or text/html, inline-capable channels only (email today), at most 50 recipients, and the send row's retention capped at 7 days.

The Mandatory-Seal Guard

A hardcoded baseline of confidential categories (secure, conversation, approval, signature) always seals and can never be delivered plaintext. A tenant's security.mandatory_seal_categories may only expand that set, never shrink it, so a confidential category can never be demoted to cleartext by a config edit. A plaintext send that targets a sealing-required category, a pointer-only channel, or too large an audience is rejected (fail-closed), never silently downgraded.

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. They are server-side helpers used by first-party platform flows (there is no preset parameter on the send endpoint), so treat the table below as the recommended bundles to assemble in your own request body. Caller-supplied fields always win over a preset's defaults; an omitted channels lets the company config resolve them per category.

Preset verification_level interaction Default channels
alertnoneviewconfig-resolved (plaintext)
secureMessageidentifierviewpush, email
conversationidentifierreplyin_app, push, email
approvalpasskeyapprovein_app, push, email
signatureRequestpasskeysignin_app, push, email
Preset categories

A preset's category (e.g. approval, signature) is an interaction bundle. The high-stakes presets map to the hardcoded confidential categories, 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; over the REST API an alert-style send travels sealed like everything else.

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

in_app websocket email sms push web_push voice whatsapp rcs webhook slack teams

push is APNs/FCM; web_push is VAPID. Per-app channel credentials live in the owner-managed config and are never account-writable.

Verified-Seen-Aware Escalation

When escalation_enabled, each step fires only if the prior step's notification is still unseen. Escalation is the next-channel delivery enqueued with a delay; the chain stops the moment the recipient's verified seen_at is recorded, so a recipient who reads the email is never also called.

json Per-send escalation override
{
  "channels": ["push", "email", "voice"],
  "escalation": { "delays": [0, 120, 600] }
}

Cumulative per-step delays in seconds: push immediately, email at 2 minutes if still unseen, a voice call at 10 minutes if still unseen. Omit escalation to derive delays from the company config's per-channel realtime_fallback_delay.

Schedule Normalization

When the config sets delivery.escalation_enabled: false, the resolved ladder collapses to the primary channel only: no fallback steps fire. delays[0] is always forced to 0 (an override's first entry is ignored), and the stored schedule is normalized to the resolved channel count: extra entries are dropped, a missing tail is derived from the config, and descending values are clamped so the schedule is always non-decreasing.

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.

Read a Recipient's Feed

GET /products/notify/inbox

A recipient's metadata-only feed, newest first. Account-scoped: returns only notifications THIS account sent to the recipient.

Query Parameters

ParameterTypeRequiredDescription
recipient_id string Required The inbox owner (the recipient identifier the send addressed).
state string Optional "all" | "unseen" | "unread" | "archived" (archived rows are hidden from "all").Default: all
limit integer Optional Page size, clamped to [1, 100].Default: 50
cursor string Optional Opaque next-page cursor from a prior response.

Response

FieldTypeDescription
notifications object[] Metadata-only rows (see below).
cursor string | null Next-page cursor, or null when the feed is exhausted.

notification row

FieldTypeDescription
notification_id string Row id (used with the state endpoint).
send_id string The send this row belongs to.
created_at integer Row creation epoch (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.
sparklink_code string Opaque pointer the client opens to unseal the content.
locked boolean Always true: the content lives behind the SparkLink ("tap to view").
thread_id string Present only for conversation threads.
bash Example Request
curl 'https://api.sparkvault.com/v1/products/notify/inbox?recipient_id=ing_019e66a4...&state=unread&limit=25' \
  -H 'X-API-Key: sv_live_your_api_key'

Mark a Row's State

POST /products/notify/inbox/:notificationId/state

Apply a terminal display state to one row. Idempotent. A cross-tenant or missing row is reported as 404.

Request Body

ParameterTypeRequiredDescription
recipient_id string Required The inbox owner.
created_at integer Required The row's created_at (the sort-key timestamp), as returned by the inbox feed.
state string Required "seen" | "read" | "archived".

Response

FieldTypeDescription
notification_id string The row that was updated.
state string The state that was applied.
updated_at integer Update epoch.

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.

GET /products/notify/sends/:sendId/status

Per-recipient delivery + read state for a send, plus aggregate counts. A send owned by another account is reported as 404.

Response

FieldTypeDescription
send_id string The send identifier.
status string The send-level status ("pending").
channels string[] The resolved channel ladder stored on the send.
created_at integer Send creation epoch.
title string | null Display title.
category string | null Display category.
type string | null Display type.
recipients object[] Per recipient: { recipient_id, enqueued, seen_at, read_at, archived_at }.
counts object Aggregate rollup: { total, enqueued, seen, read, archived }.
json Example Response
{
  "data": {
    "send_id": "ntsnd_01j9z4f6w8m3qk2c7d5h0abxyz",
    "status": "pending",
    "channels": ["email", "push"],
    "created_at": 1719446400,
    "title": "Your invoice is ready",
    "category": "transactional",
    "type": null,
    "recipients": [
      {
        "recipient_id": "user@example.com",
        "enqueued": true,
        "seen_at": 1719446460,
        "read_at": null,
        "archived_at": null
      }
    ],
    "counts": { "total": 1, "enqueued": 1, "seen": 1, "read": 0, "archived": 0 }
  },
  "meta": { "api_version": "1.2.828", "request_id": "...", "response_ms": 34, "timestamp": 1719446400 }
}

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

GET /products/notify/receipts

The account's verified-interaction receipts, newest first.

Query Parameters

ParameterTypeRequiredDescription
limit integer Optional Page size, clamped to [1, 100].Default: 50
cursor string Optional Opaque next-page cursor from a prior response.
interaction string Optional Narrow to one mode: "view" | "acknowledge" | "sign" | "approve" | "reply".

Response

FieldTypeDescription
receipts object[] Receipt rows (see the receipt table below), newest first.
cursor string | null Next-page cursor, or null when the list is exhausted.

Receipts for One Send

GET /products/notify/sends/:sendId/receipts

The signed receipts for one send, each attached to the recipient who produced it. Correlated by the per-grant SparkLink link_code (never the shared asset_id). A foreign send is reported as 404.

Response

FieldTypeDescription
send_id string The send the receipts belong to.
receipts object[] Receipt rows (see below), each carrying the recipient_id that produced it.

receipt

FieldTypeDescription
event_type string The audit subtype: sparklink_accessed / signed / approved / denied / replied.
occurred_at integer Event epoch parsed from the audit sort key.
interaction string | null The interaction mode that produced the receipt.
identity string | null The verified identity that completed the ceremony.
asset_id string | null The sealed asset behind the grant (the Spark or ingot id).
vault_id string | null The vault holding the sealed asset, when applicable.
link_code string | null The masked per-grant SparkLink code used for correlation.
link_type string | null The SparkLink type that emitted the receipt.
verification_level string | null The level the recipient satisfied.
action_hash string | null The bound action hash for approve/sign interactions.
signed_token string | null The portable, JWKS-verifiable Identity-signed EdDSA proof.
decision string | null The approve/deny decision, when applicable.
thread_id string | null Present for conversation threads.
reply_spark_id string | null The sealed reply Spark, present on reply receipts.
recipient_id string Present on the per-send variant: the recipient who produced the receipt.
Correlation by grant, not asset

Receipts are joined to a send by the per-grant SparkLink link_code (unique per recipient), 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), so on a very large receipt partition the result can be bounded rather than authoritative-complete.

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 categories recipients can mute, compliance, and branding.

Read Config

GET /products/notify/config

The account's Notify config as { effective, overrides }: defaults deep-merged with the stored override delta, so an editor can show what is customized vs. default.

Response

FieldTypeDescription
effective object The fully-resolved config (defaults merged with overrides).
overrides object The raw stored override delta for this account ({} when none).
json Example effective config (abridged)
{
  "effective": {
    "delivery": {
      "default_channels": ["in_app", "push", "web_push", "email", "sms"],
      "channel_priority": {},
      "realtime_fallback_delay": { "email_minutes": 2, "sms_minutes": 10, "voice_minutes": 30 },
      "escalation_enabled": true,
      "quiet_hours": { "enabled": false, "start": "22:00", "end": "07:00", "tz": "UTC" },
      "coalesce_window_seconds": 30
    },
    "reliability": {
      "dedup_window_seconds": 60,
      "rate_limit_per_recipient_per_minute": 0,
      "digest_frequency": "instant",
      "dlq_threshold": 8
    },
    "history": { "history_ttl_days": 30, "max_rows_per_user": 2000, "receipt_retention_days": 365 },
    "security": {
      "default_confidentiality": "encrypted_reveal",
      "verification_level": "none",
      "reveal_freshness_minutes": 0,
      "recall_window_hours": 24,
      "reply_enabled": false,
      "mandatory_seal_categories": []
    },
    "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 }
    },
    "compliance": { "marketing_suppression": true, "signature_required": {} },
    "branding": { "logo_url": null, "primary_color": null, "sender_name": null, "support_email": null, "locale": "en" }
  },
  "overrides": {}
}

Update Config

PUT /products/notify/config

Validated partial PATCH of the company config, deep-merged onto existing overrides. Returns the new { effective, overrides }.

Patchable Sections

ParameterTypeRequiredDescription
delivery object Optional default_channels, channel_priority, realtime_fallback_delay, escalation_enabled, quiet_hours, coalesce_window_seconds.
reliability object Optional dedup_window_seconds, rate_limit_per_recipient_per_minute, dlq_threshold, digest_frequency.
history object Optional history_ttl_days, max_rows_per_user, receipt_retention_days.
security object Optional default_confidentiality, verification_level, reveal_freshness_minutes, recall_window_hours, reply_enabled, mandatory_seal_categories.
categories object Optional Open-keyed map of { label, description, required }. Add your own topics (e.g. weekly_newsletter). The reserved "security" category is always required.
compliance object Optional marketing_suppression, signature_required (per-category).
branding object Optional logo_url, primary_color, sender_name, support_email, locale.
bash Add a category and tighten escalation
curl -X PUT 'https://api.sparkvault.com/v1/products/notify/config' \
  -H 'X-API-Key: sv_live_your_api_key' \
  -H 'Content-Type: application/json' \
  -d '{
    "delivery": { "default_channels": ["push", "email"] },
    "categories": {
      "weekly_newsletter": { "label": "Weekly newsletter", "description": "Our weekly roundup.", "required": false }
    }
  }'
Strict Validation

The PATCH rejects unknown top-level and nested keys, malformed values, and the owner-gated channel_credentials (per-app channel secrets are SSM parameter 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.

Categories & Recipient Preferences

Two tiers decide delivery. The send resolves an offered channel ladder once; fan-out then narrows it per recipient from their stored preferences. Precedence is strict:

Compliance floor > Recipient preference > Account default
  • Categories are the single source for what a send is classified as and what a recipient can mute. Each entry is { label, description, required }; required: true is the compliance floor and can never be muted. security is platform-reserved and always required.
  • 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.
  • Application is fail-open: a preferences fault delivers as offered rather than dropping a notification. Preferences apply to recipients addressed by SVID (ing_*); a suppressed recipient gets no row, no delivery, and is not metered.

Security & Billing

Security Properties

  • Sealed by default: confidential content is sealed per recipient; transports carry only an opaque pointer.
  • Single-use grants: each SparkLink burns on first use, so a leaked pointer cannot be replayed.
  • Metadata-only reads: inbox, status, and receipts never echo plaintext or a recipient's raw contact.
  • 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.

Billing

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

Error Handling

Errors return a stable shape. Validation errors name the offending field; tenant-isolation failures are deliberately indistinguishable from a missing resource.

Error Responses

StatusCodeDescription
400 VALIDATION_ERROR Missing/invalid field (e.g. empty recipients[], both content and ingot, an ingot send to >1 recipient, >500 recipients, or an unknown config key).
401 AUTHENTICATION_ERROR Missing or invalid account token (JWT or API key).
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 Too many requests. Retry after the indicated window.
json Validation Error Response
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Provide exactly one content source: content OR ingot",
    "details": null
  },
  "meta": { "api_version": "1.2.828" }
}