Skip to main content

Carrier Provider Setup

How PasskeyBridge asks carrier providers about a number, whose account each lookup uses, what a lookup returns, and how the result reaches the ingest pipeline.

Last reviewed October 31, 2026Fresh

Overview

shield-carrier-lookup is the acquisition layer in front of four carrier APIs: Vonage Identity Insights, Sinch Number Lookup, Vonage Number Insight Advanced and Twilio Lookup v2. By default 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. Opted-in failover may try another eligible provider within the same billing boundary; see Carrier providers for the rollout gates, routing and health controls.

Two ways to produce a carrier signal. lookup: PasskeyBridge asks a provider; 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 keyed again at rest as its own kind (hash version 2), so it does not correlate with a hosted lookup's digest for the same number: use one mode per identifier. The record request is documented below under Bring your own lookup.

Whose account a lookup uses. provider: vonage_identity_insights runs under your own Vonage application: you register its application id, region and private key under Settings, Carrier lookups, each lookup is signed with that key, and Vonage bills your account under your operator approval. See Vonage Identity Insights. provider: sinch_number_lookup runs under your own Sinch project: you register its project id, an access key id and that key's secret under Settings, Sinch Number Lookup, each lookup exchanges the key for a token, and Sinch bills your account. See Sinch Number Lookup. vonage and twilio run on PasskeyBridge's own carrier accounts, whose keys are platform secrets, and need the hosted carrier lookups add-on; without it a lookup naming either answers 402 addon_required. A lookup that names no provider runs under your own registered account: the Vonage application when you have one, otherwise the Sinch project. Through your own account the number still passes through PasskeyBridge for the one call, as on any lookup; record is the path that keeps it off the platform entirely.

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 keyed 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, including the copy normalized for your own Vonage or Sinch account, 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_identity_insights"}'
FieldTypeRequiredNotes
actionstringyeslookup or providers; record is under Bring your own lookup
tenant_idstringyesYour organization id
phonestringfor lookupE.164, non-empty. For your own Vonage or Sinch account, the country code and digits: a leading + is optional, spaces, dots, dashes and brackets are dropped, and a 00 prefix is refused
providerstringnovonage_identity_insights, sinch_number_lookup, vonage or twilio. Without it, your own registered account answers: the Vonage application first, then the Sinch project. Any other value is refused with 400
{
  "status": "ok",
  "signal": {
    "provider": "vonage_identity_insights",
    "phone_hash": "9f2c4a...",
    "signal_type": "sim_swap_detected",
    "risk_score": 0.7,
    "carrier_name": null,
    "carrier_type": null,
    "ported": false,
    "reachable": true,
    "roaming": false,
    "sim_swap_detected": true,
    "sim_swap_age_hours": 36
  },
  "latency_ms": 412,
  "recorded": false,
  "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 the two cases you can act on, where the forward could not succeed and is not dispatched, with forward_skipped_reason beside the flag: the forward carries your own key, so a key ingest would refuse is not forwarded (one that names scopes without ingest or admin, or names none and was created at or after 2026-09-03 21:00 UTC), and an organization that requires signed signals but has no signing secret would refuse it too.

Errors: 400 Unknown action. Valid: lookup, record, providers, health, set_preference, 400 Missing or invalid tenant_id, 400 Missing or invalid phone (must be a non-empty string), 400 Unknown provider. Valid: vonage_identity_insights, sinch_number_lookup, vonage, twilio, 401 or 403 from the shared authentication layer, 402 addon_required for vonage or twilio without the hosted carrier lookups add-on (payment_required when the subscription is unpaid), 404 Unknown tenant, 413 Payload too large, 502 {"error": "Carrier lookup failed", "detail": "..."} when the provider errored or could not answer, 500 on a configuration problem. The legacy detail string encodes only allowlisted error_class and bounded error_code labels. Provider error messages and response bodies are not returned or audited. The errors particular to your own account are under Vonage Identity Insights and Sinch Number Lookup.

Vonage Identity Insights

A lookup with provider: vonage_identity_insights runs a network SIM swap check through your own Vonage application. For each lookup PasskeyBridge signs a five-minute JWT with the application's private key and sends one Identity Insights request, so the lookup is billed to your Vonage account and runs under your operator approval.

Set it up once.

  1. In the Vonage dashboard, create an application and generate its key pair. Keep the private key Vonage gives you: a PKCS#8 PEM that begins -----BEGIN PRIVATE KEY-----.
  2. Get the application approved for SIM swap. Vonage states that production use of this insight requires approval from mobile operators, managed through its Network Registry. The lookup sends purpose: FraudPreventionAndDetection, so the application's network profile must allow that purpose.
  3. In PasskeyBridge, open Settings, Carrier lookups, and enter the application id, the API region (EU or US) and the private key. The key is checked before it is saved: RSA, at least 2048 bits, in PKCS#8 form. It is stored encrypted (AES-256-GCM), never shown again and never logged. Replacing it or removing the application is done there too, and each change writes an audit row.

Nothing can prove the key belongs to the application id without a billable call to Vonage, so the first lookup is that check: a mismatch fails with 502 and the HTTP status Vonage answered.

What is asked, and what comes back. One request to https://api-eu.vonage.com/identity-insights/v1/requests or https://api-us.vonage.com/identity-insights/v1/requests, for the sim_swap insight alone with a 240-hour window. No other insight is requested, so none is inferred.

Normalized fieldTypeDerived from
sim_swap_detectedbooleaninsights.sim_swap.is_swapped, read only when its status is OK
sim_swap_age_hoursnumber or nullHours since insights.sim_swap.latest_sim_swap_at when Vonage gives it; scored exact, stored rounded
portedbooleanAlways false; not requested
reachablebooleanAlways true; not requested
roamingbooleanAlways false; not requested
carrier_name, carrier_typenullNot requested

An unanswered insight is a failed lookup, never a clean one. A sim_swap status other than OK (Vonage lists NO_COVERAGE, NOT_FOUND, UNAUTHORIZED and INVALID_PURPOSE among others), or OK without a verdict, answers 502 Carrier lookup failed with bounded diagnostic labels in detail, and writes carrier.lookup_failed. An unsuccessful Vonage HTTP response answers 502 with the same bounded diagnostic shape: its body is not documented and may echo the request, so it never reaches the answer or the audit row.

Errors particular to this provider: 400 phone must be in international format: country code and digits, no 00 prefix, 400 carrier_credentials_missing when no application is registered, 503 Carrier credential lookup failed when the registration could not be read, and 500 when the stored key could not be decrypted or its region is unknown. None of them carries any part of the key.

Sinch Number Lookup

A lookup with provider: sinch_number_lookup runs Sinch Number Lookup through your own Sinch project. For each lookup PasskeyBridge exchanges your project's access key for a token at https://auth.sinch.com/oauth2/token and sends one request to https://lookup.api.sinch.com/v2/projects/{projectId}/lookups, so the lookup is billed to your Sinch account and runs under any approval your project holds. A lookup that names no provider uses this project when your organization has no Vonage application registered.

Set it up once.

  1. In the Sinch dashboard, open the project and create an access key. Sinch shows the key secret once; keep it with the key id.
  2. For SIM swap, ask Sinch to approve your project for it. Sinch reports SIM swaps for US and Canada numbers only.
  3. In PasskeyBridge, open Settings, Sinch Number Lookup, and enter the project id, the access key id, the key secret, and whether Sinch has approved SIM swap for the project. The ids must be UUIDs and the secret is taken exactly as Sinch showed it. It is stored encrypted (AES-256-GCM), never shown again and never logged. Replacing the key or removing the project is done there too, and each change writes an audit row naming the project and key ids, never the secret.

Nothing proves the secret belongs to the key id before Sinch is asked, so the first lookup is that check: a refused key fails with 502 and the HTTP status Sinch answered.

What is asked, and what comes back. LineType always. SimSwap as well only for a +1 number, and only when you said your project is approved for it; for any other number the answer is a carrier check that did not look for a swap.

Normalized fieldTypeDerived from
carrier_namestring or nullline.carrier; dropped if it carries anything shaped like an identifier
carrier_typestring or nullline.type, lower-cased
portedbooleanline.ported; Sinch corrects for porting in the US and Canada only
reachablebooleanAlways true; not reported
roamingbooleanAlways false; not reported
sim_swap_detectedbooleansimSwap.swapped, when SIM swap was asked; false otherwise
sim_swap_age_hoursnumber or nullThe upper bound of Sinch's swap window (SP4H is 4, SP24H is 24, SP7D is 168), so a swap is never scored as more recent than Sinch said; null for a window this adapter does not read

An unanswered feature is a failed lookup, never a clean one. Line data that is missing or carries an error, or a SIM swap that was asked and came back without a boolean verdict or with an error, answers 502 Carrier lookup failed and writes carrier.lookup_failed. An unsuccessful Sinch HTTP response answers 502 with bounded diagnostic labels: its body may echo the number, so it never reaches the answer or the audit row. The number Sinch echoes in its answer is never read.

Errors particular to this provider: 400 phone must be in international format: country code and digits, no 00 prefix, 400 carrier_credentials_missing when no Sinch project is registered, 503 Carrier credential lookup failed when the registration could not be read, and 500 when the stored secret could not be decrypted or the registration is malformed. None of them carries any part of the secret.

Vonage Number Insight Advanced (hosted)

Endpoint https://api.nexmo.com/ni/advanced/json, authenticated with HTTP Basic using the platform's API key and secret in the Authorization header. The credentials are not included in the URL. Available with the hosted carrier lookups add-on.

Normalized fieldTypeDerived from
carrier_namestring or nullcurrent_carrier.name
carrier_typestring or nullcurrent_carrier.network_type
portedbooleanported is ported or assumed_ported
reachablebooleanFalse when reachable is undeliverable, absent, bad_number or blacklisted; true otherwise, unknown included
roamingbooleanroaming.status is roaming; false when Vonage answers unknown
sim_swap_detectedbooleanAlways false: Number Insight reports no SIM swap
sim_swap_age_hoursnullAlways null

No SIM swap check. Number Insight Advanced reports the carrier, porting, reachability and roaming, and nothing about SIM swaps: Vonage's reference lists no swap field in its response. A vonage lookup is therefore always forwarded as carrier_check and scores only the ported and unreachable components. For a SIM swap check use vonage_identity_insights. Until 2026-09-29 this adapter read a swap field Vonage does not send, so it could never have reported a swap.

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>, and a lookup_outcome of 2, which Vonage documents as failed, is a failed lookup rather than empty fields read as not ported and reachable. Both return 502 and write an audit row with action carrier.lookup_failed.

Twilio Lookup v2 (hosted)

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. Available with the hosted carrier lookups add-on. Lookup v2 has no carrier package; the adapter asked for one and read the v1 shape until 2026-09-18.

Normalized fieldTypeDerived from
carrier_namestring or nullline_type_intelligence.carrier_name, else sim_swap.carrier_name
carrier_typestring or nullline_type_intelligence.type
portedbooleanAlways false; Lookup v2 reports no porting history
reachablebooleanInferred: true when valid is not false and a carrier name came back
roamingbooleanAlways false; Lookup v2 does not report roaming
sim_swap_detectedbooleansim_swap.last_sim_swap.swapped_in_period
sim_swap_age_hoursnumber or nullHours since sim_swap.last_sim_swap.last_sim_swap_date; scored exact, stored rounded

A non-2xx response returns 502 and writes carrier.lookup_failed with the HTTP status as a bounded error_code. Its body is discarded without reading it.

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

Every provider feeds one additive function. The result is capped at 1.0 and rounded to three decimals. Identity Insights asks only about a SIM swap, so it contributes the swap components and never the ported or unreachable ones; Number Insight reports no SIM swap, so it contributes only those two.

ConditionContribution
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": null,
  "carrier_type": null,
  "ported": false,
  "sim_swap_detected": true,
  "sim_swap_age_hours": 36,
  "provider": "vonage_identity_insights"
}

