NewHope SMS API
A complete REST API to send SMS, OTP, and delivery reports in Tanzania. Works with every programming language — if it can make an HTTP request, it works with NewHope SMS.
⚡ Quick Start — Send Your First SMS in 5 Minutes
Follow these 3 steps. No SDK installation required — just HTTP.
Create an account and generate API keys
Sign up at sms.newhope.co.tz/sign-up/ → go to API Keys → click Generate New Key → choose a sender ID → save both keys shown to you. The Secret Key is shown only once.
Send your first SMS
Replace YOUR_API_KEY and YOUR_SECRET_KEY in the cURL command below, then run it in any terminal. You should receive an SMS on the phone number you specified.
curl -X POST https://sms.newhope.co.tz/v1/sms/send/ \ -H "Authorization: ApiKey YOUR_API_KEY:YOUR_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"recipient":"+255712345678","message":"Hello from NewHope SMS!","sender_id":"NewHope"}'
Check the response
A successful response looks like this. The id is your message ID — use it to check delivery status.
{
"id": "9f3a1c2e-4b5d-…",
"status": "sent",
"recipient": "+255712345678",
"segments": 1,
"cost": "20.00"
}
Any language. Any framework. cURL above is just a raw HTTP POST. In your code, use whatever HTTP client your language/framework provides — requests (Python), axios/fetch (JS), HttpClient (.NET), OkHttp (Java/Android), http package (Dart/Flutter), curl_exec (PHP). They all send the same HTTP request.
Introduction
The NewHope SMS API is a RESTful HTTP API targeting businesses, schools, hospitals, NGOs, churches, and developers in Tanzania.
| Property | Value |
|---|---|
| Base URL | https://sms.newhope.co.tz/v1 |
| Protocol | HTTPS only — plain HTTP is rejected |
| Format | JSON — set Content-Type: application/json on all POST/PUT requests |
| Authentication | Authorization: ApiKey YOUR_API_KEY:YOUR_SECRET_KEY |
| Rate limit | 200 requests per minute per API key (429 if exceeded) |
| Phone format | E.164 international — +255XXXXXXXXX for Tanzania |
| Timestamps | UTC, ISO 8601 format — e.g. 2026-06-07T10:00:00Z |
| Pagination | Paginated lists return count, next, previous, results |
| Support | support@newhope.co.tz |
Authentication
Every request must include both your API Key and your Secret Key in the Authorization header, joined by a colon, using the ApiKey scheme.
Authorization: ApiKey YOUR_API_KEY:YOUR_SECRET_KEY
Do not use Authorization: Bearer YOUR_API_KEY. That format is JWT and will return 401 Token is invalid or expired. External API clients must use ApiKey key:secret.
API Key types
| Type | Access |
|---|---|
| Demo | Created automatically for every account. Test only: NewHope sender, 5 test messages, switched off once your Sender Name is approved — see Demo Keys |
| General | Full access — all endpoints |
| SMS Sender | Send SMS using one specific sender ID only |
| OTP | Only /otp/send/ and /otp/verify/. Generated per approved Sender Name on the API Keys page (separate key + secret from your SMS key); active only while that sender is approved. |
Generate keys: sms.newhope.co.tz/api-keys
Code examples — Authentication setup
curl https://sms.newhope.co.tz/v1/sender-ids/ \
-H "Authorization: ApiKey YOUR_API_KEY:YOUR_SECRET_KEY"
🐍 Python (requests)
import requests API_KEY = "YOUR_API_KEY" # dashboard → API Keys SECRET_KEY = "YOUR_SECRET_KEY" # save once, never shown again BASE = "https://sms.newhope.co.tz/v1" session = requests.Session() session.headers.update({ "Authorization": f"ApiKey {API_KEY}:{SECRET_KEY}", "Content-Type": "application/json", }) r = session.get(f"{BASE}/sender-ids/") print(r.json())
🟨 JavaScript / Node.js (axios)
const axios = require('axios'); const API_KEY = 'YOUR_API_KEY'; // dashboard → API Keys const SECRET_KEY = 'YOUR_SECRET_KEY'; // save once, never shown again const api = axios.create({ baseURL: 'https://sms.newhope.co.tz/v1', headers: { 'Authorization': `ApiKey ${API_KEY}:${SECRET_KEY}`, 'Content-Type': 'application/json', }, }); const { data } = await api.get('/sender-ids/'); console.log(data);
🟦 JavaScript / Browser (fetch)
const API_KEY = 'YOUR_API_KEY'; const SECRET_KEY = 'YOUR_SECRET_KEY'; const headers = { 'Authorization': `ApiKey ${API_KEY}:${SECRET_KEY}`, 'Content-Type': 'application/json', }; const res = await fetch('https://sms.newhope.co.tz/v1/sender-ids/', { headers }); const data = await res.json(); console.log(data);
🐘 PHP (cURL / GuzzleHTTP)
<?php $apiKey = 'YOUR_API_KEY'; // dashboard → API Keys $secretKey = 'YOUR_SECRET_KEY'; // save once, never shown again $ch = curl_init('https://sms.newhope.co.tz/v1/sender-ids/'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: ApiKey ' . $apiKey . ':' . $secretKey, ], ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); print_r($data);
<?php
use GuzzleHttp\Client;
$client = new Client([
'base_uri' => 'https://sms.newhope.co.tz/v1/',
'headers' => [
'Authorization' => 'ApiKey ' . $apiKey . ':' . $secretKey,
'Content-Type' => 'application/json',
],
]);
$res = $client->get('sender-ids/');
$data = json_decode($res->getBody(), true);
☕ Java (OkHttp / HttpClient)
import okhttp3.*; String apiKey = "YOUR_API_KEY"; String secretKey = "YOUR_SECRET_KEY"; OkHttpClient client = new OkHttpClient(); Request request = new Request.Builder() .url("https://sms.newhope.co.tz/v1/sender-ids/") .addHeader("Authorization", "ApiKey " + apiKey + ":" + secretKey) .build(); try (Response response = client.newCall(request).execute()) { System.out.println(response.body().string()); }
import java.net.http.*;
import java.net.URI;
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://sms.newhope.co.tz/v1/sender-ids/"))
.header("Authorization", "ApiKey " + apiKey + ":" + secretKey)
.GET()
.build();
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
🔷 C# / .NET (HttpClient)
using System.Net.Http; using System.Net.Http.Json; var apiKey = "YOUR_API_KEY"; var secretKey = "YOUR_SECRET_KEY"; var client = new HttpClient(); client.DefaultRequestHeaders.Add("Authorization", $"ApiKey {apiKey}:{secretKey}"); var senderIds = await client.GetFromJsonAsync<List<object>>( "https://sms.newhope.co.tz/v1/sender-ids/"); Console.WriteLine(senderIds);
🎯 Dart / Flutter (http package)
import 'package:http/http.dart' as http; import 'dart:convert'; // Add to pubspec.yaml: http: ^1.2.0 const apiKey = 'YOUR_API_KEY'; const secretKey = 'YOUR_SECRET_KEY'; final headers = { 'Authorization': 'ApiKey $apiKey:$secretKey', 'Content-Type': 'application/json', }; final res = await http.get( Uri.parse('https://sms.newhope.co.tz/v1/sender-ids/'), headers: headers, ); final data = jsonDecode(res.body); print(data);
🦀 Go (net/http)
package main
import (
"fmt"; "io"; "net/http"
)
func main() {
apiKey := "YOUR_API_KEY"
secretKey := "YOUR_SECRET_KEY"
req, _ := http.NewRequest("GET",
"https://sms.newhope.co.tz/v1/sender-ids/", nil)
req.Header.Set("Authorization", "ApiKey "+apiKey+":"+secretKey)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body))
}
💎 Ruby (Net::HTTP / Faraday)
require 'net/http' require 'json' api_key = 'YOUR_API_KEY' secret_key = 'YOUR_SECRET_KEY' uri = URI('https://sms.newhope.co.tz/v1/sender-ids/') req = Net::HTTP::Get.new(uri) req['Authorization'] = "ApiKey #{api_key}:#{secret_key}" res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) } data = JSON.parse(res.body) puts data
Demo Keys (testing)
Every account gets a demo SMS key and a demo OTP key the moment it is created — dashboard → API Keys → Demo keys. Use them to build and test your integration while your own Sender Name is being reviewed. They work exactly like live keys, with these rules:
| Rule | Detail |
|---|---|
| Sender | Only the shared NewHope sender. Send "sender_id": "NewHope" (leaving it out also defaults to NewHope). Any other sender returns 403 demo_sender_only. |
| Test limit | 5 test messages in total (SMS and OTP together). After that you get 403 SYSTEM_SENDER_LIMIT_REACHED. |
| OTP | The demo OTP key works without registering for the OTP service. |
| Not for production | Demo keys switch off automatically and permanently as soon as your own Sender Name is approved. After that they return 403 demo_retired — you must use your live keys. |
curl -X POST https://sms.newhope.co.tz/v1/sms/send/ \ -H "Authorization: ApiKey DEMO_API_KEY:DEMO_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"recipient":"+255712345678","message":"Hello from the demo key","sender_id":"NewHope"}'
Build once — go live by swapping the keys
Demo keys and live keys use the same endpoints, request bodies, responses and error formats. If your integration works with the demo keys, it works with live keys: the only thing you change is the API key and secret. Keep them in configuration (environment variables), never in your code:
# while testing NEWHOPE_API_KEY=your_demo_api_key NEWHOPE_SECRET_KEY=your_demo_secret_key # after your Sender Name is approved — replace with the live pair, nothing else NEWHOPE_API_KEY=your_live_api_key NEWHOPE_SECRET_KEY=your_live_secret_key
| Demo keys | Live keys | |
|---|---|---|
| Sends from | The shared NewHope sender | Your own approved Sender Name |
| Volume | 5 test messages in total | As many as your credits allow |
| OTP | Works straight away | One-time Register for OTP click in the dashboard |
| Everything else | Identical — endpoints, fields, responses, errors, rate limits | |
sender_id in your request: with a live key it is ignored and the message always goes out from the sender that key belongs to, so a request you wrote for the demo ("sender_id": "NewHope") keeps working unchanged. You may leave it, remove it, or set it to your own sender name.
Going live
- Get your own Sender Name approved (dashboard → Sender IDs). Your demo keys switch off automatically at that moment and we email you what to do. They stay off — requests with them keep returning
demo_retireduntil you use your live keys. - Dashboard → API Keys → create your live SMS key. For OTP, click Register for OTP first, then create your live OTP key.
- Put the live API key and secret in your configuration. That is the whole change.
- A demo key used after approval returns:
{
"error": "Demo keys retired",
"code": "demo_retired",
"message": "Your Sender ID is approved, so your demo keys are switched off. Create your live API keys in the dashboard (API Keys) and use them instead."
}
Send SMS
Send a single SMS to one recipient. Consumes 1 credit per segment.
Request body (JSON)
| Field | Type | Status | Description |
|---|---|---|---|
| recipient | string | Required | E.164 phone — +255712345678 |
| message | string | Required | SMS body. Max 918 chars (6 segments). GSM-7: 160 chars/segment. Unicode/Swahili: 70 chars/segment. |
| sender_id | string | Optional | Approved sender name (max 11 chars). Uses account default if omitted. |
| webhook_url | string URL | Optional | Public http(s) URL that receives delivery receipt POSTs. Addresses that point inside a private network (localhost, 10.x, 192.168.x, …) are rejected with 400. Respond 2xx within 5 seconds. |
| schedule_at | datetime | Optional | ISO 8601 future datetime — e.g. 2026-06-10T09:00:00Z |
Response — 201 Created
{
"id": "9f3a1c2e-4b5d-…",
"status": "sent",
"recipient": "+255712345678",
"segments": 1,
"cost": "20.00"
}
status is sent when the network accepted the message, failed if it could not be sent, or pending when scheduled with schedule_at. Use GET /sms/{id}/ for the full record (sender, gateway ID, timestamps). Validation errors look like {"recipient": ["Invalid phone number. Use format: +255XXXXXXXXX"]}.
Code examples
curl -X POST https://sms.newhope.co.tz/v1/sms/send/ \ -H "Authorization: ApiKey YOUR_API_KEY:YOUR_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"recipient":"+255712345678","message":"Hello from NewHope SMS!","sender_id":"NewHope"}'
🐍 Python
r = session.post("/sms/send/", json={ "recipient": "+255712345678", "message": "Hello from NewHope SMS!", "sender_id": "NewHope", "webhook_url": "https://yourapp.com/dlr", }) print(r.json()) # {'id': '...', 'status': 'sent', 'cost': '20.00'}
🟨 Node.js
const { data } = await api.post('/sms/send/', {
recipient: '+255712345678',
message: 'Hello from NewHope SMS!',
sender_id: 'NewHope',
webhook_url: 'https://yourapp.com/dlr',
});
🐘 PHP
$ch = curl_init('https://sms.newhope.co.tz/v1/sms/send/'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: ApiKey '.$apiKey.':'.$secretKey, 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode(['recipient'=>'+255712345678','message'=>'Hello!','sender_id'=>'NewHope']), ]); print_r(json_decode(curl_exec($ch), true)); curl_close($ch);
☕ Java
String body = """{"recipient":"+255712345678","message":"Hello!","sender_id":"NewHope"}"""; RequestBody rb = RequestBody.create(body, MediaType.get("application/json")); Request req = new Request.Builder() .url("https://sms.newhope.co.tz/v1/sms/send/") .addHeader("Authorization", "ApiKey " + apiKey + ":" + secretKey) .post(rb).build(); try (Response r = client.newCall(req).execute()) { System.out.println(r.body().string()); }
🔷 C#
var payload = new { recipient = "+255712345678", message = "Hello!", sender_id = "NewHope" };
var res = await client.PostAsJsonAsync("https://sms.newhope.co.tz/v1/sms/send/", payload);
var data = await res.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(data);
🎯 Dart / Flutter
final res = await http.post( Uri.parse('https://sms.newhope.co.tz/v1/sms/send/'), headers: headers, body: jsonEncode({'recipient': '+255712345678', 'message': 'Hello!', 'sender_id': 'NewHope'}), ); print(jsonDecode(res.body));
Bulk SMS
Send the same message to an entire contact list in one request.
| Field | Type | Status | Description |
|---|---|---|---|
| contact_list_id | uuid | Required | UUID of the contact list. Get it from GET /contacts/ |
| message | string | Required | SMS message body. |
| sender_id | string | Optional | Approved sender name. Uses default if omitted. |
| schedule_at | datetime | Optional | ISO 8601 future send time. |
Response — 202 Accepted
{ "message": "Batch SMS queued for 250 recipient(s)." }
Messages are sent in the background. Track each one with GET /sms/reports/ or a webhook_url.
Credits consumed = contacts × message segments. Ensure sufficient balance before sending. Returns 402 if insufficient.
curl -X POST https://sms.newhope.co.tz/v1/sms/send-batch/ \ -H "Authorization: ApiKey YOUR_API_KEY:YOUR_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"contact_list_id":"YOUR_LIST_UUID","message":"Hello customers!","sender_id":"NewHope"}'
SMS Status
Check the current delivery status of a specific message by its ID.
Response — 200 OK
{
"status": "delivered",
"delivered_at": "2026-06-07T10:00:05+03:00" // null until delivered
}
curl https://sms.newhope.co.tz/v1/sms/9f3a1c2e-4b5d-.../status/ \
-H "Authorization: ApiKey YOUR_API_KEY:YOUR_SECRET_KEY"
If you prefer real-time delivery updates instead of polling, use the webhook_url field in your send request. NewHope SMS will POST to your URL the moment status changes.
SMS Reports
Retrieve a paginated list of your sent messages and delivery statuses.
| Query Param | Type | Status | Description |
|---|---|---|---|
| status | string | Optional | pending · sent · delivered · failed |
| page | integer | Optional | Page number. Default: 1. |
| page_size | integer | Optional | Results per page. Max 100. Default: 20. |
{
"count": 1, "next": null, "previous": null,
"results": [
{
"id": "9f3a1c2e-…", "recipient": "+255712345678",
"message_text": "Hello from NewHope SMS!", "status": "sent",
"sms_segments": 1, "cost": "20.00", "sender_id_name": "ACME",
"sent_at": "2026-06-07T10:00:00+03:00", "delivered_at": null,
"created_at": "2026-06-07T10:00:00+03:00"
}
]
}
curl "https://sms.newhope.co.tz/v1/sms/reports/?status=delivered&page=1" \ -H "Authorization: ApiKey YOUR_API_KEY:YOUR_SECRET_KEY"
Contact Lists
Manage contact lists used for bulk SMS campaigns. A contact list is a named group of phone numbers.
List all contact lists
[
{
"id": "a1b2c3d4-…",
"name": "Customers Q1 2026",
"description": "VIP customers who purchased in Q1",
"contacts_count": 1240,
"created_at": "2026-01-01T09:00:00Z"
}
]
curl https://sms.newhope.co.tz/v1/contacts/ \
-H "Authorization: ApiKey YOUR_API_KEY:YOUR_SECRET_KEY"
Create a contact list
| Field | Type | Status | Description |
|---|---|---|---|
| name | string | Required | Name of the contact list. |
| description | string | Optional | Optional description. |
curl -X POST https://sms.newhope.co.tz/v1/contacts/ \ -H "Authorization: ApiKey YOUR_API_KEY:YOUR_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"My Customers","description":"Q1 2026 customers"}'
Delete a contact list
Returns 204 No Content on success.
List contacts in a list
| Query Param | Type | Description |
|---|---|---|
| page | integer | Page number. Default: 1. |
| page_size | integer | Results per page. |
| q | string | Search by phone, first name, or last name. |
{
"count": 1240, "next": "…?page=2", "previous": null,
"results": [
{ "id": "…", "phone_number": "+255712345678", "first_name": "Ali", "last_name": "Hassan", "created_at": "…" }
]
}
Add a single contact
| Field | Type | Status | Description |
|---|---|---|---|
| phone_number | string | Required | E.164 phone number — +255712345678 |
| first_name | string | Optional | Contact first name. |
| last_name | string | Optional | Contact last name. |
curl -X POST https://sms.newhope.co.tz/v1/contacts/LIST_UUID/contacts/ \ -H "Authorization: ApiKey YOUR_API_KEY:YOUR_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"phone_number":"+255712345678","first_name":"Ali","last_name":"Hassan"}'
Remove a contact
Returns 204 No Content on success.
Bulk import contacts from CSV
Upload a CSV file to import many contacts at once. Use multipart/form-data, not JSON.
| Field | Type | Description |
|---|---|---|
| file | file (CSV) | CSV with column headers: phone_number, first_name (optional), last_name (optional) |
phone_number,first_name,last_name +255712345678,Ali,Hassan +255754321098,Fatuma,Mwangi +255765432109,John,Doe
curl -X POST https://sms.newhope.co.tz/v1/contacts/LIST_UUID/import/ \ -H "Authorization: ApiKey YOUR_API_KEY:YOUR_SECRET_KEY" \ -F "file=@contacts.csv"
{
"imported": 1200,
"skipped": 15,
"errors": ["Row 5: invalid phone number"],
"total_in_list": 2440
}
🐍 Python — CSV import
with open("contacts.csv", "rb") as f: r = requests.post( f"{BASE}/contacts/LIST_UUID/import/", files={"file": ("contacts.csv", f, "text/csv")}, headers={"Authorization": f"ApiKey {API_KEY}:{SECRET_KEY}"}, ) print(r.json()) # {'imported': 1200, 'skipped': 15, ...}
🟨 Node.js — CSV import
const FormData = require('form-data'); const fs = require('fs'); const form = new FormData(); form.append('file', fs.createReadStream('contacts.csv')); const { data } = await axios.post('/contacts/LIST_UUID/import/', form, { headers: { ...form.getHeaders() }, }); console.log(data);
🎯 Dart / Flutter — CSV import
var request = http.MultipartRequest( 'POST', Uri.parse('https://sms.newhope.co.tz/v1/contacts/LIST_UUID/import/'), ); request.headers['Authorization'] = 'ApiKey $apiKey:$secretKey'; request.files.add(await http.MultipartFile.fromPath('file', 'contacts.csv')); var response = await request.send(); print(await response.stream.bytesToString());
Sender IDs
List sender IDs on your account. Only approved sender IDs can be used. The default "NewHope" sender ID is pre-approved on every account.
Using Sender IDs on their own
- Dashboard → Sender IDs → Request New (max 11 letters/digits). Upload business documents and pay the application fee.
- Wait for NewHope compliance review. Poll
GET /sender-ids/untilstatusisapproved(any of your API keys can call it). - Dashboard → API Keys: every approved Sender ID gets its own SMS key pair and its own OTP key pair. They are independent — generate only what you need.
- Use the SMS key on
/sms/send/and the OTP key on/otp/send/. Each key is locked to its Sender ID.
If a Sender ID is rejected or revoked, both of its keys stop working immediately (401). Keys cannot be regenerated for a sender that is not approved.
[
{
"id": "a1b2c3d4-…",
"sender_name": "ACME",
"status": "approved",
"is_system": false,
"is_default": false,
"approved_at": "2026-01-10T08:00:00+03:00",
"rejection_reason": ""
}
]
| Status | Meaning |
|---|---|
| awaiting_payment | Application fee (TZS 5,000) not yet paid |
| pending | Fee paid — approval 7–14 days after the application is completed |
| under_review | Under review by the NewHope compliance team |
| approved | Active — can be used for sending |
| rejected | Denied — reason in rejection_reason field |
To apply for a custom sender ID: log in → Sender IDs → Request New. Upload Brela / TIN / Business Licence (PDF), pay TZS 5,000 via mobile money. Sender ID names: max 11 characters, letters and numbers only, no spaces.
OTP — Send
Send a one-time password via SMS. NewHope generates a 6-digit code and delivers it as a normal SMS from your own approved Sender ID. The code is never returned by the API — the only way to receive it is the SMS on the user's phone, and the only way to check it is /otp/verify/. You keep the request_id and check what the user types. Lifetime is returned as expires_in (seconds). Costs 1 SMS credit. Requires an OTP API key — an SMS key cannot request or verify codes.
Integrate OTP on its own (no SMS key needed)
- Try it first with your demo OTP key (see Demo Keys) — no registration needed, NewHope sender, 5 test messages.
- For production, get an approved Sender ID (see Sender IDs). OTP is active only while that sender is approved.
- Dashboard → API Keys → click Register for OTP. OTP is a separate service from SMS: until you register, OTP keys and the
/otp/*endpoints are not available to you. (SMS-only customers don't need this.) - On your approved sender choose OTP keys → Generate OTP API Key. Copy the secret key now; it is shown once.
- Call
POST /otp/send/withAuthorization: ApiKey <otp_api_key>:<otp_secret_key>and keep the returnedrequest_idwith the user's session. - When the user types the code, call
POST /otp/verify/with thatrequest_idand the code. Continue only ifvalidistrue.
OTP keys can only call /otp/send/, /otp/verify/ and GET /sender-ids/; they cannot send normal SMS. Keep keys on your server — never in a mobile app or browser.
How verification works
- Your server calls
POST /otp/send/with the user's phone number. NewHope generates a code and SMSes it from your Sender ID, and returns arequest_idplusexpires_in. - The user receives the SMS and types the PIN into your app.
- Your server calls
POST /otp/verify/with therequest_idand the PIN. - If
validistrue, let the user continue. The PIN can never be used again.
OTP lifecycle
| Rule | Detail |
|---|---|
| Expiry | A PIN is valid for expires_in seconds (default 300 = 5 minutes). After that, verify returns OTP has expired — request a new one. |
| One-time use | After a successful verify the PIN is consumed. A second verify returns OTP already used. |
| Wrong attempts | A code allows 3 wrong guesses. After that verify returns Too many attempts — even for the right code — and the user must request a new OTP. |
| SMS text | The user receives: Your verification code is 123456. Valid for 5 minutes. Do not share this code. The sender name is your approved Sender ID. |
| Resend | One new code per number every 30 seconds and at most 5 per hour. Sooner returns 429 with retry_after (seconds). |
| New request | Requesting a new OTP for the same phone and otp_type invalidates the previous one. |
| Billing | 1 SMS credit per OTP, charged only when the SMS is accepted for delivery. Failed sends (502) cost nothing and your previous valid code keeps working. Verifying is free. |
| Rate limit | 5 sends per minute and 10 verifies per minute per account. Over the limit you get 429 — wait and retry. |
Security best practices
- Call the OTP API from your server only. Never put your key or secret in a mobile app, web page or public repo.
- Always decide on the server from
valid. Never trust a "verified" flag sent by the browser or app. - Store the
request_idin the user's server-side session, not in a URL. - Do not log or store the PIN. NewHope never shows it to you — only the user receives it.
- Show one generic message for every failure ("Invalid or expired code") and offer "Resend code" after the cooldown.
- Throttle resends in your own app too, so one phone number cannot be spammed.
- If a key may have leaked, regenerate it in the dashboard — the old pair stops working immediately.
| Field | Type | Status | Description |
|---|---|---|---|
| phone | string | Required | Tanzania mobile number, e.g. +255712345678 (255712345678 and 0712345678 are also accepted) |
| sender_id | string | Optional | Ignored when you use an OTP key — the SMS is always sent from that key's Sender ID. |
| otp_type | string | Optional | registration, login (default), password_reset or transaction_verify |
{ "request_id": "uuid-…", "phone_number": "+255712345678", "expires_at": "2026-06-07T10:05:00Z", "expires_in": 300, "message": "OTP sent successfully." }
Errors: 400 invalid phone ({"phone": ["…"]}) · 401 bad key or Sender ID not approved · 402 insufficient credits · 403 wrong key type, not registered for OTP, or API restricted · 429 too soon or too many for this number ({"error": "…", "retry_after": 30} and a Retry-After header) · 502 the SMS could not be delivered (no credits charged).
The user receives: Your verification code is 123456. Valid for 5 minutes. Do not share this code.
curl -X POST https://sms.newhope.co.tz/v1/otp/send/ \ -H "Authorization: ApiKey YOUR_API_KEY:YOUR_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"phone":"+255712345678"}'
OTP — Verify
Verify the 6-digit code submitted by the user against the request_id from /otp/send/.
| Field | Type | Status | Description |
|---|---|---|---|
| request_id | uuid | Required | The request_id returned by /otp/send/ |
| otp | string | Required | The code the user received — exactly 6 digits |
{ "valid": true, "message": "OTP is valid" } // or { "valid": false, "message": "Invalid OTP code" }
A wrong, expired or already-used code returns 200 with valid: false (messages: Invalid OTP code, OTP has expired, Too many attempts, OTP already used). Other responses: 400 unknown request_id or malformed body, 401/403 as for sending, 429 too many verify calls (10/min). A request_id only works for the account that created it.
curl -X POST https://sms.newhope.co.tz/v1/otp/verify/ \ -H "Authorization: ApiKey YOUR_API_KEY:YOUR_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"request_id":"uuid-…","otp":"847291"}'
Verify results
| HTTP | Response | What to do |
|---|---|---|
| 200 | {"valid": true, "message": "OTP is valid"} | Continue — the PIN is now consumed. |
| 200 | {"valid": false, "message": "Invalid OTP code"} | Let the user retry (attempts are limited). |
| 200 | {"valid": false, "message": "OTP has expired"} | Request a new OTP. |
| 200 | {"valid": false, "message": "Too many attempts"} | Request a new OTP. |
| 200 | {"valid": false, "message": "OTP already used"} | Request a new OTP. |
| 400 | {"error": "No OTP found for this request."} | Unknown request_id — use the one from /otp/send/. |
| 400 | {"otp": ["This field is required."]} | Fix the request body (otp must be exactly 6 characters). |
| 502 | {"valid": false, "message": "…"} | Only with Beem OTP mode when Beem is unreachable — retry the same request. |
OTP errors & limits
| HTTP | Meaning / what to do |
|---|---|
| 400 | Invalid phone (Tanzania numbers only) or missing field. |
| 401 | Wrong key/secret, the key's Sender ID is not approved (or was revoked), or your OTP service was suspended. Check GET /sender-ids/. |
| 402 | Not enough SMS credits (1 per OTP). Top up in the dashboard. |
| 403 | You used an SMS/other key instead of an OTP key, you have not registered for the OTP service, the account is restricted/disabled, or NewHope has put your API on hold ("code": "api_restricted" — see Error Handling). |
| 429 | Too many requests: 5 sends/min and 10 verifies/min per account, or a resend to the same number within 30 s / over 5 per hour (retry_after tells you how long to wait). |
| 502 | The SMS could not be delivered. No credits charged and any earlier valid code still works — safe to retry. |
🐍 Python — Full OTP flow
# Step 1 — send OTP send = session.post(f"{BASE}/otp/send/", json={"phone": "+255712345678"}) request_id = send.json()["request_id"] print(f"OTP sent — expires in {send.json()['expires_in']}s") # Step 2 — user reads code; then verify verify = session.post(f"{BASE}/otp/verify/", json={"request_id": request_id, "otp": user_input}) print("Valid:", verify.json()["valid"])
🎯 Dart / Flutter — Full OTP flow
// Step 1 — send OTP final sendRes = await http.post(Uri.parse('$BASE/otp/send/'), headers: headers, body: jsonEncode({'phone': '+255712345678'})); final requestId = jsonDecode(sendRes.body)['request_id']; // Step 2 — verify OTP final verifyRes = await http.post(Uri.parse('$BASE/otp/verify/'), headers: headers, body: jsonEncode({'request_id': requestId, 'otp': userInput})); final valid = jsonDecode(verifyRes.body)['valid']; print('OTP valid: $valid');
Webhooks / Delivery Reports (DLR)
Set webhook_url in your send request. NewHope SMS will POST to that URL every time delivery status changes.
Your endpoint must be a public URL and respond with a 2xx status within 5 seconds. Redirects are not followed. A delivery that times out, cannot connect, or gets a 5xx answer is retried up to 3 times with back-off; a 4xx answer is not retried.
Treat the notification as a hint, not as proof. The payload is not signed, so anyone who learns your URL could post to it. Serve it over HTTPS, put a long random secret in the URL (e.g. https://yourapp.com/dlr?token=…) and check it, and before acting on a message confirm it with GET /sms/{sms_id}/status/ using your API key.
Payload — POST to your URL
{
"sms_id": "9f3a1c2e-…", // the "id" returned when you sent the message
"message_id": "9f3a1c2e-…", // same value, kept as an alias
"recipient": "+255712345678",
"status": "delivered",
"delivered_at": "2026-06-07T10:00:05Z"
}
🐍 Python — Flask webhook handler
from flask import Flask, request app = Flask(__name__) @app.route('/dlr', methods=['POST']) def dlr(): d = request.json print(f"[{d['status'].upper()}] {d['recipient']} — {d['message_id']}") if d['status'] == 'delivered': pass # update DB, send follow-up, etc. return 'OK', 200 # MUST return 200
🟨 Node.js — Express webhook handler
app.post('/dlr', (req, res) => { const { message_id, recipient, status } = req.body; console.log(`[${status.toUpperCase()}] ${recipient} — ${message_id}`); if (status === 'delivered') { /* update DB */ } res.sendStatus(200); // MUST return 200 });
🐘 PHP webhook handler
$d = json_decode(file_get_contents('php://input'), true); error_log("DLR [{$d['status']}] {$d['recipient']} — {$d['message_id']}"); if ($d['status'] === 'delivered') { /* update DB */ } http_response_code(200); echo 'OK';
☕ Java — Spring Boot webhook
@PostMapping("/dlr") public ResponseEntity<String> dlr(@RequestBody Map<String, Object> payload) { String status = (String) payload.get("status"); log.info("DLR [{}] {} — {}", status, payload.get("recipient"), payload.get("message_id")); if ("delivered".equals(status)) { /* update DB */ } return ResponseEntity.ok("OK"); // MUST return 200 }
🔷 C# — ASP.NET Core webhook
[HttpPost("/dlr")] public IActionResult Dlr([FromBody] JsonElement payload) { var status = payload.GetProperty("status").GetString(); var recipient = payload.GetProperty("recipient").GetString(); _logger.LogInformation("DLR [{status}] {recipient}", status, recipient); if (status == "delivered") { /* update DB */ } return Ok("OK"); // MUST return 200 }
Error Handling Best Practices
Build resilient integrations using these patterns.
Error response format
// 400 — a field is invalid (key = field name) { "recipient": ["Invalid phone number. Use format: +255XXXXXXXXX"] } // 402 / 403 / 502 — business rule failed { "error": "Insufficient SMS credits." } // 401 / 429 — authentication or throttling { "detail": "Invalid API key or secret key. Ensure you are using Authorization: ApiKey <api_key>:<secret_key>" } { "detail": "Request was throttled. Expected available in 60 seconds." }
Read error first, then detail; for 400 validation errors, the keys are the field names.
API restricted (403)
If NewHope SMS puts your API on hold for breaking the rules (for example spam or messages sent without consent), every request made with the affected key returns 403 and no message is sent. The restriction can cover all APIs, only SMS, only OTP, or a single key. Your dashboard keeps working so you can see the reason.
{
"error": "API restricted",
"code": "api_restricted",
"message": "Your API access has been restricted by NewHope SMS. Messages cannot be sent from your system until the restriction is lifted.",
"reason": "Unsolicited promotional SMS without consent",
"contact": "support@newhope.co.tz"
}
Do not retry in a loop. Check code == "api_restricted", show the reason to your operator, and contact support to have it lifted. All messages must go through the NewHope SMS API, so a restricted key cannot send anything by any other route.
Retry with exponential back-off (for 429 / 5xx)
🐍 Python — retry pattern
import time, requests
def send_sms_with_retry(session, payload, max_retries=3):
for attempt in range(max_retries):
r = session.post(f"{BASE}/sms/send/", json=payload)
if r.status_code == 201:
return r.json()
if r.status_code == 429 or r.status_code >= 500:
wait = 2 ** attempt # 1s, 2s, 4s
time.sleep(wait)
continue
r.raise_for_status() # 4xx errors — don't retry
raise Exception("Max retries exceeded")
🟨 Node.js — retry pattern
async function sendWithRetry(payload, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
const { data } = await api.post('/sms/send/', payload);
return data;
} catch (err) {
const status = err.response?.status;
if (status === 429 || status >= 500) {
await new Promise(r => setTimeout(r, 1000 * 2 ** i));
continue;
}
throw err; // 4xx — don't retry
}
}
throw new Error('Max retries exceeded');
}
Rate limit headers
When approaching the rate limit (200 req/min), the API returns 429 Too Many Requests. Back off and retry after a short delay. Do not retry immediately.
Balance checks (avoid 402)
curl https://sms.newhope.co.tz/v1/profile/balance/ \ -H "Authorization: ApiKey YOUR_API_KEY:YOUR_SECRET_KEY" # Returns: {"sms_balance": 5000, "currency": "TZS"}
Error Code Reference
Authorization: ApiKey API_KEY:SECRET_KEY. Do not use Bearer.code field: api_key_scope (the key was used outside the public SMS / OTP / contacts / sender-ids API — use the dashboard for that), api_restricted (NewHope put your API on hold), demo_sender_only / demo_retired (demo keys).Pricing (TZS per SMS credit)
Credits are pre-purchased and never expire. The more you buy, the lower the per-SMS cost.
| SMS Range | Price per SMS (TZS) |
|---|---|
| 1 – 10,000 | TZS 20.00 |
| 10,001 – 25,000 | TZS 18.00 |
| 25,001 – 50,000 | TZS 17.00 |
| 50,001 – 100,000 | TZS 16.00 |
| 100,001 – 250,000 | TZS 14.00 |
| 250,001 – 500,000 | TZS 13.00 |
| 500,001 – 1,000,000 | TZS 12.00 |
| 1,000,001 – 2,000,000 | TZS 10.70 |
Payment methods: M-Pesa (Vodacom), Airtel Money, Tigo Pesa, TTCL Pesa, Halotel, Bank Transfer — added instantly after mobile money confirmation.
Buy credits: sms.newhope.co.tz/purchases
Tools & Libraries
Import the OpenAPI spec into any API client to get auto-generated documentation, request builders, and code snippets in your language.
Recommended HTTP libraries by language
| Language / Platform | Recommended library | Install |
|---|---|---|
| Python | requests | pip install requests |
| Node.js | axios or native fetch | npm install axios |
| Browser JS | Native fetch (built-in) | — |
| PHP | cURL (built-in) or GuzzleHTTP | composer require guzzlehttp/guzzle |
| Java | OkHttp or Java 11 HttpClient | implementation 'com.squareup.okhttp3:okhttp:4.12.0' |
| Android | OkHttp or Retrofit | implementation 'com.squareup.retrofit2:retrofit:2.11.0' |
| C# / .NET | System.Net.Http.HttpClient (built-in) | — |
| Dart / Flutter | http package | flutter pub add http |
| Go | net/http (built-in) | — |
| Ruby | Net::HTTP (built-in) or faraday | gem install faraday |
| Kotlin | OkHttp or Ktor | implementation 'io.ktor:ktor-client-core:2.3.12' |
| Swift / iOS | URLSession (built-in) or Alamofire | pod 'Alamofire' |
Need help integrating?
Our team assists with API integration questions, Sender ID approvals, and billing issues.