📖 API Reference

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.

https://sms.newhope.co.tz/v1 JSON 200 req/min HTTPS only Postman / OpenAPI

⚡ Quick Start — Send Your First SMS in 5 Minutes

Follow these 3 steps. No SDK installation required — just HTTP.

1

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.

2

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 — works on Linux, macOS, Windows, Git Bash, WSL
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"}'
3

Check the response

A successful response looks like this. The id is your message ID — use it to check delivery status.

Response — 201 Created
{
  "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.

PropertyValue
Base URLhttps://sms.newhope.co.tz/v1
ProtocolHTTPS only — plain HTTP is rejected
FormatJSON — set Content-Type: application/json on all POST/PUT requests
AuthenticationAuthorization: ApiKey YOUR_API_KEY:YOUR_SECRET_KEY
Rate limit200 requests per minute per API key (429 if exceeded)
Phone formatE.164 international — +255XXXXXXXXX for Tanzania
TimestampsUTC, ISO 8601 format — e.g. 2026-06-07T10:00:00Z
PaginationPaginated lists return count, next, previous, results
Supportsupport@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 header format
Authorization: ApiKey YOUR_API_KEY:YOUR_SECRET_KEY
API Key
Visible any time in the dashboard → API Keys. Identifies your account.
Secret Key
Shown once at generation only. Copy it immediately to a secure place — it cannot be recovered.
⚠

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

TypeAccess
DemoCreated automatically for every account. Test only: NewHope sender, 5 test messages, switched off once your Sender Name is approved — see Demo Keys
GeneralFull access — all endpoints
SMS SenderSend SMS using one specific sender ID only
OTPOnly /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 (universal — works on any OS, any language)
curl https://sms.newhope.co.tz/v1/sender-ids/ \
  -H "Authorization: ApiKey YOUR_API_KEY:YOUR_SECRET_KEY"
🐍 Python (requests)
Python
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)
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)
Browser / Deno / Bun — native 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 — cURL
<?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 — GuzzleHTTP (if installed)
<?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)
Java — OkHttp (Android / Spring / Desktop)
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());
}
Java — HttpClient (Java 11+, no extra libraries)
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)
C# — HttpClient (.NET 6+, .NET MAUI, Xamarin, ASP.NET)
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)
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)
Go — standard library, no dependencies
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)
Ruby — standard library
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:

RuleDetail
SenderOnly the shared NewHope sender. Send "sender_id": "NewHope" (leaving it out also defaults to NewHope). Any other sender returns 403 demo_sender_only.
Test limit5 test messages in total (SMS and OTP together). After that you get 403 SYSTEM_SENDER_LIMIT_REACHED.
OTPThe demo OTP key works without registering for the OTP service.
Not for productionDemo 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 — demo SMS
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:

.env — the only file that changes
# 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 keysLive keys
Sends fromThe shared NewHope senderYour own approved Sender Name
Volume5 test messages in totalAs many as your credits allow
OTPWorks straight awayOne-time Register for OTP click in the dashboard
Everything elseIdentical — 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

  1. 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_retired until you use your live keys.
  2. Dashboard → API Keys → create your live SMS key. For OTP, click Register for OTP first, then create your live OTP key.
  3. Put the live API key and secret in your configuration. That is the whole change.
  4. A demo key used after approval returns:
