DEV Community

Cover image for Check a Phone Number Before You Send an OTP
Gunnar Grosch
Gunnar Grosch

Posted on Originally published at sinch.com

Check a Phone Number Before You Send an OTP

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

  1. In the Sinch Build Dashboard, open Number Lookup and then Access Requirements.
  2. 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."
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Run it with your own number in E.164 format:

npx tsx scripts/lookup.ts YOUR_PHONE_NUMBER
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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" };
};
Enter fullscreen mode Exit fullscreen mode

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" });
  });
});
Enter fullscreen mode Exit fullscreen mode

Run the tests:

npx vitest run
Enter fullscreen mode Exit fullscreen mode

You should see:

 Test Files  1 passed (1)
      Tests  11 passed (11)
Enter fullscreen mode Exit fullscreen mode

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<LookupResult>;
}

/** Lookup gave no answer after the retries. */
export class LookupUnavailableError extends Error {}

/** Lookup returned 404: the number isn't in its data set. */
export class NumberNotFoundError extends Error {}

export interface RetryOptions {
  maxAttempts: number;
  baseDelayMs: number;
  sleep?: (delayMs: number) => Promise<void>;
}

export const DEFAULT_RETRY: RetryOptions = { maxAttempts: 3, baseDelayMs: 200 };

/** The SDK's RequestFailedError has the HTTP status in `statusCode`. */
export const getStatusCode = (error: unknown): number | undefined => {
  const statusCode = (error as { statusCode?: unknown } | null)?.statusCode;
  return typeof statusCode === "number" ? statusCode : undefined;
};

/** The SDK's RequestFailedError has the response body as a JSON string in `data`. */
export const getErrorBody = (error: unknown): Record<string, unknown> | undefined => {
  const data = (error as { data?: unknown } | null)?.data;
  if (typeof data !== "string") {
    return undefined;
  }
  try {
    const body: unknown = JSON.parse(data);
    return typeof body === "object" && body !== null ? (body as Record<string, unknown>) : undefined;
  } catch {
    return undefined; // Not JSON: the error isn't recognized, so it isn't retried.
  }
};

/**
 * Retry 429, 5xx, and errors without a status (timeouts and network failures). Of the 403s, only
 * retry undeclared use cases: A new declaration can take a few minutes to apply.
 */
const isRetryable = (error: unknown): boolean => {
  const statusCode = getStatusCode(error);
  if (statusCode === 403) {
    return getErrorBody(error)?.["title"] === "Line type use cases not specified.";
  }
  return statusCode === undefined || statusCode === 429 || statusCode >= 500;
};

export const lookupNumber = async (
  client: LookupClient,
  phoneNumber: string,
  features: string[] = ["LineType"],
  retry: RetryOptions = DEFAULT_RETRY,
): Promise<LookupResult> => {
  const sleep = retry.sleep ?? ((delayMs: number) => new Promise<void>((resolve) => setTimeout(resolve, delayMs)));
  let lastError: unknown;

  for (let attempt = 1; attempt <= retry.maxAttempts; attempt += 1) {
    try {
      return await client.lookup({ numberLookupRequestBody: { number: phoneNumber, features } });
    } catch (error) {
      if (getStatusCode(error) === 404) {
        throw new NumberNotFoundError("Number not found", { cause: error });
      }
      if (!isRetryable(error)) {
        throw error;
      }
      lastError = error;
      if (attempt < retry.maxAttempts) {
        await sleep(retry.baseDelayMs * 2 ** (attempt - 1));
      }
    }
  }
  throw new LookupUnavailableError(`Lookup unavailable after ${retry.maxAttempts} attempts`, { cause: lastError });
};
Enter fullscreen mode Exit fullscreen mode

A timeout or a network failure throws without a statusCode. HTTP 429, 5xx, timeouts, network errors, and the 403 for undeclared use cases are retried. HTTP 400 for a malformed number, 401 for a wrong key, and the other 403s, for a disabled country or feature, won't change on a retry, so they stop right away. The three 403s share a status code, so the code reads the title from the body to tell them apart.

