diff --git a/apps/web/src/pages/admin/TrunksPage.tsx b/apps/web/src/pages/admin/TrunksPage.tsx
index db7ef99..ec65623 100644
--- a/apps/web/src/pages/admin/TrunksPage.tsx
+++ b/apps/web/src/pages/admin/TrunksPage.tsx
@@ -591,6 +591,11 @@ function VoipmsChecklist() {
G.711u, DTMF RFC 2833, NAT Yes, CallerID number = your main
number, international calling off unless you need it.
+
+ Turn on IP restriction (edit the sub-account → Security) and allow only
+ this server's public IP. A stolen password then can't make calls from anywhere
+ else. If your public IP changes, update it there too.
+
Pick a POP server close to you (e.g. chicago1.voip.ms). Enter the same
one below.
diff --git a/docs/admin-guide/maintenance.md b/docs/admin-guide/maintenance.md
index fadd4d0..0c3858f 100644
--- a/docs/admin-guide/maintenance.md
+++ b/docs/admin-guide/maintenance.md
@@ -142,7 +142,7 @@ backup.
3. Run `./scripts/install.sh`. It keeps the existing `.env`.
4. Run `./scripts/restore.sh `.
5. If the public IP changed: update it on **Settings → Network**, change your DNS record and any
- IP-based settings at voip.ms (IP authentication, API whitelist), then
+ IP-based settings at voip.ms (IP restriction, IP authentication, API whitelist), then
`docker compose restart asterisk`.
6. Desk phones fetch their setup from the host name, so they follow the DNS change.
diff --git a/docs/admin-guide/network.md b/docs/admin-guide/network.md
index 555decd..502fcf8 100644
--- a/docs/admin-guide/network.md
+++ b/docs/admin-guide/network.md
@@ -345,6 +345,9 @@ Someone with a stolen SIP password calling expensive international numbers is th
real money. Keep:
- international calling **off** at voip.ms and in **Outbound rules** unless you need it;
+- **IP restriction** on the voip.ms sub-account, allowing only your server's public IP: a stolen
+ trunk password used from another server skips every limit below
+ ([voip.ms setup](voipms.md#1-in-the-voipms-portal));
- the voip.ms **balance alerts and spending limits** on;
- the **desk phone network limit** above on if your phones are all in the office;
- the **outside calls at once** limits (Settings → Security) as low as your office needs: per
diff --git a/docs/admin-guide/voipms.md b/docs/admin-guide/voipms.md
index abb6fbc..78da0a9 100644
--- a/docs/admin-guide/voipms.md
+++ b/docs/admin-guide/voipms.md
@@ -18,6 +18,11 @@ then; if a menu name below doesn't match, look for the closest one.
- **International calling:** leave it **off** unless you need it. It's your best protection
against toll fraud if a password ever leaks.
- **Encrypted SIP traffic (TLS/SRTP):** optional; see [SIP over TLS](#sip-over-tls).
+ - **IP restriction** (edit the sub-account → Security): turn it **on** and allow only your
+ server's public IP. voip.ms then refuses outgoing calls from anywhere else, so a stolen
+ sub-account password can't be used from the thief's own server, where none of SelfPhoneNG's
+ limits apply. It needs a fixed public IP: if yours changes, outgoing calls fail until you
+ update it here (SelfPhoneNG emails admins when it sees the change).
2. **Pick a POP server** near the PBX, e.g. `chicago1.voip.ms`. Write it down: the trunk and every
number must use the same one.
3. **Point each number at the sub-account** (DID Numbers → Manage DIDs → edit the number):
diff --git a/docs/spec/06-voipms.md b/docs/spec/06-voipms.md
index 314c922..699cc30 100644
--- a/docs/spec/06-voipms.md
+++ b/docs/spec/06-voipms.md
@@ -19,6 +19,13 @@ trunk fallback (same fields, no voip.ms-specific help text or API features).
- Encrypted SIP traffic (TLS/SRTP): optional. If on, set our trunk transport to TLS, port 5061, SRTP on. Needs Settings → Security → Accept SIP over TLS (a certificate in `data/tls`, 08 §2.1) first. voip.ms's certificate names every POP, so the trunk checks it (`verify_server`).
- CallerID Number: your main DID.
- International calling: leave **disabled** unless needed (toll-fraud protection on their side too).
+ - IP restriction (owner, 2026-10-29; edit the sub-account → Security, **verify** the label):
+ **on**, allowing only the PBX's public IP. voip.ms then refuses outgoing calls from anywhere
+ else, so a stolen sub-account password can't be used from the thief's own server, which skips
+ every limit the PBX has (08 §8). Whether it also refuses a REGISTER from elsewhere (which would
+ take incoming calls) is unverified. Needs a fixed public IP: when it changes, outgoing calls
+ fail until the list is updated (`publicIpWatch` mails admins, 04 §8). IP authentication is
+ the same protection without a password.
2. **Choose a POP server** close to the PBX (e.g. `chicago1.voip.ms`). The sub-account registers to this server.
3. **Point each DID at the sub-account** (DID Numbers → Manage DIDs → edit):
- Routing: **SIP/IAX → the sub-account**
@@ -83,14 +90,25 @@ GET https://voip.ms/api/v1/rest.php
- Messages over 160 characters: use `method=sendMMS` (`message` up to 2048 chars **(verify limit)**),
plus `media1`…`media3` URLs for pictures. We split nothing ourselves.
- Numbers: voip.ms wants 10 digits for NANP, so strip the leading `1`.
-- The API password goes in a query string, so **never log the request URL**.
+- The API password goes in a query string, so **never log the request URL**. voip.ms now also
+ offers a Bearer token (owner, 2026-10-29); we keep `api_username`/`api_password` for now.
- Rate limit our sends to 1 per second per DID (queue in memory).
- Other methods used: `getDIDsInfo` (DID import), `getServersInfo` (server dropdown),
`getSMS` (optional polling fallback). All behind `voipmsApi(method, params)` with a 15 s timeout.
- `getBalance` (owner, 2026-10-21: the balance on Reports): `balance.current_balance`, a string
like `"12.3456"` in the account's currency **(verify the shape and that the API login needs
nothing more, with a real account)**. Each Reports load asks once per voip.ms trunk that has an
- API login.
+ API login. The fraud alerts ask every 15 minutes too (08 §8.3).
+- `getCDR` (owner, 2026-10-29: fraud alerts, 08 §8.3): `date_from`, `date_to` (YYYY-MM-DD, at most
+ 92 days), `answered`, `noanswer`, `busy`, `failed` (`1` each), `timezone=0` (times come back in
+ UTC, no zone suffix; left out, they're in the account's own zone, which no API call reports),
+ `account` (the sub-account). Answer `cdr: [{date: "2026-09-16 19:14:35", callerid, destination,
+ description, account, disposition, duration, seconds, rate, total, uniqueid}]`; no calls is
+ status `no_cdr`. voip.ms's example bundle (`docs/voip.ms-api`: `server.wsdl`,
+ `Examples REST for PHP/07.CDR.php`) confirms the request, `no_cdr` and the fields `date`,
+ `callerid`, `destination`, `description`, `duration`, `seconds`, `rate`, `total`.
+ `account`, `disposition` and `uniqueid` in the answer are **(verify)**; without them the
+ check still works (the request already names the sub-account).
### 5.2 Receiving
Endpoint: `GET|POST /hooks/voipms/sms/:token`. `:token` is a 32-char random value stored