Skip to content

API reference/phone

Monitor a phone list on a schedule

POST/api/v1/phone/monitors

authentication
Secret API key as a bearer token
billing
Creation is free. Each run bills every number like a single lookup: 1 credit for a fresh valid answer, plus the live-check credits when the check answered; invalid, unassigned, suppressed and 7-day repeats are free, the base credit is shared with any other lookup of the number in the last 7 days, and a check the account paid for in the last 7 days is free while the network's answer is held (a day).

Saves up to 500 numbers and re-checks them every week or every month. The first run starts right away and only sets the baseline; from the next run on, every number that was valid last time and is now invalid, in a block nobody holds or unreachable counts as decayed and is reported to the account's email; a number you suppressed yourself (an import, your own failure or abuse reports, an opt-out) answers suppressed and is not decay. A number that still answers valid but changed is flagged and reported in a second email: an SMS-receiving website started listing it, three or more accounts reported abuse, its risk rose to high, or its line type changed — and with the live check on, it was ported or another network serves it (see PhoneMonitorFlag; this is not SIM-swap detection). Runs honour the suppression list and the 7-day repeat cache. With live_check true, runs ask for the live carrier check whenever the service has it enabled, so decay also covers handsets that stopped answering. There is no daily cadence: most regulator tables behind a run refresh weekly (NANPA's and the SMS-receiving lists daily), a weekly run already catches a change within days, and a daily live check would pay for the network seven times a week.

Answers 201 with the monitor. The baseline run is queued, not finished: poll the monitor for last_run_at and last_summary.

With a webhook_url, a finished run is POSTed there as { "event": "phone_monitor.run", "monitor": { "id", "name", "cadence", "last_run_at", "next_run_at" }, "summary": { … the same block as last_summary … }, "changes": [ … ], "flags": { … } }, where changes names the numbers the two alert emails name — decayed and newly flagged, in list order, each once — and flags maps each newly flagged number to the flags it raised ({} when none did). The X-Spaw-Signature-V2 header carries t={unix seconds},v2={lowercase hex}, where the hex is the HMAC-SHA256 of v2:{t}:{raw body} keyed with webhook_secret, which this response returns once; a receiver should refuse anything whose t is more than five minutes from its own clock, since a signature over the body alone would let a captured delivery be replayed at it forever. The original X-Spaw-Signature, that HMAC of the body alone, is sent beside it until 1 March 2027. Connection errors and 5xx answers are retried twice with a short backoff, three attempts in all, and the outcome is readable afterwards as webhook_status and webhook_detail. A URL that resolves onto a private network is never posted to.

Request body

field type description
namerequiredstringA label for the dashboard and the decay alerts. At most 100 characters.
numbersrequiredstring[]1 to 500 numbers in any notation; each item at most 64 characters. Each is saved in E.164, read against `country` where it has no calling code, so two spellings of one number are one entry and the limit counts distinct numbers; an entry that does not parse is saved as typed and answers invalid on every run.
cadencerequiredstringHow often the list is re-checked. There is no daily option: most regulator tables refresh weekly (NANPA's and the SMS-receiving lists daily), a weekly run already catches a number that stopped being valid or started being listed within days, and a daily live check would pay for the network seven times a week. One of: weekly, monthly.
countrystring | nullThe region for numbers written without a calling code.
live_checkbooleanAsk for the live carrier check on every run where the service has it enabled. Default: .
webhook_urlstring | nullAn https URL every reported run is POSTed to. The signing secret is returned once, in this response, and never again. At most 2,048 characters.
webhook_eventsstringWhen to post. `changes` posts only when the run found something new — decay, a status change, a newly flagged entry — or stopped early; `every_run` posts after every run, which also says the monitor is still running. One of: changes, every_run. Default: changes.

Example request

curl -X POST https://spaw.co/api/v1/phone/monitors \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "SMS audience",
  "numbers": [
    "+44 7911 012345",
    "020 3013 0018"
  ],
  "cadence": "monthly",
  "country": "GB"
}'
const response = await fetch('https://spaw.co/api/v1/phone/monitors', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer sk_live_…',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "name": "SMS audience",
    "numbers": [
      "+44 7911 012345",
      "020 3013 0018"
    ],
    "cadence": "monthly",
    "country": "GB"
  }),
});
const result = await response.json();
import requests

response = requests.post(
    'https://spaw.co/api/v1/phone/monitors',
    headers={'Authorization': 'Bearer sk_live_…'},
    json={
        'name': 'SMS audience',
        'numbers': [
            '+44 7911 012345',
            '020 3013 0018'
        ],
        'cadence': 'monthly',
        'country': 'GB'
    },
)
result = response.json()
$ch = curl_init('https://spaw.co/api/v1/phone/monitors');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS => json_encode([
        'name' => 'SMS audience',
        'numbers' => [
            '+44 7911 012345',
            '020 3013 0018'
        ],
        'cadence' => 'monthly',
        'country' => 'GB'
    ]),
]);
$result = json_decode(curl_exec($ch), true);
from spaw import Client

client = Client('sk_live_…')
result = client.create_phone_monitor(name='SMS audience', numbers=[
    '+44 7911 012345',
    '020 3013 0018'
], cadence='monthly', country='GB')
import Spaw from 'spaw';

const spaw = new Spaw({ apiKey: 'sk_live_…' });
const result = await spaw.createPhoneMonitor({
    name: 'SMS audience',
    numbers: [
        '+44 7911 012345',
        '020 3013 0018'
    ],
    cadence: 'monthly',
    country: 'GB'
});
use Spaw\Client;

$spaw = new Client('sk_live_…');
$result = $spaw->createPhoneMonitor(name: 'SMS audience', numbers: [
    '+44 7911 012345',
    '020 3013 0018'
], cadence: 'monthly', country: 'GB');

This endpoint has no console on its page. It writes to your account. A documentation page can show you the request; making the change is for the dashboard or for a call you make yourself.

Responses

201The monitor was saved and its baseline run queued.

{
    "success": true,
    "data": {
        "monitor": {
            "id": 7,
            "name": "SMS audience",
            "cadence": "monthly",
            "number_count": 2,
            "country": "GB",
            "live_check": false,
            "next_run_at": "2026-10-05T10:12:44+00:00",
            "last_run_at": null,
            "last_summary": null,
            "created_at": "2026-09-05T10:12:44+00:00"
        }
    }
}

401The key is missing, malformed, or revoked.

{
    "success": false,
    "error": {
        "code": "UNAUTHENTICATED",
        "message": "Provide a valid API key as a bearer token.",
        "request_id": "req_01m1kgdm4xngzmbmff68g94w0c"
    }
}

422The request body could not be validated; `error.errors` lists the fields.

{
    "success": false,
    "error": {
        "code": "VALIDATION_FAILED",
        "message": "The email field is required.",
        "errors": {
            "email": [
                "The email field is required."
            ]
        },
        "request_id": "req_01m1kgdm4xngzmbmff68g94w0c"
    }
}

429Over 5 requests per second for the key. Retry after the limit resets.

{
    "success": false,
    "error": {
        "code": "RATE_LIMITED",
        "message": "Too many requests. Retry after the limit resets.",
        "request_id": "req_01m1kgdm4xngzmbmff68g94w0c"
    }
}

Error codes

Failures answer { success: false, error: { code, message, request_id } }. Each code has its own page.

markdown version·openapi.json