Three attempts at up to two seconds each, plus 600 milliseconds of waiting, put the worst case for one lookup at about 6.6 seconds, and the first call also fetches an OAuth token. Lower maxAttempts if your signup needs a faster answer.

Start the verification

Create src/verify.ts. It starts an SMS or phone call verification, and wraps a failed start so the caller can tell a denial from an outage:

import { getErrorBody, getStatusCode } from "./lookup.js";
import type { VerificationMethod } from "./types.js";

type Identity = { type: "number"; endpoint: string };

/** The part of the SDK's `sinch.verification.verifications` that this project uses. */
export interface VerificationsClient {
  startSms(data: { startVerificationWithSmsRequestBody: { identity: Identity } }): Promise<{ id?: string }>;
  startPhoneCall(data: { startVerificationWithPhoneCallRequestBody: { identity: Identity } }): Promise<{ id?: string }>;
}

/** The start failed. `errorCode` is the Verification API's own code, for example 42202. */
export class VerificationStartError extends Error {
  readonly statusCode: number | undefined;
  readonly errorCode: number | undefined;

  constructor(error: unknown) {
    const statusCode = getStatusCode(error);
    const errorCode = getErrorBody(error)?.["errorCode"];
    super(`Verification start failed: HTTP ${statusCode ?? "none"}, error code ${String(errorCode ?? "none")}`, {
      cause: error,
    });
    this.statusCode = statusCode;
    this.errorCode = typeof errorCode === "number" ? errorCode : undefined;
  }

  /** Error code 40303: the approval callback denied the start, or fraud protection stopped it. */
  get denied(): boolean {
    return this.errorCode === 40303;
  }
}

export type StartVerification = (
  phoneNumber: string,
  method: VerificationMethod,
) => Promise<{ verificationId: string | undefined }>;

export const createStartVerification =
  (client: VerificationsClient): StartVerification =>
  async (phoneNumber, method) => {
    const identity: Identity = { type: "number", endpoint: phoneNumber };
    try {
      const response =
        method === "sms"
          ? await client.startSms({ startVerificationWithSmsRequestBody: { identity } })
          : await client.startPhoneCall({ startVerificationWithPhoneCallRequestBody: { identity } });
      return { verificationId: response.id };
    } catch (error) {
      throw new VerificationStartError(error);
    }
  };
Enter fullscreen mode Exit fullscreen mode

Create src/gate.ts. It validates the number, looks it up, decides, and starts a verification when the decision is to send:

import { decide } from "./decide.js";
import { LookupUnavailableError, NumberNotFoundError } from "./lookup.js";
import type { Decision, LookupResult } from "./types.js";
import { VerificationStartError, type StartVerification } from "./verify.js";

export type FailureMode = "fail-open" | "fail-closed";

export class InvalidPhoneNumberError extends Error {}

export interface GateDeps {
  lookup: (phoneNumber: string) => Promise<LookupResult>;
  /** Leave undefined for a dry run that only reports the decision. */
  startVerification?: StartVerification;
  failureMode: FailureMode;
}

export interface GateOutcome {
  decision: Decision;
  lookupFailed: boolean;
  lookupResult?: LookupResult;
  verificationId?: string;
}

export const isE164 = (phoneNumber: string): boolean => /^\+[1-9]\d{6,14}$/.test(phoneNumber);

/**
 * Looks the number up, decides, and starts a verification when the decision is "send".
 * A 404 from Lookup and a denied start become "block". When Lookup stays unavailable,
 * fail-open sends an SMS and fail-closed rethrows. Every other error reaches the caller.
 */
