Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![PyPI version](https://img.shields.io/pypi/v/checkthatphone)](https://pypi.org/project/checkthatphone/) [![CI](https://github.com/CheckThatPhone/checkthatphone-python/actions/workflows/ci.yml/badge.svg)](https://github.com/CheckThatPhone/checkthatphone-python/actions/workflows/ci.yml) [![python >= 3.9](https://img.shields.io/pypi/pyversions/checkthatphone)](https://pypi.org/project/checkthatphone/)

Official Python client for the [CheckThatPhone](https://checkthatphone.com) phone validation API. Validate US and Canadian phone numbers in real time: carrier and line type from live carrier data, portability and deliverability, GeoIP and timezone, plus optional TCPA litigator screening and a free state do-not-call scrub — one call, one credit.
Official Python client for the [CheckThatPhone](https://checkthatphone.com) phone validation API. Validate US and Canadian phone numbers in real time: carrier and line type from live carrier data, portability and deliverability, GeoIP and timezone, plus optional TCPA litigator screening and free state do-not-call and complainer scrubs — one call, one credit.

Zero dependencies (standard library only). Python 3.9+.

Expand Down Expand Up @@ -40,17 +40,22 @@ if result.data.get("litigator") == "true":
suppress(result.data["subscriber"])
```

## State DNC scrub (free)
## State DNC and complainer scrub (free)

Screen state do-not-call registries (40 states) and a national complainer list at no extra credit:
Screen state do-not-call registries (38 states plus DC) and a national complainer list at no extra credit. Each check is its own flag:

```python
result = client.lookup("8182925409", dnc_other=True)
result.data.get("dncStateResult") # "STATE DNC" or ""
result.data.get("dncComplainerResult") # "DNC COMPLAINER" or ""
result.data.get("dncStateCovered") # "false" = state not in the data; don't read "" as clear
result = client.lookup("8182925409", dnc_state=True, dnc_complainer=True)
result.data.get("dncStateChecked") # "true" when the state check ran; absent for the 12 states with no registry data
result.data.get("dncStateResult") # "STATE DNC" on a match, "" otherwise (only when dncStateChecked is "true")
result.data.get("dncComplainerChecked") # "true" when the complainer check ran
result.data.get("dncComplainerResult") # "DNC COMPLAINER" on a match, "" otherwise
```

Read `dncStateChecked` before `dncStateResult`: a number in an uncovered state comes back with neither field, which means not checked, not clear. `"error"` means the check could not complete; treat it as unavailable.

`dnc_other=True` still works as a shorthand for both checks, but it is deprecated.

## Landline SMS reachability

Some landlines can receive texts. Detect them instead of dropping them (+1 credit, charged only when the number is a landline):
Expand Down
25 changes: 21 additions & 4 deletions checkthatphone/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@

import json
import urllib.error
import warnings
import urllib.request
from typing import Any, Optional

__version__ = "0.1.0"
__version__ = "0.2.0"
__all__ = ["CheckThatPhone", "CheckThatPhoneError", "LookupResult"]

_DEFAULT_BASE_URL = "https://api.checkthatphone.com"
Expand Down Expand Up @@ -71,7 +72,7 @@ class CheckThatPhone:
from checkthatphone import CheckThatPhone

client = CheckThatPhone(api_key="ctp_live_...")
result = client.lookup("8182925409", litigator_filter=True, dnc_other=True)
result = client.lookup("8182925409", litigator_filter=True, dnc_state=True)
if result.data.get("litigator") == "true":
...
"""
Expand All @@ -92,6 +93,8 @@ def lookup(
ip: Optional[str] = None,
litigator_filter: bool = False,
landline_sms_lookup: bool = False,
dnc_state: bool = False,
dnc_complainer: bool = False,
dnc_other: bool = False,
) -> LookupResult:
"""Validate one US/Canada phone number.
Expand All @@ -104,7 +107,13 @@ def lookup(
litigator_filter: TCPA litigator scrub (+1 credit).
landline_sms_lookup: landline SMS reachability (+1 credit,
charged only when the number is a landline).
dnc_other: state DNC & complainers scrub (free).
dnc_state: state do-not-call registry check (free; 38 states
plus DC). Adds ``dncStateChecked`` and ``dncStateResult``;
both are absent for the 12 states with no registry data, so
absent means not checked, never clear.
dnc_complainer: national complainer-list check (free). Adds
``dncComplainerChecked`` and ``dncComplainerResult``.
dnc_other: deprecated alias that turns on both DNC checks.

Raises:
CheckThatPhoneError: for any non-2xx API response.
Expand All @@ -117,7 +126,15 @@ def lookup(
if landline_sms_lookup:
body["landlineSmsLookup"] = True
if dnc_other:
body["dncOther"] = True
warnings.warn(
"dnc_other is deprecated; use dnc_state and dnc_complainer",
DeprecationWarning,
stacklevel=2,
)
if dnc_state or dnc_other:
body["dncState"] = True
if dnc_complainer or dnc_other:
body["dncComplainer"] = True

req = urllib.request.Request(
f"{self._base_url}/v1/lookup",
Expand Down
26 changes: 26 additions & 0 deletions context7.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"$schema": "https://context7.com/schema/context7.json",
"projectTitle": "CheckThatPhone Python SDK",
"description": "Official Python client for the CheckThatPhone phone validation API: US and Canada carrier lookup, line type, portability, deliverability, GeoIP and timezone, TCPA litigator screening and a free state do-not-call scrub in one call.",
"folders": [],
"excludeFolders": [
"checkthatphone",
"tests",
".github"
],
"excludeFiles": [
"LICENSE"
],
"rules": [
"Phone numbers must be US or Canadian: 10 digits, or 11 digits starting with 1. Strip formatting before calling client.lookup(); anything else is rejected with HTTP 400 and costs nothing.",
"Keep the API key server-side, e.g. CheckThatPhone(api_key=os.environ['CHECKTHATPHONE_API_KEY']). Never embed it in client-side code.",
"Every value in result.data is a string: compare with 'true' / 'false' (result.data.get('deliverable') == 'true'), never with booleans. Use .get(), because add-on fields are absent unless requested.",
"Use result.data['action'] and result.data['deliverable'] as the recommendation for a number. Do not re-derive deliverability from raw carrier fields.",
"Pass ip= (the contact's IP address) whenever you have it: it upgrades location to city level and sets an IANA timezone (result.data['timezone']) at no extra credit. Without it, location falls back to the area code.",
"Add-ons are opt-in keyword arguments: litigator_filter=True costs +1 credit per lookup, landline_sms_lookup=True costs +1 credit only when the number is a landline, dnc_state=True (state do-not-call registries) and dnc_complainer=True (national complainer list) are free. Enable only the add-ons you read. dnc_other is a deprecated alias for both DNC checks.",
"With dnc_state, read result.data.get('dncStateChecked') before dncStateResult: both are absent for the 12 states with no registry data (not checked, never clear), and 'error' means the check could not complete.",
"Catch CheckThatPhoneError and check its retryable attribute before retrying; a 429 means the per-minute rate limit was hit, so back off. Failed and invalid requests cost 0 credits.",
"One lookup costs one credit plus any add-ons (result.credits_used). The free tier is 500 lookups per month and hard-capped, so cache results if you re-check the same numbers often."
],
"previousVersions": []
}
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "checkthatphone"
version = "0.1.0"
version = "0.2.0"
description = "Official Python client for the CheckThatPhone phone validation API — US & Canada carrier lookup, line type, TCPA litigator and state DNC screening"
readme = "README.md"
license = "MIT"
Expand Down
27 changes: 25 additions & 2 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,41 @@ def fake_urlopen(req, timeout=None):

with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen):
client = CheckThatPhone("ctp_live_test")
result = client.lookup("(818) 292-5409", litigator_filter=True, dnc_other=True)
result = client.lookup("(818) 292-5409", litigator_filter=True, dnc_state=True, dnc_complainer=True)

self.assertEqual(captured["url"], "https://api.checkthatphone.com/v1/lookup")
self.assertEqual(
captured["body"],
{"phone": "(818) 292-5409", "litigatorFilter": True, "dncOther": True},
{"phone": "(818) 292-5409", "litigatorFilter": True, "dncState": True, "dncComplainer": True},
)
self.assertEqual(captured["auth"], "Bearer ctp_live_test")
self.assertTrue(result.success)
self.assertEqual(result.credits_used, 2)
self.assertEqual(result.data["nanpType"], "mobile")

def test_deprecated_dnc_other_sends_both_new_flags(self):
captured = {}

def fake_urlopen(req, timeout=None):
captured["body"] = json.loads(req.data.decode())
return _fake_response({"success": True, "credits_used": 1, "data": {}})

with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen):
with self.assertWarns(DeprecationWarning):
CheckThatPhone("k").lookup("8182925409", dnc_other=True)
self.assertEqual(captured["body"], {"phone": "8182925409", "dncState": True, "dncComplainer": True})

def test_sends_only_the_requested_dnc_check(self):
captured = {}

def fake_urlopen(req, timeout=None):
captured["body"] = json.loads(req.data.decode())
return _fake_response({"success": True, "credits_used": 1, "data": {}})

with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen):
CheckThatPhone("k").lookup("8182925409", dnc_complainer=True)
self.assertEqual(captured["body"], {"phone": "8182925409", "dncComplainer": True})

def test_http_error_becomes_typed_exception(self):
err = urllib.error.HTTPError(
url="x",
Expand Down
Loading