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: 3 means £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

  1. nothanumber sells real phone numbers, SMS and (soon) voice calls to software, including AI agents.
  2. You are an invited customer. Accounts are created by an administrator; there is no public sign-up.
  3. 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.
  4. 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.
  5. 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.
  6. 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 900123 is wrong; always include the country code.
  • Capabilities. Each entry in GET /numbers has sms and voice booleans. voice: true means the number may place and receive calls (once voice ships). Sender IDs are always voice: false. Calls can only be made from a number assigned to your account with voice: 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. balanceMinor is your balance. creditLimitMinor is how far below zero you may go (normally 0: you pre-pay). availableMinor = balanceMinor + creditLimitMinor is what you can spend. If availableMinor is 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 priceMinor in 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 429 as normal and retry.
  • There is no idempotency key. If a POST /sms times 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 on clientRef, to and body. Put a unique clientRef on every send so this lookup is reliable.
  • A 502 means nothing was sent, so resending is safe. A 429 means 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, and from must 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 as ACME can never make calls.
  • Anything else in from is refused with 403 and a code: from_not_permitted (not one of your numbers, or not active) or voice_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-token with {"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 poll GET /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. topUpUrl already appears in GET /balance and in 402 responses but is null until card top-up is switched on; until then the administrator adds money for you.
  • Per-key daily spend cap. Hitting it will return 429 with the code key_spend_cap_reached so 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

  1. GET /balance returns 200 with the key from an environment variable (not hard-coded).
  2. GET /numbers was called and from comes from that list.
  3. Numbers are E.164 (+ and country code). Money is integer minor units.
  4. Every POST /sms has a unique clientRef.
  5. 429 and 502/503 are retried with backoff; 400/401/402/403/422 are not retried blindly.
  6. A timed-out POST /sms is reconciled by listing messages, not blindly resent.
  7. On 402 the agent stops and asks a human to top up; it does not loop. It also watches lowBalance and asks for a top-up before the wallet is empty.
  8. Replies and receipts are read by polling, or by a verified webhook with de-duplication on id.
  9. The first real test goes to a phone you control, one message.
  10. You honour STOP and other opt-outs on your side.

13. Common mistakes

  • Sending from a number you merely own elsewhere. Only senders listed by GET /numbers work.
  • 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: 3 as £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 returns 404.
  • 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 clear to stop your audio.
  • Webhook: a message we send to your web address when something happens.