Sending
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); requests over 5 MB are rejected with
413.
{
"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
{
"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.
{
"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.
{
"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 first.
{
"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.
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
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.")
// 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
// 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");
}
// 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")
}
// 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());
}
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 yourreference, themessage_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.
referencestringOne handoff's deliveries, e.g. ?reference=shift-8841.
updated_sincestring<date-time>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.
cursorlimitstring, integerKeyset pages of up to 500; follow next_cursor until it is null.