DEV Community

Cover image for Phone-Based User Verification in TypeScript and Python
Gunnar Grosch
Gunnar Grosch

Posted on

Phone-Based User Verification in TypeScript and Python

A user signs up for your app. You need to confirm the phone number they entered actually belongs to them. A returning user wants to log in without a password. An account change needs a second factor before it goes through.

The underlying pattern is always the same: send a code to the device, wait for the user to confirm it.

The Sinch Verification API handles this with a two-step flow. Your backend calls start to trigger code delivery, and report to validate what the user submits. It supports four methods: SMS, FlashCall (a missed call where the incoming caller ID is the code), voice (text-to-speech), and Data Verification (silent authentication through the carrier's network, no user input at all). The payload shape is the same for all four, so switching methods is a one-field change.

This post covers the backend piece. Whether your frontend is a web app, iOS, or Android, the pattern is the same: your client calls your API, your API calls Sinch. A mobile app calling a Lambda function, a web form submitting to an Express server, a Next.js API route. The code here works for all of them.

This post uses the official Sinch Node.js SDK. Python and no-dependency Node.js examples are in the raw HTTP section at the end.

Prerequisites

You'll need a Sinch account (sign up here, a trial account is enough to test, see pricing for details) and Node.js 18+ or Python 3.9+.

In the Sinch Dashboard:

  1. Create a Verification app. Go to Verification > Apps and click New app. Enter a name and optional description.
  2. Copy your App Key and App Secret. They appear on the right side of the creation dialog under "Your summary". Copy both before closing.
  3. Set the authentication level. In the Settings panel on the left, find Minimal Authentication Level and set it to Application. This ensures only signed requests (like the ones in this post) are accepted. The default is Public, which allows unsigned requests and is intended for mobile clients calling Sinch directly.

Note: on a trial account you can only verify numbers on your verified numbers list. To verify any number, upgrade your account.

How verification works

The flow is two steps:

  1. Start: your backend calls the Verification API with the phone number and method. Sinch delivers the code to the device.
  2. Report: the user enters the code. Your backend calls the API with the phone number and code. Sinch returns SUCCESSFUL or FAILED.

For SMS and voice, the code is a numeric OTP. For FlashCall, the "code" is the full incoming caller ID from the missed call. For Data Verification there is no report step: Sinch authenticates the device silently through the carrier's network and returns the result in the start response.

The SDK approach (Node.js)

The Node.js SDK handles authentication internally. You initialize it once with your credentials. The Verification API has method-specific calls for start and report rather than a single generic endpoint, so each method gets its own SDK call.

Note: the Python SDK doesn't include Verification yet. Python developers can use the raw HTTP section below.

Install the SDK:

npm install @sinch/sdk-core
Enter fullscreen mode Exit fullscreen mode
import { SinchClient } from "@sinch/sdk-core";

const sinchClient = new SinchClient({
  applicationKey: process.env.SINCH_APP_KEY!,
  applicationSecret: process.env.SINCH_APP_SECRET!,
});

const E164_REGEX = /^\+[1-9]\d{1,14}$/;

export async function startVerification(
  phoneNumber: string,
  method: "sms" | "flashcall" | "callout" | "data" = "sms"
) {
  if (!E164_REGEX.test(phoneNumber)) {
    throw new Error(`Invalid phone number format. Use E.164 (e.g. +15551234567), got: ${phoneNumber}`);
  }

  if (method === "sms") {
    return sinchClient.verification.verifications.startSms({
      startVerificationWithSmsRequestBody: {
        identity: { type: "number", endpoint: phoneNumber },
      },
    });
  }
  if (method === "flashcall") {
    return sinchClient.verification.verifications.startFlashCall({
      startVerificationWithFlashCallRequestBody: {
        identity: { type: "number", endpoint: phoneNumber },
      },
    });
  }
  if (method === "callout") {
    return sinchClient.verification.verifications.startPhoneCall({
      startVerificationWithPhoneCallRequestBody: {
        identity: { type: "number", endpoint: phoneNumber },
      },
    });
  }
  return sinchClient.verification.verifications.startData({
    startDataVerificationRequestBody: {
      identity: { type: "number", endpoint: phoneNumber },
    },
  });
}

export async function reportVerification(
  phoneNumber: string,
  code: string,
  method: "sms" | "flashcall" | "callout" = "sms"
) {
  if (method === "sms") {
    return sinchClient.verification.verifications.reportSmsByIdentity({
      endpoint: phoneNumber,
      reportSmsVerificationByIdentityRequestBody: { sms: { code } },
    });
  }
  if (method === "flashcall") {
    return sinchClient.verification.verifications.reportFlashCallByIdentity({
      endpoint: phoneNumber,
      reportFlashCallVerificationByIdentityRequestBody: { flashCall: { cli: code } },
    });
  }
  return sinchClient.verification.verifications.reportPhoneCallByIdentity({
    endpoint: phoneNumber,
    reportPhoneCallVerificationByIdentityRequestBody: { phoneCall: { code } },
  });
}
Enter fullscreen mode Exit fullscreen mode

Call startVerification when the user submits their phone number, and reportVerification when they submit the code:

// Step 1: user submits phone number
const started = await startVerification("+15559876543", "sms");
console.log(started.id);

// Step 2: user submits the code from their device
const result = await reportVerification("+15559876543", "123456", "sms");
if (result.status === "SUCCESSFUL") {
  // issue session token, complete login, approve account change, etc.
}
Enter fullscreen mode Exit fullscreen mode

A successful start response:

{
  "id": "01ABC123DEF456GHI789JKL012",
  "method": "sms",
  "sms": {
    "template": "Your verification code is {{CODE}}. Verified by Sinch",
    "interceptionTimeout": 198
  }
}
Enter fullscreen mode Exit fullscreen mode

The response includes additional fields (_links, etc.) that you don't need for the basic flow.

A successful report response:

{
  "id": "01ABC123DEF456GHI789JKL012",
  "method": "sms",
  "status": "SUCCESSFUL",
  "source": "manual"
}
Enter fullscreen mode Exit fullscreen mode

If the code is wrong or expired, the SDK throws. Treat that as a prompt for the user to request a new code, not a retry of the same one.

Raw HTTP

If you're using Python, or prefer no runtime dependency in Node.js, the same flow works with plain HTTP. Each request must be signed with HMAC-SHA256 using your App Secret. The signature covers the HTTP method, a hash of the request body, the content type, a timestamp, and the request path. This lets the server verify the request hasn't been tampered with and isn't a replay.

Python

import hashlib
import hmac as hmac_mod
import base64
import json
import os
import re
import urllib.request
import urllib.error
import urllib.parse
from datetime import datetime, timezone

APP_KEY = os.environ["SINCH_APP_KEY"]
APP_SECRET = os.environ["SINCH_APP_SECRET"]

CONTENT_TYPE = "application/json; charset=UTF-8"
E164_REGEX = re.compile(r"^\+[1-9]\d{1,14}$")


def sign(method, path, body):
    timestamp = datetime.now(timezone.utc).isoformat()
    content_md5 = base64.b64encode(hashlib.md5(body.encode()).digest()).decode() if body else ""
    string_to_sign = f"{method}\n{content_md5}\n{CONTENT_TYPE}\nx-timestamp:{timestamp}\n{path}"
    signature = base64.b64encode(
        hmac_mod.new(base64.b64decode(APP_SECRET), string_to_sign.encode(), hashlib.sha256).digest()
    ).decode()
    return f"Application {APP_KEY}:{signature}", timestamp


def sinch_request(url, http_method, payload):
    path = urllib.parse.urlparse(url).path
    body = json.dumps(payload)
    auth, timestamp = sign(http_method, path, body)
    req = urllib.request.Request(
        url, data=body.encode(),
        headers={"Content-Type": CONTENT_TYPE, "Authorization": auth, "x-timestamp": timestamp},
        method=http_method,
    )
    try:
        with urllib.request.urlopen(req) as resp:
            return json.loads(resp.read())
    except urllib.error.HTTPError as e:
        raise RuntimeError(f"Sinch API error {e.code}: {e.read().decode()}")


def start_verification(phone_number, method="sms"):
    if not E164_REGEX.match(phone_number):
        raise ValueError(f"Invalid phone number format. Use E.164 (e.g. +15551234567), got: {phone_number}")
    return sinch_request(
        "https://verification.api.sinch.com/verification/v1/verifications", "POST",
        {"identity": {"type": "number", "endpoint": phone_number}, "method": method},
    )


def report_verification(phone_number, code, method="sms"):
    body = {"method": method}
    if method == "sms": body["sms"] = {"code": code}
    elif method == "flashcall": body["flashcall"] = {"cli": code}
    elif method == "callout": body["callout"] = {"code": code}
    return sinch_request(
        f"https://verification.api.sinch.com/verification/v1/verifications/number/{phone_number}",
        "PUT", body,
    )
Enter fullscreen mode Exit fullscreen mode

Node.js (no SDK)

import { createHmac, createHash } from "crypto";

const APP_KEY = process.env.SINCH_APP_KEY!;
const APP_SECRET = process.env.SINCH_APP_SECRET!;

const CONTENT_TYPE = "application/json; charset=UTF-8";

function sign(method: string, path: string, body: string) {
  const timestamp = new Date().toISOString();
  const contentMd5 = body
    ? createHash("md5").update(body, "utf8").digest("base64")
    : "";
  const stringToSign = `${method}\n${contentMd5}\n${CONTENT_TYPE}\nx-timestamp:${timestamp}\n${path}`;
  const signature = createHmac("sha256", Buffer.from(APP_SECRET, "base64"))
    .update(stringToSign)
    .digest("base64");
  return { auth: `Application ${APP_KEY}:${signature}`, timestamp };
}

async function sinchRequest(method: string, url: string, body?: object) {
  const bodyStr = body ? JSON.stringify(body) : "";
  const path = new URL(url).pathname;
  const { auth, timestamp } = sign(method, path, bodyStr);
  const response = await fetch(url, {
    method,
    headers: { "Content-Type": CONTENT_TYPE, Authorization: auth, "x-timestamp": timestamp },
    ...(bodyStr && { body: bodyStr }),
  });
  if (!response.ok) throw new Error(`Sinch API error ${response.status}: ${await response.text()}`);
  return response.json();
}

export async function startVerification(phoneNumber: string, method = "sms") {
  return sinchRequest("POST", "https://verification.api.sinch.com/verification/v1/verifications", {
    identity: { type: "number", endpoint: phoneNumber },
    method,
  });
}

export async function reportVerification(phoneNumber: string, code: string, method = "sms") {
  const body: Record<string, unknown> = { method };
  if (method === "sms") body.sms = { code };
  else if (method === "flashcall") body.flashcall = { cli: code };
  else if (method === "callout") body.callout = { code };
  return sinchRequest(
    "PUT",
    `https://verification.api.sinch.com/verification/v1/verifications/number/${phoneNumber}`,
    body,
  );
}
Enter fullscreen mode Exit fullscreen mode

Both functions raise on a non-2xx response. A wrong or expired code returns HTTP 400. Catch that and prompt the user to request a new code.

Sinch also publishes SDKs for Java and .NET if those are your languages.

Things worth knowing

Verifications expire

A started verification is only valid for a short window: a few minutes for SMS. If the user takes too long, the report call will fail. Build your UI to handle this: show a "resend code" option and treat expiry errors as a prompt to start fresh, not a permanent failure.

Phone numbers must be E.164

The phone number must be in E.164 format: +15551234567, not 5551234567 or (555) 123-4567. Both the SDK and raw HTTP examples validate this before calling the API. The Sinch Verification API won't normalize it for you.

FlashCall code format

For FlashCall, the code you pass to reportVerification is the full incoming caller ID: the complete phone number that called the device. Your mobile client captures this from the call log and passes it to your backend.

Data Verification requires cellular data

Data Verification only works when the device is on cellular data. If the user is on Wi-Fi, the carrier can't authenticate the device. Configure a fallback chain: attempt data first, then fall back to FlashCall or SMS. The API supports this natively so you don't have to retry manually.

Wrapping up

Two functions, a clear two-step flow, and support for four verification methods. The SDK version needs no auth code. Initialize once and call. The raw HTTP version adds no dependencies at all. Both drop into any backend regardless of framework or platform.

If you're building a mobile app, Sinch also publishes native SDKs for iOS and Android. They handle the client side of verification: automatically capturing the incoming caller ID for FlashCall so the user never has to read it, and managing the carrier network handshake for Data Verification. The backend code in this post pairs directly with those SDKs.

Additional Resources

What are you building that needs user verification? Let me know in the comments.

Top comments (0)