preflight-sdk 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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 IBUR Ventures LLC
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,148 @@
1
+ Metadata-Version: 2.4
2
+ Name: preflight-sdk
3
+ Version: 1.0.0
4
+ Summary: Thin fail-closed client for the Preflight pre-dial API. Standard library only.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://www.preflightapi.com/docs/
7
+ Project-URL: Documentation, https://www.preflightapi.com/docs/
8
+ Project-URL: Support, https://www.preflightapi.com/support.html
9
+ Project-URL: Source, https://github.com/palfi-andras/preflight
10
+ Keywords: preflight,tcpa,dnc,opt-out,consent,voice,sms
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Communications :: Telephony
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # Preflight Python SDK
23
+
24
+ A thin client for the Preflight pre-dial API. Standard library only (Python 3.9+), no dependencies. Typed (`py.typed`).
25
+
26
+ ```sh
27
+ pip install preflight-sdk
28
+ ```
29
+
30
+ Preflight applies encoded rules and keeps evidence. This SDK does not make anyone compliant and does not replace
31
+ counsel.
32
+
33
+ ```python
34
+ from preflight_sdk import Preflight
35
+
36
+ pf = Preflight("https://api.preflightapi.com", os.environ["PREFLIGHT_KEY"])
37
+
38
+ d = pf.check({"to": "(702) 555-0142", "channel": "voice", "purpose": "marketing", "method": "ai_voice"})
39
+ if d["decision"] == "allow":
40
+ place_call() # d["obligations"] belong in your opening script
41
+ elif d["decision"] == "defer":
42
+ reschedule(d["next_allowed_at"])
43
+ else:
44
+ drop_lead(d["reasons"]) # includes "preflight_unavailable"
45
+
46
+ pf.record_opt_out({"phone": "+12065550142", "channel": "voice", "source": "in_call", "raw": "stop calling me"})
47
+ pf.record_consent({"phone": "+17025550142", "kind": "express_written", "source": "https://acme.example/quote", "wording": "..."})
48
+ job = pf.batch(items) # polls until done; job["results"] is in item order
49
+ pf.report_outcome(check_id, "placed", "CA123") # after the call or text ends: placed | not_placed | failed
50
+ ev = pf.get_evidence("+17025550142")
51
+ ```
52
+
53
+ Account and data calls, each one request to the endpoint of the same name in the [API reference](https://www.preflightapi.com/docs/api.html):
54
+
55
+ ```python
56
+ pf.revoke_consent(consent_id, "asked to stop")
57
+ pf.get_consent_capture(capture_id) # consent widget certificate
58
+ pf.claim_consent_capture(capture_id, "+17025550142") # 409 unless it is the capture's phone
59
+ lst = pf.create_list({"kind": "litigator", "name": "Vendor feed"})
60
+ pf.import_list_entries(lst["id"], file_text, options={"mode": "replace", "edition_date": "2026-10-01"}, timeout=120)
61
+ pf.list_lists()
62
+ pf.get_check_evidence(check_id)
63
+ r = pf.get_check_evidence_pdf(check_id) # {"pdf": bytes, "bundle_sha256": ...}
64
+ pf.get_rule_changes("2026-10-14.3") # or a date, or None for all
65
+ pf.get_rule_impact(limit=1000) # follow next_cursor with cursor=
66
+ hook = pf.create_webhook("https://acme.example/preflight", ["opt_out.created"]) # secret shown once
67
+ pf.list_webhooks()
68
+ pf.delete_webhook(hook["id"])
69
+ ```
70
+
71
+ ## Verifying webhooks
72
+
73
+ ```python
74
+ from preflight_sdk import verify_webhook
75
+
76
+ # raw body exactly as received: request.get_data() (Flask), await request.body() (Starlette), never a parsed dict
77
+ if not verify_webhook(request.get_data(), request.headers.get("preflight-signature"), os.environ["PREFLIGHT_WEBHOOK_SECRET"]):
78
+ abort(400)
79
+ ```
80
+
81
+ Returns `True` only when a `v1` value in `preflight-signature` is the HMAC-SHA256 of `<t>.<raw body>` under the
82
+ endpoint secret (the whole `whsec_...` string from `create_webhook`) and `t` is within `tolerance` seconds (default
83
+ 300) of now in either direction. Anything else (missing or malformed header, non-numeric, stale or future `t`, wrong
84
+ signature) returns `False`; it never raises for bad input from the network. It raises `TypeError` for programming
85
+ errors: a body that is not bytes or str (for example a parsed dict), an empty secret, a negative tolerance. The
86
+ compare is constant time (`hmac.compare_digest`), and any matching `v1` is accepted (for secret rotation).
87
+ `verify_webhook(raw_body, header, secret, tolerance=300, now=None)`; `now` is Unix seconds, for tests.
88
+ Receiver guide: [Webhooks guide](https://www.preflightapi.com/docs/).
89
+
90
+ ## Fail-closed behaviour
91
+
92
+ - `check()` defaults to a **300 ms** timeout per attempt. On a timeout, a network error, a 429, a 5xx (after one
93
+ quick retry) or any 4xx it **returns** `{"decision": "block", "reasons": [{"code": "preflight_unavailable", ...}]}` instead of
94
+ raising. Treat it like any other `block`: do not dial or text. A 4xx also carries its HTTP `status` in the reason.
95
+ - A timeout can leave a check the server already recorded as an allowed attempt that you never dialed; it keeps
96
+ counting toward the frequency and volume caps. The block's reason carries the `idempotency_key` the check was sent
97
+ under. Call `pf.release_unplaced(req, d)` (same request, the block decision) within 5 minutes: it replays the key and,
98
+ if the stored check was an allow, reports `not_placed`. Over-counting is the safe direction, so skipping this only
99
+ costs reachable leads. Never use it for a call you placed.
100
+ - There is no fail-open option. The only alternative is `throw_on_error=True` (on the client or per call), which
101
+ raises `PreflightError` (with `status`, `code`, `body`, `retry_after`) so you can handle the failure yourself. Even then, no
102
+ answer means do not dial.
103
+ - Retries: a network error, timeout, 429 or 5xx is retried; any other status is not. `check()` gets one quick retry
104
+ (`check_max_retries`, default 1) and never sleeps longer than its timeout, so a `Retry-After` longer than that ends
105
+ it at once with the block decision: worst case about two timeouts. Every other call gets `max_retries` (default 2)
106
+ with jittered exponential backoff (`retry_base_delay`, default 0.2 s) or the server's `Retry-After` when it sends
107
+ one, up to `max_retry_delay` (default 10 s); a longer `Retry-After` raises `RateLimitError` (or `ServerError` for
108
+ a 503) with `retry_after` so you can schedule the retry.
109
+ - A retry never records a write twice: every attempt of a POST sends the **same** `Idempotency-Key`, and the server
110
+ replays a key it has already answered instead of running the request again. The key is a UUID generated
111
+ per call; when you retry a failed `record_opt_out` (or any write) yourself, pass `idempotency_key=` (your inbound
112
+ message id, or `new_idempotency_key()` kept for the job) so your retries are replays too. The server keeps keys
113
+ for 24 hours: a retry later than that runs again (for an opt-out, a second opt-out event: it still blocks, but the
114
+ audit trail shows it twice).
115
+ - Every method other than `check()` always raises `PreflightError` on failure (default timeout 10 s): swallowing a
116
+ failed opt-out or revocation would be unsafe, so retry it until it succeeds.
117
+ - `import_list_entries` and `create_list` retry like any write; a replayed import returns the first import's counts
118
+ and does not apply the file again.
119
+ - `create_webhook` retries like any write; a replay returns the same signing secret, so a retry never makes a second
120
+ endpoint.
121
+ - `batch()` is screening only (items must not set `commit`). It raises if the job fails or polling exceeds
122
+ `max_wait`; an item the server could not evaluate comes back as `{"error": {...}}`: treat it as do-not-dial.
123
+
124
+ The per-attempt timeout is a total deadline (not per socket read), covering an error response's body too, so a server that drips bytes cannot outlast it. The SDK never follows redirects (a 30x fails closed without a retry and the API key is never sent elsewhere), and `check()` rejects a response whose `reasons` is not a list.
125
+
126
+ ## Errors
127
+
128
+ Every failure is a `PreflightError`; the subclass says why: `InvalidRequestError` (400, 422), `AuthenticationError`
129
+ (401), `PermissionDeniedError` (403), `NotFoundError` (404), `ConflictError` (409), `RateLimitError` (429),
130
+ `ServerError` (5xx), `NetworkError` (no response) and `RequestTimeoutError`. Other statuses (a refused redirect, 413,
131
+ 421) are a plain `PreflightError`.
132
+
133
+ ```python
134
+ from preflight_sdk import AuthenticationError, PreflightError
135
+
136
+ try:
137
+ pf.record_opt_out(opt_out, idempotency_key=inbound_message_id)
138
+ except AuthenticationError:
139
+ alert_ops("Preflight key rejected")
140
+ except PreflightError as e:
141
+ requeue(opt_out, delay=e.retry_after) # keep the same key on the next try
142
+ ```
143
+
144
+ Requests send `User-Agent: preflight-sdk-python/<SDK_VERSION>`. `SDK_VERSION` and `API_VERSION` (the `/v1` API this
145
+ SDK speaks) are exported.
146
+
147
+ Request and response types (`TypedDict`) are in `preflight_sdk.types`.
148
+
@@ -0,0 +1,127 @@
1
+ # Preflight Python SDK
2
+
3
+ A thin client for the Preflight pre-dial API. Standard library only (Python 3.9+), no dependencies. Typed (`py.typed`).
4
+
5
+ ```sh
6
+ pip install preflight-sdk
7
+ ```
8
+
9
+ Preflight applies encoded rules and keeps evidence. This SDK does not make anyone compliant and does not replace
10
+ counsel.
11
+
12
+ ```python
13
+ from preflight_sdk import Preflight
14
+
15
+ pf = Preflight("https://api.preflightapi.com", os.environ["PREFLIGHT_KEY"])
16
+
17
+ d = pf.check({"to": "(702) 555-0142", "channel": "voice", "purpose": "marketing", "method": "ai_voice"})
18
+ if d["decision"] == "allow":
19
+ place_call() # d["obligations"] belong in your opening script
20
+ elif d["decision"] == "defer":
21
+ reschedule(d["next_allowed_at"])
22
+ else:
23
+ drop_lead(d["reasons"]) # includes "preflight_unavailable"
24
+
25
+ pf.record_opt_out({"phone": "+12065550142", "channel": "voice", "source": "in_call", "raw": "stop calling me"})
26
+ pf.record_consent({"phone": "+17025550142", "kind": "express_written", "source": "https://acme.example/quote", "wording": "..."})
27
+ job = pf.batch(items) # polls until done; job["results"] is in item order
28
+ pf.report_outcome(check_id, "placed", "CA123") # after the call or text ends: placed | not_placed | failed
29
+ ev = pf.get_evidence("+17025550142")
30
+ ```
31
+
32
+ Account and data calls, each one request to the endpoint of the same name in the [API reference](https://www.preflightapi.com/docs/api.html):
33
+
34
+ ```python
35
+ pf.revoke_consent(consent_id, "asked to stop")
36
+ pf.get_consent_capture(capture_id) # consent widget certificate
37
+ pf.claim_consent_capture(capture_id, "+17025550142") # 409 unless it is the capture's phone
38
+ lst = pf.create_list({"kind": "litigator", "name": "Vendor feed"})
39
+ pf.import_list_entries(lst["id"], file_text, options={"mode": "replace", "edition_date": "2026-10-01"}, timeout=120)
40
+ pf.list_lists()
41
+ pf.get_check_evidence(check_id)
42
+ r = pf.get_check_evidence_pdf(check_id) # {"pdf": bytes, "bundle_sha256": ...}
43
+ pf.get_rule_changes("2026-10-14.3") # or a date, or None for all
44
+ pf.get_rule_impact(limit=1000) # follow next_cursor with cursor=
45
+ hook = pf.create_webhook("https://acme.example/preflight", ["opt_out.created"]) # secret shown once
46
+ pf.list_webhooks()
47
+ pf.delete_webhook(hook["id"])
48
+ ```
49
+
50
+ ## Verifying webhooks
51
+
52
+ ```python
53
+ from preflight_sdk import verify_webhook
54
+
55
+ # raw body exactly as received: request.get_data() (Flask), await request.body() (Starlette), never a parsed dict
56
+ if not verify_webhook(request.get_data(), request.headers.get("preflight-signature"), os.environ["PREFLIGHT_WEBHOOK_SECRET"]):
57
+ abort(400)
58
+ ```
59
+
60
+ Returns `True` only when a `v1` value in `preflight-signature` is the HMAC-SHA256 of `<t>.<raw body>` under the
61
+ endpoint secret (the whole `whsec_...` string from `create_webhook`) and `t` is within `tolerance` seconds (default
62
+ 300) of now in either direction. Anything else (missing or malformed header, non-numeric, stale or future `t`, wrong
63
+ signature) returns `False`; it never raises for bad input from the network. It raises `TypeError` for programming
64
+ errors: a body that is not bytes or str (for example a parsed dict), an empty secret, a negative tolerance. The
65
+ compare is constant time (`hmac.compare_digest`), and any matching `v1` is accepted (for secret rotation).
66
+ `verify_webhook(raw_body, header, secret, tolerance=300, now=None)`; `now` is Unix seconds, for tests.
67
+ Receiver guide: [Webhooks guide](https://www.preflightapi.com/docs/).
68
+
69
+ ## Fail-closed behaviour
70
+
71
+ - `check()` defaults to a **300 ms** timeout per attempt. On a timeout, a network error, a 429, a 5xx (after one
72
+ quick retry) or any 4xx it **returns** `{"decision": "block", "reasons": [{"code": "preflight_unavailable", ...}]}` instead of
73
+ raising. Treat it like any other `block`: do not dial or text. A 4xx also carries its HTTP `status` in the reason.
74
+ - A timeout can leave a check the server already recorded as an allowed attempt that you never dialed; it keeps
75
+ counting toward the frequency and volume caps. The block's reason carries the `idempotency_key` the check was sent
76
+ under. Call `pf.release_unplaced(req, d)` (same request, the block decision) within 5 minutes: it replays the key and,
77
+ if the stored check was an allow, reports `not_placed`. Over-counting is the safe direction, so skipping this only
78
+ costs reachable leads. Never use it for a call you placed.
79
+ - There is no fail-open option. The only alternative is `throw_on_error=True` (on the client or per call), which
80
+ raises `PreflightError` (with `status`, `code`, `body`, `retry_after`) so you can handle the failure yourself. Even then, no
81
+ answer means do not dial.
82
+ - Retries: a network error, timeout, 429 or 5xx is retried; any other status is not. `check()` gets one quick retry
83
+ (`check_max_retries`, default 1) and never sleeps longer than its timeout, so a `Retry-After` longer than that ends
84
+ it at once with the block decision: worst case about two timeouts. Every other call gets `max_retries` (default 2)
85
+ with jittered exponential backoff (`retry_base_delay`, default 0.2 s) or the server's `Retry-After` when it sends
86
+ one, up to `max_retry_delay` (default 10 s); a longer `Retry-After` raises `RateLimitError` (or `ServerError` for
87
+ a 503) with `retry_after` so you can schedule the retry.
88
+ - A retry never records a write twice: every attempt of a POST sends the **same** `Idempotency-Key`, and the server
89
+ replays a key it has already answered instead of running the request again. The key is a UUID generated
90
+ per call; when you retry a failed `record_opt_out` (or any write) yourself, pass `idempotency_key=` (your inbound
91
+ message id, or `new_idempotency_key()` kept for the job) so your retries are replays too. The server keeps keys
92
+ for 24 hours: a retry later than that runs again (for an opt-out, a second opt-out event: it still blocks, but the
93
+ audit trail shows it twice).
94
+ - Every method other than `check()` always raises `PreflightError` on failure (default timeout 10 s): swallowing a
95
+ failed opt-out or revocation would be unsafe, so retry it until it succeeds.
96
+ - `import_list_entries` and `create_list` retry like any write; a replayed import returns the first import's counts
97
+ and does not apply the file again.
98
+ - `create_webhook` retries like any write; a replay returns the same signing secret, so a retry never makes a second
99
+ endpoint.
100
+ - `batch()` is screening only (items must not set `commit`). It raises if the job fails or polling exceeds
101
+ `max_wait`; an item the server could not evaluate comes back as `{"error": {...}}`: treat it as do-not-dial.
102
+
103
+ The per-attempt timeout is a total deadline (not per socket read), covering an error response's body too, so a server that drips bytes cannot outlast it. The SDK never follows redirects (a 30x fails closed without a retry and the API key is never sent elsewhere), and `check()` rejects a response whose `reasons` is not a list.
104
+
105
+ ## Errors
106
+
107
+ Every failure is a `PreflightError`; the subclass says why: `InvalidRequestError` (400, 422), `AuthenticationError`
108
+ (401), `PermissionDeniedError` (403), `NotFoundError` (404), `ConflictError` (409), `RateLimitError` (429),
109
+ `ServerError` (5xx), `NetworkError` (no response) and `RequestTimeoutError`. Other statuses (a refused redirect, 413,
110
+ 421) are a plain `PreflightError`.
111
+
112
+ ```python
113
+ from preflight_sdk import AuthenticationError, PreflightError
114
+
115
+ try:
116
+ pf.record_opt_out(opt_out, idempotency_key=inbound_message_id)
117
+ except AuthenticationError:
118
+ alert_ops("Preflight key rejected")
119
+ except PreflightError as e:
120
+ requeue(opt_out, delay=e.retry_after) # keep the same key on the next try
121
+ ```
122
+
123
+ Requests send `User-Agent: preflight-sdk-python/<SDK_VERSION>`. `SDK_VERSION` and `API_VERSION` (the `/v1` API this
124
+ SDK speaks) are exported.
125
+
126
+ Request and response types (`TypedDict`) are in `preflight_sdk.types`.
127
+
@@ -0,0 +1,45 @@
1
+ from ._version import API_VERSION, SDK_VERSION
2
+ from .client import UNAVAILABLE, Preflight, new_idempotency_key, parse_retry_after, unavailable_decision
3
+ from .errors import (
4
+ AuthenticationError,
5
+ ConflictError,
6
+ InvalidRequestError,
7
+ NetworkError,
8
+ NotFoundError,
9
+ PermissionDeniedError,
10
+ PreflightError,
11
+ RateLimitError,
12
+ RequestTimeoutError,
13
+ ServerError,
14
+ )
15
+ from .webhooks import verify_webhook
16
+ from .types import (
17
+ BatchJob,
18
+ BatchResult,
19
+ CheckRequest,
20
+ ConsentRequest,
21
+ Decision,
22
+ Evidence,
23
+ EvidencePdf,
24
+ ImportOptions,
25
+ ListKind,
26
+ NewList,
27
+ Obligation,
28
+ OptOutRequest,
29
+ Outcome,
30
+ OutcomeReceipt,
31
+ Reason,
32
+ ReasonCode,
33
+ )
34
+
35
+ __all__ = [
36
+ "UNAVAILABLE", "Preflight", "PreflightError", "unavailable_decision",
37
+ "BatchJob", "BatchResult", "CheckRequest", "ConsentRequest", "Decision", "Evidence", "EvidencePdf", "ImportOptions", "ListKind", "NewList", "Obligation",
38
+ "OptOutRequest", "Outcome", "OutcomeReceipt", "Reason", "ReasonCode",
39
+ ]
40
+ __all__ += ["verify_webhook"]
41
+ __all__ += [
42
+ "API_VERSION", "SDK_VERSION", "new_idempotency_key", "parse_retry_after",
43
+ "AuthenticationError", "ConflictError", "InvalidRequestError", "NetworkError", "NotFoundError",
44
+ "PermissionDeniedError", "RateLimitError", "RequestTimeoutError", "ServerError",
45
+ ]
@@ -0,0 +1,4 @@
1
+ SDK_VERSION = "1.0.0"
2
+ """This SDK's version. Sent as ``User-Agent: preflight-sdk-python/<version>``."""
3
+ API_VERSION = "1"
4
+ """The API version this SDK speaks: the ``/v1`` path prefix and docs/openapi.yaml ``info.version``."""