---
title: "Send a notification"
method: POST
path: "/products/notify/send"
tags: ["notify"]
---

# Send a notification

`POST /products/notify/send`

Fan out one notification to up to 500 recipients. Requires a Notify subscription: without one the call is refused with 403 NOTIFY_SUBSCRIPTION_REQUIRED. Supply `send_id` to make retries safe — the call is idempotent on it within the account.

## Request body

- NotifySendRequest — Exactly one of `content` or `ingot` must be supplied.
  - `send_id` string — Idempotency key, unique within the account. Server-generated (ntsnd_…) when omitted. Replaying one returns the original send with `idempotent: true` and fans out nothing further.
  - `recipients` NotifyRecipient[], required
    - union — A bare identifier string (SparkVault user id, email, or E.164 phone), or an object naming one. Recipients are deduplicated by resolved identity. 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 that every channel would skip. Address a recipient by your OWN id as `{ "id": "…" }`. ON A SEALED SEND, an id carries no address to verify against, so a recipient given as `{ "id": … }` alone works only under `verification_level: "none"` — see NotifySendPolicy. Supply `{ "id": …, "email": … }` to get both the first-party inbox row and a verifiable, deliverable address.
      - string
      - object
        - `id` string — SparkVault user id (usr_…) or SVID (ing_…).
        - `email` string, email
        - `phone` string — E.164.
  - `content` object
    - `payload` string, required — Sealed: up to 256000 bytes. Plaintext: up to 16384 bytes, stored and rendered verbatim.
    - `content_type` string — Plaintext sends accept only text/plain or text/html.
    - `filename` string
    - `ttl_minutes` integer — Sealed content lifetime.
  - `ingot` object — Send an existing encrypted asset. Sealed only, and one recipient only.
    - `ingot_id` string, required
    - `vault_id` string, required
  - `delivery` 'sealed' | 'plaintext' — Sealed hands the recipient a pointer to encrypted content behind the policy. Plaintext puts the body inline: no ceremony, no ingot, at most 50 recipients, and only over in_app, push, web_push, or email.
  - `policy` NotifySendPolicy — Access policy for a SEALED send. Unset fields fall back to the account config security defaults, and the shipped default `security.verification_level` is "identifier" — NOT "none". A sealed send delivers a pointer to a named recipient, so with no verification that pointer is a bearer token: anyone who sees the link opens the content. CONSEQUENCE: a sealed send to a recipient with no email or phone (`{ "id": "usr_…" }` alone) and no explicit policy is rejected 400 — the grant could not be scoped to anyone, so any verified identity would satisfy it. Either address the recipient by email/phone as well, or state `verification_level: "none"` deliberately. A plaintext send must leave this trivial (verification_level "none", interaction "view"). The interaction and the verification level are otherwise independent, with one coupling: anything past "view" needs at least "identifier".
    - `verification_level` 'none' | 'identifier' | 'passkey' — How hard the recipient must prove who they are before the content unseals. "none" opens to whoever holds the link; "identifier" makes them prove control of the address the send was already addressed to; "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.
    - `interaction` 'view' | 'acknowledge' | 'sign' | 'approve' | 'reply' — The ceremony the SparkLink requires. Anything past "view" needs a verification_level of at least "identifier": `{ "verification_level": "none", "interaction": "approve" }` is rejected 400, because a grant that renders an action bar the identity gate then refuses is dead on arrival.
    - `reveal_freshness_minutes` integer — Require a verification no older than this many minutes. Omit for no freshness bar.
    - `action_hash` string — SHA-256 (64 lowercase hex) of the canonical bytes of the thing being approved or signed. Stamped onto the grant and returned verbatim as the receipt `action_hash` claim — the only way to bind a receipt to YOUR document. 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, which no hash recomputed from your own copy can match.
  - `channels` string[] — Overrides the account config channel resolution; order is the escalation order, and duplicates collapse to their first position. Naming a channel that is not on this list is rejected with 400 VALIDATION_ERROR quoting the bad value and the valid set: naming channels states an intent, so a typo has to be heard about rather than dropped into a ladder you never asked for. Channels resolved from the account CONFIG are filtered silently instead — they are validated on write, and a standing default must never break a live send.
  - `escalation` object
    - `delays` number[], required — Cumulative SECONDS from send time, one per channel step. Normalized to the resolved channel count, clamped non-decreasing; step 0 always fires immediately.
  - `title` string — Channel-agnostic display title. Longer titles are truncated, not rejected.
  - `category` string, required — Required. One of the account configured categories, or a preset: alert, secure, conversation, approval, signature. It decides whether the recipient may mute the send, 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. Omitting it returns 400 naming the categories this account accepts.
  - `type` string — Free-form display type, carried through untouched.
  - `instructions` string — Non-secret text shown alongside a sealed send. Never the sealed content itself.
  - `history_ttl_days` integer — Inbox-row retention. Falls back to the account history config, then 30. Plaintext rows are capped at 7 days.

## Response `200`

Send accepted and queued for fan-out.

- object
  - `data` NotifySendResponse, required
    - `send_id` string, required
    - `recipients` integer, required — Recipients accepted after deduplication.
    - `status` 'pending', required — Always "pending": the stored, WRITE-ONCE acceptance marker. It says the send was accepted and fan-out will be driven asynchronously, and it never advances — a send accepted a second ago and one that finished an hour ago both read "pending" here. It is not a lifecycle. Poll `.../sends/{sendId}/status` (or read `state` on the send-history list) for the derived delivery state.
    - `idempotent` true — Present only when this send_id already existed. Absent on a fresh send.
  - `meta` ResponseMeta — Envelope metadata present on every successful response.
    - `api_version` string
    - `request_id` string
    - `response_ms` integer
    - `timestamp` integer — Unix seconds.
    - `pools` object — Storage and bandwidth capacity snapshot for the account.
    - `billing` object
      - `past_due` boolean
    - `quota` object — This minute of the account request budget. Present on authenticated success responses that passed the throttle; absent otherwise.
      - `limit` integer
      - `used` integer
      - `remaining` integer
      - `resets_at` integer — Unix seconds.

## Other responses

- `400` — VALIDATION_ERROR — the message names the offending field.
- `401` — AUTHENTICATION_ERROR — missing or invalid credentials.
- `403` — FORBIDDEN, or NOTIFY_SUBSCRIPTION_REQUIRED when the account holds no Notify tier.
- `404` — NOT_FOUND — the account record is missing.
- `429` — RATE_LIMIT_EXCEEDED — the account exceeded 300 requests this minute. `Retry-After` carries the seconds to wait, and `details` carries limit/resets_at, plus `used` whenever the request was counted. A request refused because the limiter itself was unavailable reports no `used`.

---

[API](https://skmtc.dev/sparkvault/apis/sparkvault-api.md) · [All operations](https://skmtc.dev/sparkvault/apis/sparkvault-api/llms.txt) · [OpenAPI document](https://skmtc-service-production.skmtc.workers.dev/v1/apis/sparkvault/sparkvault-api/revisions/aaf3fd4981d9/schema)