export const gate = async (phoneNumber: string, deps: GateDeps): Promise<GateOutcome> => {
  if (!isE164(phoneNumber)) {
    throw new InvalidPhoneNumberError(`Not an E.164 phone number: ${phoneNumber}`);
  }

  const outcome: GateOutcome = { decision: { action: "block", reason: "" }, lookupFailed: false };
  try {
    outcome.lookupResult = await deps.lookup(phoneNumber);
    outcome.decision = decide(outcome.lookupResult);
  } catch (error) {
    if (error instanceof NumberNotFoundError) {
      outcome.decision = { action: "block", reason: "number not found" };
    } else if (error instanceof LookupUnavailableError && deps.failureMode === "fail-open") {
      outcome.lookupFailed = true;
      outcome.decision = { action: "send", method: "sms", reason: "lookup unavailable, failing open" };
    } else {
      throw error;
    }
  }

  if (outcome.decision.action === "send" && deps.startVerification) {
    try {
      const { verificationId } = await deps.startVerification(phoneNumber, outcome.decision.method);
      if (verificationId !== undefined) {
        outcome.verificationId = verificationId;
      }
    } catch (error) {
      if (!(error instanceof VerificationStartError && error.denied)) {
        throw error;
      }
      outcome.decision = { action: "block", reason: "verification start denied" };
    }
  }
  return outcome;
};
Enter fullscreen mode Exit fullscreen mode

Two errors become block decisions. A 404 means the number isn't in the Lookup data set, and this code treats that as a reason not to send. In testing, numbers in ranges set aside for fiction returned 404. A start that fails with error code 40303 is blocked too: Your approval callback denied it, or fraud protection stopped it. Every other error reaches the caller.

Run it

Create scripts/check.ts. It runs the gate for one number and prints the outcome. It starts a verification only when you pass --send:

import {
  createLookupClient,
  createVerificationClient,
  credentialsFromEnv,
  verificationCredentialsFromEnv,
} from "../src/env.js";
import { gate } from "../src/gate.js";
import { lookupNumber } from "../src/lookup.js";
import { createStartVerification } from "../src/verify.js";

const [phoneNumber, ...flags] = process.argv.slice(2);
if (!phoneNumber) {
  console.error("Usage: npx tsx scripts/check.ts +46701234567 [--send] [--fail-closed]");
  process.exit(1);
}

// Starting a verification also needs the application key and secret.
const send = flags.includes("--send");
const credentials = send ? verificationCredentialsFromEnv() : credentialsFromEnv();
const lookupClient = createLookupClient(credentials);
const startVerification = send
  ? createStartVerification(createVerificationClient(credentials).verifications)
  : undefined;

try {
  const outcome = await gate(phoneNumber, {
    lookup: (number) => lookupNumber(lookupClient, number),
    ...(startVerification ? { startVerification } : {}),
    failureMode: flags.includes("--fail-closed") ? "fail-closed" : "fail-open",
  });
  const { decision, lookupFailed, lookupResult, verificationId } = outcome;
  const printed = { decision, lookupFailed, line: lookupResult?.line, traceId: lookupResult?.traceId, verificationId };
  console.log(JSON.stringify(printed, null, 2));
} catch (error) {
  console.error(error instanceof Error ? error.message : error);
  process.exitCode = 1;
}
Enter fullscreen mode Exit fullscreen mode

Check your own number:

npx tsx scripts/check.ts YOUR_PHONE_NUMBER
Enter fullscreen mode Exit fullscreen mode

For a mobile number you should see:

{
  "decision": {
    "action": "send",
    "method": "sms",
    "reason": "mobile line"
  },
  "lookupFailed": false,
  "line": {
    "carrier": "Telia Sverige AB",
    "type": "Mobile",
    "mobileCountryCode": "240",
    "mobileNetworkCode": "01"
  },
  "traceId": "4ca9dd249c2547c914ef623d3c032716"
}
Enter fullscreen mode Exit fullscreen mode

A VoIP number gets a challenge, and a landline gets a phone call:

