layercall 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- layercall-1.0.0/PKG-INFO +123 -0
- layercall-1.0.0/README.md +104 -0
- layercall-1.0.0/layercall/__init__.py +231 -0
- layercall-1.0.0/layercall.egg-info/PKG-INFO +123 -0
- layercall-1.0.0/layercall.egg-info/SOURCES.txt +7 -0
- layercall-1.0.0/layercall.egg-info/dependency_links.txt +1 -0
- layercall-1.0.0/layercall.egg-info/top_level.txt +1 -0
- layercall-1.0.0/pyproject.toml +30 -0
- layercall-1.0.0/setup.cfg +4 -0
layercall-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: layercall
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official LayerCall client — score an IP, email, phone, domain or a whole signup for fraud in one call. Zero dependencies.
|
|
5
|
+
Author: LayerCall
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://www.layercall.com
|
|
8
|
+
Project-URL: Documentation, https://www.layercall.com/docs
|
|
9
|
+
Project-URL: Source, https://github.com/hirajha/layercall-sdk
|
|
10
|
+
Keywords: fraud,fraud-detection,vpn-detection,proxy-detection,tor,email-validation,disposable-email,phone-validation,ip-lookup,bot-detection,signup-fraud,risk-score
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# layercall
|
|
21
|
+
|
|
22
|
+
Official Python client for [LayerCall](https://www.layercall.com) — score an
|
|
23
|
+
IP, email, phone number, domain, or a whole signup for fraud in a single call.
|
|
24
|
+
|
|
25
|
+
**Zero dependencies.** Standard library only. A trust check sits on your signup
|
|
26
|
+
path, which is the worst place to add a dependency tree that has to resolve
|
|
27
|
+
against whatever your app already pins.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install layercall
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import os
|
|
37
|
+
from layercall import LayerCall
|
|
38
|
+
|
|
39
|
+
lc = LayerCall(os.environ["LAYERCALL_API_KEY"])
|
|
40
|
+
|
|
41
|
+
result = lc.score_user(ip=request.remote_addr, email=form["email"])
|
|
42
|
+
|
|
43
|
+
if result["verdict"] == "review":
|
|
44
|
+
send_otp(form["email"]) # a real user clears it themselves
|
|
45
|
+
elif result["verdict"] == "block":
|
|
46
|
+
queue_for_review(result)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
[Get a free API key](https://www.layercall.com/get-key) — 1,000 lookups a
|
|
50
|
+
month, no card.
|
|
51
|
+
|
|
52
|
+
## Acting on a verdict
|
|
53
|
+
|
|
54
|
+
| Verdict | Do this | Not this |
|
|
55
|
+
| --- | --- | --- |
|
|
56
|
+
| `allow` | Let them through | — |
|
|
57
|
+
| `review` | **Step up** — OTP, SMS, 3-D Secure | Don't reject. This band includes ordinary VPN users. |
|
|
58
|
+
| `block` | Reject, or send to a human queue | Don't reject silently |
|
|
59
|
+
|
|
60
|
+
Prefer a challenge over a rejection. A real user clears it in seconds; an
|
|
61
|
+
attacker cannot. A false positive then costs friction instead of a customer.
|
|
62
|
+
|
|
63
|
+
**Already send an OTP to everyone?** Passwordless products can't use an email
|
|
64
|
+
code as a step-up — it's already mandatory. Score after sign-in and use a
|
|
65
|
+
different lever: a limited account state, a delayed payout, or a review queue.
|
|
66
|
+
|
|
67
|
+
## Methods
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
lc.score_ip("185.220.101.1")
|
|
71
|
+
lc.verify_email("someone@mailinator.com")
|
|
72
|
+
lc.lookup_phone("+14155552671")
|
|
73
|
+
lc.lookup_phone("4155552671", country="US")
|
|
74
|
+
lc.score_domain("example.com")
|
|
75
|
+
lc.score_user(ip=ip, email=email, phone=phone, device_id=device_id)
|
|
76
|
+
lc.batch("email", ["a@x.com", "b@y.com"]) # up to 500
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Every method takes `strictness` (`0` lenient → `3` paranoid). It moves the
|
|
80
|
+
verdict thresholds only; the risk score never changes, so you can re-tune
|
|
81
|
+
without re-scoring anything.
|
|
82
|
+
|
|
83
|
+
## None means unknown, never "no"
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
signals = lc.score_domain("example.de")["signals"]
|
|
87
|
+
signals["newly_registered"] # None — .de publishes no RDAP, so age is unknown
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`None` means we could not determine it. It is **not** a negative finding.
|
|
91
|
+
Treating it as "established" is exactly the mistake the field exists to
|
|
92
|
+
prevent.
|
|
93
|
+
|
|
94
|
+
Same for `mailbox_exists`: Gmail and Yahoo accept mail for addresses that do
|
|
95
|
+
not exist, so we return `None` rather than a guess.
|
|
96
|
+
|
|
97
|
+
## Errors
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from layercall import LayerCallError
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
lc.score_ip(ip)
|
|
104
|
+
except LayerCallError as err:
|
|
105
|
+
if err.is_quota: # 402 — out of quota or spend cap reached
|
|
106
|
+
...
|
|
107
|
+
if err.is_auth: # 401/403 — bad or revoked key
|
|
108
|
+
...
|
|
109
|
+
print(err.request_id) # quote this in a support ticket
|
|
110
|
+
except Exception:
|
|
111
|
+
pass # fail open: let the signup through
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
429s and 5xx retry automatically (2 attempts, short backoff). Other 4xx fail
|
|
115
|
+
fast — they will not succeed on a retry.
|
|
116
|
+
|
|
117
|
+
## Server-side only
|
|
118
|
+
|
|
119
|
+
An API key in anything a user can read is public and bills to your account.
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# layercall
|
|
2
|
+
|
|
3
|
+
Official Python client for [LayerCall](https://www.layercall.com) — score an
|
|
4
|
+
IP, email, phone number, domain, or a whole signup for fraud in a single call.
|
|
5
|
+
|
|
6
|
+
**Zero dependencies.** Standard library only. A trust check sits on your signup
|
|
7
|
+
path, which is the worst place to add a dependency tree that has to resolve
|
|
8
|
+
against whatever your app already pins.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install layercall
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import os
|
|
18
|
+
from layercall import LayerCall
|
|
19
|
+
|
|
20
|
+
lc = LayerCall(os.environ["LAYERCALL_API_KEY"])
|
|
21
|
+
|
|
22
|
+
result = lc.score_user(ip=request.remote_addr, email=form["email"])
|
|
23
|
+
|
|
24
|
+
if result["verdict"] == "review":
|
|
25
|
+
send_otp(form["email"]) # a real user clears it themselves
|
|
26
|
+
elif result["verdict"] == "block":
|
|
27
|
+
queue_for_review(result)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
[Get a free API key](https://www.layercall.com/get-key) — 1,000 lookups a
|
|
31
|
+
month, no card.
|
|
32
|
+
|
|
33
|
+
## Acting on a verdict
|
|
34
|
+
|
|
35
|
+
| Verdict | Do this | Not this |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| `allow` | Let them through | — |
|
|
38
|
+
| `review` | **Step up** — OTP, SMS, 3-D Secure | Don't reject. This band includes ordinary VPN users. |
|
|
39
|
+
| `block` | Reject, or send to a human queue | Don't reject silently |
|
|
40
|
+
|
|
41
|
+
Prefer a challenge over a rejection. A real user clears it in seconds; an
|
|
42
|
+
attacker cannot. A false positive then costs friction instead of a customer.
|
|
43
|
+
|
|
44
|
+
**Already send an OTP to everyone?** Passwordless products can't use an email
|
|
45
|
+
code as a step-up — it's already mandatory. Score after sign-in and use a
|
|
46
|
+
different lever: a limited account state, a delayed payout, or a review queue.
|
|
47
|
+
|
|
48
|
+
## Methods
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
lc.score_ip("185.220.101.1")
|
|
52
|
+
lc.verify_email("someone@mailinator.com")
|
|
53
|
+
lc.lookup_phone("+14155552671")
|
|
54
|
+
lc.lookup_phone("4155552671", country="US")
|
|
55
|
+
lc.score_domain("example.com")
|
|
56
|
+
lc.score_user(ip=ip, email=email, phone=phone, device_id=device_id)
|
|
57
|
+
lc.batch("email", ["a@x.com", "b@y.com"]) # up to 500
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Every method takes `strictness` (`0` lenient → `3` paranoid). It moves the
|
|
61
|
+
verdict thresholds only; the risk score never changes, so you can re-tune
|
|
62
|
+
without re-scoring anything.
|
|
63
|
+
|
|
64
|
+
## None means unknown, never "no"
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
signals = lc.score_domain("example.de")["signals"]
|
|
68
|
+
signals["newly_registered"] # None — .de publishes no RDAP, so age is unknown
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`None` means we could not determine it. It is **not** a negative finding.
|
|
72
|
+
Treating it as "established" is exactly the mistake the field exists to
|
|
73
|
+
prevent.
|
|
74
|
+
|
|
75
|
+
Same for `mailbox_exists`: Gmail and Yahoo accept mail for addresses that do
|
|
76
|
+
not exist, so we return `None` rather than a guess.
|
|
77
|
+
|
|
78
|
+
## Errors
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from layercall import LayerCallError
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
lc.score_ip(ip)
|
|
85
|
+
except LayerCallError as err:
|
|
86
|
+
if err.is_quota: # 402 — out of quota or spend cap reached
|
|
87
|
+
...
|
|
88
|
+
if err.is_auth: # 401/403 — bad or revoked key
|
|
89
|
+
...
|
|
90
|
+
print(err.request_id) # quote this in a support ticket
|
|
91
|
+
except Exception:
|
|
92
|
+
pass # fail open: let the signup through
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
429s and 5xx retry automatically (2 attempts, short backoff). Other 4xx fail
|
|
96
|
+
fast — they will not succeed on a retry.
|
|
97
|
+
|
|
98
|
+
## Server-side only
|
|
99
|
+
|
|
100
|
+
An API key in anything a user can read is public and bills to your account.
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Official LayerCall client.
|
|
2
|
+
|
|
3
|
+
Score an IP, email, phone number, domain, or a whole signup for fraud in a
|
|
4
|
+
single call.
|
|
5
|
+
|
|
6
|
+
Zero dependencies, standard library only. A trust check sits on the signup
|
|
7
|
+
path, which is the worst place in an application to introduce a dependency
|
|
8
|
+
tree that has to be resolved against whatever the host app already pins.
|
|
9
|
+
|
|
10
|
+
Test keys return synthetic data
|
|
11
|
+
-------------------------------
|
|
12
|
+
A key beginning ``tl_test_`` returns fabricated scores and signals, derived
|
|
13
|
+
from the value you sent rather than from anything known about it. Response
|
|
14
|
+
shapes, error codes and your own allow/block rules behave exactly as in
|
|
15
|
+
production, so integration tests against a test key are valid — the numbers
|
|
16
|
+
are not. Those responses carry ``test_mode: True`` and a ``test_mode_note``.
|
|
17
|
+
|
|
18
|
+
result = client.score_ip("8.8.8.8")
|
|
19
|
+
if result.get("test_mode"):
|
|
20
|
+
... # pointed at a test key, do not assert on risk_score
|
|
21
|
+
|
|
22
|
+
Use a ``tl_live_`` key to score real values.
|
|
23
|
+
See https://www.layercall.com/docs/test-mode
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import time
|
|
30
|
+
import urllib.error
|
|
31
|
+
import urllib.parse
|
|
32
|
+
import urllib.request
|
|
33
|
+
from typing import Any, Literal, Mapping, Sequence
|
|
34
|
+
|
|
35
|
+
__version__ = "1.0.0"
|
|
36
|
+
__all__ = ["LayerCall", "LayerCallError"]
|
|
37
|
+
|
|
38
|
+
Verdict = Literal["allow", "review", "block"]
|
|
39
|
+
Strictness = Literal[0, 1, 2, 3]
|
|
40
|
+
|
|
41
|
+
_DEFAULT_BASE = "https://www.layercall.com"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LayerCallError(Exception):
|
|
45
|
+
"""A non-2xx response. Branch on ``status`` or ``code``."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
message: str,
|
|
50
|
+
status: int,
|
|
51
|
+
code: str | None = None,
|
|
52
|
+
request_id: str | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
super().__init__(message)
|
|
55
|
+
self.status = status
|
|
56
|
+
self.code = code
|
|
57
|
+
self.request_id = request_id
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_quota(self) -> bool:
|
|
61
|
+
"""Quota exhausted or spend cap reached — upgrade or raise the cap."""
|
|
62
|
+
return self.status == 402
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_auth(self) -> bool:
|
|
66
|
+
"""Bad or revoked key."""
|
|
67
|
+
return self.status in (401, 403)
|
|
68
|
+
|
|
69
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
70
|
+
return f"LayerCallError(status={self.status}, code={self.code!r}, message={self!s})"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class LayerCall:
|
|
74
|
+
"""Client for the LayerCall API.
|
|
75
|
+
|
|
76
|
+
Server-side only. An API key in anything a user can read is public, and it
|
|
77
|
+
bills to your account.
|
|
78
|
+
|
|
79
|
+
lc = LayerCall(os.environ["LAYERCALL_API_KEY"])
|
|
80
|
+
result = lc.score_user(ip=request.remote_addr, email=form["email"])
|
|
81
|
+
if result["verdict"] == "review":
|
|
82
|
+
send_otp(form["email"]) # a real user clears it themselves
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
api_key: str,
|
|
88
|
+
*,
|
|
89
|
+
base_url: str = _DEFAULT_BASE,
|
|
90
|
+
timeout: float = 5.0,
|
|
91
|
+
retries: int = 2,
|
|
92
|
+
) -> None:
|
|
93
|
+
if not api_key:
|
|
94
|
+
raise ValueError("LayerCall: api_key is required.")
|
|
95
|
+
self._key = api_key
|
|
96
|
+
self._base = base_url.rstrip("/")
|
|
97
|
+
self._timeout = timeout
|
|
98
|
+
self._retries = retries
|
|
99
|
+
|
|
100
|
+
# -- transport --------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def _request(
|
|
103
|
+
self,
|
|
104
|
+
path: str,
|
|
105
|
+
*,
|
|
106
|
+
query: Mapping[str, Any] | None = None,
|
|
107
|
+
body: Mapping[str, Any] | None = None,
|
|
108
|
+
) -> dict[str, Any]:
|
|
109
|
+
url = self._base + path
|
|
110
|
+
if query:
|
|
111
|
+
clean = {k: v for k, v in query.items() if v is not None}
|
|
112
|
+
if clean:
|
|
113
|
+
url += "?" + urllib.parse.urlencode(clean)
|
|
114
|
+
|
|
115
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
116
|
+
last_exc: Exception | None = None
|
|
117
|
+
|
|
118
|
+
for attempt in range(self._retries + 1):
|
|
119
|
+
req = urllib.request.Request(
|
|
120
|
+
url,
|
|
121
|
+
data=data,
|
|
122
|
+
method="POST" if data is not None else "GET",
|
|
123
|
+
headers={
|
|
124
|
+
"X-Api-Key": self._key,
|
|
125
|
+
"Content-Type": "application/json",
|
|
126
|
+
"User-Agent": f"layercall-python/{__version__}",
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
try:
|
|
130
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
131
|
+
return json.loads(resp.read().decode() or "{}")
|
|
132
|
+
except urllib.error.HTTPError as exc:
|
|
133
|
+
try:
|
|
134
|
+
payload = json.loads(exc.read().decode() or "{}")
|
|
135
|
+
except Exception:
|
|
136
|
+
payload = {}
|
|
137
|
+
err = LayerCallError(
|
|
138
|
+
payload.get("error") or f"HTTP {exc.code}",
|
|
139
|
+
exc.code,
|
|
140
|
+
payload.get("code"),
|
|
141
|
+
payload.get("request_id"),
|
|
142
|
+
)
|
|
143
|
+
# A 4xx other than 429 fails identically on retry. Surfacing it
|
|
144
|
+
# now beats spending a signup path's latency budget proving it.
|
|
145
|
+
if not (exc.code == 429 or exc.code >= 500) or attempt == self._retries:
|
|
146
|
+
raise err from None
|
|
147
|
+
last_exc = err
|
|
148
|
+
except Exception as exc: # timeouts, DNS, connection resets
|
|
149
|
+
last_exc = exc
|
|
150
|
+
if attempt == self._retries:
|
|
151
|
+
break
|
|
152
|
+
# Short exponential backoff — this sits in front of a user.
|
|
153
|
+
time.sleep(0.15 * (2**attempt))
|
|
154
|
+
|
|
155
|
+
raise last_exc if last_exc else RuntimeError("LayerCall: request failed")
|
|
156
|
+
|
|
157
|
+
# -- endpoints --------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def score_ip(self, ip: str, *, strictness: Strictness | None = None) -> dict[str, Any]:
|
|
160
|
+
"""VPN, proxy, Tor, datacenter, geolocation and ASN for an IP."""
|
|
161
|
+
return self._request("/v1/score/ip", query={"ip": ip, "strictness": strictness})
|
|
162
|
+
|
|
163
|
+
def verify_email(self, email: str, *, strictness: Strictness | None = None) -> dict[str, Any]:
|
|
164
|
+
"""Syntax, MX, disposable, role account, homograph and domain age."""
|
|
165
|
+
return self._request("/v1/verify/email", query={"email": email, "strictness": strictness})
|
|
166
|
+
|
|
167
|
+
def lookup_phone(
|
|
168
|
+
self,
|
|
169
|
+
phone: str,
|
|
170
|
+
*,
|
|
171
|
+
country: str | None = None,
|
|
172
|
+
strictness: Strictness | None = None,
|
|
173
|
+
) -> dict[str, Any]:
|
|
174
|
+
"""Numbering-plan validation worldwide. ``country`` only for national format."""
|
|
175
|
+
return self._request(
|
|
176
|
+
"/v1/lookup/phone",
|
|
177
|
+
query={"phone": phone, "country": country, "strictness": strictness},
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def score_domain(self, domain: str) -> dict[str, Any]:
|
|
181
|
+
"""RDAP registration date, registrar, MX/SPF/DMARC, risky TLD."""
|
|
182
|
+
return self._request("/v1/score/domain", query={"domain": domain})
|
|
183
|
+
|
|
184
|
+
def score_user(
|
|
185
|
+
self,
|
|
186
|
+
*,
|
|
187
|
+
ip: str | None = None,
|
|
188
|
+
email: str | None = None,
|
|
189
|
+
phone: str | None = None,
|
|
190
|
+
phone_country: str | None = None,
|
|
191
|
+
domain: str | None = None,
|
|
192
|
+
device_id: str | None = None,
|
|
193
|
+
strictness: Strictness | None = None,
|
|
194
|
+
) -> dict[str, Any]:
|
|
195
|
+
"""Score a whole signup. A hard block on one component is never averaged away."""
|
|
196
|
+
payload = {
|
|
197
|
+
k: v
|
|
198
|
+
for k, v in {
|
|
199
|
+
"ip": ip,
|
|
200
|
+
"email": email,
|
|
201
|
+
"phone": phone,
|
|
202
|
+
"phone_country": phone_country,
|
|
203
|
+
"domain": domain,
|
|
204
|
+
"device_id": device_id,
|
|
205
|
+
"strictness": strictness,
|
|
206
|
+
}.items()
|
|
207
|
+
if v is not None
|
|
208
|
+
}
|
|
209
|
+
if not payload:
|
|
210
|
+
raise ValueError("score_user: provide at least one of ip, email or phone.")
|
|
211
|
+
return self._request("/v1/score/user", body=payload)
|
|
212
|
+
|
|
213
|
+
def batch(
|
|
214
|
+
self,
|
|
215
|
+
type: Literal["ip", "email", "phone", "domain"],
|
|
216
|
+
items: Sequence[str],
|
|
217
|
+
*,
|
|
218
|
+
strictness: Strictness | None = None,
|
|
219
|
+
phone_country: str | None = None,
|
|
220
|
+
) -> dict[str, Any]:
|
|
221
|
+
"""Up to 500 values of one type. Each item carries its own error."""
|
|
222
|
+
if len(items) > 500:
|
|
223
|
+
raise ValueError(
|
|
224
|
+
f"batch: {len(items)} items exceeds the limit of 500 — send several batches."
|
|
225
|
+
)
|
|
226
|
+
payload: dict[str, Any] = {"type": type, "items": list(items)}
|
|
227
|
+
if strictness is not None:
|
|
228
|
+
payload["strictness"] = strictness
|
|
229
|
+
if phone_country is not None:
|
|
230
|
+
payload["phone_country"] = phone_country
|
|
231
|
+
return self._request("/v1/batch", body=payload)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: layercall
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official LayerCall client — score an IP, email, phone, domain or a whole signup for fraud in one call. Zero dependencies.
|
|
5
|
+
Author: LayerCall
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://www.layercall.com
|
|
8
|
+
Project-URL: Documentation, https://www.layercall.com/docs
|
|
9
|
+
Project-URL: Source, https://github.com/hirajha/layercall-sdk
|
|
10
|
+
Keywords: fraud,fraud-detection,vpn-detection,proxy-detection,tor,email-validation,disposable-email,phone-validation,ip-lookup,bot-detection,signup-fraud,risk-score
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# layercall
|
|
21
|
+
|
|
22
|
+
Official Python client for [LayerCall](https://www.layercall.com) — score an
|
|
23
|
+
IP, email, phone number, domain, or a whole signup for fraud in a single call.
|
|
24
|
+
|
|
25
|
+
**Zero dependencies.** Standard library only. A trust check sits on your signup
|
|
26
|
+
path, which is the worst place to add a dependency tree that has to resolve
|
|
27
|
+
against whatever your app already pins.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install layercall
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import os
|
|
37
|
+
from layercall import LayerCall
|
|
38
|
+
|
|
39
|
+
lc = LayerCall(os.environ["LAYERCALL_API_KEY"])
|
|
40
|
+
|
|
41
|
+
result = lc.score_user(ip=request.remote_addr, email=form["email"])
|
|
42
|
+
|
|
43
|
+
if result["verdict"] == "review":
|
|
44
|
+
send_otp(form["email"]) # a real user clears it themselves
|
|
45
|
+
elif result["verdict"] == "block":
|
|
46
|
+
queue_for_review(result)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
[Get a free API key](https://www.layercall.com/get-key) — 1,000 lookups a
|
|
50
|
+
month, no card.
|
|
51
|
+
|
|
52
|
+
## Acting on a verdict
|
|
53
|
+
|
|
54
|
+
| Verdict | Do this | Not this |
|
|
55
|
+
| --- | --- | --- |
|
|
56
|
+
| `allow` | Let them through | — |
|
|
57
|
+
| `review` | **Step up** — OTP, SMS, 3-D Secure | Don't reject. This band includes ordinary VPN users. |
|
|
58
|
+
| `block` | Reject, or send to a human queue | Don't reject silently |
|
|
59
|
+
|
|
60
|
+
Prefer a challenge over a rejection. A real user clears it in seconds; an
|
|
61
|
+
attacker cannot. A false positive then costs friction instead of a customer.
|
|
62
|
+
|
|
63
|
+
**Already send an OTP to everyone?** Passwordless products can't use an email
|
|
64
|
+
code as a step-up — it's already mandatory. Score after sign-in and use a
|
|
65
|
+
different lever: a limited account state, a delayed payout, or a review queue.
|
|
66
|
+
|
|
67
|
+
## Methods
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
lc.score_ip("185.220.101.1")
|
|
71
|
+
lc.verify_email("someone@mailinator.com")
|
|
72
|
+
lc.lookup_phone("+14155552671")
|
|
73
|
+
lc.lookup_phone("4155552671", country="US")
|
|
74
|
+
lc.score_domain("example.com")
|
|
75
|
+
lc.score_user(ip=ip, email=email, phone=phone, device_id=device_id)
|
|
76
|
+
lc.batch("email", ["a@x.com", "b@y.com"]) # up to 500
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Every method takes `strictness` (`0` lenient → `3` paranoid). It moves the
|
|
80
|
+
verdict thresholds only; the risk score never changes, so you can re-tune
|
|
81
|
+
without re-scoring anything.
|
|
82
|
+
|
|
83
|
+
## None means unknown, never "no"
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
signals = lc.score_domain("example.de")["signals"]
|
|
87
|
+
signals["newly_registered"] # None — .de publishes no RDAP, so age is unknown
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`None` means we could not determine it. It is **not** a negative finding.
|
|
91
|
+
Treating it as "established" is exactly the mistake the field exists to
|
|
92
|
+
prevent.
|
|
93
|
+
|
|
94
|
+
Same for `mailbox_exists`: Gmail and Yahoo accept mail for addresses that do
|
|
95
|
+
not exist, so we return `None` rather than a guess.
|
|
96
|
+
|
|
97
|
+
## Errors
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from layercall import LayerCallError
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
lc.score_ip(ip)
|
|
104
|
+
except LayerCallError as err:
|
|
105
|
+
if err.is_quota: # 402 — out of quota or spend cap reached
|
|
106
|
+
...
|
|
107
|
+
if err.is_auth: # 401/403 — bad or revoked key
|
|
108
|
+
...
|
|
109
|
+
print(err.request_id) # quote this in a support ticket
|
|
110
|
+
except Exception:
|
|
111
|
+
pass # fail open: let the signup through
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
429s and 5xx retry automatically (2 attempts, short backoff). Other 4xx fail
|
|
115
|
+
fast — they will not succeed on a retry.
|
|
116
|
+
|
|
117
|
+
## Server-side only
|
|
118
|
+
|
|
119
|
+
An API key in anything a user can read is public and bills to your account.
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
layercall
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "layercall"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official LayerCall client — score an IP, email, phone, domain or a whole signup for fraud in one call. Zero dependencies."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "LayerCall" }]
|
|
13
|
+
keywords = ["fraud", "fraud-detection", "vpn-detection", "proxy-detection", "tor", "email-validation", "disposable-email", "phone-validation", "ip-lookup", "bot-detection", "signup-fraud", "risk-score"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Security",
|
|
20
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
21
|
+
]
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://www.layercall.com"
|
|
26
|
+
Documentation = "https://www.layercall.com/docs"
|
|
27
|
+
Source = "https://github.com/hirajha/layercall-sdk"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
include = ["layercall*"]
|