kaidn 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.
- kaidn-1.0.0/.gitignore +18 -0
- kaidn-1.0.0/CONTRIBUTING.md +50 -0
- kaidn-1.0.0/LICENSE +21 -0
- kaidn-1.0.0/PKG-INFO +209 -0
- kaidn-1.0.0/README.md +179 -0
- kaidn-1.0.0/pyproject.toml +76 -0
- kaidn-1.0.0/src/kaidn/__init__.py +42 -0
- kaidn-1.0.0/src/kaidn/client.py +266 -0
- kaidn-1.0.0/src/kaidn/errors.py +45 -0
- kaidn-1.0.0/src/kaidn/models.py +197 -0
- kaidn-1.0.0/src/kaidn/py.typed +0 -0
kaidn-1.0.0/.gitignore
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
This repository is the source of truth for the Kaidn Python client. Pull requests are
|
|
4
|
+
welcome here directly.
|
|
5
|
+
|
|
6
|
+
## Setup
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
python -m venv .venv && source .venv/bin/activate
|
|
10
|
+
pip install -e ".[dev]"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## The gate
|
|
14
|
+
|
|
15
|
+
All three must pass before a change lands. CI runs the same commands.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pytest tests -q # 12 tests, against a real local HTTP server
|
|
19
|
+
mypy src/kaidn --strict
|
|
20
|
+
ruff check src tests
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## House rules, and the reasoning behind them
|
|
24
|
+
|
|
25
|
+
**Zero runtime dependencies.** This client runs inside somebody's signup and checkout
|
|
26
|
+
path. Every dependency it carries is one more thing that can break their deploy or appear
|
|
27
|
+
in their vulnerability scanner. If you need something the standard library does not have,
|
|
28
|
+
open an issue first, because the answer is usually to write the twenty lines.
|
|
29
|
+
|
|
30
|
+
**Never drop a field we do not recognise.** Responses keep unknown keys in `.extra`, and
|
|
31
|
+
`score()` passes unknown kwargs straight through. Both ends of the API refuse to discard
|
|
32
|
+
data, which is what lets a signal shipped next week reach code running a year-old release.
|
|
33
|
+
A change that filters the payload is a bug even when it makes the types tidier.
|
|
34
|
+
|
|
35
|
+
**Python 3.9 stays supported.** It is end of life upstream and it is still what a lot of
|
|
36
|
+
the shops using this are running. That is why annotations use `Optional[X]` rather than
|
|
37
|
+
`X | None`: the newer form parses under `from __future__ import annotations`, but anything
|
|
38
|
+
that resolves the hints at runtime still breaks on 3.9, and in a library that code belongs
|
|
39
|
+
to somebody else.
|
|
40
|
+
|
|
41
|
+
**Tests hit a real HTTP server, not a mock.** A mocked transport only proves the code
|
|
42
|
+
calls the mock. The parts that break in someone else's environment are headers, timeouts,
|
|
43
|
+
retries and error bodies, so the tests exercise those over a real socket.
|
|
44
|
+
|
|
45
|
+
**Never retry a 4xx.** A bad key fails identically the second time. Retrying spends the
|
|
46
|
+
caller's quota and delays the error reaching whoever can fix it.
|
|
47
|
+
|
|
48
|
+
## Reporting a security issue
|
|
49
|
+
|
|
50
|
+
Do not open a public issue. Email security@kaidn.io.
|
kaidn-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaidn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
kaidn-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: kaidn
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python client for Kaidn, the fraud and abuse scoring API.
|
|
5
|
+
Project-URL: Homepage, https://kaidn.io
|
|
6
|
+
Project-URL: Documentation, https://kaidn.io/docs
|
|
7
|
+
Project-URL: Source, https://github.com/Kaidn-io/kaidn-python
|
|
8
|
+
Project-URL: Issues, https://github.com/Kaidn-io/kaidn-python/issues
|
|
9
|
+
Author-email: Kaidn <support@kaidn.io>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: abuse,account-takeover,bot-detection,chargeback,device-fingerprinting,disposable-email,fraud,fraud-detection,fraud-prevention,ip-reputation,multi-accounting,proxy-detection,vpn-detection
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Security
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# kaidn
|
|
32
|
+
|
|
33
|
+
Official Python client for [Kaidn](https://kaidn.io), the fraud and abuse scoring API.
|
|
34
|
+
|
|
35
|
+
Send one user action, get back `allow`, `review` or `block`, with the reasons attached.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install kaidn
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from kaidn import KaidnClient
|
|
43
|
+
|
|
44
|
+
client = KaidnClient() # reads $KAIDN_API_KEY
|
|
45
|
+
|
|
46
|
+
r = client.score(event="signup", ip=ip, email=email)
|
|
47
|
+
|
|
48
|
+
if r.blocked:
|
|
49
|
+
raise Denied(r.reason_text)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Zero runtime dependencies.** This runs in your signup and checkout path, so every
|
|
53
|
+
dependency it carried would be one more thing that can break your deploy or turn up in
|
|
54
|
+
your vulnerability scanner. It uses the standard library and nothing else.
|
|
55
|
+
|
|
56
|
+
Requires Python 3.9+. Server-side only: it holds your secret key, so never ship it to a
|
|
57
|
+
browser. The browser half is [`@kaidn/fp`](https://www.npmjs.com/package/@kaidn/fp) and
|
|
58
|
+
uses a separate publishable key.
|
|
59
|
+
|
|
60
|
+
## Score an event
|
|
61
|
+
|
|
62
|
+
`event` is the only required field, and the name is yours to choose. Send whatever else
|
|
63
|
+
you already collect; the answer sharpens as you send more.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
r = client.score(
|
|
67
|
+
event="signup",
|
|
68
|
+
user_id=user.id,
|
|
69
|
+
ip=request.remote_addr,
|
|
70
|
+
email=form["email"],
|
|
71
|
+
device_id=form.get("kaidn_device_id"), # from @kaidn/fp, if installed
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
r.verdict # "allow" | "review" | "block"
|
|
75
|
+
r.reasons # ["datacenter_ip", "disposable_email"]
|
|
76
|
+
r.reason_text # a sentence you could send to the customer
|
|
77
|
+
r.score # 0-100. Bookkeeping, not a probability: branch on the verdict
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Branching, with the three cases people actually use:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
if r.blocked:
|
|
84
|
+
return deny() # generic message: a specific one teaches the next attempt
|
|
85
|
+
if r.needs_review:
|
|
86
|
+
create_account(hold_rewards=True) # they can use the product, they just cannot earn yet
|
|
87
|
+
flag_for_review(r.event_id, r.reason_text)
|
|
88
|
+
else:
|
|
89
|
+
create_account()
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Read the evidence
|
|
93
|
+
|
|
94
|
+
Every verdict shows its work. `key` is the config key you would edit to retune that
|
|
95
|
+
check, so a decision tells you how to change it next time.
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
for c in r.checks:
|
|
99
|
+
print(c.reason, c.weight, c.key, c.evidence)
|
|
100
|
+
# datacenter_ip 45 datacenterIp {'asn': '16509'}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Recognise a returning device
|
|
104
|
+
|
|
105
|
+
A browser fingerprint is not a person: on production traffic one iOS Safari fingerprint
|
|
106
|
+
covers 2.30 different people. So use `resolved_id`, not `id`, and weigh it with
|
|
107
|
+
`collision_risk`.
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
d = r.device
|
|
111
|
+
if d:
|
|
112
|
+
d.resolved_id # the identity. Link visits on this
|
|
113
|
+
d.collision_risk # measured P(covers more than one person)
|
|
114
|
+
d.account_count # includes fingerprint collisions
|
|
115
|
+
d.account_count_same_network # the number you can defend to an angry user
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Store `r.device_token` as a first-party cookie on your own domain and pass it back as
|
|
119
|
+
`device_token` next time. The identity then becomes `deterministic`: remembered rather
|
|
120
|
+
than inferred.
|
|
121
|
+
|
|
122
|
+
## Dedupe one inbox, not one address
|
|
123
|
+
|
|
124
|
+
`bob+1@gmail.com`, `b.o.b@gmail.com` and `bob@googlemail.com` are one mailbox.
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
if r.identity and User.exists(email_canonical=r.identity.email_canonical):
|
|
128
|
+
return reject("an account already uses this inbox")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Check an identifier on its own
|
|
132
|
+
|
|
133
|
+
No event recorded, useful at the form or when cleaning a list.
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
client.check.email("x9f2kq@mailinator.com").fraud_score # 75
|
|
137
|
+
client.check.ip("3.5.140.1").report.get("is_datacenter") # True
|
|
138
|
+
client.check.phone("+14155550123", country="US")
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Report what really happened
|
|
142
|
+
|
|
143
|
+
Feedback is what sharpens scoring. `legit` marks your own false positive and never
|
|
144
|
+
lowers anyone else's risk.
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
client.label(label="chargeback", event_id=r.event_id)
|
|
148
|
+
client.label(label="legit", event_id=r.event_id)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Errors
|
|
152
|
+
|
|
153
|
+
Everything raises `KaidnError`, with the API's own message.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from kaidn import KaidnError
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
r = client.score(event="signup", email=email)
|
|
160
|
+
except KaidnError as err:
|
|
161
|
+
if err.status == 429:
|
|
162
|
+
notify_ops("Kaidn quota exhausted")
|
|
163
|
+
raise
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Network failures, timeouts, 429s and 5xx are retried automatically (2 extra attempts by
|
|
167
|
+
default, honouring `Retry-After`). A 4xx is not: a bad key fails identically the second
|
|
168
|
+
time, and retrying it just spends quota and delays the error reaching whoever can fix it.
|
|
169
|
+
|
|
170
|
+
**Set a timeout and fail open.** A fraud vendor that can take down your signup form is a
|
|
171
|
+
worse problem than the fraud:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
try:
|
|
175
|
+
r = client.score(event="signup", email=email)
|
|
176
|
+
except KaidnError:
|
|
177
|
+
r = None # create the account. Do not let our outage become yours.
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Fields we have not named yet
|
|
181
|
+
|
|
182
|
+
Every response keeps what this version does not recognise, so a signal the API ships next
|
|
183
|
+
week reaches code running the library you installed last year.
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
r.get("a_field_added_after_this_release")
|
|
187
|
+
r.device.get("some_new_signal")
|
|
188
|
+
r.extra # everything unrecognised
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Requests work the same way: any extra keyword to `score()` is passed through untouched.
|
|
192
|
+
|
|
193
|
+
## Configuration
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
KaidnClient(
|
|
197
|
+
api_key="kdn_live_...", # default: $KAIDN_API_KEY
|
|
198
|
+
base_url="https://api.kaidn.io",
|
|
199
|
+
timeout=10.0, # seconds per attempt
|
|
200
|
+
retries=2, # extra attempts on a transient failure
|
|
201
|
+
)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Links
|
|
205
|
+
|
|
206
|
+
- [Docs](https://kaidn.io/docs) · [Guides](https://kaidn.io/docs/guides) · [Glossary](https://kaidn.io/glossary)
|
|
207
|
+
- [Pricing](https://kaidn.io/pricing): 10,000 events a month free, no card
|
|
208
|
+
|
|
209
|
+
MIT
|
kaidn-1.0.0/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# kaidn
|
|
2
|
+
|
|
3
|
+
Official Python client for [Kaidn](https://kaidn.io), the fraud and abuse scoring API.
|
|
4
|
+
|
|
5
|
+
Send one user action, get back `allow`, `review` or `block`, with the reasons attached.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install kaidn
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from kaidn import KaidnClient
|
|
13
|
+
|
|
14
|
+
client = KaidnClient() # reads $KAIDN_API_KEY
|
|
15
|
+
|
|
16
|
+
r = client.score(event="signup", ip=ip, email=email)
|
|
17
|
+
|
|
18
|
+
if r.blocked:
|
|
19
|
+
raise Denied(r.reason_text)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Zero runtime dependencies.** This runs in your signup and checkout path, so every
|
|
23
|
+
dependency it carried would be one more thing that can break your deploy or turn up in
|
|
24
|
+
your vulnerability scanner. It uses the standard library and nothing else.
|
|
25
|
+
|
|
26
|
+
Requires Python 3.9+. Server-side only: it holds your secret key, so never ship it to a
|
|
27
|
+
browser. The browser half is [`@kaidn/fp`](https://www.npmjs.com/package/@kaidn/fp) and
|
|
28
|
+
uses a separate publishable key.
|
|
29
|
+
|
|
30
|
+
## Score an event
|
|
31
|
+
|
|
32
|
+
`event` is the only required field, and the name is yours to choose. Send whatever else
|
|
33
|
+
you already collect; the answer sharpens as you send more.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
r = client.score(
|
|
37
|
+
event="signup",
|
|
38
|
+
user_id=user.id,
|
|
39
|
+
ip=request.remote_addr,
|
|
40
|
+
email=form["email"],
|
|
41
|
+
device_id=form.get("kaidn_device_id"), # from @kaidn/fp, if installed
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
r.verdict # "allow" | "review" | "block"
|
|
45
|
+
r.reasons # ["datacenter_ip", "disposable_email"]
|
|
46
|
+
r.reason_text # a sentence you could send to the customer
|
|
47
|
+
r.score # 0-100. Bookkeeping, not a probability: branch on the verdict
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Branching, with the three cases people actually use:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
if r.blocked:
|
|
54
|
+
return deny() # generic message: a specific one teaches the next attempt
|
|
55
|
+
if r.needs_review:
|
|
56
|
+
create_account(hold_rewards=True) # they can use the product, they just cannot earn yet
|
|
57
|
+
flag_for_review(r.event_id, r.reason_text)
|
|
58
|
+
else:
|
|
59
|
+
create_account()
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Read the evidence
|
|
63
|
+
|
|
64
|
+
Every verdict shows its work. `key` is the config key you would edit to retune that
|
|
65
|
+
check, so a decision tells you how to change it next time.
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
for c in r.checks:
|
|
69
|
+
print(c.reason, c.weight, c.key, c.evidence)
|
|
70
|
+
# datacenter_ip 45 datacenterIp {'asn': '16509'}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Recognise a returning device
|
|
74
|
+
|
|
75
|
+
A browser fingerprint is not a person: on production traffic one iOS Safari fingerprint
|
|
76
|
+
covers 2.30 different people. So use `resolved_id`, not `id`, and weigh it with
|
|
77
|
+
`collision_risk`.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
d = r.device
|
|
81
|
+
if d:
|
|
82
|
+
d.resolved_id # the identity. Link visits on this
|
|
83
|
+
d.collision_risk # measured P(covers more than one person)
|
|
84
|
+
d.account_count # includes fingerprint collisions
|
|
85
|
+
d.account_count_same_network # the number you can defend to an angry user
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Store `r.device_token` as a first-party cookie on your own domain and pass it back as
|
|
89
|
+
`device_token` next time. The identity then becomes `deterministic`: remembered rather
|
|
90
|
+
than inferred.
|
|
91
|
+
|
|
92
|
+
## Dedupe one inbox, not one address
|
|
93
|
+
|
|
94
|
+
`bob+1@gmail.com`, `b.o.b@gmail.com` and `bob@googlemail.com` are one mailbox.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
if r.identity and User.exists(email_canonical=r.identity.email_canonical):
|
|
98
|
+
return reject("an account already uses this inbox")
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Check an identifier on its own
|
|
102
|
+
|
|
103
|
+
No event recorded, useful at the form or when cleaning a list.
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
client.check.email("x9f2kq@mailinator.com").fraud_score # 75
|
|
107
|
+
client.check.ip("3.5.140.1").report.get("is_datacenter") # True
|
|
108
|
+
client.check.phone("+14155550123", country="US")
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Report what really happened
|
|
112
|
+
|
|
113
|
+
Feedback is what sharpens scoring. `legit` marks your own false positive and never
|
|
114
|
+
lowers anyone else's risk.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
client.label(label="chargeback", event_id=r.event_id)
|
|
118
|
+
client.label(label="legit", event_id=r.event_id)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Errors
|
|
122
|
+
|
|
123
|
+
Everything raises `KaidnError`, with the API's own message.
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from kaidn import KaidnError
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
r = client.score(event="signup", email=email)
|
|
130
|
+
except KaidnError as err:
|
|
131
|
+
if err.status == 429:
|
|
132
|
+
notify_ops("Kaidn quota exhausted")
|
|
133
|
+
raise
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Network failures, timeouts, 429s and 5xx are retried automatically (2 extra attempts by
|
|
137
|
+
default, honouring `Retry-After`). A 4xx is not: a bad key fails identically the second
|
|
138
|
+
time, and retrying it just spends quota and delays the error reaching whoever can fix it.
|
|
139
|
+
|
|
140
|
+
**Set a timeout and fail open.** A fraud vendor that can take down your signup form is a
|
|
141
|
+
worse problem than the fraud:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
try:
|
|
145
|
+
r = client.score(event="signup", email=email)
|
|
146
|
+
except KaidnError:
|
|
147
|
+
r = None # create the account. Do not let our outage become yours.
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Fields we have not named yet
|
|
151
|
+
|
|
152
|
+
Every response keeps what this version does not recognise, so a signal the API ships next
|
|
153
|
+
week reaches code running the library you installed last year.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
r.get("a_field_added_after_this_release")
|
|
157
|
+
r.device.get("some_new_signal")
|
|
158
|
+
r.extra # everything unrecognised
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Requests work the same way: any extra keyword to `score()` is passed through untouched.
|
|
162
|
+
|
|
163
|
+
## Configuration
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
KaidnClient(
|
|
167
|
+
api_key="kdn_live_...", # default: $KAIDN_API_KEY
|
|
168
|
+
base_url="https://api.kaidn.io",
|
|
169
|
+
timeout=10.0, # seconds per attempt
|
|
170
|
+
retries=2, # extra attempts on a transient failure
|
|
171
|
+
)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Links
|
|
175
|
+
|
|
176
|
+
- [Docs](https://kaidn.io/docs) · [Guides](https://kaidn.io/docs/guides) · [Glossary](https://kaidn.io/glossary)
|
|
177
|
+
- [Pricing](https://kaidn.io/pricing): 10,000 events a month free, no card
|
|
178
|
+
|
|
179
|
+
MIT
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kaidn"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official Python client for Kaidn, the fraud and abuse scoring API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Kaidn", email = "support@kaidn.io" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"fraud", "fraud-detection", "fraud-prevention", "abuse", "bot-detection",
|
|
15
|
+
"device-fingerprinting", "ip-reputation", "proxy-detection", "vpn-detection",
|
|
16
|
+
"disposable-email", "multi-accounting", "account-takeover", "chargeback",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 4 - Beta",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.9",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Topic :: Security",
|
|
29
|
+
"Typing :: Typed",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
# ZERO RUNTIME DEPENDENCIES, deliberately, matching @kaidn/sdk which runs on
|
|
33
|
+
# built-in fetch. This client goes in a signup and checkout path, so every
|
|
34
|
+
# dependency it carries is one more thing that can break somebody's deploy or
|
|
35
|
+
# turn up in their vulnerability scanner. urllib is enough.
|
|
36
|
+
dependencies = []
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://kaidn.io"
|
|
40
|
+
Documentation = "https://kaidn.io/docs"
|
|
41
|
+
Source = "https://github.com/Kaidn-io/kaidn-python"
|
|
42
|
+
Issues = "https://github.com/Kaidn-io/kaidn-python/issues"
|
|
43
|
+
|
|
44
|
+
[project.optional-dependencies]
|
|
45
|
+
dev = ["pytest>=8", "mypy>=1.8", "ruff>=0.6"]
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/kaidn"]
|
|
49
|
+
|
|
50
|
+
# examples/ and tests/ ship in the REPO, so people can clone and run them, and
|
|
51
|
+
# never in the wheel: nobody should download a demo as part of `pip install`.
|
|
52
|
+
[tool.hatch.build.targets.sdist]
|
|
53
|
+
exclude = ["examples", "tests", ".github"]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
|
|
58
|
+
[tool.mypy]
|
|
59
|
+
strict = true
|
|
60
|
+
|
|
61
|
+
[tool.ruff]
|
|
62
|
+
line-length = 100
|
|
63
|
+
# 3.9 on purpose. It is EOL upstream, and it is still what a lot of the
|
|
64
|
+
# rewards/affiliate shops this sells into are running. An SDK that refuses to
|
|
65
|
+
# install is worse than one using Optional[] instead of `| None`, so ruff is
|
|
66
|
+
# told the target rather than being allowed to suggest syntax that would break
|
|
67
|
+
# those installs.
|
|
68
|
+
target-version = "py39"
|
|
69
|
+
|
|
70
|
+
[tool.ruff.lint]
|
|
71
|
+
# "UP" is deliberately absent. With `from __future__ import annotations` ruff is
|
|
72
|
+
# right that `str | None` PARSES on 3.9, but anything that resolves the hints at
|
|
73
|
+
# runtime (typing.get_type_hints, pydantic, some DI frameworks) still raises
|
|
74
|
+
# there. This is a library: the code that resolves our annotations belongs to
|
|
75
|
+
# someone else, so we do not get to make that gamble on their behalf.
|
|
76
|
+
select = ["E", "F", "W", "I", "B", "RUF"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Official Python client for `Kaidn <https://kaidn.io>`_, the fraud and abuse
|
|
2
|
+
scoring API.
|
|
3
|
+
|
|
4
|
+
Send one user action, get back ``allow``, ``review`` or ``block`` with the
|
|
5
|
+
reasons attached::
|
|
6
|
+
|
|
7
|
+
from kaidn import KaidnClient
|
|
8
|
+
|
|
9
|
+
client = KaidnClient() # reads $KAIDN_API_KEY
|
|
10
|
+
r = client.score(event="signup", ip=ip, email=email)
|
|
11
|
+
|
|
12
|
+
if r.blocked:
|
|
13
|
+
raise Denied(r.reason_text)
|
|
14
|
+
|
|
15
|
+
Zero runtime dependencies, on purpose: this runs in a signup path, and every
|
|
16
|
+
dependency it carried would be one more thing that can break your deploy.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from importlib.metadata import PackageNotFoundError
|
|
20
|
+
from importlib.metadata import version as _pkg_version
|
|
21
|
+
|
|
22
|
+
from .client import KaidnClient
|
|
23
|
+
from .errors import KaidnError
|
|
24
|
+
from .models import Check, CheckResult, Device, Identity, Report, ScoreResult
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Check",
|
|
28
|
+
"CheckResult",
|
|
29
|
+
"Device",
|
|
30
|
+
"Identity",
|
|
31
|
+
"KaidnClient",
|
|
32
|
+
"KaidnError",
|
|
33
|
+
"Report",
|
|
34
|
+
"ScoreResult",
|
|
35
|
+
]
|
|
36
|
+
try:
|
|
37
|
+
# Single source of truth: pyproject.toml, read back from the installed
|
|
38
|
+
# metadata. A version literal repeated across three files is the classic way
|
|
39
|
+
# a release ships with the user-agent still reporting last month.
|
|
40
|
+
__version__ = _pkg_version("kaidn")
|
|
41
|
+
except PackageNotFoundError: # running from a source checkout, not installed
|
|
42
|
+
__version__ = "0.0.0.dev0"
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""The Kaidn API client.
|
|
2
|
+
|
|
3
|
+
Server-side only. It holds your secret key, so construct it in your backend and
|
|
4
|
+
never ship it to a browser: the browser half is a separate publishable key.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.parse
|
|
15
|
+
import urllib.request
|
|
16
|
+
from typing import Any, Dict, Mapping, Optional
|
|
17
|
+
|
|
18
|
+
from .errors import KaidnError
|
|
19
|
+
from .models import CheckResult, ScoreResult
|
|
20
|
+
|
|
21
|
+
__all__ = ["KaidnClient"]
|
|
22
|
+
|
|
23
|
+
DEFAULT_BASE_URL = "https://api.kaidn.io"
|
|
24
|
+
DEFAULT_TIMEOUT = 10.0
|
|
25
|
+
DEFAULT_RETRIES = 2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _version() -> str:
|
|
29
|
+
"""Read the version from package metadata rather than a second literal.
|
|
30
|
+
|
|
31
|
+
Imported lazily so a circular import at module load is impossible: the
|
|
32
|
+
package __init__ imports this module.
|
|
33
|
+
"""
|
|
34
|
+
from . import __version__
|
|
35
|
+
|
|
36
|
+
return __version__
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _Checks:
|
|
42
|
+
"""``client.check.email(...)`` and friends."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, client: "KaidnClient") -> None:
|
|
45
|
+
self._c = client
|
|
46
|
+
|
|
47
|
+
def email(self, email: str) -> CheckResult:
|
|
48
|
+
"""Judge one address on its own, without recording an event."""
|
|
49
|
+
data = self._c._request("POST", "/v1/check/email", {"email": email})
|
|
50
|
+
return CheckResult.parse(data, "email")
|
|
51
|
+
|
|
52
|
+
def ip(self, ip: str) -> CheckResult:
|
|
53
|
+
"""Judge one IP: datacenter, proxy, VPN, Tor, geolocation."""
|
|
54
|
+
return CheckResult.parse(self._c._request("POST", "/v1/check/ip", {"ip": ip}), "ip")
|
|
55
|
+
|
|
56
|
+
def phone(self, phone: str, country: Optional[str] = None) -> CheckResult:
|
|
57
|
+
"""Judge one phone number: validity, VOIP, disposable, line type."""
|
|
58
|
+
body: Dict[str, Any] = {"phone": phone}
|
|
59
|
+
if country:
|
|
60
|
+
body["country"] = country
|
|
61
|
+
return CheckResult.parse(self._c._request("POST", "/v1/check/phone", body), "phone")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class KaidnClient:
|
|
65
|
+
"""Score events and run enrichment lookups against the Kaidn API.
|
|
66
|
+
|
|
67
|
+
The key is read from ``KAIDN_API_KEY`` when you do not pass one, so the
|
|
68
|
+
common case needs no arguments and no secret in your source::
|
|
69
|
+
|
|
70
|
+
from kaidn import KaidnClient
|
|
71
|
+
|
|
72
|
+
client = KaidnClient()
|
|
73
|
+
r = client.score(event="signup", ip=ip, email=email)
|
|
74
|
+
if r.blocked:
|
|
75
|
+
raise Denied(r.reason_text)
|
|
76
|
+
|
|
77
|
+
:param api_key: your secret key. Defaults to ``$KAIDN_API_KEY``.
|
|
78
|
+
:param base_url: override the API host.
|
|
79
|
+
:param timeout: seconds per attempt. Default 10.
|
|
80
|
+
:param retries: extra attempts on a transient failure. Default 2.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
api_key: Optional[str] = None,
|
|
86
|
+
*,
|
|
87
|
+
base_url: Optional[str] = None,
|
|
88
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
89
|
+
retries: int = DEFAULT_RETRIES,
|
|
90
|
+
) -> None:
|
|
91
|
+
key = api_key or os.environ.get("KAIDN_API_KEY")
|
|
92
|
+
if not key:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
"KaidnClient: no API key. Pass api_key=... or set KAIDN_API_KEY. "
|
|
95
|
+
"Get one free at https://kaidn.io/register"
|
|
96
|
+
)
|
|
97
|
+
# A publishable key in a server client is a common and confusing
|
|
98
|
+
# mistake: it cannot score, so the failure would arrive later as an
|
|
99
|
+
# opaque 401. Say so now, while the traceback still points here.
|
|
100
|
+
if key.startswith("pk_"):
|
|
101
|
+
raise ValueError(
|
|
102
|
+
"KaidnClient: that looks like a publishable tracker key (pk_...), which can only "
|
|
103
|
+
"send fingerprints from a browser. This client needs your SECRET key."
|
|
104
|
+
)
|
|
105
|
+
self._api_key = key
|
|
106
|
+
self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
107
|
+
self._timeout = timeout
|
|
108
|
+
self._retries = retries
|
|
109
|
+
self.check = _Checks(self)
|
|
110
|
+
|
|
111
|
+
# ---- scoring ---------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def score(
|
|
114
|
+
self,
|
|
115
|
+
*,
|
|
116
|
+
event: str,
|
|
117
|
+
user_id: Optional[str] = None,
|
|
118
|
+
ip: Optional[str] = None,
|
|
119
|
+
email: Optional[str] = None,
|
|
120
|
+
phone: Optional[str] = None,
|
|
121
|
+
device_id: Optional[str] = None,
|
|
122
|
+
device_token: Optional[str] = None,
|
|
123
|
+
event_country: Optional[str] = None,
|
|
124
|
+
ip_country: Optional[str] = None,
|
|
125
|
+
**extra: Any,
|
|
126
|
+
) -> ScoreResult:
|
|
127
|
+
"""Score one thing that just happened.
|
|
128
|
+
|
|
129
|
+
``event`` is the only required field and the name is yours to choose:
|
|
130
|
+
``"signup"``, ``"login"``, ``"cashout"``, ``"trial_start"``. Send
|
|
131
|
+
whatever else you already collect; the answer sharpens as you send more.
|
|
132
|
+
|
|
133
|
+
``**extra`` is passed through untouched, so a field the API gains later
|
|
134
|
+
works without waiting for a release of this library.
|
|
135
|
+
"""
|
|
136
|
+
body: Dict[str, Any] = {"event": event}
|
|
137
|
+
for k, v in (
|
|
138
|
+
("user_id", user_id), ("ip", ip), ("email", email), ("phone", phone),
|
|
139
|
+
("device_id", device_id), ("device_token", device_token),
|
|
140
|
+
("event_country", event_country), ("ip_country", ip_country),
|
|
141
|
+
):
|
|
142
|
+
if v is not None:
|
|
143
|
+
body[k] = v
|
|
144
|
+
body.update(extra)
|
|
145
|
+
return ScoreResult.parse(self._request("POST", "/v1/score", body))
|
|
146
|
+
|
|
147
|
+
# ---- feedback, which is what sharpens the shared graph ----------------
|
|
148
|
+
|
|
149
|
+
def label(
|
|
150
|
+
self,
|
|
151
|
+
*,
|
|
152
|
+
label: str,
|
|
153
|
+
event_id: Optional[str] = None,
|
|
154
|
+
note: Optional[str] = None,
|
|
155
|
+
**extra: Any,
|
|
156
|
+
) -> Dict[str, Any]:
|
|
157
|
+
"""Report the real outcome: ``"fraud"``, ``"chargeback"`` or ``"legit"``.
|
|
158
|
+
|
|
159
|
+
``legit`` marks your own false positive. It suppresses that entity for
|
|
160
|
+
you and never lowers anyone else's risk.
|
|
161
|
+
"""
|
|
162
|
+
body: Dict[str, Any] = {"label": label}
|
|
163
|
+
if event_id:
|
|
164
|
+
body["event_id"] = event_id
|
|
165
|
+
if note:
|
|
166
|
+
body["note"] = note
|
|
167
|
+
body.update(extra)
|
|
168
|
+
return self._request("POST", "/v1/label", body)
|
|
169
|
+
|
|
170
|
+
# ---- reading your own data -------------------------------------------
|
|
171
|
+
|
|
172
|
+
def events(self, *, limit: Optional[int] = None, offset: Optional[int] = None,
|
|
173
|
+
verdict: Optional[str] = None, event: Optional[str] = None) -> Dict[str, Any]:
|
|
174
|
+
"""Newest-first list of what you have scored."""
|
|
175
|
+
return self._request("GET", "/v1/events", query={
|
|
176
|
+
"limit": limit, "offset": offset, "verdict": verdict, "event": event,
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
def stats(self, window_hours: Optional[int] = None) -> Dict[str, Any]:
|
|
180
|
+
"""Totals by verdict, average score, and the top reason codes."""
|
|
181
|
+
return self._request("GET", "/v1/stats", query={"window_hours": window_hours})
|
|
182
|
+
|
|
183
|
+
def health(self) -> Dict[str, Any]:
|
|
184
|
+
"""Public liveness plus the size of every loaded intel dataset."""
|
|
185
|
+
return self._request("GET", "/v1/health")
|
|
186
|
+
|
|
187
|
+
# ---- transport -------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def _request(
|
|
190
|
+
self,
|
|
191
|
+
method: str,
|
|
192
|
+
path: str,
|
|
193
|
+
body: Optional[Mapping[str, Any]] = None,
|
|
194
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
195
|
+
) -> Dict[str, Any]:
|
|
196
|
+
url = self._base_url + path
|
|
197
|
+
if query:
|
|
198
|
+
clean = {k: str(v) for k, v in query.items() if v is not None and v != ""}
|
|
199
|
+
if clean:
|
|
200
|
+
url += "?" + urllib.parse.urlencode(clean)
|
|
201
|
+
|
|
202
|
+
payload = json.dumps(body).encode() if body is not None else None
|
|
203
|
+
headers = {
|
|
204
|
+
"x-api-key": self._api_key,
|
|
205
|
+
"user-agent": f"kaidn-python/{_version()}",
|
|
206
|
+
"accept": "application/json",
|
|
207
|
+
}
|
|
208
|
+
if payload is not None:
|
|
209
|
+
headers["content-type"] = "application/json"
|
|
210
|
+
|
|
211
|
+
last: Optional[KaidnError] = None
|
|
212
|
+
for attempt in range(self._retries + 1):
|
|
213
|
+
req = urllib.request.Request(url, data=payload, headers=headers, method=method)
|
|
214
|
+
try:
|
|
215
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as res:
|
|
216
|
+
raw = res.read().decode("utf-8") or "{}"
|
|
217
|
+
return json.loads(raw) # type: ignore[no-any-return]
|
|
218
|
+
except urllib.error.HTTPError as err:
|
|
219
|
+
status = err.code
|
|
220
|
+
message, parsed = _read_error(err)
|
|
221
|
+
error = KaidnError(status, message, parsed)
|
|
222
|
+
retry_after = _retry_after(err.headers.get("Retry-After"))
|
|
223
|
+
except Exception as err:
|
|
224
|
+
# status 0 keeps "the request never got an answer" distinct from
|
|
225
|
+
# "the server answered and said no", which are different bugs.
|
|
226
|
+
error = KaidnError(0, str(err) or "network error")
|
|
227
|
+
retry_after = None
|
|
228
|
+
|
|
229
|
+
if error.retryable and attempt < self._retries:
|
|
230
|
+
last = error
|
|
231
|
+
time.sleep(retry_after if retry_after is not None else _backoff(attempt))
|
|
232
|
+
continue
|
|
233
|
+
raise error
|
|
234
|
+
|
|
235
|
+
raise last or KaidnError(0, "request failed") # pragma: no cover
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _read_error(err: "urllib.error.HTTPError") -> "tuple[str, Optional[Any]]":
|
|
239
|
+
"""Prefer the API's own ``{"error": ...}`` string over a generic status."""
|
|
240
|
+
try:
|
|
241
|
+
text = err.read().decode("utf-8")
|
|
242
|
+
except Exception:
|
|
243
|
+
text = ""
|
|
244
|
+
try:
|
|
245
|
+
parsed = json.loads(text)
|
|
246
|
+
if isinstance(parsed, dict) and isinstance(parsed.get("error"), str):
|
|
247
|
+
return parsed["error"], parsed
|
|
248
|
+
return (text[:300] or f"request failed ({err.code})"), parsed
|
|
249
|
+
except Exception:
|
|
250
|
+
return (text[:300] or f"request failed ({err.code})"), None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _retry_after(header: Optional[str]) -> Optional[float]:
|
|
254
|
+
"""Honour Retry-After when the server sends one. It knows better than we do."""
|
|
255
|
+
if not header:
|
|
256
|
+
return None
|
|
257
|
+
try:
|
|
258
|
+
return max(0.0, float(header))
|
|
259
|
+
except ValueError:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _backoff(attempt: int) -> float:
|
|
264
|
+
"""Exponential with jitter. The jitter matters: without it, every client
|
|
265
|
+
that got rate-limited at the same moment retries at the same moment."""
|
|
266
|
+
return float(0.25 * (2**attempt) + random.uniform(0, 0.1))
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""The one exception this client raises."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class KaidnError(Exception):
|
|
9
|
+
"""Raised on any non-2xx response, and on a network or timeout failure with
|
|
10
|
+
``status = 0``.
|
|
11
|
+
|
|
12
|
+
``message`` is the API's ``{"error": ...}`` string when it sent one, so the
|
|
13
|
+
thing a developer reads first is the thing the server actually said.
|
|
14
|
+
|
|
15
|
+
Quota exhaustion arrives as ``status == 429``. Check for it explicitly if
|
|
16
|
+
you want to prompt an upgrade rather than back off::
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
r = client.score(event="signup", email=email)
|
|
20
|
+
except KaidnError as err:
|
|
21
|
+
if err.status == 429:
|
|
22
|
+
notify_ops("Kaidn quota exhausted")
|
|
23
|
+
raise
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, status: int, message: str, body: Optional[Any] = None) -> None:
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.status = status
|
|
29
|
+
self.message = message
|
|
30
|
+
#: the parsed JSON error body, when the response had one
|
|
31
|
+
self.body = body
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def retryable(self) -> bool:
|
|
35
|
+
"""True when the failure is transient and worth retrying: a network
|
|
36
|
+
error, a timeout, a rate limit, or a 5xx.
|
|
37
|
+
|
|
38
|
+
A 4xx is not retryable. A bad key or a malformed body will fail exactly
|
|
39
|
+
the same way the second time, and retrying it just spends your quota
|
|
40
|
+
and delays the error reaching whoever can fix it.
|
|
41
|
+
"""
|
|
42
|
+
return self.status == 0 or self.status == 429 or self.status >= 500
|
|
43
|
+
|
|
44
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
45
|
+
return f"KaidnError(status={self.status}, message={self.message!r})"
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Response models.
|
|
2
|
+
|
|
3
|
+
## Why these are dataclasses and not dicts
|
|
4
|
+
|
|
5
|
+
A dict gives you no autocomplete and no typo protection: ``r["verdcit"]`` is a
|
|
6
|
+
``KeyError`` at 3am rather than a red squiggle. Every response here is a
|
|
7
|
+
dataclass with real attributes.
|
|
8
|
+
|
|
9
|
+
## Why they keep unknown fields
|
|
10
|
+
|
|
11
|
+
Every response schema on the Kaidn API sets ``additionalProperties: true``,
|
|
12
|
+
because a schema that omits a field would silently delete it from the wire.
|
|
13
|
+
These models take the same position from the other end: an unrecognised key is
|
|
14
|
+
kept in ``.extra`` rather than dropped.
|
|
15
|
+
|
|
16
|
+
That matters more than it sounds. It means a field added to the API next week
|
|
17
|
+
reaches your code on the version of this library you installed last year, and
|
|
18
|
+
an SDK upgrade is never the thing standing between you and a new signal.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass, field, fields
|
|
24
|
+
from typing import Any, Dict, List, Optional, Type, TypeVar
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T", bound="_Model")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class _Model:
|
|
31
|
+
"""Base: builds from a dict, keeps whatever it did not recognise."""
|
|
32
|
+
|
|
33
|
+
#: keys the server sent that this version of the library does not name yet
|
|
34
|
+
extra: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def _from(cls: Type[T], data: Optional[Dict[str, Any]]) -> Optional[T]:
|
|
38
|
+
if data is None:
|
|
39
|
+
return None
|
|
40
|
+
known = {f.name for f in fields(cls)} - {"extra"}
|
|
41
|
+
kwargs = {k: v for k, v in data.items() if k in known}
|
|
42
|
+
extra = {k: v for k, v in data.items() if k not in known}
|
|
43
|
+
obj = cls(**kwargs)
|
|
44
|
+
obj.extra = extra
|
|
45
|
+
return obj
|
|
46
|
+
|
|
47
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
48
|
+
"""Read a field by name, including one this version does not know about.
|
|
49
|
+
|
|
50
|
+
``r.device.get("some_new_signal")`` works the day the API ships it.
|
|
51
|
+
"""
|
|
52
|
+
if key in {f.name for f in fields(self)}:
|
|
53
|
+
return getattr(self, key)
|
|
54
|
+
return self.extra.get(key, default)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Check(_Model):
|
|
59
|
+
"""One check that fired, with the numbers behind it."""
|
|
60
|
+
|
|
61
|
+
check: str = ""
|
|
62
|
+
#: the config key that sets this check's weight, so a verdict tells you
|
|
63
|
+
#: exactly what to edit in /v1/config to change it next time
|
|
64
|
+
key: Optional[str] = None
|
|
65
|
+
weight: float = 0.0
|
|
66
|
+
reason: str = ""
|
|
67
|
+
message: str = ""
|
|
68
|
+
#: free-form: each check attaches whatever it measured
|
|
69
|
+
evidence: Dict[str, Any] = field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Device(_Model):
|
|
74
|
+
"""Server-side device profile. Present when the event carried a device_id."""
|
|
75
|
+
|
|
76
|
+
#: the RAW browser fingerprint. Useful for debugging, and it COLLIDES across
|
|
77
|
+
#: unrelated people, so never treat it as an identity. Use resolved_id.
|
|
78
|
+
id: str = ""
|
|
79
|
+
#: the resolved device identity. This is the one that identifies a browser.
|
|
80
|
+
resolved_id: Optional[str] = None
|
|
81
|
+
#: "deterministic" (a device token was replayed) or "probabilistic"
|
|
82
|
+
resolution: Optional[str] = None
|
|
83
|
+
#: 1 = device token, 2 = fingerprint + network, 3 = neither, links nothing
|
|
84
|
+
resolution_rung: Optional[int] = None
|
|
85
|
+
#: measured P(this identity covers more than one person), 0-1
|
|
86
|
+
collision_risk: Optional[float] = None
|
|
87
|
+
#: whether collision_risk was measured for this platform, or is the average
|
|
88
|
+
risk_measured: Optional[bool] = None
|
|
89
|
+
#: set when a token was sent but unusable: usually a broken integration
|
|
90
|
+
token_rejected: Optional[str] = None
|
|
91
|
+
#: accounts on the raw fingerprint. Includes collisions, so it is NOT a
|
|
92
|
+
#: person count. Prefer the field below when you have to justify a decision.
|
|
93
|
+
account_count: int = 0
|
|
94
|
+
#: accounts on this fingerprint AND this network. The number you can defend.
|
|
95
|
+
account_count_same_network: Optional[int] = None
|
|
96
|
+
distinct_ips: int = 0
|
|
97
|
+
unique: bool = True
|
|
98
|
+
connection_type: Optional[str] = None
|
|
99
|
+
os: Optional[str] = None
|
|
100
|
+
browser: Optional[str] = None
|
|
101
|
+
mobile: Optional[bool] = None
|
|
102
|
+
is_headless: Optional[bool] = None
|
|
103
|
+
ua_consistent: Optional[bool] = None
|
|
104
|
+
is_emulated: Optional[bool] = None
|
|
105
|
+
is_noise_injected: Optional[bool] = None
|
|
106
|
+
is_tampered: Optional[bool] = None
|
|
107
|
+
is_context_mismatch: Optional[bool] = None
|
|
108
|
+
is_engine_mismatch: Optional[bool] = None
|
|
109
|
+
is_os_mismatch: Optional[bool] = None
|
|
110
|
+
timezone: Optional[str] = None
|
|
111
|
+
ja4: bool = False
|
|
112
|
+
ja4_known_tool: Optional[str] = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class Identity(_Model):
|
|
117
|
+
"""Dedupe keys derived from the event's identifiers."""
|
|
118
|
+
|
|
119
|
+
#: every alias of one mailbox collapses to this. Dedupe on it and one inbox
|
|
120
|
+
#: can no longer register as several people.
|
|
121
|
+
email_canonical: str = ""
|
|
122
|
+
email_is_aliased: bool = False
|
|
123
|
+
email_alias_tricks: List[str] = field(default_factory=list)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class ScoreResult(_Model):
|
|
128
|
+
"""What POST /v1/score returns."""
|
|
129
|
+
|
|
130
|
+
event_id: str = ""
|
|
131
|
+
score: float = 0.0
|
|
132
|
+
#: "allow", "review" or "block". Branch on this.
|
|
133
|
+
verdict: str = "allow"
|
|
134
|
+
#: stable machine-matchable names. Branch on these rather than the score.
|
|
135
|
+
reasons: List[str] = field(default_factory=list)
|
|
136
|
+
#: the same finding as a sentence, clear enough to send to a customer
|
|
137
|
+
reason_text: str = ""
|
|
138
|
+
checks: List[Check] = field(default_factory=list)
|
|
139
|
+
device: Optional[Device] = None
|
|
140
|
+
#: store as a first-party cookie on YOUR domain, send back next time
|
|
141
|
+
device_token: Optional[str] = None
|
|
142
|
+
identity: Optional[Identity] = None
|
|
143
|
+
#: dropped or malformed optional fields: reported, not rejected
|
|
144
|
+
warnings: List[str] = field(default_factory=list)
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def blocked(self) -> bool:
|
|
148
|
+
return self.verdict == "block"
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def needs_review(self) -> bool:
|
|
152
|
+
return self.verdict == "review"
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def parse(cls, data: Dict[str, Any]) -> "ScoreResult":
|
|
156
|
+
r = cls._from(data)
|
|
157
|
+
assert r is not None
|
|
158
|
+
r.checks = [c for c in (Check._from(c) for c in data.get("checks", [])) if c]
|
|
159
|
+
r.device = Device._from(data.get("device"))
|
|
160
|
+
r.identity = Identity._from(data.get("identity"))
|
|
161
|
+
return r
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class Report(_Model):
|
|
166
|
+
"""An enrichment report from /v1/check/*.
|
|
167
|
+
|
|
168
|
+
Deliberately open: the field set is large, differs per identifier, and grows
|
|
169
|
+
as intel sources are added. ``fraud_score`` is the only guarantee, and
|
|
170
|
+
everything else is reachable through ``.get()`` or ``.extra``.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
fraud_score: float = 0.0
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass
|
|
177
|
+
class CheckResult(_Model):
|
|
178
|
+
"""What POST /v1/check/{email,ip,phone} returns."""
|
|
179
|
+
|
|
180
|
+
report: Optional[Report] = None
|
|
181
|
+
reputation: Dict[str, Any] = field(default_factory=dict)
|
|
182
|
+
summary: str = ""
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def parse(cls, data: Dict[str, Any], kind: str) -> "CheckResult":
|
|
186
|
+
obj = cls(
|
|
187
|
+
report=Report._from(data.get(kind) or {}),
|
|
188
|
+
reputation=data.get("reputation", {}),
|
|
189
|
+
summary=data.get("summary", ""),
|
|
190
|
+
)
|
|
191
|
+
obj.extra = {k: v for k, v in data.items() if k not in {kind, "reputation", "summary"}}
|
|
192
|
+
return obj
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def fraud_score(self) -> float:
|
|
196
|
+
"""Shortcut for the one field every report guarantees."""
|
|
197
|
+
return self.report.fraud_score if self.report else 0.0
|
|
File without changes
|