Build a Phone Verification Flow
Step-by-step tutorial for building a complete phone verification flow with Node.js and Express using the ScamVerify™ API.
This tutorial walks you through building a complete phone verification flow in Node.js with Express. By the end, you will have a working API endpoint that accepts a phone number, calls the ScamVerify™ API, interprets the verdict, and returns a clear result to your user.
Prerequisites
- Node.js 18 or later
- A ScamVerify™ API key (get one at scamverify.ai/settings/api)
- Basic familiarity with Express.js
What You Will Build
A /verify endpoint that:
- Accepts a phone number from a form or API call
- Validates the input format
- Calls the ScamVerify™ phone lookup API
- Interprets the risk score and verdict
- Returns a structured result to the user
- Handles errors gracefully
Set up the project
Create a new directory and initialize the project.
mkdir phone-verifier && cd phone-verifier
npm init -y
npm install express dotenvCreate a .env file with your API key:
SCAMVERIFY_API_KEY=sv_live_your_key_here
PORT=3000Create the ScamVerify™ client
Create a reusable client module that handles API communication, retries, and error classification.
// scamverify.js
class ScamVerifyClient {
constructor(apiKey) {
if (!apiKey || !apiKey.startsWith('sv_')) {
throw new Error('Invalid ScamVerify API key. Keys must start with sv_live_ or sv_test_');
}
this.apiKey = apiKey;
this.baseUrl = 'https://scamverify.ai/api/v1';
}
async lookupPhone(phoneNumber) {
const response = await fetch(`${this.baseUrl}/phone/lookup`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ phone_number: phoneNumber }),
});
if (response.status === 401) {
throw new Error('Invalid or revoked API key');
}
if (response.status === 402) {
throw new Error('Quota exhausted. Upgrade your plan or wait for the monthly reset.');
}
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After') || '60';
throw new Error(`Rate limited. Retry after ${retryAfter} seconds.`);
}
if (!response.ok) {
throw new Error(`ScamVerify API error: ${response.status}`);
}
return response.json();
}
}
module.exports = { ScamVerifyClient };Build the verification endpoint
Create the Express server with input validation, API call, and result interpretation.
// server.js
require('dotenv').config();
const express = require('express');
const { ScamVerifyClient } = require('./scamverify');
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const client = new ScamVerifyClient(process.env.SCAMVERIFY_API_KEY);
// Phone number format validation
function isValidPhoneFormat(phone) {
// Accept E.164, (xxx) xxx-xxxx, xxx-xxx-xxxx, and 10-digit formats
const cleaned = phone.replace(/[\s\-\(\)\.]/g, '');
if (cleaned.startsWith('+1') && cleaned.length === 12) return true;
if (cleaned.startsWith('1') && cleaned.length === 11) return true;
if (cleaned.length === 10 && /^\d+$/.test(cleaned)) return true;
return false;
}
// Normalize to E.164 format
function normalizePhone(phone) {
const digits = phone.replace(/\D/g, '');
if (digits.length === 10) return `+1${digits}`;
if (digits.length === 11 && digits.startsWith('1')) return `+${digits}`;
return phone; // Already in E.164 or let the API handle it
}
// Interpret the verdict into a user-friendly result
function interpretResult(apiResponse) {
const { risk_score, verdict, explanation, signals, cached } = apiResponse;
const levels = {
safe: { label: 'Safe', color: 'green', action: 'This number appears legitimate.' },
low_risk: { label: 'Low Risk', color: 'blue', action: 'This number is probably safe, but exercise normal caution.' },
medium_risk: { label: 'Medium Risk', color: 'yellow', action: 'This number has some risk factors. Proceed with caution.' },
high_risk: { label: 'High Risk', color: 'orange', action: 'This number has significant risk indicators. We recommend avoiding it.' },
critical: { label: 'Dangerous', color: 'red', action: 'This number is associated with known scam activity. Do not engage.' },
};
const level = levels[verdict] || levels.medium_risk;
return {
riskScore: risk_score,
verdict: level.label,
color: level.color,
recommendation: level.action,
explanation,
details: {
carrier: signals.carrier || 'Unknown',
lineType: signals.line_type || 'Unknown',
ftcComplaints: signals.ftc_complaints || 0,
fccComplaints: signals.fcc_complaints || 0,
robocallDetected: signals.robocall_detected || false,
communityReports: signals.community_reports || 0,
},
cached,
};
}
// POST /verify - Main verification endpoint
app.post('/verify', async (req, res) => {
const { phone_number } = req.body;
// Validate input
if (!phone_number) {
return res.status(400).json({
error: 'Missing phone_number field',
hint: 'Send a JSON body with {"phone_number": "+12025551234"}',
});
}
if (!isValidPhoneFormat(phone_number)) {
return res.status(400).json({
error: 'Invalid phone number format',
hint: 'Use E.164 format (+12025551234), (202) 555-1234, or 2025551234',
});
}
try {
const normalized = normalizePhone(phone_number);
const apiResponse = await client.lookupPhone(normalized);
const result = interpretResult(apiResponse);
return res.json({
success: true,
phoneNumber: normalized,
...result,
});
} catch (error) {
console.error('Verification failed:', error.message);
if (error.message.includes('Quota exhausted')) {
return res.status(503).json({
error: 'Service temporarily unavailable',
message: 'Verification quota has been reached. Please try again later.',
});
}
if (error.message.includes('Rate limited')) {
return res.status(429).json({
error: 'Too many requests',
message: 'Please wait a moment before trying again.',
});
}
return res.status(500).json({
error: 'Verification failed',
message: 'Unable to verify this phone number. Please try again.',
});
}
});
// GET /health - Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', apiKeyConfigured: !!process.env.SCAMVERIFY_API_KEY });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Phone verification server running on port ${PORT}`);
});Test the endpoint
Start the server and test with cURL.
node server.jsIn another terminal:
# Test with E.164 format
curl -X POST http://localhost:3000/verify \
-H "Content-Type: application/json" \
-d '{"phone_number": "+12025551234"}'
# Test with standard US format
curl -X POST http://localhost:3000/verify \
-H "Content-Type: application/json" \
-d '{"phone_number": "(202) 555-1234"}'
# Test with missing number (should return 400)
curl -X POST http://localhost:3000/verify \
-H "Content-Type: application/json" \
-d '{}'Handle the response in your frontend
Here is how you might call the /verify endpoint from a web form.
<form id="verifyForm">
<input type="tel" id="phoneInput" placeholder="(555) 123-4567" required />
<button type="submit">Verify Number</button>
</form>
<div id="result"></div>
<script>
document.getElementById('verifyForm').addEventListener('submit', async (e) => {
e.preventDefault();
const phone = document.getElementById('phoneInput').value;
const resultDiv = document.getElementById('result');
resultDiv.textContent = 'Checking...';
try {
const response = await fetch('/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone_number: phone }),
});
const data = await response.json();
if (data.success) {
resultDiv.innerHTML = `
<strong>${data.verdict}</strong> (Score: ${data.riskScore}/100)<br>
${data.recommendation}<br>
<small>Carrier: ${data.details.carrier} | Type: ${data.details.lineType}</small>
`;
} else {
resultDiv.textContent = data.message || data.error;
}
} catch (err) {
resultDiv.textContent = 'Failed to verify. Please try again.';
}
});
</script>Add error handling best practices
For production use, add retry logic for transient failures.
async function lookupWithRetry(client, phoneNumber, maxRetries = 2) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await client.lookupPhone(phoneNumber);
} catch (error) {
// Do not retry auth or quota errors
if (error.message.includes('Invalid') || error.message.includes('Quota')) {
throw error;
}
if (attempt === maxRetries) {
throw error;
}
// Wait before retrying (exponential backoff)
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}Complete Project Structure
phone-verifier/
.env # API key and port
scamverify.js # API client module
server.js # Express server with /verify endpoint
package.jsonUse test keys for development. Replace sv_live_ with sv_test_ in your .env file during development. Test keys return realistic mock data without consuming your quota.
Next Steps
- Phone Lookup API Reference for full request and response schemas
- Authentication for key management best practices
- Fintech KYC Use Case for a deeper dive on KYC integration