`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 be one ingest accepts: a key that names scopes must include ingest or admin, and a key that names none is accepted only if it was created before 2026-09-03 21:00 UTC. Any other key does not forward, and the lookup says why (below). An organization that requires signed signals has the forward signed with its current signing secret. Test-mode and sandbox provenance survives the hop, so an unmetered lookup stays unmetered.

Failures are logged rather than surfaced, with two exceptions. 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 exceptions are a key ingest would refuse and an organization that requires signed signals with no signing secret set: each fails every time rather than intermittently and is knowable before the call, so the lookup answers forwarded_to_ingest: false with a forward_skipped_reason. Before 2026-09-17 a missing scope 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"}'
FieldTypeRequiredNotes
phone_hashstringyesExactly 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
providerstringyes1 to 32 lowercase letters, digits, underscore or hyphen, starting with a letter. replay is reserved
sim_swap_detectedbooleanyes
sim_swap_age_hoursnumber or nullnoNon-negative; scored as sent, stored rounded
portedbooleannoDefault false
reachablebooleannoDefault true
roamingbooleannoDefault false
carrier_name, carrier_typestring or nullnoUp 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 (a registered source's id requires that source's signature), 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.

Signatures. The pipeline verifies an x-pb-signature header whenever one is sent, and refuses an unsigned signal once Require signed signals is on in Settings. The header is t=<unix seconds>,v1=<hex>, an HMAC-SHA-256 of <unix seconds>.<raw body> under your organization's signing secret, and the timestamp must be within 300 seconds of the server clock. A bare hex digest of the raw body is accepted only while that organization has not required timestamps, and only until 2027-01-31. Each acceptance is counted. A signal that names a registered source must be signed with that source's own secret instead. The public host forwards the header to the function.

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 four-entry list; credentials says whose account a lookup through each one uses:

{
  "providers": [
    { "id": "vonage", "name": "Vonage Number Insight", "capabilities": ["carrier_info", "line_type", "port_check", "reachability", "roaming"], "credentials": "platform" },
    { "id": "twilio", "name": "Twilio Lookup v2", "capabilities": ["sim_swap", "carrier_info", "line_type"], "credentials": "platform" },
    { "id": "vonage_identity_insights", "name": "Vonage Identity Insights", "capabilities": ["sim_swap"], "credentials": "tenant" },
    { "id": "sinch_number_lookup", "name": "Sinch Number Lookup", "capabilities": ["carrier_info", "line_type", "port_check", "sim_swap"], "credentials": "tenant" }
  ]
}

This list is hard-coded and deliberately says nothing about whether a lookup would succeed: it does not check your add-on, your registered accounts or the platform's secrets. Sinch's sim_swap covers US and Canada numbers on a project Sinch has approved for it. A lookup through a platform provider without the add-on answers 402, one through vonage_identity_insights or sinch_number_lookup with nothing registered for it answers 400, and a missing platform secret surfaces as 502.

Test mode skips revocation. Sandbox keys and x-pb-test-mode suppress outbound email, Slack and webhook sends, the state-changing playbook actions, and hard-signal revocation. A hard signal sent that way records the skip and leaves delegates and negotiations in place. A live key still revokes. The replay card stays restricted to soft signals.

Audit trail. Every lookup that reaches a provider 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 provider and bounded diagnostic labels. A recorded answer writes carrier.lookup_recorded, and a request refused before any provider is called writes no carrier row. The resource id is the first 12 characters of the identifier hash. All three actions 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