nothanumber phone numbers, SMS and voice for AI agents
Audience: an LLM or coding agent integrating this API. Read the whole file once, then use the
section headings as a lookup table. Everything here is meant to be unambiguous: where something is
not built yet it says PLANNED and you must not call it.
- Document version: 2026-11-08 (updated with prepaid balance fields and voice pricing)
- "The administrator" below means the nothanumber staff who set up your account (support@nothanumber.com).
- Base URL:
https://app.nothanumber.com/api/v1/wholesale - Format: JSON request and response bodies, UTF-8, HTTPS only
- Money: integers in minor units (pence for GBP).
priceMinor: 3means £0.03. Never use floats. - Time: ISO 8601 UTC strings (
2026-11-08T21:00:00.000Z).
0. Status: what exists today
| Capability | Status | Notes |
|---|---|---|
| Send an SMS (text) | LIVE | POST /sms |
| Receive SMS and delivery receipts | LIVE | poll GET /sms, or webhooks |
| List and read messages | LIVE | GET /sms, GET /sms/{id} |
| List your numbers and senders | LIVE | GET /numbers (each says whether it can do sms and voice) |
| Check balance | LIVE | GET /balance |
| Log in with a simple API key | LIVE | Authorization: Bearer … |
| Log in with signed requests | LIVE | more secure alternative (section 3) |
| Get a phone number | LIVE, by hand | an administrator assigns it. You cannot buy one by API yet |
| Voice-enable a number | LIVE as a setting | the administrator turns it on per number, and only on a number assigned to you; it is shown as voice: true in GET /numbers. Calls themselves are PLANNED |
| Place and receive voice calls | PLANNED | section 9. /voice/* returns 404 until it ships |
| Event stream (WebSocket) | PLANNED | section 10. Use polling or webhooks today |
| Buy a number by API, top up by API | PLANNED | section 10 |
If a path is marked PLANNED and you call it, expect 404. Treat a 404 on /voice/* as "not
available yet", not as a bug in your code.
1. The offer in six sentences
- nothanumber sells real phone numbers, SMS and (soon) voice calls to software, including AI agents.
- You are an invited customer. Accounts are created by an administrator; there is no public sign-up.
- You pre-pay. Charges come out of a wallet. When the wallet cannot cover a message, the API
refuses it with
402. Nothing is billed on credit unless the administrator has set a credit limit. - An SMS is charged per segment at the price agreed for your account. The price is returned in every send response, so you always know what you paid.
- A two-way number is a normal phone number that can send and receive texts, and, if the administrator has turned voice on for it, make and receive calls. Calls can only ever be made from a number we assigned to you, so you cannot spoof a caller ID, and without an assigned two-way number there is no voice at all.
- An alphanumeric sender ID (for example
ACME) is a brand name you may send from. It is one-way: people cannot reply to it, and it cannot make calls.
1a. Getting access (invite-only for now)
There is no public sign-up while we are in a controlled launch. To request an account, email support@nothanumber.com with: who you are (company or project), what your agent will do (for example appointment reminders, one-time passcodes, customer service calls), the countries you will send to, and expected volume. We may ask for identity and business details before activating an account. You will receive an API key and at least one assigned number. Pricing is agreed per account.
2. Quick start (do these in order)
You need two things from the administrator first: an API key (nthk_live_<id>_<secret>) and at
least one sender: a two-way number (e.g. +447537000002) or an approved sender ID.
Set the key in an environment variable. Never put it in code, logs or a URL.
export NTH_API_KEY='nthk_live_…'
export NTH=https://app.nothanumber.com/api/v1/wholesale
Step 1. Check the key works and see your money. Free and read-only. Do this first.
curl -sS "$NTH/balance" -H "Authorization: Bearer $NTH_API_KEY"
# {"balanceMinor":5000,"creditLimitMinor":0,"availableMinor":5000,"currency":"GBP",
# "lowBalance":false,"lowBalanceThresholdMinor":500,"topUpUrl":null}
200 means the key is good. 401 means it is not (section 7).
Step 2. See which senders you can use and what each can do.
curl -sS "$NTH/numbers" -H "Authorization: Bearer $NTH_API_KEY"
# {"numbers":[{"e164":"+447537000002","countryCode":"GB","twoWay":true,"sms":true,"voice":false}],
# "senderIds":[{"senderId":"ACME","twoWay":false,"sms":true,"voice":false}]}
Use one of these values as from. Anything else gets 403.
Step 3. Send one SMS to a phone you control.
curl -sS -X POST "$NTH/sms" \
-H "Authorization: Bearer $NTH_API_KEY" -H "content-type: application/json" \
-d '{"from":"+447537000002","to":"+447700900123","body":"Hello from my agent","clientRef":"test-1"}'
# 201 {"id":"6f1c…","status":"SENT","from":"+447537000002","to":"+447700900123",
# "segments":1,"encoding":"GSM7","priceMinor":3,"currency":"GBP",
# "availableMinor":4997,"lowBalance":false}
Step 4. Confirm delivery. status moves SENT → DELIVERED (or FAILED).
curl -sS "$NTH/sms/6f1c…" -H "Authorization: Bearer $NTH_API_KEY"
Step 5. Read replies. Text your number from your phone, then:
curl -sS "$NTH/sms?direction=inbound&limit=10" -H "Authorization: Bearer $NTH_API_KEY"
3. Authentication
Two ways. Use one per request. Both are checked against the database on every request, so revoking a credential takes effect on the very next call.
3a. API key (simplest; use this unless told otherwise)
Authorization: Bearer nthk_live_<id>_<secret>
The key is shown to you once; we keep only a hash. Treat it like a password: anyone holding it can spend your balance. If it leaks, ask the administrator to revoke it and issue a new one.
3b. Signed requests (more secure; optional)
For when you can hold a private key. Send four headers on every request: X-Nth-Key-Id,
X-Nth-Timestamp (Unix seconds, within 5 minutes), X-Nth-Nonce (16-64 chars of A-Za-z0-9_-, never
reused) and X-Nth-Signature (base64 Ed25519 / ECDSA P-256 signature, or HMAC-SHA256, of this string
joined with \n, no trailing newline):
NTH-SIG-V1
<HTTP METHOD, uppercase>
<path plus query string exactly as sent, e.g. /api/v1/wholesale/sms?limit=10>
<timestamp>
<nonce>
<lowercase hex SHA-256 of the exact request body bytes; SHA-256 of "" for GET>
Prefer the API key unless you can protect a private key.
Auth failures are deliberately vague
Every authentication failure is the same response: 401 {"error":"Request signature not accepted"}.
Do not try to tell the causes apart from the response. Check, in this order: the key is complete and
unmodified; it is sent as Authorization: Bearer <key> (not in a URL or other header); you did not
also send a signature for an API key; the administrator has not revoked, paused or expired it.
Reference helper (copy this)
// Node 18+. Reads the key from the environment.
const BASE = "https://app.nothanumber.com/api/v1/wholesale";
const KEY = process.env.NTH_API_KEY;
export async function nth(method, path, payload) { // path like "/sms" or "/sms?limit=10"
const res = await fetch(BASE + path, {
method,
headers: { authorization: `Bearer ${KEY}`, ...(payload ? { "content-type": "application/json" } : {}) },
body: payload ? JSON.stringify(payload) : undefined,
});
return { status: res.status, retryAfter: res.headers.get("retry-after"), json: await res.json().catch(() => null) };
}
import os, requests
BASE = "https://app.nothanumber.com/api/v1/wholesale"
def nth(method, path, payload=None):
return requests.request(method, BASE + path, json=payload,
headers={"Authorization": f"Bearer {os.environ['NTH_API_KEY']}"}, timeout=30)
4. Concepts you must get right
- Sender = what you put in
from. Either a two-way number (E.164, e.g.+447537000002) or an alphanumeric sender ID (e.g.ACME, up to 11 letters, digits or spaces). Only senders the administrator assigned to your account work. - E.164 = international phone format:
+, country code, number, no spaces.+447700900123.07700 900123is wrong; always include the country code. - Capabilities. Each entry in
GET /numbershassmsandvoicebooleans.voice: truemeans the number may place and receive calls (once voice ships). Sender IDs are alwaysvoice: false. Calls can only be made from a number assigned to your account withvoice: true; see section 9. - Segment. One billed SMS unit. A plain-text (GSM-7) message is 160 characters, or 153 per part
when longer. A message with emoji or other non-GSM characters (UCS-2) is 70, or 67 per part. You
may send 1 to 3 segments: up to 459 plain characters, or 201 with emoji. Longer is refused with
400. - Wallet.
balanceMinoris your balance.creditLimitMinoris how far below zero you may go (normally0: you pre-pay).availableMinor = balanceMinor + creditLimitMinoris what you can spend. IfavailableMinoris less than the price, the send is refused. clientRef. Your own label (up to 100 characters) echoed back on the message and its receipts. It is not an idempotency key (section 8).
5. Endpoint reference (all LIVE)
All paths are relative to the base URL. All need authentication (section 3).
POST /sms: send one SMS
Request:
{ "from": "+447537000002", "to": "+447700900123", "body": "Your code is 123456", "clientRef": "order-42" }
| Field | Rules |
|---|---|
from |
required; one of your senders (section 4). Case-insensitive for sender IDs |
to |
required; E.164 international number. Premium-rate and special-service numbers are refused with 400 |
body |
required; 1 to 3 segments (section 4) |
clientRef |
optional; up to 100 characters |
Success 201:
{ "id": "6f1c…", "status": "SENT", "from": "+447537000002", "to": "+447700900123",
"segments": 1, "encoding": "GSM7", "priceMinor": 3, "currency": "GBP",
"availableMinor": 4997, "lowBalance": false }
availableMinor is what you can still spend after this message. lowBalance is true when it is at
or below the low-balance threshold (500 minor units, i.e. £5.00, unless agreed otherwise): when you
see it, ask a human to top up soon, before sends start failing.
The charge is taken when the gateway accepts the message. A later delivery failure is not refunded. Sending to another nothanumber number is delivered internally.
GET /sms/{id}: one message
Returns { "message": <Message> }, or 404 if it is not yours.
GET /sms: list messages, newest first
Query: direction=inbound|outbound, since=<ISO date>, limit=1..100 (default 50), cursor=<id>.
Returns { "messages": [<Message>, …], "nextCursor": "<id>" | null }. Pass nextCursor back as
cursor to get the next page; null means no more. This is also how you recover anything a webhook
missed.
Message object
{ "id": "6f1c…", "direction": "outbound", "from": "ACME", "to": "+447700900123",
"body": "Your code is 123456", "status": "DELIVERED", "segments": 1, "encoding": "GSM7",
"priceMinor": 3, "currency": "GBP", "clientRef": "order-42", "errorCode": null,
"sentAt": "2026-11-08T12:00:00.000Z", "deliveredAt": "2026-11-08T12:00:04.000Z" }
direction is outbound or inbound. status is one of:
| status | meaning |
|---|---|
QUEUED |
accepted, not yet with the network |
SENT |
with the network, waiting for a receipt |
DELIVERED |
delivered to the handset |
FAILED |
not delivered; see errorCode |
RECEIVED |
an inbound text (direction inbound) |
GET /numbers: your senders and what each can do
{ "numbers": [ { "e164": "+447537000002", "countryCode": "GB", "twoWay": true, "sms": true, "voice": true } ],
"senderIds": [ { "senderId": "ACME", "twoWay": false, "sms": true, "voice": false } ] }
Only active numbers and un-revoked sender IDs are listed.
GET /balance
{ "balanceMinor": 4950, "creditLimitMinor": 0, "availableMinor": 4950, "currency": "GBP",
"lowBalance": false, "lowBalanceThresholdMinor": 500, "topUpUrl": null }
A negative balanceMinor is money you owe (only possible with a credit limit). lowBalance is true
when availableMinor is at or below lowBalanceThresholdMinor. topUpUrl is a link where the
account owner can add money, or null if there is none yet (then ask the administrator).
6. Pricing and billing
- SMS out: your agreed price per segment for the destination country, taken when the message is
accepted. The exact charge is
priceMinorin the send response. - SMS in: free unless your agreement says otherwise.
- Numbers: a monthly rental, usually taken from the wallet when the number is assigned (the administrator can waive the first month) and then monthly. Turning voice on does not change the rental.
- Voice (PLANNED): charged at the same per-minute rates as the nothanumber app, by destination (including the on-net rate for calls to other nothanumber numbers). Calls are billed in whole minutes, rounded up, with a one-minute minimum for any answered call. Calls that are not answered are not charged. There is no monthly fee for enabling voice: you pay the number's monthly rental and the per-minute call charges, nothing else.
- If a charge cannot be covered the request is refused (
402); you are never silently overdrawn. - Your price list is agreed with the administrator. There is no public price list yet.
7. Errors: what each means and what to do
Errors are JSON: { "error": "<message or object>", …details }.
| HTTP | Meaning | What you should do | Retry? |
|---|---|---|---|
| 400 | A field is invalid, or the message is over 3 segments | Fix the request using the error details |
No |
| 401 | Not authenticated (always the same vague message) | Check the key (section 3). Do not loop | No |
| 402 | Not enough available balance. Body has code (wallet_empty for a prepaid account, credit_limit_reached for one on credit), retryable: false, balanceMinor, creditLimitMinor, availableMinor, requiredMinor, currency, topUpUrl (or null) and nextStep |
Stop sending. Tell a human to top up the wallet (use topUpUrl if present), then retry. Do not retry in a loop |
After a top-up |
| 403 | from is not one of your senders |
Call GET /numbers and use a listed value |
No |
| 404 | Not found, or not yours | Check the id | No |
| 422 | No price is configured for that destination | Ask the administrator to add a price | No |
| 429 | Rate limit, or another send from your account is in progress. Has a Retry-After header (seconds) |
Wait Retry-After, then retry the same request |
Yes |
| 502 | Our SMS gateway is down. Nothing was sent or charged | Retry with exponential backoff (1s, 2s, 4s … up to 5 times) | Yes |
| 503 | The API is switched off or briefly unavailable | Retry later with backoff | Yes |
8. Limits, retries and idempotency
- Rate limit: 60 requests per minute per account by default (higher on request).
- Sends are processed one at a time per account. Send sequentially, or treat
429as normal and retry. - There is no idempotency key. If a
POST /smstimes out or you lose the response, do not just resend: you may send, and pay for, the text twice. First look for it:GET /sms?direction=outbound&since=<a minute before you sent>and match onclientRef,toandbody. Put a uniqueclientRefon every send so this lookup is reliable. - A
502means nothing was sent, so resending is safe. A429means the request was not processed.
9. Voice (PLANNED: draft contract, do not call yet)
Everything in this section is a draft design so you can plan. These endpoints do not exist yet and return
404. Details may change before launch; this section will be updated when voice ships.
Model. You never handle phone signalling. You ask for a call over HTTP, then open one outgoing WebSocket that carries the call's audio both ways. It works from a laptop behind a home router: no inbound ports, no VPN, no SIP.
Prerequisite and the caller ID rule (a security rule, not negotiable). You can only call from a
two-way number that we assigned to your account, with voice: true in GET /numbers. Therefore:
- No assigned two-way number means no voice at all. Ask the administrator for a number first, then ask them to enable voice on it (a per-number switch; the monthly rental is unchanged).
- You cannot choose or change the caller ID. The caller ID of every call is the number you called
from, andfrommust be one of your voice-enabled numbers. There is no caller ID override, no "bring your own number", and no anonymous or withheld caller ID. A sender ID such asACMEcan never make calls. - Anything else in
fromis refused with403and acode:from_not_permitted(not one of your numbers, or not active) orvoice_not_enabled(your number, but voice is off: ask the administrator). Do not retry with another value to "find one that works".
Place a call.
POST /voice/calls
{ "from": "+447537000002", "to": "+447700900123", "clientRef": "call-1",
"audio": { "format": "pcm16_16000" } }
201 { "id": "call_…", "status": "QUEUED", "from": "…", "to": "…", "holdMinor": 150, "currency": "GBP" }
audio.format is pcm16_16000 (16-bit little-endian PCM, 16 kHz, mono; best for speech models) or
mulaw_8000 (G.711 µ-law, 8 kHz, mono; the phone network's native format).
Open the audio stream.
POST /stream-token { "purpose": "call", "callId": "call_…" }
200 { "url": "wss://…", "token": "…", "expiresInSeconds": 60 }
Open the WebSocket to url with header Sec-WebSocket-Protocol: nth.v1, <token>. The token works once and
expires in 60 seconds. Never put your API key in a WebSocket URL.
Messages are JSON text frames. Server to you:
{ "event": "started", "callId": "call_…" }
{ "event": "media", "seq": 42, "audio": "<base64 audio in the call's format>" }
{ "event": "dtmf", "digit": "5" }
{ "event": "ended", "reason": "completed", "durationSeconds": 73, "priceMinor": 120, "currency": "GBP" }
You to server:
{ "event": "media", "audio": "<base64 audio>" }
{ "event": "clear" }
{ "event": "dtmf", "digits": "123#" }
{ "event": "hangup" }
clear drops any audio you queued but that has not played yet. Use it when the caller interrupts
(barge-in) so your agent stops talking immediately. Send audio in chunks of about 20 ms and keep
the stream real-time; sending much faster than real time is not buffered.
ended.reason is one of completed, no_answer, busy, failed, hangup, insufficient_balance,
max_duration.
Money and limits (voice). When a call starts we hold its maximum cost from your wallet, then
release the unused part when it ends. A call ends by itself when its hold is used up
(insufficient_balance). At first each account can run one call at a time. Place a call when
availableMinor is too low and you get 402, exactly as with SMS.
Incoming calls. When someone calls a voice-enabled number you receive a call.incoming event
({ callId, from, to }) on the event stream (section 10) and have a short time to answer:
POST /voice/calls/{id}/answer then open the audio stream as above
POST /voice/calls/{id}/reject
Read calls. GET /voice/calls/{id} and GET /voice/calls?since=…&cursor=…, shaped like the SMS reads.
10. Other PLANNED features
- Event stream. One outgoing WebSocket (
POST /stream-tokenwith{"purpose":"events"}) that delivers inbound texts, delivery receipts and call events, so a laptop needs no public webhook. Events are stored for 48 hours; reconnect with?after=<eventId>or pollGET /events?after=<eventId>to catch up on anything missed while asleep or offline. - Buy and release numbers by API, paid from the wallet.
- Card top-up by link.
topUpUrlalready appears inGET /balanceand in402responses but isnulluntil card top-up is switched on; until then the administrator adds money for you. - Per-key daily spend cap. Hitting it will return
429with the codekey_spend_cap_reachedso you do not mistake it for an empty wallet (402,wallet_empty). - MCP server wrapping these endpoints as tools, an OpenAPI file and
llms.txt.
Until then use polling or webhooks.
11. Receiving texts and delivery receipts today
Option A: poll (works from anywhere, nothing to host). Every few seconds call
GET /sms?direction=inbound&since=<time of the newest message you have seen> and de-duplicate on id.
Delivery receipts: re-read the outbound message with GET /sms/{id} until status is DELIVERED or FAILED.
Option B: webhooks. The administrator sets a callback URL (public https://, port 443; private,
Tailscale 100.x and localhost addresses are refused). We POST JSON there:
{ "type": "message.status", "id": "6f1c…", "clientRef": "order-42", "status": "DELIVERED",
"from": "ACME", "to": "+447700900123", "deliveredAt": "…", "errorCode": null, "timestamp": "…" }
{ "type": "message.inbound", "id": "a81d…", "from": "+447700900300", "to": "+447537000002",
"body": "STOP", "segments": 1, "receivedAt": "…", "timestamp": "…" }
Headers: x-nothanumber-event, x-nothanumber-timestamp (Unix seconds),
x-nothanumber-signature: sha256=<hex HMAC-SHA256(webhook secret, timestamp + "." + rawBody)>.
Verify the signature over the raw body before trusting it, compare in constant time, and reject
timestamps over 5 minutes old. Reply 2xx within 5 seconds. Failures retry after 1, 5, 15, 60 and 180
minutes, then are dropped, so de-duplicate on id and run the polling call now and then to catch gaps.
If your agent runs on a laptop, use polling.
12. Checklist before you call it done
GET /balancereturns200with the key from an environment variable (not hard-coded).GET /numberswas called andfromcomes from that list.- Numbers are E.164 (
+and country code). Money is integer minor units. - Every
POST /smshas a uniqueclientRef. 429and502/503are retried with backoff;400/401/402/403/422are not retried blindly.- A timed-out
POST /smsis reconciled by listing messages, not blindly resent. - On
402the agent stops and asks a human to top up; it does not loop. It also watcheslowBalanceand asks for a top-up before the wallet is empty. - Replies and receipts are read by polling, or by a verified webhook with de-duplication on
id. - The first real test goes to a phone you control, one message.
- You honour
STOPand other opt-outs on your side.
13. Common mistakes
- Sending from a number you merely own elsewhere. Only senders listed by
GET /numberswork. - Using
07700…instead of+447700…. - Putting the key in a URL, a query string, logs or a repository.
- Re-serialising the JSON after signing it (signed requests only).
- Treating
priceMinor: 3as £3. It is 3 pence. - Expecting a reply to an alphanumeric sender ID. It is one-way.
- Calling
/voice/*today. It is not live; it returns404. - Trying to call "from" a sender ID, a number you own elsewhere, or any value that is not one of your voice-enabled numbers. The caller ID cannot be set to anything else.
- Looping on
402. Only a human top-up fixes it.
14. Machine-readable endpoint index
{
"baseUrl": "https://app.nothanumber.com/api/v1/wholesale",
"auth": { "bearer": "Authorization: Bearer nthk_live_<id>_<secret>", "signed": "X-Nth-* headers, see section 3b" },
"moneyUnit": "minor units (integers)",
"endpoints": [
{ "method": "GET", "path": "/balance", "status": "live" },
{ "method": "GET", "path": "/numbers", "status": "live" },
{ "method": "POST", "path": "/sms", "status": "live" },
{ "method": "GET", "path": "/sms", "status": "live" },
{ "method": "GET", "path": "/sms/{id}", "status": "live" },
{ "method": "POST", "path": "/stream-token", "status": "planned" },
{ "method": "GET", "path": "/events", "status": "planned" },
{ "method": "POST", "path": "/voice/calls", "status": "planned" },
{ "method": "GET", "path": "/voice/calls", "status": "planned" },
{ "method": "GET", "path": "/voice/calls/{id}", "status": "planned" },
{ "method": "POST", "path": "/voice/calls/{id}/answer", "status": "planned" },
{ "method": "POST", "path": "/voice/calls/{id}/reject", "status": "planned" }
],
"retryable": [429, 502, 503],
"neverRetryBlindly": [400, 401, 402, 403, 404, 422]
}
15. Glossary
- E.164: international phone number format,
+<country code><number>. - Minor units: the smallest currency unit (pence, cents). Money in this API is always an integer of these.
- Segment: one billed SMS unit (section 4).
- Two-way number: a number that can send and receive texts (and calls, if voice is enabled).
- Sender ID: a brand name used as the sender of an SMS; one-way.
- Wallet / balance: prepaid money from which charges are taken.
- Hold: money reserved while a call is in progress, released when it ends.
- DTMF: the tones a caller makes by pressing keypad digits.
- Barge-in: the caller speaking over your agent; use
clearto stop your audio. - Webhook: a message we send to your web address when something happens.