preflight-sdk 1.0.0__py3-none-any.whl

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,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``."""
@@ -0,0 +1,514 @@
1
+ from __future__ import annotations
2
+
3
+ import email.utils
4
+ import json
5
+ import random
6
+ import threading
7
+ import time
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ import uuid
12
+ from typing import Any, Dict, List, Optional, Sequence
13
+
14
+ from ._version import SDK_VERSION
15
+ from .errors import NetworkError, PreflightError, RequestTimeoutError, error_for_status
16
+ from .types import (
17
+ BatchJob,
18
+ CheckRequest,
19
+ ConsentRequest,
20
+ Decision,
21
+ Evidence,
22
+ EvidencePdf,
23
+ ImportOptions,
24
+ NewList,
25
+ OptOutRequest,
26
+ )
27
+
28
+ UNAVAILABLE = "preflight_unavailable"
29
+ OUTCOMES = ("placed", "not_placed", "failed")
30
+
31
+
32
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
33
+ """Refuse every redirect: urllib would copy Authorization to the new location, even on another host. The 30x
34
+ then surfaces as an HTTPError (a 4xx-class failure, so check() fails closed and nothing is retried)."""
35
+
36
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
37
+ return None
38
+
39
+
40
+ _OPENER = urllib.request.build_opener(_NoRedirect)
41
+
42
+
43
+ def _seg(s: str) -> str:
44
+ """One path segment, percent-encoded."""
45
+ return urllib.parse.quote(s, safe="")
46
+
47
+
48
+ def _query(q: Dict[str, Any]) -> str:
49
+ """`?a=1&b=2` from the values that are not None (booleans as true/false), or "" when there are none."""
50
+ pairs = [(k, str(v).lower() if isinstance(v, bool) else str(v)) for k, v in q.items() if v is not None]
51
+ return "?" + urllib.parse.urlencode(pairs) if pairs else ""
52
+
53
+
54
+ _ERROR_BODY_CAP = 64 * 1024 # an error body only carries {"error": {"code", "message", "request_id"}}
55
+
56
+
57
+ def _read_body(resp: Any, deadline: float, cap: Optional[int] = None) -> bytes:
58
+ """Read a response body in chunks, raising TimeoutError once the monotonic deadline passes; stop at `cap`."""
59
+ chunks: List[bytes] = []
60
+ n = 0
61
+ while cap is None or n < cap:
62
+ if time.monotonic() >= deadline:
63
+ raise TimeoutError("deadline exceeded while reading the response")
64
+ chunk = resp.read1(65536)
65
+ if not chunk:
66
+ break
67
+ chunks.append(chunk)
68
+ n += len(chunk)
69
+ return b"".join(chunks)[:cap]
70
+
71
+
72
+ def unavailable_decision(message: str, status: Optional[int] = None, idempotency_key: Optional[str] = None) -> Decision:
73
+ """The decision to act on when there is no answer: do not dial. There is no fail-open mode."""
74
+ reason: Dict[str, Any] = {"code": UNAVAILABLE, "message": message}
75
+ if status is not None:
76
+ reason["status"] = status
77
+ if idempotency_key is not None:
78
+ reason["idempotency_key"] = idempotency_key
79
+ return {"decision": "block", "reasons": [reason], "obligations": [], "warnings": []} # type: ignore[return-value]
80
+
81
+
82
+ def new_idempotency_key() -> str:
83
+ """A fresh Idempotency-Key. Every write already gets one per call; pass your own (`idempotency_key=`) when you
84
+ retry a failed call yourself, so a write that landed before its answer was lost is replayed, not recorded twice."""
85
+ return str(uuid.uuid4())
86
+
87
+
88
+ def parse_retry_after(value: Optional[str], now: Optional[float] = None) -> Optional[float]:
89
+ """Seconds to wait from a Retry-After header (delay-seconds or an HTTP date), or None when absent or unreadable."""
90
+ if value is None:
91
+ return None
92
+ v = value.strip()
93
+ if v.isascii() and v.isdigit() and len(v) <= 9:
94
+ return float(int(v))
95
+ try:
96
+ at = email.utils.parsedate_to_datetime(v)
97
+ except (TypeError, ValueError, IndexError):
98
+ return None
99
+ if at is None or at.tzinfo is None:
100
+ return None
101
+ return max(0.0, at.timestamp() - (time.time() if now is None else now))
102
+
103
+
104
+ def _retryable(e: PreflightError) -> bool:
105
+ """Whether a failed attempt may be repeated: no response, a timeout, a 429 or a 5xx."""
106
+ return e.status is None or e.status == 429 or e.status >= 500
107
+
108
+
109
+ class _Retry:
110
+ """How many times, and how patiently, a call is retried. Backoff before retry n (from 0) is uniform in
111
+ [0, min(max_delay, base * 2**n)) ("full jitter"); a Retry-After longer than `max_delay` ends the call."""
112
+
113
+ def __init__(self, retries: int, base: float, max_delay: float):
114
+ self.retries = retries
115
+ self.base = base
116
+ self.max_delay = max_delay
117
+
118
+
119
+ class Preflight:
120
+ def __init__(
121
+ self,
122
+ base_url: str,
123
+ api_key: str,
124
+ *,
125
+ check_timeout: float = 0.3,
126
+ request_timeout: float = 10.0,
127
+ throw_on_error: bool = False,
128
+ max_retries: int = 2,
129
+ check_max_retries: int = 1,
130
+ retry_base_delay: float = 0.2,
131
+ max_retry_delay: float = 10.0,
132
+ ):
133
+ """Timeouts and delays are in seconds; timeouts are per attempt. `check_timeout` defaults to 300 ms.
134
+
135
+ A network error, timeout, 429 or 5xx is retried: `max_retries` times for every call except check() and the
136
+ creates the server does not deduplicate (never retried), `check_max_retries` times for check(). Retry n waits
137
+ a random time up to `retry_base_delay * 2**n`, or the server's Retry-After, at most `max_retry_delay`; a
138
+ longer Retry-After raises at once. check() never waits longer than its timeout."""
139
+ self._base = base_url.rstrip("/")
140
+ self._key = api_key
141
+ self._check_timeout = check_timeout
142
+ self._timeout = request_timeout
143
+ self._throw = throw_on_error
144
+ self._retry = _Retry(max_retries, retry_base_delay, max_retry_delay)
145
+ # check() sits in front of a dial: a Retry-After (whole seconds) ends it at once with the fail-closed decision.
146
+ self._check_retry = _Retry(check_max_retries, min(retry_base_delay, 0.025), check_timeout)
147
+
148
+ def _attempt(
149
+ self, method: str, path: str, data: Optional[bytes], headers: Dict[str, str], timeout: float
150
+ ) -> "tuple[bytes, int, Any]":
151
+ """One attempt with a total deadline. `urlopen(timeout=)` only bounds each socket operation, so a server
152
+ that drips bytes could outlast it: the attempt runs in a daemon thread that we stop waiting for at the
153
+ deadline (the body is read in chunks against the same monotonic deadline, so the thread ends soon after)."""
154
+ deadline = time.monotonic() + timeout
155
+ box: Dict[str, Any] = {}
156
+
157
+ def run() -> None:
158
+ try:
159
+ req = urllib.request.Request(self._base + path, data=data, headers=headers, method=method)
160
+ with _OPENER.open(req, timeout=timeout) as resp:
161
+ box["ok"] = (_read_body(resp, deadline), resp.status, resp.headers)
162
+ except urllib.error.HTTPError as e:
163
+ # A non-2xx (or refused 30x): read its body here, under the same deadline and capped, not in the
164
+ # caller's thread where only the per-socket timeout would bound a server that drips its error page.
165
+ try:
166
+ e.preflight_body = _read_body(e, deadline, _ERROR_BODY_CAP) # type: ignore[attr-defined]
167
+ except Exception: # a failure while reading the error body is still a server-side failure
168
+ e.preflight_body = b"" # type: ignore[attr-defined]
169
+ finally:
170
+ e.close()
171
+ box["err"] = e
172
+ except BaseException as e: # handed back to the caller's thread
173
+ box["err"] = e
174
+
175
+ t = threading.Thread(target=run, daemon=True)
176
+ t.start()
177
+ t.join(max(0.0, deadline - time.monotonic()))
178
+ if t.is_alive():
179
+ raise TimeoutError(f"no complete response within {timeout}s")
180
+ if "err" in box:
181
+ raise box["err"]
182
+ return box["ok"]
183
+
184
+ def _request(
185
+ self,
186
+ method: str,
187
+ path: str,
188
+ body: Any,
189
+ timeout: float,
190
+ idempotency_key: Optional[str],
191
+ *,
192
+ content_type: Optional[str] = None,
193
+ binary: bool = False,
194
+ retry: Optional[_Retry] = None,
195
+ ) -> Any:
196
+ """A network error, a timeout, a 429 or a 5xx is retried (up to the policy's count, with jittered
197
+ exponential backoff or the server's Retry-After); any other status is not. Every attempt of a POST sends the
198
+ same Idempotency-Key, and the server replays a key it has already answered, so a retry never records a
199
+ write twice. Returns parsed JSON or raises PreflightError.
200
+
201
+ `content_type` sends `body` (bytes or str) as is instead of as JSON; `binary` returns (bytes, headers)
202
+ instead of parsing JSON."""
203
+ headers = {"Authorization": f"Bearer {self._key}", "User-Agent": f"preflight-sdk-python/{SDK_VERSION}"}
204
+ data = None
205
+ if body is not None:
206
+ if content_type:
207
+ data = body.encode() if isinstance(body, str) else bytes(body)
208
+ else:
209
+ data = json.dumps(body).encode()
210
+ headers["Content-Type"] = content_type or "application/json"
211
+ if method == "POST":
212
+ headers["Idempotency-Key"] = idempotency_key or new_idempotency_key()
213
+ policy = retry or self._retry
214
+ retries = policy.retries
215
+ attempt = 0
216
+ while True:
217
+ try:
218
+ return self._one(method, path, data, headers, timeout, binary)
219
+ except PreflightError as e:
220
+ err = e
221
+ if attempt >= retries or not _retryable(err):
222
+ raise err
223
+ if err.retry_after is not None:
224
+ if err.retry_after > policy.max_delay:
225
+ raise err # the caller decides whether to wait that long
226
+ wait = err.retry_after
227
+ else:
228
+ wait = random.uniform(0, min(policy.max_delay, policy.base * 2**attempt))
229
+ time.sleep(wait)
230
+ attempt += 1
231
+
232
+ def _one(
233
+ self, method: str, path: str, data: Optional[bytes], headers: Dict[str, str], timeout: float, binary: bool
234
+ ) -> Any:
235
+ """One attempt: the response, or a PreflightError subclass saying why not."""
236
+ try:
237
+ raw, status, resp_headers = self._attempt(method, path, data, headers, timeout)
238
+ except urllib.error.HTTPError as e:
239
+ text = getattr(e, "preflight_body", b"").decode(errors="replace")
240
+ try:
241
+ parsed = json.loads(text)
242
+ except ValueError:
243
+ parsed = None
244
+ err = parsed.get("error") if isinstance(parsed, dict) else None
245
+ err = err if isinstance(err, dict) else {}
246
+ retry_after = parse_retry_after(e.headers.get("Retry-After") if e.headers is not None else None)
247
+ raise error_for_status(
248
+ e.code, err.get("message") or f"preflight {e.code}", err.get("code"), parsed, retry_after
249
+ ) from None
250
+ except Exception as e: # URLError, timeouts, http.client.HTTPException, bad encodings
251
+ reason = getattr(e, "reason", None)
252
+ if isinstance(e, TimeoutError) or isinstance(reason, TimeoutError):
253
+ raise RequestTimeoutError(f"no complete response within {timeout}s: {e!r}") from None
254
+ raise NetworkError(f"network error: {e!r}") from None
255
+ if binary:
256
+ return raw, resp_headers
257
+ try:
258
+ return json.loads(raw.decode())
259
+ except ValueError:
260
+ raise PreflightError("unreadable response body", status) from None
261
+
262
+ def check(
263
+ self,
264
+ req: CheckRequest,
265
+ *,
266
+ idempotency_key: Optional[str] = None,
267
+ timeout: Optional[float] = None,
268
+ throw_on_error: Optional[bool] = None,
269
+ ) -> Decision:
270
+ """Ask before every call or text. Never raises by default: on a timeout, network error, 429, 5xx (after one
271
+ quick retry, never a wait longer than the timeout) or 4xx it returns {"decision": "block", "reasons": [{"code": "preflight_unavailable"}]}. Treat that
272
+ as do-not-dial. With throw_on_error=True it raises PreflightError instead. There is no fail-open mode."""
273
+ idempotency_key = idempotency_key or new_idempotency_key()
274
+ try:
275
+ d = self._request(
276
+ "POST",
277
+ "/v1/check",
278
+ dict(req),
279
+ self._check_timeout if timeout is None else timeout,
280
+ idempotency_key,
281
+ retry=self._check_retry,
282
+ )
283
+ if (
284
+ not isinstance(d, dict)
285
+ or d.get("decision") not in ("allow", "defer", "block")
286
+ or not isinstance(d.get("reasons"), list)
287
+ ):
288
+ raise PreflightError("malformed decision in response", 200)
289
+ return d
290
+ except Exception as e: # never let a check() call raise unless asked to: fail closed
291
+ pe = e if isinstance(e, PreflightError) else PreflightError(f"unexpected error: {e!r}")
292
+ if self._throw if throw_on_error is None else throw_on_error:
293
+ if pe is e:
294
+ raise
295
+ raise pe from e
296
+ return unavailable_decision(str(pe), pe.status, idempotency_key)
297
+
298
+ def release_unplaced(
299
+ self, req: CheckRequest, unavailable: Decision, *, timeout: Optional[float] = None
300
+ ) -> Optional[Dict[str, Any]]:
301
+ """After check() returned the preflight_unavailable block, the server may still have recorded the check as
302
+ an allowed attempt that you never dialed. This replays the check under the key the block carries (the server
303
+ answers with the stored decision while the key is within its 5 minute window) and, if that was an allowed,
304
+ committed check, reports "not_placed" so it stops counting toward frequency and volume caps. Pass the same
305
+ request and the block decision. Returns the receipt, or None when there was nothing to release (no key on
306
+ the decision, or the check was not an allow). Raises PreflightError on any failure: retry it."""
307
+ key = next((r.get("idempotency_key") for r in unavailable["reasons"] if r.get("code") == UNAVAILABLE), None)
308
+ if key is None:
309
+ return None
310
+ d = self._request("POST", "/v1/check", dict(req), self._t(timeout), key)
311
+ if not isinstance(d, dict) or d.get("decision") != "allow" or d.get("committed") is not True or not isinstance(d.get("check_id"), str):
312
+ return None
313
+ return self.report_outcome(d["check_id"], "not_placed", timeout=timeout)
314
+
315
+ def record_consent(
316
+ self, req: ConsentRequest, *, idempotency_key: Optional[str] = None, timeout: Optional[float] = None
317
+ ) -> Dict[str, Any]:
318
+ """Record consent where you collect it. Raises PreflightError on any failure."""
319
+ return self._request(
320
+ "POST", "/v1/consents", dict(req), self._timeout if timeout is None else timeout, idempotency_key
321
+ )
322
+
323
+ def record_opt_out(
324
+ self, req: OptOutRequest, *, idempotency_key: Optional[str] = None, timeout: Optional[float] = None
325
+ ) -> Dict[str, Any]:
326
+ """Send every opt-out here. Raises PreflightError on any failure: retry it until it succeeds."""
327
+ return self._request(
328
+ "POST", "/v1/opt-outs", dict(req), self._timeout if timeout is None else timeout, idempotency_key
329
+ )
330
+
331
+ def report_outcome(
332
+ self,
333
+ check_id: str,
334
+ outcome: str,
335
+ provider_ref: Optional[str] = None,
336
+ *,
337
+ idempotency_key: Optional[str] = None,
338
+ timeout: Optional[float] = None,
339
+ ) -> Dict[str, Any]:
340
+ """Report what happened to an allowed, committed check once the call or text ends: "placed", "not_placed"
341
+ (never sent) or "failed" (sent to the provider, never reached the line). Only "placed" keeps counting as an
342
+ attempt. Raises PreflightError on any failure, including a bad `outcome`: retry it until it succeeds."""
343
+ if outcome not in OUTCOMES:
344
+ raise PreflightError(f"outcome must be one of {', '.join(OUTCOMES)}", None, "invalid_outcome")
345
+ body: Dict[str, Any] = {"outcome": outcome}
346
+ if provider_ref is not None:
347
+ body["provider_ref"] = provider_ref
348
+ path = f"/v1/checks/{urllib.parse.quote(check_id, safe='')}/outcome"
349
+ return self._request("POST", path, body, self._timeout if timeout is None else timeout, idempotency_key)
350
+
351
+ def get_evidence(self, phone: str, *, timeout: Optional[float] = None) -> Evidence:
352
+ """Everything Preflight holds about one number."""
353
+ path = "/v1/evidence?" + urllib.parse.urlencode({"phone": phone})
354
+ return self._request("GET", path, None, self._timeout if timeout is None else timeout, None)
355
+
356
+ def _t(self, timeout: Optional[float]) -> float:
357
+ return self._timeout if timeout is None else timeout
358
+
359
+ def revoke_consent(
360
+ self,
361
+ consent_id: str,
362
+ reason: Optional[str] = None,
363
+ *,
364
+ idempotency_key: Optional[str] = None,
365
+ timeout: Optional[float] = None,
366
+ ) -> Dict[str, Any]:
367
+ """Revoke a consent (idempotent: revoking again keeps the first revocation). Raises PreflightError on any
368
+ failure."""
369
+ body: Dict[str, Any] = {} if reason is None else {"reason": reason}
370
+ return self._request("POST", f"/v1/consents/{_seg(consent_id)}/revoke", body, self._t(timeout), idempotency_key)
371
+
372
+ def get_consent_capture(self, capture_id: str, *, timeout: Optional[float] = None) -> Dict[str, Any]:
373
+ """A consent capture's certificate (from the consent widget), pending, claimed or expired."""
374
+ return self._request("GET", f"/v1/consent-captures/{_seg(capture_id)}", None, self._t(timeout), None)
375
+
376
+ def claim_consent_capture(
377
+ self, capture_id: str, phone: str, *, idempotency_key: Optional[str] = None, timeout: Optional[float] = None
378
+ ) -> Dict[str, Any]:
379
+ """Claim a widget capture as consent. `phone` must be the phone from the form submission you accepted; the
380
+ server refuses the claim (409) if it is not the capture's. Raises PreflightError on any failure."""
381
+ path = f"/v1/consent-captures/{_seg(capture_id)}/claim"
382
+ return self._request("POST", path, {"phone": phone}, self._t(timeout), idempotency_key)
383
+
384
+ def create_list(
385
+ self, list_: NewList, *, idempotency_key: Optional[str] = None, timeout: Optional[float] = None
386
+ ) -> Dict[str, Any]:
387
+ """Create a list (national_dnc, state_dnc, litigator, reassigned or custom). Retried with the same
388
+ Idempotency-Key, so a retry never creates a second list."""
389
+ return self._request("POST", "/v1/lists", dict(list_), self._t(timeout), idempotency_key)
390
+
391
+ def _all_pages(self, path: str, timeout: Optional[float]) -> Dict[str, Any]:
392
+ """Every row of a list endpoint, following next_cursor page by page: {"data": [...]}."""
393
+ data: list = []
394
+ cursor: Optional[str] = None
395
+ while True:
396
+ q = _query({"limit": 1000, "cursor": cursor})
397
+ page = self._request("GET", path + q, None, self._t(timeout), None)
398
+ data.extend(page.get("data") or [])
399
+ cursor = page.get("next_cursor")
400
+ if not cursor:
401
+ return {"data": data}
402
+
403
+ def list_lists(self, *, timeout: Optional[float] = None) -> Dict[str, Any]:
404
+ """The workspace's lists with entry counts and freshness, every page: {"data": [...]}."""
405
+ return self._all_pages("/v1/lists", timeout)
406
+
407
+ def import_list_entries(
408
+ self,
409
+ list_id: str,
410
+ text: "str | bytes",
411
+ *,
412
+ options: Optional[ImportOptions] = None,
413
+ idempotency_key: Optional[str] = None,
414
+ timeout: Optional[float] = None,
415
+ ) -> Dict[str, Any]:
416
+ """Import entries into a list: delimited text, one entry per line, sent as is (see the README for the
417
+ format and options). A large file needs a larger `timeout` than the default. Raises PreflightError on any
418
+ failure, including a refused replace. Retried with the same Idempotency-Key: the server answers a repeat of
419
+ the same file and options with the first import's result instead of importing again."""
420
+ path = f"/v1/lists/{_seg(list_id)}/entries" + _query(dict(options or {}))
421
+ return self._request("POST", path, text, self._t(timeout), idempotency_key, content_type="text/plain")
422
+
423
+ def get_check_evidence(self, check_id: str, *, timeout: Optional[float] = None) -> Dict[str, Any]:
424
+ """The evidence bundle for one check, with its SHA-256 and the audit chain status."""
425
+ return self._request("GET", f"/v1/checks/{_seg(check_id)}/evidence", None, self._t(timeout), None)
426
+
427
+ def get_check_evidence_pdf(self, check_id: str, *, timeout: Optional[float] = None) -> EvidencePdf:
428
+ """The same evidence as a PDF, with the bundle's SHA-256 from the response header."""
429
+ path = f"/v1/checks/{_seg(check_id)}/evidence.pdf"
430
+ pdf, headers = self._request("GET", path, None, self._t(timeout), None, binary=True)
431
+ return {"pdf": pdf, "bundle_sha256": headers.get("X-Preflight-Evidence-Sha256")}
432
+
433
+ def get_rule_changes(self, since: Optional[str] = None, *, timeout: Optional[float] = None) -> Dict[str, Any]:
434
+ """Changes to the rule data since a rules version (YYYY-MM-DD.N) or a date, oldest first."""
435
+ return self._request("GET", "/v1/rules/changes" + _query({"since": since}), None, self._t(timeout), None)
436
+
437
+ def get_rule_impact(
438
+ self,
439
+ *,
440
+ since: Optional[str] = None,
441
+ until: Optional[str] = None,
442
+ cursor: Optional[str] = None,
443
+ limit: Optional[int] = None,
444
+ timeout: Optional[float] = None,
445
+ ) -> Dict[str, Any]:
446
+ """Your recent checks re-evaluated under the deployed rules (last 30 days, paged by next_cursor)."""
447
+ q = _query({"since": since, "until": until, "cursor": cursor, "limit": limit})
448
+ return self._request("GET", "/v1/rules/impact" + q, None, self._t(timeout), None)
449
+
450
+ def create_webhook(
451
+ self,
452
+ url: str,
453
+ events: Optional[Sequence[str]] = None,
454
+ *,
455
+ idempotency_key: Optional[str] = None,
456
+ timeout: Optional[float] = None,
457
+ ) -> Dict[str, Any]:
458
+ """Register a webhook endpoint. The response carries its signing secret. Retried with the same
459
+ Idempotency-Key: the server replays the first response (secret included), so a retry never makes a second
460
+ endpoint."""
461
+ body: Dict[str, Any] = {"url": url}
462
+ if events is not None:
463
+ body["events"] = list(events)
464
+ return self._request("POST", "/v1/webhooks", body, self._t(timeout), idempotency_key)
465
+
466
+ def list_webhooks(self, *, timeout: Optional[float] = None) -> Dict[str, Any]:
467
+ """The workspace's webhook endpoints with pending and failed delivery counts, every page: {"data": [...]}."""
468
+ return self._all_pages("/v1/webhooks", timeout)
469
+
470
+ def delete_webhook(self, webhook_id: str, *, timeout: Optional[float] = None) -> Dict[str, Any]:
471
+ """Disable a webhook endpoint and cancel its pending deliveries (deleting again changes nothing)."""
472
+ return self._request("DELETE", f"/v1/webhooks/{_seg(webhook_id)}", None, self._t(timeout), None)
473
+
474
+ def batch(
475
+ self,
476
+ items: Sequence[CheckRequest],
477
+ *,
478
+ idempotency_key: Optional[str] = None,
479
+ timeout: Optional[float] = None,
480
+ poll_interval: float = 1.0,
481
+ max_wait: float = 600.0,
482
+ page_size: int = 1000,
483
+ ) -> BatchJob:
484
+ """Screen a list (screening only: items must not set commit=True). Submits the job, polls until it is
485
+ done and returns every result in item order. Raises PreflightError if the job fails, polling runs out of
486
+ time or a call fails: no answer means do not dial. An item the server could not evaluate comes back as
487
+ {"error": {...}}."""
488
+ t = self._timeout if timeout is None else timeout
489
+ job_id = self._request("POST", "/v1/checks/batch", {"items": list(items)}, t, idempotency_key)["job_id"]
490
+ path = f"/v1/checks/batch/{_seg(job_id)}"
491
+ deadline = time.monotonic() + max_wait
492
+ while True:
493
+ s = self._request("GET", f"{path}?limit=1", None, t, None)
494
+ if s.get("status") == "done":
495
+ break
496
+ if s.get("status") == "failed":
497
+ raise PreflightError(f"batch job {job_id} failed: {s.get('error') or 'unknown'}", None, "batch_failed", s)
498
+ if time.monotonic() >= deadline:
499
+ raise PreflightError(f"batch job {job_id} not done in time", None, "batch_timeout")
500
+ time.sleep(poll_interval)
501
+ results: List[Any] = []
502
+ cursor: Optional[str] = None
503
+ last: Dict[str, Any] = {}
504
+ while True:
505
+ q = f"limit={page_size}" + (f"&cursor={urllib.parse.quote(cursor, safe='')}" if cursor else "")
506
+ last = self._request("GET", f"{path}?{q}", None, t, None)
507
+ results.extend(last["results"])
508
+ cursor = last.get("next_cursor")
509
+ if not cursor:
510
+ break
511
+ last["results"] = results
512
+ last.pop("next_cursor", None)
513
+ last.pop("next_offset", None)
514
+ return last # type: ignore[return-value]
@@ -0,0 +1,85 @@
1
+ """Every failure is a ``PreflightError``; the subclasses say why, so callers can branch with ``except`` instead of
2
+ reading status codes. Statuses with no subclass of their own (a refused redirect, 413, 421) are plain
3
+ ``PreflightError``."""
4
+ from __future__ import annotations
5
+
6
+ from typing import Any, Optional
7
+
8
+
9
+ class PreflightError(Exception):
10
+ """`status` is the HTTP status, or None for a network error or timeout. `code` is the server's error code.
11
+ `retry_after` is the server's Retry-After in seconds (429, 503), when it sent one."""
12
+
13
+ def __init__(
14
+ self,
15
+ message: str,
16
+ status: Optional[int] = None,
17
+ code: Optional[str] = None,
18
+ body: Any = None,
19
+ retry_after: Optional[float] = None,
20
+ ):
21
+ super().__init__(message)
22
+ self.status = status
23
+ self.code = code
24
+ self.body = body
25
+ self.retry_after = retry_after
26
+
27
+ @property
28
+ def request_id(self) -> Optional[str]:
29
+ """The server's id for the failed request (`error.request_id`, also the x-request-id header): quote it to
30
+ support. None when there was no server response."""
31
+ err = self.body.get("error") if isinstance(self.body, dict) else None
32
+ rid = err.get("request_id") if isinstance(err, dict) else None
33
+ return rid if isinstance(rid, str) else None
34
+
35
+
36
+ class InvalidRequestError(PreflightError):
37
+ """400 or 422: the request is malformed or refused as sent (including a reused Idempotency-Key)."""
38
+
39
+
40
+ class AuthenticationError(PreflightError):
41
+ """401: the API key is missing, wrong or revoked."""
42
+
43
+
44
+ class PermissionDeniedError(PreflightError):
45
+ """403: the key is valid but may not do this (for example a read-only key on a write)."""
46
+
47
+
48
+ class NotFoundError(PreflightError):
49
+ """404: no such resource in this workspace."""
50
+
51
+
52
+ class ConflictError(PreflightError):
53
+ """409: the request conflicts with what is recorded (for example downgrading a placed outcome)."""
54
+
55
+
56
+ class RateLimitError(PreflightError):
57
+ """429: slow down; `retry_after` says for how long."""
58
+
59
+
60
+ class ServerError(PreflightError):
61
+ """5xx: the server failed; safe to retry later with the same Idempotency-Key."""
62
+
63
+
64
+ class NetworkError(PreflightError):
65
+ """No HTTP response: DNS, connection refused or reset, TLS."""
66
+
67
+
68
+ class RequestTimeoutError(PreflightError):
69
+ """No complete response within the per-attempt timeout."""
70
+
71
+
72
+ def error_for_status(
73
+ status: int, message: str, code: Optional[str] = None, body: Any = None, retry_after: Optional[float] = None
74
+ ) -> PreflightError:
75
+ """The subclass for an HTTP status."""
76
+ cls = {
77
+ 400: InvalidRequestError,
78
+ 401: AuthenticationError,
79
+ 403: PermissionDeniedError,
80
+ 404: NotFoundError,
81
+ 409: ConflictError,
82
+ 422: InvalidRequestError,
83
+ 429: RateLimitError,
84
+ }.get(status, ServerError if status >= 500 else PreflightError)
85
+ return cls(message, status, code, body, retry_after)
preflight_sdk/py.typed ADDED
File without changes
preflight_sdk/types.py ADDED
@@ -0,0 +1,209 @@
1
+ """Request and response types. Field names match the API's JSON (snake_case), so dicts pass straight through."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
5
+
6
+ Channel = Literal["voice", "sms"]
7
+ Purpose = Literal["marketing", "informational", "emergency"]
8
+ Method = Literal["ai_voice", "prerecorded", "autodialed", "manual", "ai_text", "ai_agent", "ai_prerecorded"]
9
+ ConsentKind = Literal["express_written", "express", "ebr_purchase", "ebr_inquiry"]
10
+ Verdict = Literal["allow", "defer", "block"]
11
+ Outcome = Literal["placed", "not_placed", "failed"]
12
+
13
+
14
+ class _CheckRequired(TypedDict):
15
+ to: str
16
+ channel: Channel
17
+ purpose: Purpose
18
+
19
+
20
+ class CheckRequest(_CheckRequired, total=False):
21
+ method: Method
22
+ seller: str
23
+ subject: str
24
+ recipient_state: str
25
+ at: str # ISO 8601; what-if time, only with commit=False (committed: within 5 min of server time)
26
+ commit: bool # batch items must leave it false or unset
27
+ line_type: Literal["wireless", "residential", "unknown"] # only "residential" changes a verdict; a workspace with caller_attributes.tax_exempt_nonprofit true gets the 3-in-30 limit for any non-emergency purpose
28
+ existing_debt_or_contract: bool # contact is primarily about an existing, uncompleted debt or contract; lifts Florida's do-not-call list only (Fla. Stat. 501.059(1)(k)2)
29
+ existing_customer: bool # person called is an NJ "existing customer" (open payment obligation on merchandise, or a written contract with performance outstanding); lifts only the NJ mobile sales-call ban (N.J.A.C. 13:45D-1.3)
30
+ health_care_message: bool # HIPAA health-care message to a residential line; needs caller_attributes.hipaa_covered_entity
31
+ message_text: str # exact SMS text (max 2000 chars); with free_to_end_user the 160-character limit and STOP wording are checked
32
+ free_to_end_user: dict # {"category": "package"|"inmate"|"financial"|"healthcare", "event_id", "conditions": [...], "signature_required"}; 47 CFR 64.1200(a)(9), needs the workspace's caller_attributes.free_to_end_user_<category> and a current free_to_end_user_attestations entry; the conditions are your representation, not verified
33
+
34
+
35
+ # Every code a reason can carry (README "Reason codes"). New codes may appear: keep a default branch.
36
+ ReasonCode = Literal[
37
+ "opted_out",
38
+ "consent_missing",
39
+ "national_dnc",
40
+ "state_dnc",
41
+ "state_ban",
42
+ "state_volume_cap",
43
+ "state_registration_missing",
44
+ "known_litigator",
45
+ "suppressed",
46
+ "dnc_list_missing",
47
+ "dnc_list_stale",
48
+ "dnc_list_not_covering",
49
+ "state_dnc_list_missing",
50
+ "litigator_list_missing",
51
+ "litigator_list_stale",
52
+ "unknown_area_code",
53
+ "non_geographic_number",
54
+ "unsupported_jurisdiction",
55
+ "emergency_disabled",
56
+ "emergency_subject_not_allowed",
57
+ "emergency_rule",
58
+ "no_allowed_time",
59
+ "outside_federal_window",
60
+ "outside_state_window",
61
+ "window_closing",
62
+ "state_holiday",
63
+ "state_no_calls_today",
64
+ "frequency_cap",
65
+ "rnd_unavailable",
66
+ "preflight_unavailable",
67
+ ]
68
+
69
+
70
+ class Reason(TypedDict, total=False):
71
+ code: str # a ReasonCode today; typed as str so a code added to the server later still type-checks
72
+ message: str
73
+ rule: str
74
+ source: str
75
+ verified: bool
76
+ status: int # only on the SDK's own preflight_unavailable reason, when the server answered with a 4xx
77
+ idempotency_key: str # only on the SDK's own preflight_unavailable reason: the key the check was sent under; see release_unplaced()
78
+
79
+
80
+ class Obligation(TypedDict, total=False):
81
+ code: str
82
+ message: str
83
+ source: str
84
+ state: str
85
+ required: bool
86
+ trigger: str
87
+ timing: str
88
+ text: str
89
+ conditions: str
90
+ verified: bool
91
+
92
+
93
+ class SourceText(TypedDict):
94
+ url: str
95
+ sha256: str
96
+ fetched_at: str
97
+
98
+
99
+ class RulesReview(TypedDict):
100
+ reviewed_by: str
101
+ reviewed_on: str
102
+ scope: str
103
+
104
+
105
+ class Decision(TypedDict, total=False):
106
+ decision: Verdict
107
+ reasons: List[Reason]
108
+ next_allowed_at: str
109
+ obligations: List[Obligation]
110
+ jurisdiction: Dict[str, Any]
111
+ warnings: List[str]
112
+ consent_ids: List[str]
113
+ check_id: str
114
+ committed: bool
115
+ evaluated_at: str
116
+ reference_snapshot: Optional[Dict[str, Any]]
117
+ rules_version: str
118
+ rules_in_force: List[str]
119
+ source_texts: List[SourceText]
120
+ rules_review: RulesReview
121
+
122
+
123
+ class _ConsentRequired(TypedDict):
124
+ phone: str
125
+ kind: ConsentKind
126
+ source: str
127
+
128
+
129
+ class ConsentRequest(_ConsentRequired, total=False):
130
+ seller: str
131
+ channels: List[Channel]
132
+ purposes: List[Purpose]
133
+ obtained_at: str
134
+ wording: str # required for express_written: the exact wording shown to the person
135
+ ip: str
136
+ user_agent: str
137
+ evidence: Any
138
+
139
+
140
+ class _OptOutRequired(TypedDict):
141
+ phone: str
142
+
143
+
144
+ class OptOutRequest(_OptOutRequired, total=False):
145
+ channel: Channel # omit to opt out on every channel
146
+ source: str
147
+ raw: str
148
+ received_at: str
149
+
150
+
151
+ # A batch item the server could not evaluate is {"error": {"code", "message"}}: treat it as do-not-dial.
152
+ BatchResult = Union[Decision, Dict[str, Any]]
153
+
154
+
155
+ class BatchJob(TypedDict):
156
+ job_id: str
157
+ status: str
158
+ total: int
159
+ processed: int
160
+ counts: Dict[str, int]
161
+ results: List[BatchResult] # in item order
162
+
163
+
164
+ # The server's JSON is passed through as-is.
165
+ Evidence = Dict[str, Any]
166
+
167
+
168
+ class OutcomeReceipt(TypedDict):
169
+ check_id: str
170
+ outcome: Outcome
171
+ outcome_at: str
172
+ provider_ref: Optional[str]
173
+ counts_as_attempt: bool # True only for "placed"
174
+ changed: bool # False when the same outcome was already recorded
175
+
176
+
177
+ ListKind = Literal["national_dnc", "state_dnc", "litigator", "reassigned", "custom"]
178
+
179
+
180
+ class _NewListRequired(TypedDict):
181
+ kind: ListKind
182
+ name: str
183
+
184
+
185
+ class NewList(_NewListRequired, total=False):
186
+ state: str # two-letter state, required for state_dnc
187
+ source: str
188
+ area_codes: List[str]
189
+
190
+
191
+ class ImportOptions(TypedDict, total=False):
192
+ """Query options of POST /v1/lists/{id}/entries; see the README's import table."""
193
+
194
+ mode: Literal["merge", "replace"]
195
+ delimiter: Literal["comma", "tab", "pipe", "semicolon"]
196
+ header: bool
197
+ phone_col: str
198
+ area_code_col: str
199
+ registered_col: str
200
+ disconnected_col: str
201
+ edition_date: str # YYYY-MM-DD
202
+ source: str
203
+ area_codes: str # comma-separated, e.g. "303,720"
204
+ force: bool
205
+
206
+
207
+ class EvidencePdf(TypedDict):
208
+ pdf: bytes
209
+ bundle_sha256: Optional[str] # from the X-Preflight-Evidence-Sha256 header
@@ -0,0 +1,78 @@
1
+ """Verifies the ``preflight-signature`` header on webhook deliveries (#280).
2
+
3
+ Scheme (src/webhooks.rs ``sign``)::
4
+
5
+ preflight-signature: t=<unix seconds>,v1=<hex(HMAC-SHA256(secret, "<t>.<raw body>"))>
6
+
7
+ The key is the endpoint secret exactly as returned by ``create_webhook`` (including ``whsec_``), as UTF-8 bytes.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import hmac
13
+ import math
14
+ import re
15
+ import time
16
+ from typing import Optional, Union
17
+
18
+ _HEX64 = re.compile(r"[0-9a-f]{64}")
19
+ _DIGITS = re.compile(r"[0-9]{1,15}")
20
+
21
+
22
+ def verify_webhook(
23
+ raw_body: Union[bytes, bytearray, memoryview, str],
24
+ header: Optional[str],
25
+ secret: str,
26
+ tolerance: float = 300,
27
+ now: Optional[float] = None,
28
+ ) -> bool:
29
+ """True only when ``header`` carries a ``v1`` signature of ``raw_body`` under ``secret`` and its ``t`` is within
30
+ ``tolerance`` seconds of ``now`` (default ``time.time()``) in either direction.
31
+
32
+ Every other case returns False (fail closed): missing or malformed header, non-numeric, stale or future
33
+ timestamp, wrong signature. Pass the body exactly as received (``request.get_data()``, ``await request.body()``)
34
+ or its undecoded text; a parsed dict does not match and raises ``TypeError``, as do an empty secret and a bad
35
+ tolerance, because those are programming errors. Any one matching ``v1`` value is accepted.
36
+ """
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("verify_webhook needs the raw request body (bytes or text), not a parsed object")
43
+ if not isinstance(secret, str) or not secret:
44
+ raise TypeError("verify_webhook needs the endpoint secret")
45
+ if isinstance(tolerance, bool) or not isinstance(tolerance, (int, float)) or not (0 <= tolerance < math.inf):
46
+ raise TypeError("tolerance must be a non-negative number")
47
+ if not isinstance(header, str):
48
+ return False
49
+ current = time.time() if now is None else now
50
+ if not isinstance(current, (int, float)) or not math.isfinite(current):
51
+ return False
52
+
53
+ t: Optional[str] = None
54
+ sigs = []
55
+ for part in header.split(","):
56
+ key, eq, value = part.partition("=")
57
+ if not eq:
58
+ return False
59
+ key, value = key.strip(), value.strip()
60
+ if key == "t":
61
+ if t is not None: # two timestamps: ambiguous, refuse
62
+ return False
63
+ t = value
64
+ elif key == "v1":
65
+ sigs.append(value)
66
+ # other schemes are ignored
67
+ if t is None or not _DIGITS.fullmatch(t) or not sigs:
68
+ return False
69
+ if abs(current - int(t)) > tolerance:
70
+ return False
71
+
72
+ expected = hmac.new(secret.encode("utf-8"), t.encode("ascii") + b"." + body, hashlib.sha256).hexdigest()
73
+ ok = False
74
+ for sig in sigs:
75
+ # Compare every candidate (no early exit); compare_digest is constant time for equal-length inputs.
76
+ if _HEX64.fullmatch(sig) and hmac.compare_digest(sig.encode("ascii"), expected.encode("ascii")):
77
+ ok = True
78
+ return ok
@@ -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,12 @@
1
+ preflight_sdk/__init__.py,sha256=DKTC5Wl_tIb3O4rTH8DB-v7acSWtvypWROMYOf2uQTM,1336
2
+ preflight_sdk/_version.py,sha256=_PY6d1b1O7YeLRCErRzOn60IKqRXzy14pEJAhflkK2I,225
3
+ preflight_sdk/client.py,sha256=K_sht-trve5DkFd0TJzD_gm6dak00nviBm7Z9Mx_53c,25079
4
+ preflight_sdk/errors.py,sha256=EK7vNchh5C-d775xGDk2O9iyC5XvCr8RUnRCrgBoeb8,2957
5
+ preflight_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ preflight_sdk/types.py,sha256=sgnmCZoArAATPjd4IaCQey6yxiWKjY7hqe_XZuTl4F4,6511
7
+ preflight_sdk/webhooks.py,sha256=Zps2I10rS6VpgZPOHmPYx-FZ7FEfPxHvsGzjPiHwipo,3142
8
+ preflight_sdk-1.0.0.dist-info/licenses/LICENSE,sha256=P6ChiR3AXZTZj_O0VVHfZpD_s66vqdh-je564oqI1A4,1074
9
+ preflight_sdk-1.0.0.dist-info/METADATA,sha256=n_VrWbWgegdELCHDLXxcRai0tyqWjY0rcE8rIDjS6-M,8770
10
+ preflight_sdk-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
11
+ preflight_sdk-1.0.0.dist-info/top_level.txt,sha256=iQplzLkBdOzJn85UlQoMvWYs-7T9m8r2OAHWWgt_s68,14
12
+ preflight_sdk-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ preflight_sdk