{
  "decision": {
    "action": "step-up",
    "reason": "line type VoIP"
  },
  "lookupFailed": false,
  "line": {
    "carrier": "Sinch Voice",
    "type": "VoIP"
  },
  "traceId": "7886cdb869cebc7f218d8024ab8a2d76"
}
Enter fullscreen mode Exit fullscreen mode
{
  "decision": {
    "action": "send",
    "method": "callout",
    "reason": "landline cannot receive SMS"
  },
  "lookupFailed": false,
  "line": {
    "carrier": "DIDWW Ireland Limited",
    "type": "Landline"
  },
  "traceId": "ad30d8578ba9bd182f68e4cce0854a44"
}
Enter fullscreen mode Exit fullscreen mode

+12025550123 is in a range set aside for fiction. In testing it returned 404, so it's blocked:

npx tsx scripts/check.ts +12025550123
Enter fullscreen mode Exit fullscreen mode
{
  "decision": {
    "action": "block",
    "reason": "number not found"
  },
  "lookupFailed": false
}
Enter fullscreen mode Exit fullscreen mode

To use SIM swap and VoIP detection once they're enabled for your project, request them in the lookup line of scripts/check.ts:

    lookup: (number) => lookupNumber(lookupClient, number, ["LineType", "SimSwap", "VoIPDetection"]),
Enter fullscreen mode Exit fullscreen mode

A feature that returns an error leaves the decision to the other signals.

To send a real code, add --send. It needs SINCH_APP_KEY and SINCH_APP_SECRET too, and stops with a message if either is missing:

npx tsx scripts/check.ts YOUR_PHONE_NUMBER --send
Enter fullscreen mode Exit fullscreen mode

You should see the same decision with a verificationId, and an SMS arrives:

{
  "decision": {
    "action": "send",
    "method": "sms",
    "reason": "mobile line"
  },
  "lookupFailed": false,
  "line": {
    "carrier": "Telia Sverige AB",
    "type": "Mobile",
    "mobileCountryCode": "240",
    "mobileNetworkCode": "01"
  },
  "traceId": "4bb8a902474ad56216ef42d56bab57a0",
  "verificationId": "01a12046-c7d4-83ab-367e-000000000000"
}
Enter fullscreen mode Exit fullscreen mode

The CLI is a demonstration. A public signup endpoint still owns request limits, spend controls, the challenge state, and whether the verification completed. In that route, gate takes the place of the start call, and your code acts on the decision. The sketch assumes deps includes startVerification. Without it, gate is a dry run and returns send without starting anything:

const { decision, verificationId } = await gate(phoneNumber, deps);
if (decision.action === "send") {
  // A code is on its way. Keep verificationId for the report.
} else if (decision.action === "step-up") {
  // Show your challenge, and start the verification after it passes.
} else {
  // Blocked: Ask for another number.
}
Enter fullscreen mode Exit fullscreen mode

The gate keeps no record of challenges, so the same number gets the same step-up until your flow handles it: Validate the challenge on your server, tie the result to the number and the signup attempt, and then start the verification with a method the number supports. Special, Freephone, and Other numbers take the same path.

Check the code

A verification ID means the code went out, not that the number is verified. Create scripts/report.ts. It reports the code from the SMS:

import { createVerificationClient, requireEnv, verificationCredentialsFromEnv } from "../src/env.js";

const verification = createVerificationClient(verificationCredentialsFromEnv());
const verificationId = requireEnv("VERIFICATION_ID");
const code = requireEnv("CODE");

try {
  const report = await verification.verifications.reportSmsById({
    id: verificationId,
    reportSmsVerificationByIdRequestBody: { sms: { code } },
  });
  // Continue the signup only when status is SUCCESSFUL.
  console.log("Report:", JSON.stringify(report));
} catch (error) {
  // A wrong code throws with HTTP 400 and error code 40003.
  const { statusCode, data, message } = error as { statusCode?: number; data?: string; message?: string };
  console.error(statusCode ? `Report failed with HTTP ${statusCode}: ${data}` : `Report failed: ${message}`);
  process.exitCode = 1;
}
Enter fullscreen mode Exit fullscreen mode

