inlineshield 1.0.1__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.
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 1.0.1 (2026-11-03)
4
+
5
+ - MIT licence (`LICENSE`, in the wheel and the sdist); `pyproject.toml` says `license = "MIT"` (SPDX; the sdist now builds with setuptools 77 or later).
6
+ - `VerifyResult` and `SiteverifyResult` now declare `mailboxStatus` (`"exists" | "not_found" | "catch_all" | "unknown"`) and `linked` (the new `Linked` TypedDict: `groupId`, `size`, `strength`, `linkedToFraud`; or `None`). The fail-open results do not carry them.
7
+ - The API already sent these fields and 1.0.0 passed them through; only the declared types change.
8
+
9
+ ## 1.0.0 (2026-11-03)
10
+
11
+ - First release: `Client` with `verify`, `siteverify`, `feedback` and `get_review`; `verify_webhook`.
12
+ - Fail-open by default (`fail_open`), a 3 s timeout (`timeout_ms`), retry of failed connections only (`max_retries`).
13
+ - Typed errors: `AuthError`, `ApiError`, `UnavailableError`, `RateLimitError`, `QuotaError`, `WebhookVerificationError`.
14
+ - Python 3.9 or later, standard library only.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 InlineShield
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.
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: inlineshield
3
+ Version: 1.0.1
4
+ Summary: Official Python client for the InlineShield sign-up screening API: verify, siteverify, feedback, review status and webhook signature checks.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Project-URL: Documentation, https://inlineshield.io/docs/sdks/python/
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Typing :: Typed
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+
13
+ # inlineshield
14
+
15
+ The official Python client for the [InlineShield](https://inlineshield.io) sign-up screening API.
16
+
17
+ - Python 3.9 or later; standard library only (`urllib`, `hmac`, `json`); typed.
18
+ - Fail-open by default: an outage never blocks your sign-ups.
19
+
20
+ Docs: https://inlineshield.io/docs/sdks/python/
21
+
22
+ ## Install
23
+
24
+ Until the package is on PyPI:
25
+
26
+ ```sh
27
+ pip install https://inlineshield.io/downloads/sdk/inlineshield-1.0.1-py3-none-any.whl
28
+ ```
29
+
30
+ ## Use
31
+
32
+ ```python
33
+ import os
34
+ from inlineshield import Client, verify_webhook
35
+
36
+ shield = Client(os.environ["INLINESHIELD_API_KEY"]) # bs_live_... or bs_test_...
37
+
38
+ # With the browser script: redeem its token, fall back to a server-side screening.
39
+ # Check `unavailable` first (fail-open: action "allow"), then `success`, then the error.
40
+ result = shield.siteverify(token, email)
41
+ if not result["unavailable"] and not result["success"]:
42
+ if result["error"] != "NO_TOKEN":
43
+ return reject(result["error"]) # INVALID_TOKEN, EXPIRED_TOKEN, ...
44
+ result = shield.verify(email, ip=visitor_ip, user_agent=visitor_user_agent)
45
+ # result["action"]: "allow", "hold" or "block".
46
+
47
+ shield.feedback(request_id, "fraud", category="bonus_abuse")
48
+ review = shield.get_review(review_id) # pending, approved or rejected
49
+
50
+ event = verify_webhook(signature_header, raw_body, os.environ["INLINESHIELD_WEBHOOK_SECRET"])
51
+ ```
52
+
53
+ Results are dicts with the API's field names, plus `unavailable`.
54
+
55
+ ## siteverify results
56
+
57
+ Check `unavailable` first: it is true when InlineShield could not answer (fail-open: `action` is "allow" and `success` is false, because no token was redeemed). Otherwise `success` true carries the decision; on `error` NO_TOKEN screen with verify; on any other error reject the sign-up.
58
+
59
+ ## Options
60
+
61
+ `Client(api_key=None, *, base_url=None, timeout_ms=3000, fail_open=True, max_retries=1)`
62
+
63
+ - `api_key`: without one, `INLINESHIELD_API_KEY` from the environment.
64
+ - `base_url`: default `INLINESHIELD_BASE_URL`, else `https://api.inlineshield.io`.
65
+ - `fail_open=True`: when InlineShield cannot be reached, times out, answers 5xx, 429 or is over quota, the methods
66
+ return `{"unavailable": True, "error": ..., "action": "allow", ...}` instead of raising. `False`: they raise.
67
+ - `timeout_ms` is a deadline for the whole request (the answer is read in a worker thread that is cut off at the
68
+ deadline); `base_url` must use https (plain http only for localhost).
69
+ - `max_retries`: only a failed connection (nothing sent) is retried; never a timeout or an answer.
70
+
71
+ A rate limit (429) or an exhausted quota also fails open by default: every further sign-up is admitted unscreened until the limit resets. To choose otherwise, check `error` on an unavailable result (`RATE_LIMIT_EXCEEDED`, `TEST_LIMIT`, `QUOTA_EXCEEDED`) and hold or reject those sign-ups, or turn fail-open off and handle the errors.
72
+
73
+ ## Errors
74
+
75
+ All extend `InlineShieldError` (`code`, `status`):
76
+
77
+ | Class | When |
78
+ |---|---|
79
+ | `AuthError` | 401, or 403 TENANT_SUSPENDED, or no key (MISSING_API_KEY). Always raised. |
80
+ | `ApiError` | 400, 404, 422, or a redirect (UNEXPECTED_REDIRECT). Always raised. |
81
+ | `UnavailableError` | TIMEOUT or UNAVAILABLE, with `fail_open=False`. |
82
+ | `RateLimitError` | RATE_LIMIT_EXCEEDED or TEST_LIMIT (`retry_after`), with `fail_open=False`. |
83
+ | `QuotaError` | QUOTA_EXCEEDED, with `fail_open=False`. |
84
+ | `WebhookVerificationError` | NO_SECRET, MALFORMED_HEADER, TIMESTAMP_OUT_OF_RANGE, SIGNATURE_MISMATCH, INVALID_PAYLOAD. |
85
+
86
+ Messages never contain the API key or an email address, and the SDK writes nothing to any output.
87
+
88
+ ## Webhooks
89
+
90
+ `verify_webhook(signature_header, raw_body, secret, tolerance_sec=300)` checks `X-InlineShield-Signature`
91
+ (`t=<seconds>,v1=<hex>`): HMAC-SHA256 of `<t>.<raw body>` with the whole `whsec_...` secret, compared in constant
92
+ time, and `t` within `tolerance_sec` of now. Pass the raw body bytes (Flask: `request.get_data()`).
93
+
94
+ ## Licence
95
+
96
+ MIT. See `LICENSE`.
@@ -0,0 +1,84 @@
1
+ # inlineshield
2
+
3
+ The official Python client for the [InlineShield](https://inlineshield.io) sign-up screening API.
4
+
5
+ - Python 3.9 or later; standard library only (`urllib`, `hmac`, `json`); typed.
6
+ - Fail-open by default: an outage never blocks your sign-ups.
7
+
8
+ Docs: https://inlineshield.io/docs/sdks/python/
9
+
10
+ ## Install
11
+
12
+ Until the package is on PyPI:
13
+
14
+ ```sh
15
+ pip install https://inlineshield.io/downloads/sdk/inlineshield-1.0.1-py3-none-any.whl
16
+ ```
17
+
18
+ ## Use
19
+
20
+ ```python
21
+ import os
22
+ from inlineshield import Client, verify_webhook
23
+
24
+ shield = Client(os.environ["INLINESHIELD_API_KEY"]) # bs_live_... or bs_test_...
25
+
26
+ # With the browser script: redeem its token, fall back to a server-side screening.
27
+ # Check `unavailable` first (fail-open: action "allow"), then `success`, then the error.
28
+ result = shield.siteverify(token, email)
29
+ if not result["unavailable"] and not result["success"]:
30
+ if result["error"] != "NO_TOKEN":
31
+ return reject(result["error"]) # INVALID_TOKEN, EXPIRED_TOKEN, ...
32
+ result = shield.verify(email, ip=visitor_ip, user_agent=visitor_user_agent)
33
+ # result["action"]: "allow", "hold" or "block".
34
+
35
+ shield.feedback(request_id, "fraud", category="bonus_abuse")
36
+ review = shield.get_review(review_id) # pending, approved or rejected
37
+
38
+ event = verify_webhook(signature_header, raw_body, os.environ["INLINESHIELD_WEBHOOK_SECRET"])
39
+ ```
40
+
41
+ Results are dicts with the API's field names, plus `unavailable`.
42
+
43
+ ## siteverify results
44
+
45
+ Check `unavailable` first: it is true when InlineShield could not answer (fail-open: `action` is "allow" and `success` is false, because no token was redeemed). Otherwise `success` true carries the decision; on `error` NO_TOKEN screen with verify; on any other error reject the sign-up.
46
+
47
+ ## Options
48
+
49
+ `Client(api_key=None, *, base_url=None, timeout_ms=3000, fail_open=True, max_retries=1)`
50
+
51
+ - `api_key`: without one, `INLINESHIELD_API_KEY` from the environment.
52
+ - `base_url`: default `INLINESHIELD_BASE_URL`, else `https://api.inlineshield.io`.
53
+ - `fail_open=True`: when InlineShield cannot be reached, times out, answers 5xx, 429 or is over quota, the methods
54
+ return `{"unavailable": True, "error": ..., "action": "allow", ...}` instead of raising. `False`: they raise.
55
+ - `timeout_ms` is a deadline for the whole request (the answer is read in a worker thread that is cut off at the
56
+ deadline); `base_url` must use https (plain http only for localhost).
57
+ - `max_retries`: only a failed connection (nothing sent) is retried; never a timeout or an answer.
58
+
59
+ A rate limit (429) or an exhausted quota also fails open by default: every further sign-up is admitted unscreened until the limit resets. To choose otherwise, check `error` on an unavailable result (`RATE_LIMIT_EXCEEDED`, `TEST_LIMIT`, `QUOTA_EXCEEDED`) and hold or reject those sign-ups, or turn fail-open off and handle the errors.
60
+
61
+ ## Errors
62
+
63
+ All extend `InlineShieldError` (`code`, `status`):
64
+
65
+ | Class | When |
66
+ |---|---|
67
+ | `AuthError` | 401, or 403 TENANT_SUSPENDED, or no key (MISSING_API_KEY). Always raised. |
68
+ | `ApiError` | 400, 404, 422, or a redirect (UNEXPECTED_REDIRECT). Always raised. |
69
+ | `UnavailableError` | TIMEOUT or UNAVAILABLE, with `fail_open=False`. |
70
+ | `RateLimitError` | RATE_LIMIT_EXCEEDED or TEST_LIMIT (`retry_after`), with `fail_open=False`. |
71
+ | `QuotaError` | QUOTA_EXCEEDED, with `fail_open=False`. |
72
+ | `WebhookVerificationError` | NO_SECRET, MALFORMED_HEADER, TIMESTAMP_OUT_OF_RANGE, SIGNATURE_MISMATCH, INVALID_PAYLOAD. |
73
+
74
+ Messages never contain the API key or an email address, and the SDK writes nothing to any output.
75
+
76
+ ## Webhooks
77
+
78
+ `verify_webhook(signature_header, raw_body, secret, tolerance_sec=300)` checks `X-InlineShield-Signature`
79
+ (`t=<seconds>,v1=<hex>`): HMAC-SHA256 of `<t>.<raw body>` with the whole `whsec_...` secret, compared in constant
80
+ time, and `t` within `tolerance_sec` of now. Pass the raw body bytes (Flask: `request.get_data()`).
81
+
82
+ ## Licence
83
+
84
+ MIT. See `LICENSE`.
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "inlineshield"
7
+ version = "1.0.1"
8
+ description = "Official Python client for the InlineShield sign-up screening API: verify, siteverify, feedback, review status and webhook signature checks."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ dependencies = []
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Typing :: Typed",
17
+ ]
18
+
19
+ [project.urls]
20
+ Documentation = "https://inlineshield.io/docs/sdks/python/"
21
+
22
+ [tool.setuptools]
23
+ package-dir = { "" = "src" }
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+
28
+ [tool.setuptools.package-data]
29
+ inlineshield = ["py.typed"]
@@ -0,0 +1,30 @@
1
+ """inlineshield: the official Python client for the InlineShield sign-up screening API.
2
+
3
+ Docs: https://inlineshield.io/docs/sdks/python/
4
+ """
5
+ from ._client import DEFAULT_BASE_URL, Client
6
+ from ._errors import (
7
+ ApiError,
8
+ AuthError,
9
+ InlineShieldError,
10
+ QuotaError,
11
+ RateLimitError,
12
+ UnavailableError,
13
+ WebhookVerificationError,
14
+ )
15
+ from ._version import __version__
16
+ from ._webhook import verify_webhook
17
+
18
+ __all__ = [
19
+ "Client",
20
+ "DEFAULT_BASE_URL",
21
+ "verify_webhook",
22
+ "InlineShieldError",
23
+ "AuthError",
24
+ "RateLimitError",
25
+ "QuotaError",
26
+ "UnavailableError",
27
+ "ApiError",
28
+ "WebhookVerificationError",
29
+ "__version__",
30
+ ]
@@ -0,0 +1,409 @@
1
+ """The InlineShield API client (standard library only: urllib, json)."""
2
+ import errno
3
+ import http.client
4
+ import json
5
+ import math
6
+ import os
7
+ import re
8
+ import socket
9
+ import threading
10
+ import time
11
+ import urllib.error
12
+ import urllib.parse
13
+ import urllib.request
14
+ from typing import Any, Callable, Dict, Optional, Tuple, TypeVar, Union
15
+
16
+ from ._errors import ApiError, AuthError, QuotaError, RateLimitError, UnavailableError
17
+ from ._types import FeedbackResult, FeedbackTarget, ReviewResult, SiteverifyResult, VerifyResult
18
+ from ._version import __version__
19
+ from ._webhook import verify_webhook as _verify_webhook
20
+
21
+ DEFAULT_BASE_URL = "https://api.inlineshield.io"
22
+ _RETRY_DELAY_S = 0.15
23
+ _EMAIL_LIKE = re.compile(r"[^\s@\"'<>]+@[^\s@\"'<>]+")
24
+ _CODE = re.compile(r"[A-Z0-9_]{1,64}", re.ASCII)
25
+ _T = TypeVar("_T")
26
+
27
+
28
+ class _NotSent(Exception):
29
+ """A connection that failed before anything was sent: the only failure that is retried."""
30
+
31
+ def __init__(self, detail: str) -> None:
32
+ super().__init__(detail)
33
+ self.detail = detail
34
+
35
+
36
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
37
+ """Never follow a redirect: the API key would go with it."""
38
+
39
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
40
+ return None
41
+
42
+
43
+ def _tracking_opener(connections: list) -> urllib.request.OpenerDirector:
44
+ """An opener (no redirects, the environment's proxies) that records every connection it opens, so a request
45
+ still running at the deadline can be cut off by closing its socket."""
46
+
47
+ class _Http(urllib.request.HTTPHandler):
48
+ def http_open(self, req): # type: ignore[no-untyped-def]
49
+ return self.do_open(self._connect, req)
50
+
51
+ def _connect(self, host, **kw): # type: ignore[no-untyped-def]
52
+ conn = http.client.HTTPConnection(host, **kw)
53
+ connections.append(conn)
54
+ return conn
55
+
56
+ class _Https(urllib.request.HTTPSHandler):
57
+ def https_open(self, req): # type: ignore[no-untyped-def]
58
+ return self.do_open(self._connect, req)
59
+
60
+ def _connect(self, host, **kw): # type: ignore[no-untyped-def]
61
+ conn = http.client.HTTPSConnection(host, **kw)
62
+ connections.append(conn)
63
+ return conn
64
+
65
+ return urllib.request.build_opener(_NoRedirect, _Http, _Https)
66
+
67
+
68
+ def _plain_http_allowed(url: str) -> bool:
69
+ """http:// only for this machine (local tests); anything else would send the key in clear text."""
70
+ return re.match(r"http://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(/|$)", url, re.IGNORECASE) is not None
71
+
72
+
73
+ def _non_empty(value: Any, what: str) -> str:
74
+ if not isinstance(value, str) or value.strip() == "":
75
+ raise ValueError("%s must be a non-empty string." % what)
76
+ return value
77
+
78
+
79
+ class Client:
80
+ """The InlineShield API client. Server-side only: it holds your secret API key.
81
+
82
+ Example::
83
+
84
+ shield = Client(os.environ["INLINESHIELD_API_KEY"])
85
+ result = shield.verify(email, ip=ip, user_agent=user_agent)
86
+ """
87
+
88
+ __slots__ = ("_api_key", "base_url", "timeout_ms", "fail_open", "max_retries")
89
+
90
+ def __init__(
91
+ self,
92
+ api_key: Optional[str] = None,
93
+ *,
94
+ base_url: Optional[str] = None,
95
+ timeout_ms: float = 3000,
96
+ fail_open: bool = True,
97
+ max_retries: int = 1,
98
+ ) -> None:
99
+ """
100
+ :param api_key: your secret API key (bs_live_... or bs_test_...). Without one, INLINESHIELD_API_KEY from the
101
+ environment.
102
+ :param base_url: the API origin. Default: INLINESHIELD_BASE_URL from the environment, else
103
+ https://api.inlineshield.io.
104
+ :param timeout_ms: the deadline of each request in milliseconds (default 3000): connecting, sending and
105
+ reading the whole answer. A DNS lookup that hangs is abandoned at the deadline too.
106
+ :param fail_open: True (default): when InlineShield cannot be reached, times out, answers 5xx, 429 or is over
107
+ quota, the methods return an "unavailable" result (``unavailable: True``, action "allow") instead of
108
+ raising. False: they raise UnavailableError, RateLimitError or QuotaError.
109
+ :param max_retries: retries after a failed connection (nothing was sent; default 1). Answers and timeouts are
110
+ never retried.
111
+ :raises AuthError: MISSING_API_KEY when there is no key at all.
112
+ """
113
+ if api_key is not None and not isinstance(api_key, str):
114
+ raise TypeError("api_key must be a string.")
115
+ key = api_key or os.environ.get("INLINESHIELD_API_KEY", "")
116
+ if not key:
117
+ raise AuthError(
118
+ "No InlineShield API key: pass one to Client() or set INLINESHIELD_API_KEY.", "MISSING_API_KEY", None
119
+ )
120
+ url = base_url if base_url is not None else (os.environ.get("INLINESHIELD_BASE_URL") or DEFAULT_BASE_URL)
121
+ if not isinstance(url, str) or not re.match(r"https?://[^\s/?#]+", url, re.IGNORECASE):
122
+ raise ValueError("base_url must be an http(s) URL.")
123
+ if url.lower().startswith("http://") and not _plain_http_allowed(url):
124
+ raise ValueError("base_url must use https:// (plain http only for localhost).")
125
+ if (isinstance(timeout_ms, bool) or not isinstance(timeout_ms, (int, float)) or not math.isfinite(timeout_ms)
126
+ or not timeout_ms > 0):
127
+ raise ValueError("timeout_ms must be a number of milliseconds above 0.")
128
+ if isinstance(max_retries, bool) or not isinstance(max_retries, int) or max_retries < 0:
129
+ raise ValueError("max_retries must be an integer, 0 or more.")
130
+ if not isinstance(fail_open, bool):
131
+ raise TypeError("fail_open must be True or False.")
132
+ self._api_key = key
133
+ self.base_url = url.rstrip("/")
134
+ self.timeout_ms = timeout_ms
135
+ self.fail_open = fail_open
136
+ self.max_retries = max_retries
137
+
138
+ def __repr__(self) -> str:
139
+ return "Client(base_url=%r, timeout_ms=%r, fail_open=%r)" % (self.base_url, self.timeout_ms, self.fail_open)
140
+
141
+ # ── API ──────────────────────────────────────────────────────────────────
142
+
143
+ def verify(
144
+ self,
145
+ email: str,
146
+ *,
147
+ ip: Optional[str] = None,
148
+ user_agent: Optional[str] = None,
149
+ username: Optional[str] = None,
150
+ client_dwell_ms: Optional[float] = None,
151
+ honeypot: Optional[str] = None,
152
+ ) -> VerifyResult:
153
+ """Screen a sign-up on the server (POST /api/v1/verify).
154
+
155
+ Pass the player's IP and User-Agent from your incoming request; without an IP, InlineShield sees your server's.
156
+ """
157
+ body: Dict[str, Any] = {"email": _non_empty(email, "email")}
158
+ for key, value in (("ip", ip), ("userAgent", user_agent), ("username", username), ("honeypot", honeypot)):
159
+ if value is not None:
160
+ body[key] = str(value)
161
+ # An unmeasured dwell time is sent as nothing (unknown), never as 0 (which reads as a bot).
162
+ if isinstance(client_dwell_ms, (int, float)) and not isinstance(client_dwell_ms, bool) and math.isfinite(client_dwell_ms):
163
+ body["clientDwellMs"] = client_dwell_ms
164
+
165
+ def call() -> VerifyResult:
166
+ data = self._send("POST", "/api/v1/verify", body)
167
+ if not isinstance(data.get("decision"), str) or not isinstance(data.get("action"), str):
168
+ raise UnavailableError("InlineShield sent an answer without a decision.", "UNAVAILABLE", 200)
169
+ return dict(data, unavailable=False) # type: ignore[return-value]
170
+
171
+ return self._fail_open(call, lambda code: {
172
+ "unavailable": True, "error": code, "requestId": None, "decision": "ALLOWED", "verdict": "ALLOWED",
173
+ "action": "allow", "recommendedAction": "allow", "riskScore": 0, "reasons": [], "triggers": [],
174
+ })
175
+
176
+ def siteverify(self, token: Optional[str], email: str) -> SiteverifyResult:
177
+ """Redeem the single-use token of the browser script (POST /api/v1/siteverify).
178
+
179
+ Check ``unavailable`` first: it is True when InlineShield could not answer (fail-open; ``action`` is "allow",
180
+ and ``success`` is False because no token was redeemed). Otherwise, ``success: True`` carries the decision; on
181
+ ``error == "NO_TOKEN"`` screen the sign-up with verify() instead; on the other errors reject it.
182
+
183
+ :param token: the betshield_token form field (None or "" when absent).
184
+ :param email: the email the player submitted: the one the token was issued for.
185
+ """
186
+ if token is not None and not isinstance(token, str):
187
+ raise TypeError("token must be a string.")
188
+ body = {"token": token or "", "email": _non_empty(email, "email")}
189
+
190
+ def call() -> SiteverifyResult:
191
+ data = self._send("POST", "/api/v1/siteverify", body)
192
+ if data.get("success") is False and data.get("error") == "QUOTA_EXCEEDED":
193
+ raise QuotaError("The InlineShield account is over its monthly quota (QUOTA_EXCEEDED).", "QUOTA_EXCEEDED", 200)
194
+ if not isinstance(data.get("success"), bool):
195
+ raise UnavailableError("InlineShield sent an unexpected answer.", "UNAVAILABLE", 200)
196
+ return dict(data, unavailable=False) # type: ignore[return-value]
197
+
198
+ return self._fail_open(call, lambda code: {
199
+ "unavailable": True, "success": False, "error": code, "requestId": None, "decision": "ALLOWED",
200
+ "action": "allow", "recommendedAction": "allow", "riskScore": 0, "reasons": [],
201
+ })
202
+
203
+ def feedback(
204
+ self,
205
+ target: FeedbackTarget,
206
+ outcome: str,
207
+ *,
208
+ category: Optional[str] = None,
209
+ note: Optional[str] = None,
210
+ ) -> FeedbackResult:
211
+ """Report the real outcome of a screened sign-up (POST /api/v1/feedback), e.g. after a chargeback or a passed KYC.
212
+
213
+ :param target: the requestId InlineShield returned, or ``{"token": ...}`` (the /check token, for about a day).
214
+ :param outcome: "fraud" or "legit".
215
+ """
216
+ body: Dict[str, Any] = {}
217
+ if isinstance(target, str):
218
+ body["requestId"] = _non_empty(target, "request_id")
219
+ elif isinstance(target, dict) and "token" in target:
220
+ body["token"] = _non_empty(target["token"], "token")
221
+ elif isinstance(target, dict) and "requestId" in target:
222
+ body["requestId"] = _non_empty(target["requestId"], "requestId")
223
+ else:
224
+ raise TypeError('feedback() takes a requestId or {"token": ...}.')
225
+ body["outcome"] = outcome
226
+ if category is not None:
227
+ body["category"] = category
228
+ if note is not None:
229
+ body["note"] = note
230
+
231
+ def call() -> FeedbackResult:
232
+ return dict(self._send("POST", "/api/v1/feedback", body), unavailable=False) # type: ignore[return-value]
233
+
234
+ return self._fail_open(call, lambda code: {"unavailable": True, "success": False, "error": code})
235
+
236
+ def get_review(self, review_id: str) -> ReviewResult:
237
+ """The status of a held sign-up (GET /api/v1/reviews/{reviewId}): pending, approved or rejected."""
238
+ rid = _non_empty(review_id, "review_id")
239
+
240
+ def call() -> ReviewResult:
241
+ path = "/api/v1/reviews/" + urllib.parse.quote(rid, safe="")
242
+ return dict(self._send("GET", path, None), unavailable=False) # type: ignore[return-value]
243
+
244
+ return self._fail_open(call, lambda code: {
245
+ "unavailable": True, "error": code, "reviewId": rid, "status": "pending", "decisionReason": None,
246
+ "decidedAt": None, "expiresAt": None,
247
+ })
248
+
249
+ @staticmethod
250
+ def verify_webhook(
251
+ signature_header: Optional[str],
252
+ raw_body: Union[bytes, bytearray, memoryview, str],
253
+ secret: Optional[str],
254
+ tolerance_sec: float = 300,
255
+ ) -> Dict[str, Any]:
256
+ """Same as inlineshield.verify_webhook()."""
257
+ return _verify_webhook(signature_header, raw_body, secret, tolerance_sec)
258
+
259
+ # ── Transport ────────────────────────────────────────────────────────────
260
+
261
+ def _fail_open(self, call: Callable[[], _T], unavailable: Callable[[str], Any]) -> _T:
262
+ try:
263
+ return call()
264
+ except (UnavailableError, RateLimitError, QuotaError) as err:
265
+ if self.fail_open:
266
+ return unavailable(err.code) # type: ignore[no-any-return]
267
+ raise
268
+
269
+ def _send(self, method: str, path: str, body: Optional[Dict[str, Any]]) -> Dict[str, Any]:
270
+ attempt = 0
271
+ while True:
272
+ try:
273
+ status, retry_after, raw = self._once(method, path, body)
274
+ except _NotSent as err:
275
+ if attempt < self.max_retries:
276
+ attempt += 1
277
+ time.sleep(_RETRY_DELAY_S)
278
+ continue
279
+ raise UnavailableError("InlineShield could not be reached (%s)." % err.detail, "UNAVAILABLE", None) from None
280
+ return self._interpret(status, retry_after, raw)
281
+
282
+ def _once(self, method: str, path: str, body: Optional[Dict[str, Any]]) -> Tuple[int, Optional[str], bytes]:
283
+ """One request under a wall-clock deadline of timeout_ms: connecting, sending and reading the whole answer.
284
+
285
+ urllib's own timeout bounds each socket operation, not the request (an answer that trickles in would never
286
+ time out), so the request runs in a worker thread; at the deadline its socket is closed and TIMEOUT raised.
287
+ """
288
+ deadline = time.monotonic() + self.timeout_ms / 1000.0
289
+ connections: list = []
290
+ box: Dict[str, Any] = {}
291
+
292
+ def work() -> None:
293
+ try:
294
+ box["ok"] = self._exchange(method, path, body, connections)
295
+ except BaseException as err: # noqa: BLE001 - re-raised in the caller's thread
296
+ box["err"] = err
297
+
298
+ worker = threading.Thread(target=work, name="inlineshield-request", daemon=True)
299
+ worker.start()
300
+ worker.join(max(0.0, deadline - time.monotonic()))
301
+ if worker.is_alive():
302
+ for conn in list(connections):
303
+ sock = getattr(conn, "sock", None)
304
+ try:
305
+ if sock is not None:
306
+ sock.shutdown(socket.SHUT_RDWR)
307
+ except OSError:
308
+ pass
309
+ try:
310
+ conn.close()
311
+ except Exception: # noqa: BLE001 - closing a broken connection
312
+ pass
313
+ raise self._timeout()
314
+ if "err" in box:
315
+ raise box["err"]
316
+ return box["ok"] # type: ignore[no-any-return]
317
+
318
+ def _exchange(
319
+ self, method: str, path: str, body: Optional[Dict[str, Any]], connections: list
320
+ ) -> Tuple[int, Optional[str], bytes]:
321
+ """One request and its whole answer (in the worker thread of _once)."""
322
+ headers = {
323
+ "X-Api-Key": self._api_key,
324
+ "Accept": "application/json",
325
+ "User-Agent": "inlineshield-python/" + __version__,
326
+ }
327
+ data = None
328
+ if body is not None:
329
+ data = json.dumps(body).encode("utf-8")
330
+ headers["Content-Type"] = "application/json"
331
+ req = urllib.request.Request(self.base_url + path, data=data, headers=headers, method=method)
332
+ timeout = self.timeout_ms / 1000.0
333
+ try:
334
+ with _tracking_opener(connections).open(req, timeout=timeout) as resp:
335
+ return resp.status, resp.headers.get("Retry-After"), resp.read()
336
+ except urllib.error.HTTPError as err:
337
+ try:
338
+ raw = err.read()
339
+ except Exception: # noqa: BLE001 - a broken error body is an empty one
340
+ raw = b""
341
+ finally:
342
+ err.close()
343
+ return err.code, err.headers.get("Retry-After") if err.headers else None, raw
344
+ except urllib.error.URLError as err:
345
+ reason = err.reason
346
+ if isinstance(reason, (socket.timeout, TimeoutError)):
347
+ raise self._timeout() from None
348
+ if isinstance(reason, (ConnectionRefusedError, socket.gaierror)) or (
349
+ isinstance(reason, OSError) and reason.errno in (errno.EHOSTUNREACH, errno.ENETUNREACH)
350
+ ):
351
+ raise _NotSent(type(reason).__name__) from None
352
+ raise UnavailableError(
353
+ "InlineShield could not be reached (%s)." % type(reason).__name__, "UNAVAILABLE", None
354
+ ) from None
355
+ except (socket.timeout, TimeoutError):
356
+ raise self._timeout() from None
357
+ except (OSError, ValueError, http.client.HTTPException) as err:
358
+ # Connection reset, an incomplete answer, a broken HTTP response.
359
+ raise UnavailableError(
360
+ "InlineShield could not be reached (%s)." % type(err).__name__, "UNAVAILABLE", None
361
+ ) from None
362
+
363
+ def _timeout(self) -> UnavailableError:
364
+ return UnavailableError("InlineShield did not answer within %s ms." % (self.timeout_ms,), "TIMEOUT", None)
365
+
366
+ def _interpret(self, status: int, retry_after_header: Optional[str], raw: bytes) -> Dict[str, Any]:
367
+ try:
368
+ parsed = json.loads(raw.decode("utf-8"))
369
+ except ValueError:
370
+ parsed = None
371
+ data = parsed if isinstance(parsed, dict) else None
372
+ if 200 <= status < 300:
373
+ if data is None:
374
+ raise UnavailableError(
375
+ "InlineShield sent an answer that is not JSON (HTTP %d)." % status, "UNAVAILABLE", status
376
+ )
377
+ return data
378
+ code = data.get("error") if data is not None else None
379
+ if not isinstance(code, str) or not _CODE.fullmatch(code):
380
+ code = None
381
+ message = data.get("message") if data is not None else None
382
+ detail = (": " + self._redact(message)) if isinstance(message, str) else "."
383
+ what = "(HTTP %d%s)" % (status, " " + code if code else "")
384
+ if 300 <= status < 400:
385
+ raise ApiError(
386
+ "InlineShield answered with a redirect (HTTP %d); check base_url." % status, "UNEXPECTED_REDIRECT", status
387
+ )
388
+ if status == 401:
389
+ raise AuthError("InlineShield refused the API key %s%s" % (what, detail), code or "UNAUTHORIZED", status)
390
+ if status == 403 and code == "TENANT_SUSPENDED":
391
+ raise AuthError("The InlineShield account is suspended %s%s" % (what, detail), code, status)
392
+ if status == 402:
393
+ raise QuotaError(
394
+ "The InlineShield account is over its monthly quota %s%s" % (what, detail), "QUOTA_EXCEEDED", status
395
+ )
396
+ if status == 429:
397
+ ra = retry_after_header.strip() if isinstance(retry_after_header, str) else ""
398
+ retry_after = int(ra) if ra.isdigit() and ra.isascii() else None
399
+ raise RateLimitError(
400
+ "InlineShield rate limit %s%s" % (what, detail), code or "RATE_LIMIT_EXCEEDED", status, retry_after
401
+ )
402
+ if status >= 500:
403
+ raise UnavailableError("InlineShield is unavailable %s%s" % (what, detail), "UNAVAILABLE", status)
404
+ raise ApiError("InlineShield refused the request %s%s" % (what, detail), code or "HTTP_%d" % status, status)
405
+
406
+ def _redact(self, text: str) -> str:
407
+ """The API key and anything that looks like an email address never go into a message."""
408
+ return _EMAIL_LIKE.sub("[redacted]", text.replace(self._api_key, "[redacted]"))[:300]
409
+
@@ -0,0 +1,53 @@
1
+ """Typed errors.
2
+
3
+ Every error the SDK raises on purpose is an InlineShieldError with a ``code`` (the API's error code, or one of the
4
+ SDK's own) and the HTTP ``status`` that carried it (None when there was no answer). Messages never contain the API
5
+ key or an email address.
6
+ """
7
+ from typing import Optional
8
+
9
+
10
+ class InlineShieldError(Exception):
11
+ """Base class of the SDK's errors."""
12
+
13
+ def __init__(self, message: str, code: str, status: Optional[int] = None) -> None:
14
+ super().__init__(message)
15
+ self.code = code
16
+ self.status = status
17
+
18
+
19
+ class AuthError(InlineShieldError):
20
+ """The API key is missing, unknown, revoked or expired (401), or the account is suspended (403 TENANT_SUSPENDED).
21
+
22
+ Always raised, whatever fail_open says: it is a configuration error.
23
+ """
24
+
25
+
26
+ class RateLimitError(InlineShieldError):
27
+ """429: RATE_LIMIT_EXCEEDED, or TEST_LIMIT for test keys. Raised only with fail_open=False."""
28
+
29
+ def __init__(self, message: str, code: str, status: Optional[int], retry_after: Optional[int]) -> None:
30
+ super().__init__(message, code, status)
31
+ self.retry_after = retry_after
32
+
33
+
34
+ class QuotaError(InlineShieldError):
35
+ """The account is over its monthly quota (402, or QUOTA_EXCEEDED from /siteverify). Raised only with fail_open=False."""
36
+
37
+
38
+ class UnavailableError(InlineShieldError):
39
+ """InlineShield could not be reached or did not answer in time (code TIMEOUT), or answered with a server error or
40
+ something that is not JSON (code UNAVAILABLE). Raised only with fail_open=False."""
41
+
42
+
43
+ class ApiError(InlineShieldError):
44
+ """The API refused the request (400 BAD_REQUEST or INVALID_IP, 404 NOT_FOUND, 422 SCREENING_TOO_OLD, ...), or
45
+ answered with a redirect (UNEXPECTED_REDIRECT: check base_url). Always raised: the request needs fixing."""
46
+
47
+
48
+ class WebhookVerificationError(InlineShieldError):
49
+ """verify_webhook() refused a delivery: NO_SECRET, MALFORMED_HEADER, TIMESTAMP_OUT_OF_RANGE, SIGNATURE_MISMATCH or
50
+ INVALID_PAYLOAD. Answer such a request with 400."""
51
+
52
+ def __init__(self, message: str, code: str) -> None:
53
+ super().__init__(message, code, None)
@@ -0,0 +1,87 @@
1
+ """The shapes of the results (plain dicts, as the API sends them, plus ``unavailable``)."""
2
+ from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
3
+
4
+ Verdict = Literal["ALLOWED", "QUARANTINED", "BLOCKED"]
5
+ Action = Literal["allow", "hold", "block"]
6
+ RecommendedAction = Literal["allow", "allow_no_bonus", "review", "block", "require_kyc"]
7
+ UnavailableCode = Literal["UNAVAILABLE", "TIMEOUT", "RATE_LIMIT_EXCEEDED", "TEST_LIMIT", "QUOTA_EXCEEDED"]
8
+ Outcome = Literal["fraud", "legit"]
9
+ FeedbackCategory = Literal["bonus_abuse", "multi_account", "chargeback", "kyc_failed", "bot", "good_customer", "other"]
10
+ MailboxStatus = Literal["exists", "not_found", "catch_all", "unknown"]
11
+ """The mailbox check of the address (an SMTP RCPT check; no message is ever sent)."""
12
+
13
+
14
+ class Linked(TypedDict):
15
+ """Linked accounts: the earlier sign-ups to your account that a screening is linked to."""
16
+
17
+ groupId: Optional[str]
18
+ size: int
19
+ strength: Literal["strong", "possible"]
20
+ linkedToFraud: bool
21
+
22
+
23
+ class VerifyResult(TypedDict, total=False):
24
+ """POST /api/v1/verify. With ``unavailable: True`` (fail-open) the action is "allow" and ``error`` says why."""
25
+
26
+ unavailable: bool
27
+ error: UnavailableCode
28
+ requestId: Optional[str]
29
+ decision: Verdict
30
+ verdict: Verdict
31
+ action: Action
32
+ recommendedAction: RecommendedAction
33
+ reviewId: str
34
+ riskScore: int
35
+ reasons: List[str]
36
+ triggers: List[str]
37
+ policyVersion: str
38
+ processingTimeMs: float
39
+ forwardedToDatabase: bool
40
+ emailAnalysis: Dict[str, Any]
41
+ botAnalysis: Dict[str, Any]
42
+ ipRisk: Dict[str, Any]
43
+ mailboxStatus: MailboxStatus
44
+ """Not in an ``unavailable`` result."""
45
+ linked: Optional[Linked]
46
+ """The linked-accounts summary, or None. Not in an ``unavailable`` result."""
47
+
48
+
49
+ class SiteverifyResult(TypedDict, total=False):
50
+ """POST /api/v1/siteverify: ``success: True`` with the decision, or ``success: False`` with ``error``."""
51
+
52
+ unavailable: bool
53
+ success: bool
54
+ error: str
55
+ requestId: Optional[str]
56
+ decision: Verdict
57
+ action: Action
58
+ recommendedAction: RecommendedAction
59
+ reviewId: str
60
+ riskScore: int
61
+ reasons: List[str]
62
+ mailboxStatus: MailboxStatus
63
+ """With ``success: True`` only."""
64
+ linked: Optional[Linked]
65
+ """With ``success: True`` only: the linked-accounts summary, or None."""
66
+
67
+
68
+ class FeedbackResult(TypedDict, total=False):
69
+ unavailable: bool
70
+ success: bool
71
+ error: UnavailableCode
72
+ outcome: Outcome
73
+ category: Optional[FeedbackCategory]
74
+ changed: bool
75
+
76
+
77
+ class ReviewResult(TypedDict, total=False):
78
+ unavailable: bool
79
+ error: UnavailableCode
80
+ reviewId: str
81
+ status: Literal["pending", "approved", "rejected"]
82
+ decisionReason: Optional[Literal["operator", "expired"]]
83
+ decidedAt: Optional[str]
84
+ expiresAt: Optional[str]
85
+
86
+
87
+ FeedbackTarget = Union[str, Dict[str, str]]
@@ -0,0 +1 @@
1
+ __version__ = "1.0.1"
@@ -0,0 +1,61 @@
1
+ """Webhook signature verification."""
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+ import re
6
+ import time
7
+ from typing import Any, Dict, Optional, Union
8
+
9
+ from ._errors import WebhookVerificationError
10
+
11
+ # X-InlineShield-Signature: exactly ``t=<unix seconds>,v1=<64 lower-case hex>``, as InlineShield sends it.
12
+ _SIGNATURE_HEADER = re.compile(r"t=(\d+),v1=([0-9a-f]{64})", re.ASCII)
13
+
14
+
15
+ def verify_webhook(
16
+ signature_header: Optional[str],
17
+ raw_body: Union[bytes, bytearray, memoryview, str],
18
+ secret: Optional[str],
19
+ tolerance_sec: float = 300,
20
+ ) -> Dict[str, Any]:
21
+ """Verify a webhook delivery and return its parsed body.
22
+
23
+ The signature is HMAC-SHA256, keyed with your endpoint's whole secret (whsec_...), over ``<t>.<raw body>``,
24
+ hex-encoded, and compared in constant time. A delivery whose t is more than ``tolerance_sec`` seconds (default
25
+ 300) before or after now is refused, so a captured delivery cannot be replayed later.
26
+
27
+ :param signature_header: the X-InlineShield-Signature header.
28
+ :param raw_body: the exact request body bytes (or the string they decode to), before any JSON parsing.
29
+ :param secret: your endpoint's signing secret.
30
+ :raises WebhookVerificationError: NO_SECRET, MALFORMED_HEADER, TIMESTAMP_OUT_OF_RANGE, SIGNATURE_MISMATCH or
31
+ INVALID_PAYLOAD.
32
+ """
33
+ if not isinstance(secret, str) or secret == "":
34
+ raise WebhookVerificationError("No webhook signing secret: never verify with an empty key.", "NO_SECRET")
35
+ if isinstance(tolerance_sec, bool) or not isinstance(tolerance_sec, (int, float)) or not tolerance_sec >= 0:
36
+ raise ValueError("tolerance_sec must be a number of seconds, 0 or more.")
37
+ if isinstance(raw_body, str):
38
+ body = raw_body.encode("utf-8")
39
+ elif isinstance(raw_body, (bytes, bytearray, memoryview)):
40
+ body = bytes(raw_body)
41
+ else:
42
+ raise TypeError("raw_body must be the raw request body: bytes or a string.")
43
+ m = _SIGNATURE_HEADER.fullmatch(signature_header) if isinstance(signature_header, str) else None
44
+ if m is None:
45
+ raise WebhookVerificationError(
46
+ "Malformed X-InlineShield-Signature header (expected t=<seconds>,v1=<hex>).", "MALFORMED_HEADER"
47
+ )
48
+ if abs(int(time.time()) - int(m.group(1))) > tolerance_sec:
49
+ raise WebhookVerificationError(
50
+ "The webhook timestamp is more than %s seconds away from now." % (tolerance_sec,), "TIMESTAMP_OUT_OF_RANGE"
51
+ )
52
+ expected = hmac.new(secret.encode("utf-8"), m.group(1).encode("ascii") + b"." + body, hashlib.sha256).hexdigest()
53
+ if not hmac.compare_digest(expected, m.group(2)):
54
+ raise WebhookVerificationError("The webhook signature does not match.", "SIGNATURE_MISMATCH")
55
+ try:
56
+ event = json.loads(body.decode("utf-8"))
57
+ except ValueError:
58
+ event = None
59
+ if not isinstance(event, dict):
60
+ raise WebhookVerificationError("The webhook body is not a JSON object.", "INVALID_PAYLOAD")
61
+ return event
File without changes