# Sending on v2

> One handoff, any mix of channels. The message shape for SMS, Email, Push and WhatsApp, and how to follow each delivery.

`POST /v2/messages` accepts one handoff: a tenant, an event type, and one
entry per channel. Each entry fans out to one delivery per recipient.
Every send needs an `Idempotency-Key` header (see
[Authentication](authentication)); requests over 5 MB are rejected with
`413`.

```json
{
  "tenant": "company-uuid-1",
  "event_type": "shift.closed",
  "reference": "shift-8841",
  "callback_url": "https://api.acme.co.tz/hooks/nofy",
  "messages": [
    {
      "channel": "sms",
      "recipients": ["255700000000"],
      "content": {
        "kind": "rendered",
        "body": "Shift closed at 18:00."
      }
    }
  ]
}
```

## Channel and content

| Channel    | Kind       | Carries                                              |
|------------|------------|------------------------------------------------------|
| `sms`      | `rendered` | `body`: plain text                                   |
| `email`    | `rendered` | `subject`, `body`, optional `html` and `inline_images` |
| `push`     | `rendered` | `subject`, `body`, optional string map `data`        |
| `whatsapp` | `template` | An approved template name, language, `body_params`   |

`kind` is checked against the channel: `email` with `template`, for
example, is a `400` before anything is stored. Unknown fields are rejected
rather than silently dropped, so a misspelled `callback_url` fails loudly
instead of losing your status posts.

## Channels

### SMS
```json
{
  "channel": "sms",
  "recipients": ["255700000000"],
  "content": {
    "kind": "rendered",
    "body": "Shift closed at 18:00."
  }
}
```

Recipients are E.164 digits; a leading `+` is accepted and removed. The
delivery's `provider_message_id` carries Beem's request id.
### Email
```json
{
  "channel": "email",
  "recipients": ["ops@acme.co.tz"],
  "content": {
    "kind": "rendered",
    "subject": "Shift closed",
    "body": "Figures attached.",
    "html": "<p>Figures attached.</p>"
  }
}
```

Inline images travel as base64 in the request (`inline_images` with `cid`,
`media_type` and `data`), are stored server-side, and are referenced from
the HTML as `cid:…`. Images without an `html` part are rejected: sending
anyway would drop them silently and still report success.
### Push
```json
{
  "channel": "push",
  "recipients": ["user-uuid-1"],
  "content": {
    "kind": "rendered",
    "subject": "Shift closed",
    "body": "Tap for the summary.",
    "data": {
      "shift_id": "8841"
    }
  }
}
```

Pushes address **your user ids**, not device tokens. Nofy resolves each id
to its active handsets at send time, so [register devices](devices) first.
### WhatsApp
```json
{
  "channel": "whatsapp",
  "recipients": ["255700000000"],
  "content": {
    "kind": "template",
    "template_name": "shift_closed_v3",
    "language": "en",
    "body_params": ["18:00"]
  }
}
```

WhatsApp carries approved templates only; a free-form body is a `400` here
rather than a provider rejection later. Template parameters may not carry
layout (newlines, tabs, or 4+ spaces in a row): Meta rejects those, so the
API does first. Delivered and read receipts arrive from Meta and move the
delivery on from `sent`; see [Callbacks](callbacks).

!!! warning "Templates must be approved"
    Sending a template Meta has paused or disabled fails the delivery
    permanently (codes 132015 and 132016) and emails your alert contacts.
    Check approvals in WhatsApp Manager first.

## The same send in your language

```python Python
from ens_sdk import ENS

# ENS_SERVER_URL, ENS_CLIENT_ID, ENS_CLIENT_SECRET, ENS_TENANT
ens = ENS()
ens.send_sms("+255700000000", "Shift closed at 18:00.")
```

```javascript Node.js
// Mint a token first: POST /v1/oauth2/token/ (grant_type=client_credentials)
const res = await fetch("https://nofy.encipher.co.tz/v2/messages", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ENS_TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "notif-0001",
  },
  body: JSON.stringify({
    tenant: "company-uuid-1",
    event_type: "shift.closed",
    messages: [{
      channel: "sms",
      recipients: ["255700000000"],
      content: { kind: "rendered", body: "Shift closed at 18:00." },
    }],
  }),
});
if (res.status !== 202) throw new Error(`ENS ${res.status}`);
console.log(await res.json());
```

