numbersonline
API reference/Integration guides/Asterisk / FreePBX integration
PBX & softphones

Asterisk / FreePBX integration

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.

Endpoint

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 actions

recommended_actionMeaningSuggested PBX behaviour
allowVerified + low riskRing normally, show verified label
labelShow identity/risk, don't interruptSet CallerID name / agent banner
challenge_or_routeElevated riskIVR challenge, voicemail, or queue
block_candidateVery high risk (paid tiers only)Disabled by default; admin opt-in
allow_with_default_policyNo recordYour 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.

Method 1 — 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. Set curltimeout/conntimeout so a slow lookup can't stall the dialplan.

Method 2 — AGI script (recommended for production)

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.

Propagating results downstream (SIP headers)

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})

Caching

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.

STIR/SHAKEN

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

Free-tier restrictions

Tierblock_candidateRate limit
freedegraded to challenge_or_routeper-key/min
standardyeshigher
enterpriseyescustom

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.

Verifying the signature

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].

Need a key? Get one self-service — it’s shown once and stored only as a hash. Browse the other guides, grab copy-paste artifacts on the integrations page, or read the full machine-readable schema at /api/spec.
FreePBX CID Superfecta sourceFreeSWITCH / FusionPBX integration