# Notify API: SparkVault API Reference

> Complete API reference for SparkVault Notify, a secure, identity-gated, multi-channel notification transport. Every notification is sealed per recipient, reaches the right person across the channels they prefer, and produces a portable cryptographic receipt proving they saw, approved, or signed it.

Canonical: https://sparkvault.com/api/docs/products/notify/ · OpenAPI: https://sparkvault.com/openapi.yaml

## 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 |
| --- | --- |
| **Spark** | Sealed content + lifecycle (double-zero-trust, view-once, TTL, burn-after-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. |

> **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 |
| --- | --- | --- |
| 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](#error-handling)).

```json
{
  "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
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"]
  }'
```

```node.js
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"
```

```python
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

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `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

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `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

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

```json
{
  "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 |
| --- | --- | --- | --- |
| `alert` | none | view | config-resolved (plaintext) |
| `secureMessage` | identifier | view | push, email |
| `conversation` | identifier | reply | in\_app, push, email |
| `approval` | passkey | approve | in\_app, push, email |
| `signatureRequest` | passkey | sign | in\_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
{
  "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

| 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, or null when the feed is exhausted. |

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

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `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

| Field | Type | Description |
| --- | --- | --- |
| `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

| Field | Type | Description |
| --- | --- | --- |
| `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
{
  "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

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

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

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

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

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

```json
{
  "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

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `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
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

| Status | Code | Description |
| --- | --- | --- |
| 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
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Provide exactly one content source: content OR ingot",
    "details": null
  },
  "meta": { "api_version": "1.2.828" }
}
```

## Related

Notify builds directly on the SparkVault primitives. Explore the layers it composes:

[

### Sparks

Sealed, lifecycle-bound content.

](/api/docs/sparks/)[

### SparkLinks

Per-recipient verified-access grants.

](/api/docs/sparklinks/)[

### Identity

The verification ceremony + EdDSA tokens.

](/api/docs/products/identity/)

[View Full Pricing](/pricing/)
