In Verify phone numbers with the Sinch Verification API, an approval callback decides whether a code goes out, based on the calling code and how often a number asks. That policy doesn't know what kind of line it's sending to. The Sinch Number Lookup API can: One call returns the line type and the carrier of a number. This post puts that call in front of the verification start. Mobile numbers get an SMS, landlines get a phone call, and numbers Lookup has no record of are blocked. For a VoIP number the code returns a challenge decision and stops, because the challenge itself, such as a CAPTCHA, belongs to your signup flow. You run everything from the command line, and the decision function is tested without a phone.
You'll need
- The Sinch account and Verification app from the previous post, with its application key and secret
- Phone call verification enabled on that app, if landlines should get a code
- The project ID and an access key for the same project, from Settings and Access keys in the Sinch Build Dashboard
- Node.js 22 or later, and a phone that can receive SMS
Verifications are billable, so test with your own number. The code was tested with @sinch/sdk-core 1.6.0 on Node.js 24.
If the Verification app still has the Callback URL from the previous post, keep that server running or clear the field, because every start needs an answer from it. Clearing it also turns off that post's approval callback, so set it again later if you rely on it.
What Lookup tells you
The default feature, LineType, returns a line object with a type and, for most numbers, the carrier. type is Landline, Mobile, VoIP, Special, Freephone, or Other. The guidance on combining Lookup and Verification maps those types to a method: A landline can't receive SMS, so use a voice call; a mobile number can take either; and a VoIP number carries a higher fraud risk, so add a CAPTCHA first. The code follows that table and covers the cases it leaves open.
Line type is available worldwide, but it's corrected for numbers that moved to another carrier only in the US and Canada, so a ported number elsewhere can show its old type. Looking up Canadian numbers also requires a CLNPC license, which the overview describes.
Lookup also has SIM swap detection (beta, US and Canada, needs carrier approval) and VoIP detection (alpha), each enabled per project on request. The decision function reads both when they're in a response. The code here requests line type only, so it runs on any project with Lookup access. Each feature in a response carries its own error. In testing, a feature that isn't enabled for a project came back inside an HTTP 200 with an error object, such as {"status":100,"title":"Feature Disabled"}. The code treats that as no signal.
Set up Lookup access
- In the Sinch Build Dashboard, open Number Lookup and then Access Requirements.
- Select Submit use cases and declare what you use line type for. Until you do, Lookup returns HTTP 403 with "Line type use cases not specified."
- Under Settings and Access keys, create an access key and copy the key ID and secret.
The Number Lookup terms list fraud prevention, identity verification, and validating a phone number among the allowed uses, which covers checking a number before you send it a code.
Lookup takes the project access key, and the SDK turns it into an OAuth token for you. Verification takes the application key and secret, and the SDK signs each request with them. Store all five as environment variables:
export SINCH_PROJECT_ID=YOUR_PROJECT_ID export SINCH_KEY_ID=YOUR_ACCESS_KEY_ID export SINCH_KEY_SECRET=YOUR_ACCESS_KEY_SECRET export SINCH_APP_KEY=YOUR_SINCH_APP_KEY export SINCH_APP_SECRET=YOUR_SINCH_APP_SECRET Create the project
Every file you need is on this page. Create the project and install the dependencies:
mkdir sinch-lookup-gate && cd sinch-lookup-gate mkdir -p src scripts test npm init -y npm pkg set type=module npm install @sinch/sdk-core@1.6.0 npm install --save-dev typescript tsx vitest @types/node You'll end up with this:
sinch-lookup-gate/ tsconfig.json src/ env.ts, types.ts, decide.ts, lookup.ts, verify.ts, gate.ts scripts/ lookup.ts, check.ts, report.ts test/ decide.test.ts The decision function and its tests need no credentials. If you only want those, create tsconfig.json, then skip to Write the decision function.
Create tsconfig.json:
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "noUncheckedIndexedAccess": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src", "scripts", "test"] } Create src/env.ts. It reads the credentials and builds the SDK clients:
import { RetryPolicy, SinchClient } from "@sinch/sdk-core"; export interface SinchCredentials { projectId: string; keyId: string; keySecret: string; /** Verification application key and secret. Only needed to start or report verifications. */ applicationKey?: string; applicationSecret?: string; } /** The SDK's own retry is off because lookup.ts retries. `logger: null` stops it printing failed requests. */ const createSinchClient = (credentials: SinchCredentials, timeoutSeconds: number): SinchClient => new SinchClient({ ...credentials, retryPolicy: RetryPolicy.NONE, timeoutSeconds, logger: null }); /** Lookup fails fast, because the gate's failure mode handles an outage. Three attempts stay under seven seconds. */ export const createLookupClient = (credentials: SinchCredentials) => createSinchClient(credentials, 2).numberLookup; /** A start that times out on your side may still have been sent, so Verification gets more time. */ export const createVerificationClient = (credentials: SinchCredentials) => createSinchClient(credentials, 10).verification; export const requireEnv = (name: string): string => { const value = process.env[name]; if (!value) { throw new Error(`Missing environment variable ${name}`); } return value; }; export const credentialsFromEnv = (): SinchCredentials => { const applicationKey = process.env["SINCH_APP_KEY"]; const applicationSecret = process.env["SINCH_APP_SECRET"]; return { projectId: requireEnv("SINCH_PROJECT_ID"), keyId: requireEnv("SINCH_KEY_ID"), keySecret: requireEnv("SINCH_KEY_SECRET"), ...(applicationKey && applicationSecret ? { applicationKey, applicationSecret } : {}), }; }; /** For code that starts or reports verifications: The application key and secret are required. */ export const verificationCredentialsFromEnv = (): SinchCredentials => { const credentials = credentialsFromEnv(); if (!credentials.applicationKey || !credentials.applicationSecret) { throw new Error("Missing environment variables SINCH_APP_KEY and SINCH_APP_SECRET"); } return credentials; }; The SDK retries HTTP 429 on its own, but src/lookup.ts retries more than that, so the SDK's retry is off to keep one policy. The two clients get different timeouts instead of the 60-second default. Lookup gets two seconds per request, because the gate's failure mode handles an outage. In testing, every lookup finished well within that, including the first one with its OAuth token. Verification gets 10 seconds, because a start that times out on your side may still have gone through, and the user would get a code your app reported as failed.
Check your access
Create scripts/lookup.ts. It looks up one number and prints the raw response:
import { createLookupClient, credentialsFromEnv } from "../src/env.js"; const phoneNumber = process.argv[2]; if (!phoneNumber) { console.error("Usage: npx tsx scripts/lookup.ts +46701234567"); process.exit(1); } const lookupClient = createLookupClient(credentialsFromEnv()); try { const result = await lookupClient.lookup({ numberLookupRequestBody: { number: phoneNumber, features: ["LineType"] }, }); console.log(JSON.stringify(result, null, 2)); } catch (error) { // The SDK throws with the HTTP status in statusCode and the body as a JSON string in data. const { statusCode, data, message } = error as { statusCode?: number; data?: string; message?: string }; console.error(statusCode ? `Lookup failed with HTTP ${statusCode}: ${data}` : `Lookup failed: ${message}`); process.exitCode = 1; } Run it with your own number in E.164 format:
npx tsx scripts/lookup.ts YOUR_PHONE_NUMBER For a mobile number you should see something like:
{ "countryCode": "SE", "traceId": "ba2bf0b429267927ce2908d9acfc1cdb", "number": "+46701234567", "line": { "carrier": "Telia Sverige AB", "type": "Mobile", "mobileCountryCode": "240", "mobileNetworkCode": "01" } } A 403 with "Line type use cases not specified." means the declaration hasn't applied yet. See Troubleshooting.
Write the decision function
Create src/types.ts. It describes the parts of a response the code reads, and the three decisions:
/** * The parts of a Number Lookup response this project reads. Every field is optional: a feature * that isn't enabled returns an `error` instead of data, and many numbers leave out most `line` * fields. The SDK's NumberLookupResponse fits this shape. */ export interface LookupResult { traceId?: string; line?: { carrier?: string; type?: string; error?: unknown }; simSwap?: { swapped?: boolean; error?: unknown }; voIPDetection?: { probability?: string; error?: unknown }; } export type VerificationMethod = "sms" | "callout"; export type Decision = | { action: "block"; reason: string } | { action: "step-up"; reason: string } | { action: "send"; method: VerificationMethod; reason: string }; Create src/decide.ts. It turns a response into a decision and does no I/O:
import type { Decision, LookupResult } from "./types.js"; const STEP_UP_LINE_TYPES = new Set(["VoIP", "Special", "Freephone", "Other"]); /** Turns a Lookup response into a decision. A feature that returned an `error` carries no signal. */ export const decide = ({ line, simSwap, voIPDetection }: LookupResult): Decision => { if (simSwap && !simSwap.error && simSwap.swapped === true) { return { action: "step-up", reason: "SIM swap detected" }; } const probability = voIPDetection && !voIPDetection.error ? voIPDetection.probability : undefined; if (probability === "Likely" || probability === "High") { return { action: "step-up", reason: `VoIP probability ${probability}` }; } const lineType = line && !line.error ? line.type : undefined; if (lineType === "Landline") { return { action: "send", method: "callout", reason: "landline cannot receive SMS" }; } if (lineType === "Mobile") { return { action: "send", method: "sms", reason: "mobile line" }; } if (lineType !== undefined && STEP_UP_LINE_TYPES.has(lineType)) { return { action: "step-up", reason: `line type ${lineType}` }; } // No usable line type, or one this code doesn't know: send an SMS, as you would without Lookup. return { action: "send", method: "sms", reason: "no line type signal" }; }; A SIM swap or a high VoIP probability asks for a challenge first. Then the line type picks the method: A phone call for a landline, an SMS for a mobile number, and a challenge for VoIP and the less common types. Without a usable line type, the code sends an SMS, as you would without Lookup.
step-up means "don't send yet", and the reason says why. The right challenge depends on the signal. A CAPTCHA, which the Sinch guidance suggests for VoIP numbers, checks that a person is there. A SIM swap raises a different question: Whether the person holding the phone now is the account holder. A CAPTCHA can't answer that, so for an existing account, use a factor that doesn't depend on the phone number, such as a confirmation email or a session the user already has.
Test it without a phone
Create test/decide.test.ts. The first three responses were captured from Lookup, with fictional numbers:
import { describe, expect, it } from "vitest"; import { decide } from "../src/decide.js"; // Captured Lookup responses, with the numbers replaced by fictional ones. const seMobile = { countryCode: "SE", traceId: "a10075fd5f248094a3674f203cc0f496", number: "+46701234567", line: { carrier: "Telia Sverige AB", type: "Mobile", mobileCountryCode: "240", mobileNetworkCode: "01" }, }; const usVoip = { countryCode: "US", traceId: "3c52b29db9cf8885002e85872f387149", number: "+12025550100", line: { carrier: "Sinch Voice", type: "VoIP" }, }; const gbLandline = { countryCode: "GB", traceId: "e61554f6b76aaf759ad7cf729be283e9", number: "+441632960123", line: { carrier: "DIDWW Ireland Limited", type: "Landline" }, }; describe("decide", () => { it("should send an SMS to a mobile line", () => { expect(decide(seMobile)).toEqual({ action: "send", method: "sms", reason: "mobile line" }); }); it("should call a landline", () => { expect(decide(gbLandline)).toEqual({ action: "send", method: "callout", reason: "landline cannot receive SMS" }); }); it("should step up a VoIP line", () => { expect(decide(usVoip)).toEqual({ action: "step-up", reason: "line type VoIP" }); }); it.each(["Special", "Freephone", "Other"])("should step up line type %s", (type) => { expect(decide({ line: { type } }).action).toBe("step-up"); }); it("should step up after a SIM swap", () => { expect(decide({ ...seMobile, simSwap: { swapped: true } })).toEqual({ action: "step-up", reason: "SIM swap detected", }); }); it.each(["Likely", "High"])("should step up for VoIP probability %s", (probability) => { expect(decide({ ...seMobile, voIPDetection: { probability } }).action).toBe("step-up"); }); it("should ignore features that returned an error", () => { const featureDisabled = { status: 100, title: "Feature Disabled" }; const result = { ...seMobile, simSwap: { error: featureDisabled }, voIPDetection: { error: featureDisabled } }; expect(decide(result).action).toBe("send"); }); it("should send an SMS when there's no usable line type", () => { expect(decide({})).toEqual({ action: "send", method: "sms", reason: "no line type signal" }); }); }); Run the tests:
npx vitest run You should see:
Test Files 1 passed (1) Tests 11 passed (11) These tests cover the decision function only. The version I tested also has mocked tests for the retries, the gate, and the verification start, which this post leaves out.
Call Lookup
Create src/lookup.ts. It calls Lookup, retries what can succeed on a second try, and turns a 404 into an error of its own:
import type { LookupResult } from "./types.js"; /** The part of the SDK's `sinch.numberLookup` that this project uses. */ export interface LookupClient { lookup(data: { numberLookupRequestBody: { number: string; features?: string[] } }): Promise<Source:https://dev.to/gunnargrosch/check-a-phone-number-before-you-send-an-otp-5dl1Related posts

Siri AI is now open to all developers
Microtask vs Macrotask in JavaScript — With Node.js vs Browser Notes
The state of MCP auth, November 2026: I scanned 25 public servers
Appsmith title matches need a self-hosted security baseline
How I Give Every Story Its Own Cover Without a Designer
