Carrier Provider Setup
How PasskeyBridge queries Vonage and Twilio for carrier state, what a lookup returns, and how the result reaches the ingest pipeline.
Overview
shield-carrier-lookup is the acquisition layer in front of Vonage Number Insight Advanced and Twilio Lookup v2. It takes a phone number, asks one provider about it, normalizes the answer, derives a risk score and forwards the result into the ingest pipeline as an ordinary signal.
Two ways to produce a carrier signal. lookup: PasskeyBridge asks Vonage or Twilio; the number is sent to the provider for that one call and only its keyed digest is stored, audited or forwarded. record: you ask your own provider under your own contract and post the normalized answer with a phone_hash you computed; no number reaches PasskeyBridge, and a body carrying one is refused. Both produce the same signal. A digest you compute is stored at hash version 1 and does not correlate with a hosted lookup's digest for the same number, so use one mode per identifier. The record request is documented below under Bring your own lookup.
Credentials for `lookup` are platform-held. The provider keys are function secrets on the platform (VONAGE_API_KEY, VONAGE_API_SECRET, TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN). There is no per-organization provider configuration and no place in the dashboard to enter carrier credentials. A provider contract of your own is used through record, not by lending PasskeyBridge your keys: your keys would still put the number through this platform, and record is the path that keeps it off.
Who can call it. Any active API key on the organization, presented as x-pb-api-key, with no particular scope required, or a tenant-admin session JWT. Per-organization quota is enforced. It is available on every plan.
The number is handled in plaintext, once, on `lookup` only. A carrier lookup cannot be done on a digest, so the number is sent to the provider and then immediately hashed with HMAC-SHA-256 under the server-held pepper. Only the digest is stored, audited or forwarded. A static gate in CI (check:pii-writes, section F) enumerates every line of the function on which the number may appear and fails the build on any other.
Request and response
curl -sS -X POST https://api.passkeybridge.io/v1/shield-carrier-lookup \
-H "content-type: application/json" \
-H "x-pb-api-key: $PB_API_KEY" \
-d '{"action":"lookup","tenant_id":"YOUR_TENANT_ID","phone":"+14155550123","provider":"vonage"}'| Field | Type | Required | Notes |
|---|---|---|---|
action | string | yes | lookup or providers |
tenant_id | string | yes | Your organization id |
phone | string | for lookup | E.164, non-empty |
provider | string | no | vonage (default) or twilio. An unrecognised value falls back to vonage |
{
"status": "ok",
"signal": {
"provider": "vonage",
"phone_hash": "9f2c4a...",
"signal_type": "sim_swap_detected",
"risk_score": 0.7,
"carrier_name": "Example Mobile",
"carrier_type": "mobile",
"ported": false,
"reachable": true,
"roaming": false,
"sim_swap_detected": true,
"sim_swap_age_hours": 36
},
"latency_ms": 412,
"forwarded_to_ingest": true
}The provider's raw response is stripped before the reply. forwarded_to_ingest says the forward was dispatched rather than that it succeeded: it is fire and forget. It reads false in one case, which is the case you can act on: the forward carries your own key, so a key without the ingest scope would be refused, and rather than dispatch a call that cannot succeed the lookup skips it and returns forward_skipped_reason beside the flag.
Errors: 400 Unknown action. Valid: lookup, providers, 400 Missing or invalid tenant_id, 400 Missing or invalid phone (must be a non-empty string), 401 or 403 from the shared authentication layer, 404 Unknown tenant, 413 Payload too large, 502 {"error": "Carrier lookup failed", "detail": "..."} when the provider errored, 500 on a configuration problem. The detail string has digit runs redacted and is truncated, because provider error bodies echo the number that was looked up.
Vonage Number Insight Advanced
Endpoint https://api.nexmo.com/ni/advanced/json, authenticated with the platform's API key and secret as query parameters.
| Normalized field | Type | Derived from |
|---|---|---|
carrier_name | string or null | current_carrier.name |
carrier_type | string or null | current_carrier.network_type |
ported | boolean | ported is ported or assumed_ported |
reachable | boolean | reachable is anything but undeliverable |
roaming | boolean | roaming.status is roaming |
sim_swap_detected | boolean | real_time_data.sim_swap.status is swapped |
sim_swap_age_hours | number or null | real_time_data.sim_swap.swap_age |
The HTTP status is not the answer. Vonage reports its own status in the body, and the adapter requires status to be 0 before reading anything. A non-zero status raises Vonage API error: <status_message>, which returns 502 and writes an audit row with action carrier.lookup_failed.
Twilio Lookup v2
Endpoint https://lookups.twilio.com/v2/PhoneNumbers/{phone}?Fields=line_type_intelligence,sim_swap, authenticated with HTTP Basic using the platform's account SID and auth token. Lookup v2 has no carrier package; the adapter asked for one and read the v1 shape until 2026-09-18.
| Normalized field | Type | Derived from |
|---|---|---|
carrier_name | string or null | line_type_intelligence.carrier_name, else sim_swap.carrier_name |
carrier_type | string or null | line_type_intelligence.type |
ported | boolean | Always false; Lookup v2 reports no porting history |
reachable | boolean | Inferred: true when valid is not false and a carrier name came back |
roaming | boolean | Always false; Lookup v2 does not report roaming |
sim_swap_detected | boolean | sim_swap.last_sim_swap.swapped_in_period |
sim_swap_age_hours | number or null | Hours since sim_swap.last_sim_swap.last_sim_swap_date; scored exact, stored rounded |
A non-2xx response raises Twilio API error <status>: <body>, which returns 502 and writes carrier.lookup_failed with the body redacted and truncated.
Three fields are weaker here than they look. reachable is inferred from validity and the presence of a carrier name rather than measured, and roaming and ported are hard-coded false, so a Twilio lookup never contributes the ported component of the risk score and contributes the unreachable component only for an invalid or carrier-less number.
Risk score derivation
Both providers feed one additive function. The result is capped at 1.0 and rounded to three decimals.
| Condition | Contribution |
|---|---|
| SIM swap detected | +0.60 |
| Swap age under 24 hours | +0.20 |
| Swap age 24 to 72 hours | +0.10 |
| Number ported | +0.15 |
| Number unreachable | +0.10 |
The two age bands are exclusive: a swap under 24 hours old adds 0.20 and nothing more, a swap at 36 hours adds 0.10, and a swap with no age reported adds neither. A swap 12 hours old on a ported number scores 0.60 plus 0.20 plus 0.15, which is 0.950. A ported number with no swap scores 0.150.
The score travels to the ingest pipeline as risk_score and is stored on the event row. It does not decide anything by itself: hard and soft handling keys off the event type, and playbooks match on the type as well. To act on the score, read it in your webhook_callback receiver or alert on the high-risk metric in the Observability tab.
Auto-forwarding to ingest
After a successful lookup the normalized signal is posted to shield-ingest for the same organization, fire and forget with a 10-second timeout, and the caller's answer does not wait for it.
The forwarded body is exactly these fields:
{
"event_type": "sim_swap_detected",
"phone_hash": "9f2c4a...",
"risk_score": 0.7,
"carrier_name": "Example Mobile",
"carrier_type": "mobile",
"ported": false,
"sim_swap_detected": true,
"sim_swap_age_hours": 36,
"provider": "vonage"
}`event_type` is `sim_swap_detected` only when a swap was flagged, and carrier_check otherwise. carrier_check is in neither classification list, so a clean lookup matches playbooks and triggers no revocation and no trust evaluation. A playbook meant to fire on every lookup must therefore exist for both types.
The forward carries your own credentials rather than the platform's. The x-pb-api-key, x-shield-api-key, x-pb-test-mode and authorization headers of your lookup request are copied onto it, so the key you used must also carry the ingest scope or the forwarded signal is refused with 403 and only the lookup half of the work lands. Test-mode and sandbox provenance survives the hop, so an unmetered lookup stays unmetered.
Failures are logged rather than surfaced, with one exception. A non-2xx from the forward is written to the function log and the lookup still answers forwarded_to_ingest: true, so confirm in the Events tab rather than in the lookup response. The exception is a key missing the ingest scope: that fails every time rather than intermittently, and it is knowable before the call, so the lookup answers forwarded_to_ingest: false with a forward_skipped_reason. Before 2026-09-17 it answered true and the signal silently reached nothing.
Beyond that the signal is an ordinary one: event row, playbook match, hard or soft handling, usage counter and audit trail.
Bring your own lookup
action: "record" takes the answer you already have from your own provider and runs it through the same normalization, scoring, audit and forward as a hosted lookup. No number is accepted: a body carrying phone, phone_number, msisdn or number is refused with 400 phone_not_accepted_on_record, whatever the value, because the point of this path is that the number never reaches PasskeyBridge.
curl -sS -X POST https://api.passkeybridge.io/v1/shield-carrier-lookup \
-H "content-type: application/json" \
-H "x-pb-api-key: $PB_API_KEY" \
-d '{"action":"record","tenant_id":"YOUR_TENANT_ID","provider":"my-carrier","phone_hash":"9f2c4a...64 hex","sim_swap_detected":true,"sim_swap_age_hours":36,"ported":false,"reachable":true,"carrier_name":"Example Mobile","carrier_type":"mobile"}'| Field | Type | Required | Notes |
|---|---|---|---|
phone_hash | string | yes | Exactly 64 hex characters, the digest you computed. Keyed again under the server pepper before it is stored or audited (hash_version 2); the forwarded signal's envelope carries it as you sent it |
provider | string | yes | 1 to 32 lowercase letters, digits, underscore or hyphen, starting with a letter. replay is reserved |
sim_swap_detected | boolean | yes | |
sim_swap_age_hours | number or null | no | Non-negative; scored as sent, stored rounded |
ported | boolean | no | Default false |
reachable | boolean | no | Default true |
roaming | boolean | no | Default false |
carrier_name, carrier_type | string or null | no | Up to 128 characters each. A label that carries an email address, a phone number, an IP address or a token is refused with 400 invalid_field: these values are returned, forwarded and audited as sent |
risk_score is refused with 400 risk_score_not_accepted_on_record: the score is derived from the attributes by the table below, exactly as for a hosted lookup, so a recorded signal cannot claim a score its attributes do not support.
The response is the lookup response with recorded: true and latency_ms: 0; the audit row is carrier.lookup_recorded; the forwarded body is the one shown under Auto-forwarding, with your provider name. Any other field on the body is rejected by type (400 invalid_field names it).
Use one mode per identifier. Signals recorded with your digest and signals from a hosted lookup of the same number carry different digests and will not correlate. A tenant that wants no number on this platform uses record for every carrier signal and sends phone_hash on every ingest.
Custom providers
Any provider can be used by posting its result to /v1/shield-ingest/{tenant_id} yourself. For carrier-shaped answers (SIM swap, porting, reachability) prefer Bring your own lookup, which validates the shape, derives the score and writes the carrier audit row; this endpoint is for everything else. The endpoint accepts any event_type in the allowed character set, so you can send sim_swap to reuse the hard-signal path or your own name to keep it to playbooks.
Minimum body:
{
"event_type": "sim_swap",
"phone": "+14155550123"
}Send phone and the pipeline keys the digest under the server pepper. Send phone_hash instead, as 64 hex characters, and it is keyed again under the same pepper as its own digest kind and stored at hash_version: 2, so the stored value is not the enumerable digest you computed; the callback envelope carries your digest as sent. Prefer phone when you can, because a digest keyed from the number is the one hosted carrier lookups produce.
Optional: risk_score between 0.0 and 1.0, source_id as a UUID, subject_ref as your own opaque handle up to 128 characters, credential_sha1 for the breach check, and any additional fields your provider supplies.
Additional fields are forwarded rather than stored. They reach a webhook_callback receiver in metadata with raw identifiers stripped, and they do not appear on the event row, which keeps only the documented columns.
Provider signatures cannot be verified through the public host today. The pipeline verifies an x-pb-signature HMAC over the raw body against your organization's signing secret, but the Cloudflare Worker's forward list does not include that header, so it is dropped before the function sees it. Until that changes, leave Require signed signals off and treat the API key as the authenticator.
Testing and validation
From the dashboard. Overview > Send test signal posts a test_signal event with x-pb-test-mode: true. Replay an attack in test mode sends six credential_stuffing_attempt signals, a failed_verification and a suspicious_login as test events and reports which of your playbooks matched, once per ten minutes per organization. Neither calls a carrier.
Provider list. {"action": "providers", "tenant_id": "..."} returns a static two-entry list:
{
"providers": [
{ "id": "vonage", "name": "Vonage Number Insight", "capabilities": ["sim_swap", "port_check", "reachability", "roaming"] },
{ "id": "twilio", "name": "Twilio Lookup v2", "capabilities": ["sim_swap", "carrier_info", "line_type"] }
]
}This list is hard-coded and deliberately says nothing about whether credentials are configured, so it confirms the endpoint is reachable rather than that a lookup will succeed. A missing secret surfaces as 502 on an actual lookup.
Test mode does not gate revocation. Sandbox keys and x-pb-test-mode suppress outbound email, Slack and webhook sends only. A hard signal sent in test mode still revokes every active agent delegate and A2A negotiation on the organization. Never replay sim_swap or port_out against a production organization to see what happens; use the replay card, which is restricted to soft signals for exactly this reason.
Audit trail. Every lookup writes one audit row: carrier.lookup_completed with the provider, the signal type, the risk score and the latency, or carrier.lookup_failed with the redacted error. The resource id is the first 12 characters of the identifier hash. Both are visible in Dashboard > Audit log.
Confirming a lookup landed. The lookup response is one half; the other is the event. Open Events, filter to the forwarded type (sim_swap_detected or carrier_check), and check the newest row for the risk score and the matched playbook.
Related from the blog
- Carrier Signal Fusion: Why Single-Provider Lookups Are a Single Point of Failuresecurity · 14 min read
- The Pre-Filter Pattern: Paying for Heavy Fraud Signals Only on the Suspicious Tailsecurity · 9 min read
- Feeding a Hard Deny into Your Fraud Rules Engine: Integration Patternsengineering · 10 min read