- To
- An email address
- Content
- Subject and text, with optional HTML and inline images
- Status
- Sent once the mail server accepts it, or failed with the reason
Every notification,
one API.
Nofy is the notification layer for your product. Send one request, and Nofy delivers it on every channel, retries when a provider has a bad minute, and tells you what happened to every recipient.
Delivers by
Idempotency-Key: shift-8841-sms { "tenant": "company-uuid", "event_type": "shift.closed", "reference": "shift-8841", "messages": [{ "channel": "sms", "recipients": ["255700000000"], "content": { "kind": "rendered", "body": "Shift closed at 18:00." } }] }
Idempotency-Key: shift-8841-wa { "tenant": "company-uuid", "event_type": "shift.closed", "reference": "shift-8841", "messages": [{ "channel": "whatsapp", "recipients": ["255700000000"], "content": { "kind": "template", "template_name": "shift_closed_v3", "language": "en", "body_params": ["18:00"] } }] }
Idempotency-Key: shift-8841-email { "tenant": "company-uuid", "event_type": "shift.closed", "reference": "shift-8841", "messages": [{ "channel": "email", "recipients": ["ops@acme.co.tz"], "content": { "kind": "rendered", "subject": "Shift closed", "body": "Figures attached." } }] }
Idempotency-Key: shift-8841-push { "tenant": "company-uuid", "event_type": "shift.closed", "reference": "shift-8841", "messages": [{ "channel": "push", "recipients": ["user-uuid-1"], "content": { "kind": "rendered", "subject": "Shift closed", "body": "Tap for the summary." } }] }
Why Nofy
Stop building notifications into every product
Every product ends up with the same plumbing: an SMS gateway, a WhatsApp Business account, SMTP, Firebase. Each one has its own credentials, errors and retry rules. Then a customer asks whether the message arrived, and nobody can say.
Nofy handles all of that once, for all your products. Your code sends one request. Nofy delivers it and keeps a record you can check.
Channels
Four ways out. One way back.
Every message goes out through the channel you name, using the same request format. Every result comes back to you the same way, as a signed webhook.
- To
- A phone number
- Content
- A template approved by Meta, plus its parameters
- Status
- Sent or failed, then delivered and read as the phone reports them
Push
via Firebase- To
- Your own user ID. Nofy reaches all of that user's active phones
- Content
- Title and text, with optional data for your app
- Status
- Sent or failed. Dead device tokens are switched off automatically
SMS
via Beem- To
- A phone number, with or without the leading +
- Content
- Plain text, sent from your approved sender ID
- Status
- Sent or failed, with the provider's reason when it refuses
Webhooks
Add a callback_url to any send and Nofy posts each delivery's result to your server as soon as it's final, whatever the channel.
- Signed. Every post carries an
X-ENS-Signatureheader: an HMAC-SHA256 of the body, using a secret issued only to you. - Retried. If your endpoint is down, the post is tried again.
- Backed up. Anything you still missed is in
GET /v2/deliveries?updated_since=…
Content-Type: application/json X-ENS-Signature: 9f2c41…e07b { "delivery_id": "dL91xTo8", "reference": "shift-8841", "channel": "sms", "recipient": "255700000000", "status": "sent", "attempts": 1, "provider_message_id": "beem-req-42", "sent_at": "2026-09-25T18:00:03Z", "last_error": null }
How it works
Four API calls from credentials to delivery
Your operator issues the OAuth credentials. Everything after that is self-service. Pick a step to see the request and what comes back.
Request Step 1 of 4
POST /v1/oauth2/token/ Content-Type: application/x-www-form-urlencoded grant_type=client_credentials &client_id=YOUR_CLIENT_ID &client_secret=YOUR_CLIENT_SECRET
Response 200 OK
{
"access_token": "abc123",
"expires_in": 36000,
"token_type": "Bearer"
}
Request Step 2 of 4
POST /v2/tenants Authorization: Bearer abc123 { "external_id": "company-uuid-1", "name": "Acme Fuel" }
Response 201 Created
{
"tenant_id": "KCrU9UsxCnrc",
"external_id": "company-uuid-1",
"created": true
}
Request Step 3 of 4
POST /v2/messages Authorization: Bearer abc123 Idempotency-Key: shift-8841 { "tenant": "company-uuid-1", "event_type": "shift.closed", "reference": "shift-8841", "callback_url": "https://api.acme.co.tz/nofy/status", "messages": [{ "channel": "sms", … }] }
Response 202 Accepted
{
"reference": "shift-8841",
"message_id": "vA51xTo874Y8",
"deliveries": [{
"id": "dL91xTo8",
"channel": "sms",
"recipient": "255700000000",
"status": "pending"
}]
}
Request Step 4 of 4
GET /v2/deliveries?reference=shift-8841 Authorization: Bearer abc123
Response 200 OK
{
"deliveries": [{
"delivery_id": "dL91xTo8",
"channel": "sms",
"status": "sent",
"attempts": 1,
"provider_message_id": "beem-req-42",
"sent_at": "2026-09-25T18:00:03Z",
"last_error": null
}],
"next_cursor": null
}
Reliability
Know whether it arrived
Each recipient gets its own delivery record with its status, attempt count and the provider's message ID. When a customer asks what happened, you can answer.
Automatic retries
Temporary failures are retried up to 5 times, waiting 1, 2, 4 and then 8 minutes between tries. Hitting a rate limit never uses up an attempt.
Permanent failures stop immediately
A wrong number, a paused template or a refused address fails straight away, and the delivery records the reason.
Callbacks you can verify
Every status callback is signed with HMAC-SHA256 using a secret issued to you, so you can reject anything forged.
Outage alerts
If a whole channel starts failing, for example an expired token or an empty SMS balance, your team is emailed. A second email confirms when it recovers.
- Accepted202 returned with the idempotency key recorded
- SentWhatsApp accepted it; signed callback posted to you
- DeliveredReached the recipient's phone
- ReadRecipient opened the message
Callbacks can miss if your server is down. GET /v2/deliveries?updated_since=… always has the full record.
Built for multi-tenant products
One customer's spike won't block the rest
Tenants are your customers, identified by your own IDs. Nofy uses them to apply limits and settings to each customer separately.
Daily quotas for each tenant
A customer who goes over their allowance gets 429 responses while every other customer keeps sending. A separate daily cap covers your whole application.
Bursts are slowed, not dropped
Each provider account has a per-minute limit. When a burst goes over it, messages wait their turn instead of failing.
Your data stays yours
Sending to an unregistered tenant is rejected, so a typo can't create a new one. Each API client sees only its own deliveries.
from ens_sdk import ENS # Reads ENS_SERVER_URL, ENS_CLIENT_ID, # ENS_CLIENT_SECRET and ENS_TENANT ens = ENS() ens.send_email( "ops@acme.co.tz", "Shift closed", "Shift 7 closed.") ens.send_whatsapp_template( "+255700000000", "otp_login", "en", ["482916"]) ens.send_push( "user-uuid-1", "Shift closed", "Tap for the summary.")
Developers
Plain HTTP, plus a Python SDK
The Python SDK handles tokens, idempotency keys and error types for you. Any other language can call the same HTTP API; the docs include working examples for each.
- Unknown request fields are rejected, so a misspelled
callback_urlfails loudly - Clear status codes:
400,401,403,413,429 - Deliveries can be paged with a cursor, up to 500 per call
Does a 202 mean my message was delivered?
No. 202 means Nofy has accepted and queued it. The response lists one delivery per recipient, and each one updates to sent or failed. WhatsApp also reports when a message is delivered and read.
Can I retry a request that timed out?
Yes. Send it again with the same Idempotency-Key. You get the original response and nothing is sent twice. Keys are scoped to your client, so they can't clash with another application's.
What if my callback endpoint is down?
Callbacks are retried, but treat them as a speed-up rather than your only record. Call GET /v2/deliveries?updated_since=… to catch up on anything you missed.
Can I send free-form WhatsApp messages?
No. WhatsApp business messages must use templates Meta has approved. Nofy rejects free-form bodies with a 400 before anything is stored, rather than letting Meta reject them later.
How do I get credentials?
Your Nofy operator issues an OAuth client ID and secret. Keep the secret on your servers only; never put it in a mobile app or browser code.
Send your first notification today
The quickstart takes about five minutes. We can help with credentials, access or outages.