inbound caller intelligence
Call numbers.online from your dialplan when an inbound call arrives to label, route, or challenge it before it reaches an extension. The API returns a signed risk assessment you can act on in real time.
Positioning. numbers.online provides advisory identity, risk, and preference signals. Your PBX/operator keeps the routing and blocking decision and remains responsible for compliance. The API never asserts that a call is lawful, unlawful, "safe", or "spam" — it returns a probabilistic risk signal and a recommended action.
POST https://numbers.online/api/v1/inbound/lookup
Authorization: Bearer <api_key> # or X-API-Key: <api_key>
Content-Type: application/json
{
"number": "+14155551212",
"context": "inbound_voice", # inbound_voice | inbound_sms | inbound_waba
"to": "+442071838750", # the receiver's own number (binds call-provenance)
"attestation": "A", # STIR/SHAKEN attestation level: A | B | C
"verstat": "TN-Validation-Passed" # or the SIP verstat token / header value
}
Response (signed):
{
"schema_version": "2026-05-31",
"result": "found",
"number": "+14155551212",
"identity_type": "verified_business",
"display_label": "Acme Support",
"profile_url": "https://numbers.online/business/acme-support-1a2b",
"personal_details_exposed": false,
"risk_score": 8,
"risk_level": "low",
"signals": ["verified listing", "0 in last 30 days"],
"recommended_action": "allow",
"ttl_seconds": 600,
"receipt_id": "nol_rec_…",
"response_signature": "ed25519:…"
}
risk_score is 0–100 where higher = worse (the inverse of the public trust
score). A verified individual returns display_label: "Verified & online" and
never a name. An unknown number returns result: "no_record", risk_score: null,
and recommended_action: "allow_with_default_policy".
recommended_action | Meaning | Suggested PBX behaviour |
|---|---|---|
allow | Verified + low risk | Ring normally, show verified label |
label | Show identity/risk, don't interrupt | Set CallerID name / agent banner |
challenge_or_route | Elevated risk | IVR challenge, voicemail, or queue |
block_candidate | Very high risk (paid tiers only) | Disabled by default; admin opt-in |
allow_with_default_policy | No record | Your local default — imply nothing |
Fail open. If the lookup times out or errors, treat the call as
allow_with_default_policy and continue. Never drop a call because a lookup failed.
CURL() in extensions.conf (quickest)[from-trunk]
exten => _X.,1,NoOp(numbers.online inbound lookup)
same => n,Set(NOL=${CURL(https://numbers.online/api/v1/inbound/lookup,{"number":"${CALLERID(num)}"\,"context":"inbound_voice"})})
same => n,Set(NOL_ACTION=${REGEX("\"recommended_action\":\"([^\"]+)\"" ${NOL})})
same => n,Set(NOL_LABEL=${REGEX("\"display_label\":\"([^\"]+)\"" ${NOL})})
; fail open: empty response → just continue
same => n,GotoIf($["${NOL}" = ""]?normal)
same => n,GotoIf($["${NOL_ACTION}" = "challenge_or_route"]?challenge)
same => n,Set(CALLERID(name)=${NOL_LABEL})
same => n(normal),Dial(SIP/extension,30)
same => n,Hangup()
same => n(challenge),Answer()
same => n,Background(privacy-unident)
same => n,Goto(normal)
CURL()is documented as performing an HTTP GET by default and supports POST data. Setcurltimeout/conntimeoutso a slow lookup can't stall the dialplan.
Use an AGI program (/var/lib/asterisk/agi-bin/nol-lookup.sh) that POSTs with a
short curl --max-time, parses the JSON, and sets channel variables
(NOL_ACTION, NOL_RISK, NOL_LABEL, NOL_RECEIPT). On any non-zero curl exit,
emit SET VARIABLE NOL_ACTION allow_with_default_policy and exit 0 (fail open).
AGI connects the dialplan to external programs; ARI is available for
asynchronous control if you need it.
Inside a trusted network you can carry the result on the SIP leg. These are
advisory metadata, not a public trust mechanism — untrusted senders can forge
headers (see RFC 8224), so strip all inbound X-NumbersOnline-* headers at your
edge and inject fresh ones only after your own lookup.
same => n,SIPAddHeader(X-NumbersOnline-Action: ${NOL_ACTION})
same => n,SIPAddHeader(X-NumbersOnline-Risk: ${NOL_RISK})
same => n,SIPAddHeader(X-NumbersOnline-Receipt: ${NOL_RECEIPT})
Honour ttl_seconds (60–600 by verification tier). Cache by E.164. For native
TTLs use REDIS()/REDIS_EXISTS(); astdb has no TTL so you must age entries
yourself.
numbers.online complements STIR/SHAKEN — it does not replace it. Pass the
attestation level you received as the top-level attestation field (A|B|C)
and/or the SIP verstat token as top-level verstat (a bare token e.g.
TN-Validation-Passed, a verstat=… parameter, or a full SIP/tel header value).
When present it is a supplementary signal: a verified call means our read is
trusted for that call, a failed validation is treated as a possibly-spoofed
origin, and corroborated failures across independent sources contribute to a
population-level spoofing-prevalence signal. Combine both for display:
Caller: Acme Support ✓ STIR/SHAKEN: A, verified numbers.online: verified_business, risk=low
| Tier | block_candidate | Rate limit |
|---|---|---|
| free | degraded to challenge_or_route | per-key/min |
| standard | yes | higher |
| enterprise | yes | custom |
Allowed: inbound call/message enrichment, call logs, softphone display, routing, user-managed blocking. Prohibited: bulk enumeration, lead generation, live-number validation, scraping, resale, or legally significant automated decisions without safeguards.
Every response carries response_signature: "ed25519:<base64>" over the canonical
JSON body (keys sorted, signature field excluded). Verify with the published
Ed25519 public key — example in Python:
import json, base64
from cryptography.hazmat.primitives.serialization import load_pem_public_key
pub = load_pem_public_key(open('inbound_public.pem', 'rb').read())
def verify(resp: dict) -> bool:
sig = resp.get('response_signature', '') # .get, not .pop — don't mutate the caller's dict
if not sig.startswith('ed25519:'):
return False
# Reproduce the server's canonical bytes: top-level keys sorted, compact, and
# raw UTF-8. Node's JSON.stringify does NOT \u-escape non-ASCII, so we MUST pass
# ensure_ascii=False (a business display_label may contain accents/CJK/emoji).
# Exclude the signature field itself.
body = {k: resp[k] for k in sorted(resp) if k != 'response_signature'}
canonical = json.dumps(body, separators=(',', ':'), ensure_ascii=False)
try:
pub.verify(base64.b64decode(sig[8:]), canonical.encode('utf-8'))
return True
except Exception:
return False
For API keys or higher rate limits: [email protected].