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.
- inlineshield-1.0.1/CHANGELOG.md +14 -0
- inlineshield-1.0.1/LICENSE +21 -0
- inlineshield-1.0.1/PKG-INFO +96 -0
- inlineshield-1.0.1/README.md +84 -0
- inlineshield-1.0.1/pyproject.toml +29 -0
- inlineshield-1.0.1/src/inlineshield/__init__.py +30 -0
- inlineshield-1.0.1/src/inlineshield/_client.py +409 -0
- inlineshield-1.0.1/src/inlineshield/_errors.py +53 -0
- inlineshield-1.0.1/src/inlineshield/_types.py +87 -0
- inlineshield-1.0.1/src/inlineshield/_version.py +1 -0
- inlineshield-1.0.1/src/inlineshield/_webhook.py +61 -0
- inlineshield-1.0.1/src/inlineshield/py.typed +0 -0
|
@@ -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
|