# FreePBX & Asterisk caller ID lookup — CNAM + inbound risk signal

- Category: PBX & softphones
- Published: June 1, 2026 · Updated: June 15, 2026
- Canonical: https://numbers.online/docs/integrations/asterisk-freepbx

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

```json
{
  "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_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.

## Method 1 — `CURL()` in extensions.conf (quickest)

```ini
[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.

```ini
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

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

## Verifying the signature

Every response carries `response_signature: "ed25519:<base64>"` over the canonical
JSON body (**top-level** keys sorted, signature field excluded). Nested objects —
e.g. the `risk: { score, level, model }` object — are reproduced **in received key
order**; only the top level is sorted, so do not sort recursively. Verify with the
published Ed25519 public key — example in Python:

```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: **api@numbers.online**.

## Keys & references

Keys are self-service at https://numbers.online/docs — shown once, stored only as a hash.
Machine-readable schema: https://numbers.online/api/spec · all guides: https://numbers.online/md/docs/integrations

- Previous guide: [FreePBX CID Superfecta source](https://numbers.online/md/docs/integrations/freepbx-superfecta)
- Next guide: [FreeSWITCH & FusionPBX caller ID lookup](https://numbers.online/md/docs/integrations/freeswitch-fusionpbx)

---

Numbers Online — community-powered phone-number trust signals. Every figure on this
page is a supplementary, low-confidence signal — advisory, not a verdict on any number
or its owner.

- Canonical page (HTML): https://numbers.online/docs/integrations/asterisk-freepbx
- Report a call: https://numbers.online/report · Listed in error? Request removal: https://numbers.online/opt-out
- Machine guide to the phone-intelligence API: https://numbers.online/llms.txt · OpenAPI: https://numbers.online/api/spec
