# Notify

URL: https://docs.hanzo.ai/docs/openapi/notify

> Transactional email and SMS, sent through your org's own provider credential.

Also for this capability: **API** · [CLI](https://docs.hanzo.ai/docs/cli/notify) · [MCP](https://docs.hanzo.ai/docs/mcp-tools/notify) · [SDKs](https://docs.hanzo.ai/docs/sdks)

Transactional email and SMS, sent through your org's own provider credential.

> [All API references →](https://docs.hanzo.ai/docs/openapi) · [Get started →](https://docs.hanzo.ai/docs/guides)

|                |                                        |
| -------------- | -------------------------------------- |
| **Base URL**   | `https://api.hanzo.ai`                 |
| **Operations** | 6                                      |
| **Auth**       | `Authorization: Bearer $HANZO_API_KEY` |

## Specification

> **HIP-0061 · Notification & Messaging Service Standard** — Draft · [read the specification →](https://github.com/hanzoai/hips/blob/7ed737180339cd05b5f2935eafe7f7b83d2449a2/HIPs/hip-0061-notification-service-standard.md)

**notify** (`/v1/notify`) is transactional email and SMS, sent through the
caller org's own provider credential. The implementation is `hanzoai/cloud`
`apps/notify`.

Outbound delivery to a customer's own endpoint is the separate `webhooks`
capability — HIP-1310.

### Motivation

Notifications are everywhere in the platform — IAM sends OTP codes and login
alerts, commerce sends receipts, the cloud sends deployment and billing facts.
Without one send rail, each subsystem integrates a provider independently:
provider sprawl, N credential custody paths, and no single place to answer "did
it go out".

The first version of this HIP specified a standalone notification service —
fallback chains across push, in-app, email and SMS; a template database;
preference management; its own Postgres, Redis and WebSocket server. Measured
against what runs, almost none of that exists: the standalone `notifyd`'s only
production consumer is IAM's OTP sender, its template and provider tables are
empty in the live tenant, and the async worker plane has no consumer
(`apps/notify/notify.go` package doc). The cloud fold serves the one live
contract natively and drops the rest. What this file specifies now is what
answers.

### Specification

The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted
as in RFC 2119.

#### The addresses

Four typed operations at `/v1/notify` (plugin/notify/openapi.json): `/health`,
`/send`, `/send/email`, `/send/sms`. The per-channel routes pin the channel,
overriding whatever the body names; the generic route reads it from the body.
All typed, none declared.

The wire contract is `notifyd`'s, kept byte-compatible so IAM's OTP decoder
keeps working unchanged: one recipient answers the bare
`{message_id,status}` outcome, several answer the `{items:[…]}` envelope, and a
terminal provider failure is a \*_200 whose status is `failed`_\* with the reason
in `error` — never a transport error, so a batch reports every recipient's
outcome instead of dying on the first (`apps/notify/notify.go`).

`sync=true` is REQUIRED. Async dispatch answers 503, exactly as `notifyd` does
without a connected worker, because the queue plane that would run it is owned
elsewhere — a silent sync fallback would mask a misconfiguration.

#### Providers and custody

The provider set is the four `notifyd` services the fold constructs: `twilio`
and `plivo` for SMS, `twilio_email` and `mail` (SMTP) for email
(`apps/notify/notify.go` constructProvider). Naming no provider picks the one
whose credentials are actually configured, in that preference order, and fails
closed when none is.

Credentials are KMS only — never env, never plaintext, never logged. Each key is
read at the org-scoped, rotatable ref `orgs/<org>/notify/<service>/<key>`, the
same `/orgs/<org>` namespace integrations uses, so a credential is writable and
rotatable through the KMS surface with a validated org token. A missing key
yields an empty value and provider construction fails closed.

Templates are built-in, in code: the registry ships what the live surface needs
(the IAM OTP template, per channel), because the standalone service's template
store is empty in production and a runtime seeding step would be a second thing
to deploy. A raw `body` wins verbatim; otherwise `template_id` or the `event`
name selects from the registry. Extending the registry is the one way to add a
template; forking the render path is not.

#### What "sent" means

`status: "sent"` means the provider accepted the request. This surface confirms
nothing further — there is no bounce handling, no delivery event, no open
tracking — and a consumer MUST NOT read `sent` as delivered. The one live
consumer (IAM) treats it as submission success, which is what it is.

#### Tenancy, store, price, emissions

The org is the validated principal's (HIP-0026), never a client-supplied header
— the standalone service was cluster-internal and trusted a raw org header; the
fold is reachable through the public gateway, so the trust boundary moved into
the code. An unauthenticated caller gets 401; a signed-in caller can only send
as their own tenant.

It owns no store: no database, no queue — the only state it touches is the KMS
credential it reads per send. It is free, in those words: the plugin declares
`Price: cloud.Free` (plugin/notify/main.go:21). It publishes no events on the
platform bus, so a customer's webhooks receive nothing from it, and it emits
nothing to observability beyond the request span every route gets — a failed
send is logged with org, channel and provider, never the message body.

Its stage is `ga` — the manifest row declares none, and absent is `ga`
(HIP-0139 §8). Its upstream is `github.com/hanzoai/notify` (pinned v1.7.2 in
cloud's go.mod), a fork of nikoksr/notify (MIT); what survives in HEAD and is
imported directly is the provider packages
(`service/{twilio,twilioemail,plivo,mail}`) and the wire types (`pkg/types`).
Only the credential-to-constructor glue is mirrored in cloud, because it is
internal to that module.

### Rationale

The fold over a rewrite: `notifyd`'s provider packages are imported, not
reimplemented, so the delivery code has one home and the cloud carries only the
trust boundary and the glue its module boundary forces. The alternative — keep
the standalone Deployment and relay to it — preserves a second process, a
second credential path and a cluster-internal trust model that the public
gateway had already invalidated.

### Security Considerations

The dangerous request is the unscoped one: a send billed to another org's
provider credential, or an OTP-shaped message sent as someone else's brand.
Both close at the same boundary — the org is the validated principal's,
and the KMS ref is derived from it server-side, so there is no request shape
that names another tenant's credential. Secrets never appear in logs or
responses; message bodies are never logged.

## Four surfaces

| Surface  | Reaches this capability as                     | Coverage                                                                  |
| -------- | ---------------------------------------------- | ------------------------------------------------------------------------- |
| **REST** | `notify` at its own prefix                     | 6 operations                                                              |
| **CLI**  | `hanzo notify …`                               | 4 of 6 — the CLI pins the document on its own clock                       |
| **SDK**  | `NotifyApi` in every published client          | 6 methods                                                                 |
| **MCP**  | tool `notify` on `https://api.hanzo.ai/v1/mcp` | 4 operations, 1 under the document's own id — ask `describe` for the rest |

## Quickstart

```bash
export HANZO_API_KEY=sk-...   # console.hanzo.ai → API keys
```

Then the first call — a read that needs nothing but the key. `GET /v1/notify/health`, operation `get_notify_health`:

**CLI**

```bash
hanzo notify health
```

**SDK**

**.ts**

```ts
import { Configuration, NotifyApi } from 'hanzoai';

const api = new NotifyApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getNotifyHealth();
```

**.py**

```python
from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import NotifyApi

client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = NotifyApi(client).get_notify_health()
```

**.go**

```go
cfg := hanzoai.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := hanzoai.NewAPIClient(cfg)

resp, _, err := client.NotifyAPI.GetNotifyHealth(context.Background()).Execute()
if err != nil {
	return err
}
```

**.rs**

```rust
use hanzo_client::apis::{configuration::Configuration, notify_api};

let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();

let result = notify_api::get_notify_health(&cfg, Default::default()).await?;
```

**.java**

```java
import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.NotifyApi;

ApiClient client = new ApiClient();
client.setBearerToken(System.getenv("HANZO_API_KEY"));

var result = new NotifyApi(client).getNotifyHealth();
```

**HTTP**

```bash
curl https://api.hanzo.ai/v1/notify/health \
  -H "Authorization: Bearer $HANZO_API_KEY"
```

**MCP**

Tool `notify`, op `get_notify_health` — POST the JSON-RPC envelope to `https://api.hanzo.ai/v1/mcp`.

```bash
curl -X POST https://api.hanzo.ai/v1/mcp \
  -H "Authorization: Bearer $HANZO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "jsonrpc": "2.0",
       "id": 1,
       "method": "tools/call",
       "params": {
         "name": "notify",
         "arguments": {
           "op": "get_notify_health",
           "input": {}
         }
       }
     }'
```

Answers `200` with `object` — ok.

## Endpoints

| Endpoint                                                                                                                                   | What it does                                                                                         |
| ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| [`PUT /v1/notify/credentials/{provider}/{key}`](https://docs.hanzo.ai/docs/openapi/notify/put-notify-credentials-by-provider-by-key)       | Sets one of your org's notify provider credentials.                                                  |
| [`DELETE /v1/notify/credentials/{provider}/{key}`](https://docs.hanzo.ai/docs/openapi/notify/delete-notify-credentials-by-provider-by-key) | Removes one of your org's notify provider credentials.                                               |
| [`GET /v1/notify/health`](https://docs.hanzo.ai/docs/openapi/notify/get-notify-health)                                                     | Reports that the notify send surface is mounted.                                                     |
| [`POST /v1/notify/send/email`](https://docs.hanzo.ai/docs/openapi/notify/post-notify-send-email)                                           | Delivers one transactional email through the caller org's own provider credential.                   |
| [`POST /v1/notify/send/sms`](https://docs.hanzo.ai/docs/openapi/notify/post-notify-send-sms)                                               | Delivers one transactional SMS through the caller org's own provider credential.                     |
| [`POST /v1/notify/send`](https://docs.hanzo.ai/docs/openapi/notify/post-notify-send)                                                       | Delivers one transactional message by email or SMS through the caller org's own provider credential. |

---

[All Hanzo APIs](https://docs.hanzo.ai/docs/openapi) · [Interactive reference](https://docs.hanzo.ai/docs/openapi/reference)