Check the types of every file:

npx tsc --noEmit
Enter fullscreen mode Exit fullscreen mode

You should see no output. Run the script with the ID from the output and the code from the SMS:

VERIFICATION_ID=YOUR_VERIFICATION_ID CODE=YOUR_SMS_CODE npx tsx scripts/report.ts
Enter fullscreen mode Exit fullscreen mode

You should see:

Report: {"id":"01a12046-c7d4-83ab-367e-000000000000","method":"sms","status":"SUCCESSFUL"}
Enter fullscreen mode Exit fullscreen mode

Continue the signup only when status is SUCCESSFUL. A phone call verification reports the spoken code with reportPhoneCallById, which the Other methods table in the previous post lists.

In my own run, a mobile number went through Lookup, the gate started an SMS verification, the code arrived, and reporting it returned SUCCESSFUL.

Design decisions

A few things I learned building this:

Fail open when Lookup is down

If Lookup stays unavailable after the retries, the gate can send an SMS without the check or refuse the signup. It sends by default, because a short Lookup outage shouldn't stop real users, and the verification still runs. Pass --fail-closed, or set failureMode to fail-closed in your own code, when sending to an unchecked number is worse than a failed signup. The setting covers an outage only:

What happens fail-open fail-closed
Lookup unavailable after three attempts Sends an SMS Stops with an error
A response without a usable line type, and no other signal asking for a challenge Sends an SMS Sends an SMS
HTTP 404 "Number not found." Blocks Blocks
HTTP 400, 401, or another 403 Stops with an error Stops with an error

To stop sending when there's no line type data, change the last line of decide to return step-up. That applies in both modes, because decide doesn't know the failure mode.

Block only what Lookup can't find

A VoIP number can belong to a real person, and line type isn't corrected for ported numbers outside the US and Canada. That's why VoIP gets a challenge instead of a block, and why the only block based on Lookup is a 404. The trade-off is that a real number missing from the Lookup data set gets refused. If that costs you more than a wasted send, return step-up for it in src/gate.ts instead.

Retry only what a retry can fix

A 403 is normally permanent. The exception is "Line type use cases not specified." The code retries that one 403, so a lookup made while a new declaration is still applying gets another chance, and no other 403 is retried. One lookup is at most three requests, and each lookup happens right before the start call. Nothing is cached: A lookup takes a few hundred milliseconds, so there's little to gain, and check the Lookup terms before you keep results.

Troubleshooting

Lookup returns 403 with "Line type use cases not specified"

Declare your use cases under Number Lookup and Access Requirements. A new declaration can take a few minutes to apply. Wait and run the check again.

Every request fails with HTTP 401

Lookup uses the project access key, not the Verification app's key. Check SINCH_PROJECT_ID, SINCH_KEY_ID, and SINCH_KEY_SECRET.

The start fails with error code 42202

In testing, this meant the Verification app had a Callback URL that didn't answer. Start the callback server from the previous post, or clear the field and give it a few minutes to apply.

The start fails with error code 42901

The Verification API limits how often you can start a verification for the same number. In testing, a second start for the same number within seconds returned 429 with error code 42901. Wait before you try again.

A landline fails with error code 40001

The message lists the methods enabled for your app. Contact Sinch to enable phone call verification, or return step-up for landlines in src/decide.ts.

Clean up

If you created an access key only for this post, delete it under Settings and Access keys. If you cleared the Callback URL and rely on the approval callback, set it again.

Next steps

The gate covers the verifications your backend starts. Verifications started from the mobile SDKs skip it, and the approval callback from the previous post is where you can catch them. The callback answers only allow or deny, so treat step-up and block as deny there, and give the lookup a shorter deadline than 6.6 seconds, because the callback has about 15 seconds for everything it does.

Additional resources

How does your signup flow handle VoIP numbers today, and would you fail open or closed when Lookup is down? Let me know in the comments.

Top comments (0)