JSON — 403 demo_retired
{
  "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.

POSThttps://sms.newhope.co.tz/v1/sms/send/

Request body (JSON)

FieldTypeStatusDescription
recipientstringRequiredE.164 phone — +255712345678
messagestringRequiredSMS body. Max 918 chars (6 segments). GSM-7: 160 chars/segment. Unicode/Swahili: 70 chars/segment.
sender_idstringOptionalApproved sender name (max 11 chars). Uses account default if omitted.
webhook_urlstring URLOptionalPublic 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_atdatetimeOptionalISO 8601 future datetime — e.g. 2026-06-10T09:00:00Z

Response — 201 Created

JSON
{
  "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
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
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
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
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
Java — OkHttp
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#
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
Dart
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.

POSThttps://sms.newhope.co.tz/v1/sms/send-batch/
FieldTypeStatusDescription
contact_list_iduuidRequiredUUID of the contact list. Get it from GET /contacts/
messagestringRequiredSMS message body.
sender_idstringOptionalApproved sender name. Uses default if omitted.
schedule_atdatetimeOptionalISO 8601 future send time.

Response — 202 Accepted

JSON
{ "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
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.

GEThttps://sms.newhope.co.tz/v1/sms/{sms_id}/status/

Response — 200 OK

JSON
{
  "status":       "delivered",
  "delivered_at": "2026-06-07T10:00:05+03:00"   // null until delivered
}
cURL
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.

GEThttps://sms.newhope.co.tz/v1/sms/reports/
Query ParamTypeStatusDescription
statusstringOptionalpending · sent · delivered · failed
pageintegerOptionalPage number. Default: 1.
page_sizeintegerOptionalResults per page. Max 100. Default: 20.
Response — 200 OK
{
  "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
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

GEThttps://sms.newhope.co.tz/v1/contacts/
Response — 200 OK
[
  {
    "id":             "a1b2c3d4-…",
    "name":           "Customers Q1 2026",
    "description":    "VIP customers who purchased in Q1",
    "contacts_count": 1240,
    "created_at":     "2026-01-01T09:00:00Z"
  }
]
cURL
curl https://sms.newhope.co.tz/v1/contacts/ \
  -H "Authorization: ApiKey YOUR_API_KEY:YOUR_SECRET_KEY"

Create a contact list

POSThttps://sms.newhope.co.tz/v1/contacts/
FieldTypeStatusDescription
namestringRequiredName of the contact list.
descriptionstringOptionalOptional description.
cURL
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

DELETEhttps://sms.newhope.co.tz/v1/contacts/{list_id}/

Returns 204 No Content on success.

List contacts in a list

GEThttps://sms.newhope.co.tz/v1/contacts/{list_id}/contacts/
Query ParamTypeDescription
pageintegerPage number. Default: 1.
page_sizeintegerResults per page.
qstringSearch by phone, first name, or last name.
Response — 200 OK
{
  "count": 1240, "next": "…?page=2", "previous": null,
  "results": [
    { "id": "…", "phone_number": "+255712345678", "first_name": "Ali", "last_name": "Hassan", "created_at": "…" }
  ]
}

Add a single contact

POSThttps://sms.newhope.co.tz/v1/contacts/{list_id}/contacts/
FieldTypeStatusDescription
phone_numberstringRequiredE.164 phone number — +255712345678
first_namestringOptionalContact first name.
last_namestringOptionalContact last name.
cURL
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

DELETEhttps://sms.newhope.co.tz/v1/contacts/{list_id}/contacts/{contact_id}/

Returns 204 No Content on success.

Bulk import contacts from CSV

POSThttps://sms.newhope.co.tz/v1/contacts/{list_id}/import/

Upload a CSV file to import many contacts at once. Use multipart/form-data, not JSON.

FieldTypeDescription
filefile (CSV)CSV with column headers: phone_number, first_name (optional), last_name (optional)
CSV format example
phone_number,first_name,last_name
+255712345678,Ali,Hassan
+255754321098,Fatuma,Mwangi
+255765432109,John,Doe
cURL — file upload
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"
Response — 200 OK
{
  "imported":     1200,
  "skipped":      15,
  "errors":       ["Row 5: invalid phone number"],
  "total_in_list": 2440
}
🐍 Python — CSV import
Python
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
Node.js — form-data
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
Dart
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

  1. Dashboard → Sender IDs → Request New (max 11 letters/digits). Upload business documents and pay the application fee.
  2. Wait for NewHope compliance review. Poll GET /sender-ids/ until status is approved (any of your API keys can call it).
  3. 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.
  4. 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.

GEThttps://sms.newhope.co.tz/v1/sender-ids/
Response — 200 OK
[
  {
    "id":               "a1b2c3d4-…",
    "sender_name":      "ACME",
    "status":           "approved",
    "is_system":        false,
    "is_default":       false,
    "approved_at":      "2026-01-10T08:00:00+03:00",
    "rejection_reason": ""
  }
]
StatusMeaning
awaiting_paymentApplication fee (TZS 5,000) not yet paid
pendingFee paid — approval 7–14 days after the application is completed
under_reviewUnder review by the NewHope compliance team
approvedActive — can be used for sending
rejectedDenied — 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)

  1. Try it first with your demo OTP key (see Demo Keys) — no registration needed, NewHope sender, 5 test messages.
  2. For production, get an approved Sender ID (see Sender IDs). OTP is active only while that sender is approved.
  3. 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.)
  4. On your approved sender choose OTP keys → Generate OTP API Key. Copy the secret key now; it is shown once.
  5. Call POST /otp/send/ with Authorization: ApiKey <otp_api_key>:<otp_secret_key> and keep the returned request_id with the user's session.
  6. When the user types the code, call POST /otp/verify/ with that request_id and the code. Continue only if valid is true.

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

  1. 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 a request_id plus expires_in.
  2. The user receives the SMS and types the PIN into your app.
  3. Your server calls POST /otp/verify/ with the request_id and the PIN.
  4. If valid is true, let the user continue. The PIN can never be used again.

OTP lifecycle

RuleDetail
ExpiryA PIN is valid for expires_in seconds (default 300 = 5 minutes). After that, verify returns OTP has expired — request a new one.
One-time useAfter a successful verify the PIN is consumed. A second verify returns OTP already used.
Wrong attemptsA 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 textThe user receives: Your verification code is 123456. Valid for 5 minutes. Do not share this code. The sender name is your approved Sender ID.
ResendOne new code per number every 30 seconds and at most 5 per hour. Sooner returns 429 with retry_after (seconds).
New requestRequesting a new OTP for the same phone and otp_type invalidates the previous one.
Billing1 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 limit5 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_id in 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.
POSThttps://sms.newhope.co.tz/v1/otp/send/
FieldTypeStatusDescription
phonestringRequiredTanzania mobile number, e.g. +255712345678 (255712345678 and 0712345678 are also accepted)
sender_idstringOptionalIgnored when you use an OTP key — the SMS is always sent from that key's Sender ID.
otp_typestringOptionalregistration, login (default), password_reset or transaction_verify
Response — 201 Created
{ "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
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/.

POSThttps://sms.newhope.co.tz/v1/otp/verify/
FieldTypeStatusDescription
request_iduuidRequiredThe request_id returned by /otp/send/
otpstringRequiredThe code the user received — exactly 6 digits
Response — 200 OK
{ "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
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

HTTPResponseWhat 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

HTTPMeaning / what to do
400Invalid phone (Tanzania numbers only) or missing field.
401Wrong key/secret, the key's Sender ID is not approved (or was revoked), or your OTP service was suspended. Check GET /sender-ids/.
402Not enough SMS credits (1 per OTP). Top up in the dashboard.
403You 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).
429Too 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).
502The SMS could not be delivered. No credits charged and any earlier valid code still works — safe to retry.
🐍 Python — Full OTP flow
Python
# 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
Dart
// 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

JSON
{
  "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"
}
sent delivered failed pending
🐍 Python — Flask webhook handler
Python / Flask
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
Node.js / Express
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
PHP
$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
Java / Spring Boot
@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
C# / ASP.NET Core
[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

JSON — error body
// 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.

JSON — 403 API restricted
{
  "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
Python
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
Node.js
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 — check balance before large sends
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

400
Bad Request
Missing or invalid parameter. Check field names, required fields, and value formats.
401
Unauthorized
Missing, wrong-format, or invalid/revoked credentials. Use Authorization: ApiKey API_KEY:SECRET_KEY. Do not use Bearer.
402
Payment Required
Insufficient SMS credits. Top up at sms.newhope.co.tz/purchases
403
Forbidden
Account restricted, or the API key does not permit this action. Look at the 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).
404
Not Found
Resource (contact list UUID, sender ID, message ID) does not exist.
429
Too Many Requests
Rate limit exceeded (200 req/min). Back off and retry with exponential delay.
500
Internal Server Error
Unexpected server error. Contact support@newhope.co.tz if it persists.

Pricing (TZS per SMS credit)

Credits are pre-purchased and never expire. The more you buy, the lower the per-SMS cost.

SMS RangePrice 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 / PlatformRecommended libraryInstall
Pythonrequestspip install requests
Node.jsaxios or native fetchnpm install axios
Browser JSNative fetch (built-in)—
PHPcURL (built-in) or GuzzleHTTPcomposer require guzzlehttp/guzzle
JavaOkHttp or Java 11 HttpClientimplementation 'com.squareup.okhttp3:okhttp:4.12.0'
AndroidOkHttp or Retrofitimplementation 'com.squareup.retrofit2:retrofit:2.11.0'
C# / .NETSystem.Net.Http.HttpClient (built-in)—
Dart / Flutterhttp packageflutter pub add http
Gonet/http (built-in)—
RubyNet::HTTP (built-in) or faradaygem install faraday
KotlinOkHttp or Ktorimplementation 'io.ktor:ktor-client-core:2.3.12'
Swift / iOSURLSession (built-in) or Alamofirepod 'Alamofire'

Need help integrating?

Our team assists with API integration questions, Sender ID approvals, and billing issues.