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 ADDED
@@ -0,0 +1,18 @@
1
+ # build
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+
6
+ # environments
7
+ .venv/
8
+ venv/
9
+
10
+ # caches
11
+ __pycache__/
12
+ *.py[cod]
13
+ .pytest_cache/
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+
17
+ # local
18
+ .env
@@ -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