# nothanumber: full text for AI assistants and agents > nothanumber sells real UK phone numbers. For people it is a second-number app. For software and > AI agents it is an HTTP API for phone numbers, SMS (live) and voice calls (planned), paid from a > prepaid wallet. Access to the API is invite-only: email support@nothanumber.com. > Shorter overview: https://nothanumber.com/llms.txt. Machine-readable API description (OpenAPI 3.1): > https://nothanumber.com/developers/openapi.json. Human-readable reference: https://nothanumber.com/developers/api/. The complete API reference follows. --- # 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__`) 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. ```sh 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. ```sh 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.** ```sh 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.** ```sh 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`). ```sh curl -sS "$NTH/sms/6f1c…" -H "Authorization: Bearer $NTH_API_KEY" ``` **Step 5. Read replies.** Text your number from your phone, then: ```sh 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__ ``` 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 ``` 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 ` (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) ```js // 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) }; } ``` ```python 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: ```json { "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`: ```json { "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": }`, or `404` if it is not yours. ### `GET /sms`: list messages, newest first Query: `direction=inbound|outbound`, `since=`, `limit=1..100` (default 50), `cursor=`. Returns `{ "messages": [, …], "nextCursor": "" | 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 ```json { "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 ```json { "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` ```json { "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": "", …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=` 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, `. 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: ```json { "event": "started", "callId": "call_…" } { "event": "media", "seq": 42, "audio": "" } { "event": "dtmf", "digit": "5" } { "event": "ended", "reason": "completed", "durationSeconds": 73, "priceMinor": 120, "currency": "GBP" } ``` You to server: ```json { "event": "media", "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=` or poll `GET /events?after=` 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=