# Phone Validation API Integration Testing: A Complete Guide for Developers

> Learn best practices for testing phone validation API integrations, including unit tests, mocking strategies, and production readiness checks.

Published 2026-02-28, updated 2026-10-03 by Marcus Whelan. Canonical: https://checkthatphone.com/blog/phone-validation-api-integration-testing-a-complete-guide-for-developers

A phone validation integration rarely breaks a signup flow because the API is down. It breaks because nobody tested what the code does when the API returns a 429. A burst of traffic trips the rate limit, the integration throws an unhandled exception, and real customers get a blank error page mid-registration. The lookup works fine in every demo. The parts that aren't the happy path never got tested.

That's the gap I want to close here. Wiring up a phone validation call takes an afternoon. Making the integration survive contact with production takes a test suite that exercises the ugly cases on purpose. Below is how I'd structure that, with examples using CheckThatPhone's official SDKs (`npm install checkthatphone` and `pip install checkthatphone`) against the real `POST https://api.checkthatphone.com/v1/lookup` response shape.

## What actually breaks in production

The lookup call itself is rarely the problem. The problems live around it:

- False rejections, where a valid customer gets bounced out of registration
- Bad data slipping through, so invalid numbers land in your database anyway
- Retries firing too aggressively, which turns a brief rate limit into a longer one (failed lookups aren't billed, but the delay lands on your users)
- Validation getting bypassed entirely, which opens the door to fraudulent accounts
- Slow or unhelpful failures that the user sees as a broken form

Every one of these is catchable in a test. None of them shows up if you only test that a known-good number comes back with `action: "send"`.

One trap deserves a mention up front: every flag in the response is a string. `deliverable`, `dipPorted`, `litigator` and the rest come back as `"true"` or `"false"`, and `"false"` is truthy in both JavaScript and Python. `if (data.deliverable)` passes for an undeliverable number. Compare explicitly, and pin it with a test.

## Set up a test environment that doesn't burn quota

Before you write a single test case, separate the two worlds. Unit tests should never touch the real API. Integration tests should, but rarely. Mixing them is how you wake up to a surprise bill from your CI runner looping a validation call ten thousand times.

### Build a real test dataset

Keep a fixture file with numbers that cover the cases that bite:

- Valid numbers in several formats: 212-555-0147, (212) 555-0147, +1 212 555 0147
- Invalid numbers: wrong length, area codes starting with 0 or 1, disconnected lines
- Edge cases: VoIP, landline, ported numbers
- Canadian numbers, such as (416) 555-0123

Canada shares the +1 country code and the same 10-digit format, so Canadian numbers look identical to US ones; cover both anyway. The API ignores formatting characters, accepts 10 digits or 11 starting with 1, and returns a 400 for anything else, including an area code that starts with 0 or 1. The formatting variants still matter, because a normalization bug in your own code usually surfaces here, not in the API. The 555-01XX numbers above are reserved fictitious numbers, fine for unit tests; for integration tests against the real API, use real numbers you own or have permission to look up.

### Mock the API for unit tests

For unit tests, don't call the network at all. The cleanest way is to inject the client into your code, so a test can pass a fake one:

```javascript
// signup.js: the code under test
const { CheckThatPhone, CheckThatPhoneError } = require('checkthatphone');

// Production wiring: build the real client once at startup.
// The SDK aborts a request after timeoutMs (default 15000).
function createClient() {
  return new CheckThatPhone(process.env.CHECKTHATPHONE_API_KEY, { timeoutMs: 3000 });
}

async function checkSignupPhone(client, phone) {
  try {
    const { data } = await client.lookup(phone);
    // Flags are strings: compare explicitly, never rely on truthiness.
    if (data.action === 'send' && data.dipCarrierType === 'mobile') return 'accept';
    return 'reject';
  } catch (err) {
    if (err instanceof CheckThatPhoneError && err.status === 400) return 'reject';
    return 'review'; // timeouts, 429s, 5xx: don't block a signup on an outage
  }
}

module.exports = { createClient, checkSignupPhone };
```

```javascript
// signup.test.js (Jest)
const { checkSignupPhone } = require('./signup');

// Shaped like a real response: every value is a string, booleans included.
const base = {
  subscriber: '2125550147',
  action: 'send',
  deliverable: 'true',
  nanpType: 'mobile',
  dipCarrier: 'Verizon',
  dipCarrierType: 'mobile',
  dipCarrierSubType: 'WIRELESS',
  dipPorted: 'false',
  geoState: 'NY',
  geoSource: 'area-code',
  timezone: 'America/New_York',
  error: 'false',
};
const withData = (fields) => ({ success: true, creditsUsed: 1, data: { ...base, ...fields } });

test('accepts a deliverable mobile number', async () => {
  const fake = { lookup: jest.fn().mockResolvedValue(withData({})) };
  await expect(checkSignupPhone(fake, '212-555-0147')).resolves.toBe('accept');
});

test('rejects an undeliverable number even though "false" is truthy', async () => {
  const fake = { lookup: jest.fn().mockResolvedValue(withData({ action: 'unsubscribe', deliverable: 'false' })) };
  await expect(checkSignupPhone(fake, '212-555-0147')).resolves.toBe('reject');
});
```

Now you can drive different response shapes through your code without spending a credit. This is where you exercise the branches: what does your handler do when `dipCarrier` is missing, when `action` is `"unsubscribe"`, when an add-on field like `litigator` is absent because you didn't request it? The full list of fields and their values is in the [docs](/docs).

## The test scenarios worth your time

### 1. Basic validation

Start with the obvious checks, then move on quickly. They catch wiring mistakes, not much else. Here's the Python version of the same pattern, with the client injected:

```python
# signup.py: the code under test
from checkthatphone import CheckThatPhoneError

def classify_signup(client, phone):
    try:
        data = client.lookup(phone).data
    except CheckThatPhoneError as err:
        # 400 means the number itself is malformed; anything else is on our side
        return "reject" if err.status == 400 else "review"
    if data.get("action") != "send":  # strings, so compare explicitly
        return "reject"
    if data.get("dipCarrierSubType") == "IPES":  # IP-Enabled Service, i.e. VoIP
        return "verify_by_email"
    return "accept"
```

```python
# test_signup.py (pytest)
from unittest import mock
from checkthatphone import CheckThatPhone, CheckThatPhoneError
from signup import classify_signup

def client_returning(**data):
    client = mock.Mock(spec=CheckThatPhone)
    client.lookup.return_value = mock.Mock(success=True, credits_used=1, data=data)
    return client

def test_deliverable_mobile_is_accepted():
    client = client_returning(action="send", deliverable="true", dipCarrierType="mobile")
    assert classify_signup(client, "212-555-0147") == "accept"

def test_undeliverable_number_is_rejected():
    client = client_returning(action="unsubscribe", deliverable="false",
                              reason="Not a valid mobile number")
    assert classify_signup(client, "212-555-0147") == "reject"

def test_malformed_number_is_rejected():
    client = mock.Mock(spec=CheckThatPhone)
    client.lookup.side_effect = CheckThatPhoneError(400, "Bad Request")
    assert classify_signup(client, "123") == "reject"
```

### 2. Line type detection

CheckThatPhone tells you whether a number is mobile, landline, or VoIP: `dipCarrierType` is `"mobile"`, `"landline"`, `"invalid"`, or `"undefined"`, and `dipCarrierSubType` names the carrier class, with `IPES` meaning VoIP. If you send SMS verification codes, this is the field that decides whether a signup can even receive the code. Test the branch:

```python
def test_voip_goes_to_email_verification():
    client = client_returning(action="send", dipCarrierSubType="IPES")
    assert classify_signup(client, "212-555-0147") == "verify_by_email"
```

VoIP is the one I'd watch. Plenty of legitimate users carry a Google Voice number, and plenty of throwaway fraud accounts do too. Whatever you decide, decide it in code and pin it with a test.

### 3. Carrier lookup

If carrier drives any routing or business rule, test it explicitly. `routeFor` here stands in for your own routing code:

```javascript
test('carrier-specific routing', async () => {
  const fake = { lookup: jest.fn().mockResolvedValue(withData({ dipCarrier: 'T-Mobile' })) };
  await expect(routeFor(fake, '212-555-0147')).resolves.toBe('priority-queue');
});
```

### 4. Number portability

`dipPorted` is `"true"` when the number's routing number (`dipLrn`) differs from the number itself, which means it has moved away from its original carrier. That matters when your logic assumes the carrier on file is current: `dipCarrier` is the carrier serving the number now, while `nanpCarrier` reflects the original numbering-plan assignment.

```python
def test_ported_number_uses_current_carrier():
    client = client_returning(action="send", dipPorted="true",
                              dipCarrier="T-Mobile", nanpCarrier="verizon")
    assert carrier_on_file(client, "212-555-0147") == "T-Mobile"  # your code
```

### 5. Geolocation

If you gate by region, verify the location data lines up with what you allow. Pass the user's IP in `ip` and the geo fields come from the IP (`geoSource: "ip"`); without it they come from the area code (`geoSource: "area-code"`). Test the case where `geoState` is missing too:

```javascript
test('geolocation restrictions', async () => {
  const fake = { lookup: jest.fn().mockResolvedValue(withData({ geoState: 'TX', geoSource: 'ip' })) };
  await expect(isAllowedRegion(fake, '212-555-0147', ['NY', 'CA', 'TX'])).resolves.toBe(true);

  const noGeo = { lookup: jest.fn().mockResolvedValue(withData({ geoState: undefined })) };
  await expect(isAllowedRegion(noGeo, '212-555-0147', ['NY', 'CA', 'TX'])).resolves.toBe(false);
});
```

## Error handling is the part everyone skips

This is where integrations usually break, so it gets its own section.

### API error responses

Test what your code does with each failure mode, not just success. The [docs](/docs) list them; none of them is billed:

- 400 Bad Request: a missing or malformed `phone` (10 digits, or 11 starting with 1). Retrying won't help.
- 401 Unauthorized: missing or invalid API key.
- 402: account paused pending verification.
- 429: rate limit exceeded, or the free tier's monthly quota is used up. Back off.
- 502 and 503: an upstream error or a brief busy period. Safe to retry; honor `Retry-After` on a 503.
- Network timeouts and connection errors.

Both SDKs raise `CheckThatPhoneError` with `status`, `code`, `detail`, and a `retryable` flag that is true for 429 and 5xx. Build your retry logic on that, and test it:

```python
import time
import pytest

def lookup_with_retry(client, phone, attempts=3, sleep=time.sleep):
    for attempt in range(attempts):
        try:
            return client.lookup(phone)
        except CheckThatPhoneError as err:
            if not err.retryable or attempt == attempts - 1:
                raise
            sleep(2 ** attempt)  # exponential backoff

def test_retries_after_429():
    client = mock.Mock(spec=CheckThatPhone)
    client.lookup.side_effect = [
        CheckThatPhoneError(429, "Rate limit exceeded"),
        mock.Mock(success=True, credits_used=1, data={"action": "send"}),
    ]
    result = lookup_with_retry(client, "212-555-0147", sleep=lambda s: None)
    assert result.data["action"] == "send"
    assert client.lookup.call_count == 2

def test_does_not_retry_a_bad_number():
    client = mock.Mock(spec=CheckThatPhone)
    client.lookup.side_effect = CheckThatPhoneError(400, "Bad Request")
    with pytest.raises(CheckThatPhoneError):
        lookup_with_retry(client, "123", sleep=lambda s: None)
    assert client.lookup.call_count == 1
```

### Timeouts

Pick a timeout, then test that you actually fail cleanly when you blow past it. An integration that hangs forever is worse than one that errors fast. The Node SDK takes `timeoutMs` (default 15000) and the Python SDK takes `timeout` in seconds (default 15.0); set yours to fit the form, not the default.

```javascript
test('a timeout sends the signup to review instead of failing it', async () => {
  const timeout = Object.assign(new Error('The operation timed out'), { name: 'TimeoutError' });
  const slow = { lookup: jest.fn().mockRejectedValue(timeout) };
  await expect(checkSignupPhone(slow, '212-555-0147')).resolves.toBe('review');
});
```

## Performance and load

### Response time

If your signup flow has a latency budget, hold the integration to it. This one runs against the real API, spends a credit, and belongs in the scheduled suite, not on every commit:

```python
import os
import time
import pytest
from checkthatphone import CheckThatPhone

@pytest.mark.integration
def test_lookup_fits_latency_budget():
    client = CheckThatPhone(api_key=os.environ["CHECKTHATPHONE_TEST_KEY"])
    start = time.monotonic()
    result = client.lookup(os.environ["CTP_TEST_NUMBER"])  # a real number you own
    duration = time.monotonic() - start

    assert duration < 2.0  # your budget, not a guarantee from the API
    assert result.data["action"] in ("send", "unsubscribe")
```

### Many numbers at once

When you validate many numbers through the API, confirm you get a result or a recorded error back for every input. A silent drop is easy to miss:

```python
def validate_all(client, numbers):
    results = {}
    for number in numbers:
        try:
            results[number] = client.lookup(number).data
        except CheckThatPhoneError as err:
            results[number] = {"error": err.code, "status": err.status}
    return results

def test_every_input_gets_a_result():
    client = mock.Mock(spec=CheckThatPhone)
    client.lookup.side_effect = [
        mock.Mock(data={"action": "send"}),
        CheckThatPhoneError(400, "Bad Request"),
        mock.Mock(data={"action": "unsubscribe"}),
    ]
    numbers = ["2125550147", "123", "4165550123"]
    assert set(validate_all(client, numbers)) == set(numbers)
```

For a whole list rather than a stream, skip the loop: the dashboard's [bulk CSV upload](/bulk-phone-validation) takes files up to 1M rows and returns every field per row.

## Before you ship

Run through this list before the integration goes live:

- [ ] All test scenarios passing consistently
- [ ] Error handling tested for every API error code you can hit (400, 401, 402, 429, 502, 503)
- [ ] Logging and monitoring configured
- [ ] API credentials in a secret store, never hardcoded (more in [phone validation API security best practices](/blog/phone-validation-api-security-best-practices-protecting-your-data-and-users))
- [ ] Rate limiting and retry logic implemented and tested
- [ ] Timeout values set for your use case
- [ ] At least one integration test against the real API, not only mocks
- [ ] Docs reviewed at [CheckThatPhone docs](/docs)
- [ ] A [pricing plan](/pricing) sized for your expected volume

## Fitting this into CI

A few rules keep a pipeline cheap and reliable:

1. Split the suites. Mocked unit tests run on every commit. Real-API integration tests run on a schedule or before release, not on every push.
2. Use a separate API key for tests (you can create and revoke keys in the dashboard), so you can rotate it without touching production.
3. Watch for flaky tests. A test that fails intermittently is often telling you something about the API or your retry logic.
4. Cache responses for expensive integration checks so CI isn't paying for the same lookup repeatedly. The free tier's 500 credits a month covers a modest scheduled suite.

The test worth writing first is the one that fires a 429 and asserts the user sees a retry or a fallback, not a stack trace. Write the test for the failure that scares you most, then work backward. Start with the docs at [CheckThatPhone docs](/docs), and if you're still choosing a provider, [choosing a phone validation API](/blog/choosing-phone-validation-api-features) lists what to look for. Then pick a [pricing plan](/pricing) that matches the volume you expect to test against.

---

CheckThatPhone is a phone validation API for US and Canada numbers: https://checkthatphone.com (docs: https://checkthatphone.com/docs.md, pricing: https://checkthatphone.com/pricing.md).