```php PHP
<?php
// Mint a token first: POST /v1/oauth2/token/ (grant_type=client_credentials)
$ch = curl_init("https://nofy.encipher.co.tz/v2/messages");
curl_setopt_array($ch, [
  CURLOPT_POST => true,
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => [
    "Authorization: Bearer " . getenv("ENS_TOKEN"),
    "Content-Type: application/json",
    "Idempotency-Key: notif-0001",
  ],
  CURLOPT_POSTFIELDS => json_encode([
    "tenant" => "company-uuid-1",
    "event_type" => "shift.closed",
    "messages" => [[
      "channel" => "sms",
      "recipients" => ["255700000000"],
      "content" => ["kind" => "rendered", "body" => "Shift closed at 18:00."],
    ]],
  ]),
]);
$body = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) !== 202) {
  throw new Exception("ENS send failed");
}
```

```go Go
// Mint a token first: POST /v1/oauth2/token/ (grant_type=client_credentials)
body, _ := json.Marshal(map[string]any{
    "tenant":     "company-uuid-1",
    "event_type": "shift.closed",
    "messages": []any{map[string]any{
        "channel":    "sms",
        "recipients": []string{"255700000000"},
        "content":    map[string]string{"kind": "rendered", "body": "Shift closed at 18:00."},
    }},
})
req, _ := http.NewRequest("POST", "https://nofy.encipher.co.tz/v2/messages", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("ENS_TOKEN"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "notif-0001")
res, err := http.DefaultClient.Do(req)
if err != nil || res.StatusCode != 202 {
    log.Fatal("ENS send failed")
}
```

```rust Rust
// Cargo.toml: reqwest { version = "0.12", features = ["json", "rustls-tls"] }, tokio, serde_json
// Mint a token first: POST /v1/oauth2/token/ (grant_type=client_credentials)
let res = reqwest::Client::new()
    .post("https://nofy.encipher.co.tz/v2/messages")
    .bearer_auth(std::env::var("ENS_TOKEN")?)
    .header("Idempotency-Key", "notif-0001")
    .json(&serde_json::json!({
        "tenant": "company-uuid-1",
        "event_type": "shift.closed",
        "messages": [{
            "channel": "sms",
            "recipients": ["255700000000"],
            "content": {"kind": "rendered", "body": "Shift closed at 18:00."}
        }]
    }))
    .send()
    .await?;
if res.status() != 202 {
    anyhow::bail!("ENS send failed: {}", res.status());
}
```

```bash cURL
curl -X POST https://nofy.encipher.co.tz/v2/messages \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: notif-0001" \
  -d '{"tenant": "company-uuid-1", "event_type": "shift.closed",
       "messages": [{"channel": "sms", "recipients": ["255700000000"],
       "content": {"kind": "rendered", "body": "Shift closed at 18:00."}}]}' 
```


## Responses

- `202`: accepted and queued. The body has your `reference`, the
  `message_id`, and one delivery per recipient with its id and status.
- `400`: validation details, unknown tenant, or a bad channel/kind pairing.
- `401`: missing or invalid token. `403`: not a client-credentials token.
- `413`: body over 5 MB. `429`: tenant or client quota reached.

## Reconciling state

`GET /v2/deliveries` is the authoritative pull for your own records. It is
scoped to your client; other clients' rows are never visible.

- `reference`
One handoff's deliveries, e.g. `?reference=shift-8841`.
- `updated_since`
Everything touched since an ISO-8601 instant, e.g.
`?updated_since=2026-09-25T10:00:00Z`. Every attempt restamps a row, so
retries show up here.
- `cursor, limit`
Keyset pages of up to 500; follow `next_cursor` until it is `null`.

### Send a message
Every body field, with examples in cURL, Python and Node.js.
### List deliveries
Query parameters and the delivery record.
