citeflow-python 0.1.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,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ .mypy_cache/
8
+ .venv/
9
+ .tox/
10
+ htmlcov/
11
+ .coverage
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CiteFlow
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,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: citeflow-python
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the CiteFlow API (SEO, AEO, GEO audits).
5
+ Project-URL: Homepage, https://citeflow.io
6
+ Project-URL: Documentation, https://citeflow.io/help/partner-api
7
+ Author-email: CiteFlow <support@citeflow.io>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: aeo,api,audit,citeflow,geo,sdk,seo
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: requests>=2.28
23
+ Provides-Extra: dev
24
+ Requires-Dist: mypy>=1.10; extra == 'dev'
25
+ Requires-Dist: pytest-mock>=3.12; extra == 'dev'
26
+ Requires-Dist: pytest>=8; extra == 'dev'
27
+ Requires-Dist: responses>=0.25; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # citeflow-python
31
+
32
+ Official Python SDK for the **[CiteFlow](https://citeflow.io)** Partner
33
+ API — programmatic SEO, AEO, and GEO audits.
34
+
35
+ [CiteFlow](https://citeflow.io) is an AI-visibility scanner: it audits how
36
+ well a website can be crawled, understood, and cited by AI search engines
37
+ (ChatGPT, Claude, Perplexity, Google AI Overviews). This SDK lets partners
38
+ run those audits from their own products via the CiteFlow Partner API.
39
+
40
+ - Website: <https://citeflow.io>
41
+ - API docs & getting started: <https://citeflow.io/help/partner-api>
42
+ - Dashboard (API keys, billing, webhooks): <https://citeflow.io/dashboard/api>
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install citeflow-python
48
+ ```
49
+
50
+ Python 3.9+ required. Depends on `requests`.
51
+
52
+ ## Quickstart
53
+
54
+ ```python
55
+ import os
56
+ from citeflow import Citeflow
57
+
58
+ client = Citeflow(api_key=os.environ["CITEFLOW_API_KEY"])
59
+
60
+ # Create an audit and wait for the result (~30s for SEO).
61
+ audit = client.audits.create(url="https://example.com", type="seo")
62
+ result = client.audits.wait_for_completion(audit["audit_id"], timeout=90)
63
+
64
+ if result["status"] == "complete":
65
+ print("Scores:", result["scores"])
66
+ elif result["status"] == "failed":
67
+ print("Failure:", result["failure_reason"])
68
+
69
+ # Check remaining balance.
70
+ b = client.balance.retrieve()
71
+ print(f"Balance: {b['balance_usd']} ({b['balance']} credits)")
72
+ ```
73
+
74
+ ## Features
75
+
76
+ - **Auto-retry** on `429` and `5xx` with exponential back-off + jitter.
77
+ Honors `Retry-After`.
78
+ - **Auto Idempotency-Key** on every POST — safe to retry on network
79
+ failure without double-charging.
80
+ - **`wait_for_completion`** polling helper with configurable timeout.
81
+ - **HMAC webhook verification** with timestamp tolerance + rotation
82
+ grace handling.
83
+ - Pluggable `requests.Session` for custom proxies / adapters.
84
+
85
+ ## Webhook verification
86
+
87
+ ```python
88
+ from citeflow import verify_webhook_signature, parse_webhook_event, CiteflowSignatureError
89
+
90
+ # In your webhook handler (Flask, FastAPI, Django, …):
91
+ raw_body = request.get_data() # bytes — do NOT JSON-parse first
92
+ try:
93
+ event = parse_webhook_event(
94
+ raw_body=raw_body,
95
+ headers=request.headers,
96
+ secret=os.environ["CITEFLOW_WEBHOOK_SECRET"],
97
+ )
98
+ # event["type"] in {"audit.completed", "audit.failed", "audit.cancelled", "balance.low"}
99
+ print(event["id"], event["type"], event["data"])
100
+ return "", 200
101
+ except CiteflowSignatureError:
102
+ return "", 400
103
+ ```
104
+
105
+ ## Errors
106
+
107
+ ```python
108
+ from citeflow import CiteflowError
109
+
110
+ try:
111
+ client.audits.create(url="invalid", type="seo")
112
+ except CiteflowError as err:
113
+ print(err.code, err.request_id)
114
+ if err.code == "INSUFFICIENT_CREDITS":
115
+ print(f"Need {err.required} more credits")
116
+ ```
117
+
118
+ Full error catalog: https://citeflow.io/help/partner-api#troubleshooting.
119
+
120
+ ## Configuration
121
+
122
+ ```python
123
+ client = Citeflow(
124
+ api_key="ckf_…",
125
+ base_url="https://citeflow.io/api/v1", # override for staging
126
+ timeout=30.0, # per-request seconds
127
+ max_retries=3, # for 429 + 5xx + network
128
+ )
129
+ ```
130
+
131
+ ## License
132
+
133
+ MIT. CiteFlow API itself is a paid service — see
134
+ https://citeflow.io/terms-of-service.
@@ -0,0 +1,105 @@
1
+ # citeflow-python
2
+
3
+ Official Python SDK for the **[CiteFlow](https://citeflow.io)** Partner
4
+ API — programmatic SEO, AEO, and GEO audits.
5
+
6
+ [CiteFlow](https://citeflow.io) is an AI-visibility scanner: it audits how
7
+ well a website can be crawled, understood, and cited by AI search engines
8
+ (ChatGPT, Claude, Perplexity, Google AI Overviews). This SDK lets partners
9
+ run those audits from their own products via the CiteFlow Partner API.
10
+
11
+ - Website: <https://citeflow.io>
12
+ - API docs & getting started: <https://citeflow.io/help/partner-api>
13
+ - Dashboard (API keys, billing, webhooks): <https://citeflow.io/dashboard/api>
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install citeflow-python
19
+ ```
20
+
21
+ Python 3.9+ required. Depends on `requests`.
22
+
23
+ ## Quickstart
24
+
25
+ ```python
26
+ import os
27
+ from citeflow import Citeflow
28
+
29
+ client = Citeflow(api_key=os.environ["CITEFLOW_API_KEY"])
30
+
31
+ # Create an audit and wait for the result (~30s for SEO).
32
+ audit = client.audits.create(url="https://example.com", type="seo")
33
+ result = client.audits.wait_for_completion(audit["audit_id"], timeout=90)
34
+
35
+ if result["status"] == "complete":
36
+ print("Scores:", result["scores"])
37
+ elif result["status"] == "failed":
38
+ print("Failure:", result["failure_reason"])
39
+
40
+ # Check remaining balance.
41
+ b = client.balance.retrieve()
42
+ print(f"Balance: {b['balance_usd']} ({b['balance']} credits)")
43
+ ```
44
+
45
+ ## Features
46
+
47
+ - **Auto-retry** on `429` and `5xx` with exponential back-off + jitter.
48
+ Honors `Retry-After`.
49
+ - **Auto Idempotency-Key** on every POST — safe to retry on network
50
+ failure without double-charging.
51
+ - **`wait_for_completion`** polling helper with configurable timeout.
52
+ - **HMAC webhook verification** with timestamp tolerance + rotation
53
+ grace handling.
54
+ - Pluggable `requests.Session` for custom proxies / adapters.
55
+
56
+ ## Webhook verification
57
+
58
+ ```python
59
+ from citeflow import verify_webhook_signature, parse_webhook_event, CiteflowSignatureError
60
+
61
+ # In your webhook handler (Flask, FastAPI, Django, …):
62
+ raw_body = request.get_data() # bytes — do NOT JSON-parse first
63
+ try:
64
+ event = parse_webhook_event(
65
+ raw_body=raw_body,
66
+ headers=request.headers,
67
+ secret=os.environ["CITEFLOW_WEBHOOK_SECRET"],
68
+ )
69
+ # event["type"] in {"audit.completed", "audit.failed", "audit.cancelled", "balance.low"}
70
+ print(event["id"], event["type"], event["data"])
71
+ return "", 200
72
+ except CiteflowSignatureError:
73
+ return "", 400
74
+ ```
75
+
76
+ ## Errors
77
+
78
+ ```python
79
+ from citeflow import CiteflowError
80
+
81
+ try:
82
+ client.audits.create(url="invalid", type="seo")
83
+ except CiteflowError as err:
84
+ print(err.code, err.request_id)
85
+ if err.code == "INSUFFICIENT_CREDITS":
86
+ print(f"Need {err.required} more credits")
87
+ ```
88
+
89
+ Full error catalog: https://citeflow.io/help/partner-api#troubleshooting.
90
+
91
+ ## Configuration
92
+
93
+ ```python
94
+ client = Citeflow(
95
+ api_key="ckf_…",
96
+ base_url="https://citeflow.io/api/v1", # override for staging
97
+ timeout=30.0, # per-request seconds
98
+ max_retries=3, # for 429 + 5xx + network
99
+ )
100
+ ```
101
+
102
+ ## License
103
+
104
+ MIT. CiteFlow API itself is a paid service — see
105
+ https://citeflow.io/terms-of-service.
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "citeflow-python"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for the CiteFlow API (SEO, AEO, GEO audits)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "CiteFlow", email = "support@citeflow.io" }]
13
+ keywords = ["citeflow", "seo", "aeo", "geo", "audit", "api", "sdk"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ ]
26
+ dependencies = ["requests>=2.28"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://citeflow.io"
30
+ Documentation = "https://citeflow.io/help/partner-api"
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest>=8", "pytest-mock>=3.12", "responses>=0.25", "mypy>=1.10"]
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/citeflow"]
37
+
38
+ [tool.hatch.build.targets.sdist]
39
+ include = ["src/citeflow", "README.md", "LICENSE", "pyproject.toml"]
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
43
+ addopts = "-q"
44
+
45
+ [tool.mypy]
46
+ strict = true
47
+ python_version = "3.9"
@@ -0,0 +1,25 @@
1
+ """CiteFlow Python SDK.
2
+
3
+ Public surface mirrors @citeflow/sdk for Node. See https://docs.citeflow.io.
4
+ """
5
+
6
+ from .client import Citeflow
7
+ from .errors import (
8
+ CiteflowError,
9
+ CiteflowNetworkError,
10
+ CiteflowSignatureError,
11
+ CiteflowTimeoutError,
12
+ )
13
+ from .webhooks import parse_webhook_event, verify_webhook_signature
14
+
15
+ __all__ = [
16
+ "Citeflow",
17
+ "CiteflowError",
18
+ "CiteflowNetworkError",
19
+ "CiteflowSignatureError",
20
+ "CiteflowTimeoutError",
21
+ "parse_webhook_event",
22
+ "verify_webhook_signature",
23
+ ]
24
+
25
+ __version__ = "0.1.0"
@@ -0,0 +1,125 @@
1
+ """Base HTTP client with retry + auto Idempotency-Key.
2
+
3
+ Internal — use Citeflow.* facades, not HttpClient directly.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import random
9
+ import time
10
+ import uuid
11
+ from typing import Any, Optional
12
+
13
+ import requests
14
+
15
+ from .errors import CiteflowError, CiteflowNetworkError
16
+
17
+ DEFAULT_BASE_URL = "https://citeflow.io/api/v1"
18
+
19
+
20
+ class HttpClient:
21
+ def __init__(
22
+ self,
23
+ *,
24
+ api_key: str,
25
+ base_url: str = DEFAULT_BASE_URL,
26
+ timeout: float = 30.0,
27
+ max_retries: int = 3,
28
+ session: Optional[requests.Session] = None,
29
+ ) -> None:
30
+ if not api_key:
31
+ raise ValueError(
32
+ "`api_key` is required. Generate one at "
33
+ "https://citeflow.io/dashboard/api/keys"
34
+ )
35
+ self._api_key = api_key
36
+ self._base_url = base_url.rstrip("/")
37
+ self._timeout = timeout
38
+ self._max_retries = max_retries
39
+ # Caller can inject a configured session (e.g., with custom adapters
40
+ # or proxy settings); otherwise use a fresh one we own.
41
+ self._session = session or requests.Session()
42
+
43
+ def request(
44
+ self,
45
+ *,
46
+ method: str,
47
+ path: str,
48
+ body: Optional[Any] = None,
49
+ idempotency_key: Optional[str] = None,
50
+ auto_idempotency: bool = True,
51
+ ) -> dict[str, Any]:
52
+ url = f"{self._base_url}{path}"
53
+ headers = {
54
+ "Authorization": f"Bearer {self._api_key}",
55
+ "Accept": "application/json",
56
+ "User-Agent": "citeflow-python",
57
+ }
58
+ if body is not None:
59
+ headers["Content-Type"] = "application/json"
60
+ if method == "POST" and auto_idempotency:
61
+ headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
62
+
63
+ attempt = 0
64
+ while True:
65
+ try:
66
+ response = self._session.request(
67
+ method=method,
68
+ url=url,
69
+ headers=headers,
70
+ json=body if body is not None else None,
71
+ timeout=self._timeout,
72
+ )
73
+ except requests.RequestException as exc:
74
+ if self._should_retry_network(attempt):
75
+ self._sleep_backoff(None, attempt)
76
+ attempt += 1
77
+ continue
78
+ raise CiteflowNetworkError(
79
+ f"Network error calling {method} {path}", cause=exc
80
+ ) from exc
81
+
82
+ if response.ok:
83
+ try:
84
+ return response.json()
85
+ except ValueError as exc:
86
+ raise CiteflowNetworkError(
87
+ f"Failed to parse JSON from {method} {path}", cause=exc
88
+ ) from exc
89
+
90
+ try:
91
+ err_body = response.json()
92
+ except ValueError:
93
+ err_body = {
94
+ "error": {
95
+ "code": "INTERNAL_ERROR",
96
+ "message": f"Non-JSON {response.status_code} response",
97
+ "doc_url": "https://docs.citeflow.io/errors",
98
+ },
99
+ "request_id": response.headers.get("X-Request-Id", "unknown"),
100
+ }
101
+ error = CiteflowError.from_api_response(response.status_code, err_body)
102
+
103
+ if self._should_retry_api(error, attempt):
104
+ self._sleep_backoff(error, attempt)
105
+ attempt += 1
106
+ continue
107
+ raise error
108
+
109
+ def _should_retry_network(self, attempt: int) -> bool:
110
+ return attempt < self._max_retries
111
+
112
+ def _should_retry_api(self, error: CiteflowError, attempt: int) -> bool:
113
+ if attempt >= self._max_retries:
114
+ return False
115
+ return error.status == 429 or error.status >= 500
116
+
117
+ def _sleep_backoff(self, error: Optional[CiteflowError], attempt: int) -> None:
118
+ # Server-supplied Retry-After always wins.
119
+ if error is not None and error.retry_after:
120
+ time.sleep(error.retry_after)
121
+ return
122
+ # 250ms · 1s · 4s with ±25% jitter.
123
+ base = 0.250 * (4**attempt)
124
+ jitter = base * (random.random() * 0.5 - 0.25)
125
+ time.sleep(max(0.0, base + jitter))
@@ -0,0 +1,72 @@
1
+ """Audits resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any, Optional
7
+
8
+ from ._http import HttpClient
9
+ from .errors import CiteflowTimeoutError
10
+
11
+
12
+ class AuditsResource:
13
+ def __init__(self, http: HttpClient) -> None:
14
+ self._http = http
15
+
16
+ def create(
17
+ self,
18
+ *,
19
+ url: str,
20
+ type: str, # noqa: A002 — mirror API field name
21
+ metadata: Optional[dict[str, Any]] = None,
22
+ idempotency_key: Optional[str] = None,
23
+ ) -> dict[str, Any]:
24
+ body: dict[str, Any] = {"url": url, "type": type}
25
+ if metadata is not None:
26
+ body["metadata"] = metadata
27
+ envelope = self._http.request(
28
+ method="POST",
29
+ path="/audit",
30
+ body=body,
31
+ idempotency_key=idempotency_key,
32
+ )
33
+ return envelope["data"]
34
+
35
+ def get(self, audit_id: str) -> dict[str, Any]:
36
+ envelope = self._http.request(
37
+ method="GET",
38
+ path=f"/audit/{audit_id}",
39
+ auto_idempotency=False,
40
+ )
41
+ return envelope["data"]
42
+
43
+ def cancel(self, audit_id: str) -> dict[str, Any]:
44
+ envelope = self._http.request(
45
+ method="POST",
46
+ path=f"/audit/{audit_id}:cancel",
47
+ auto_idempotency=False,
48
+ )
49
+ return envelope["data"]
50
+
51
+ def wait_for_completion(
52
+ self,
53
+ audit_id: str,
54
+ *,
55
+ timeout: float = 90.0,
56
+ interval: float = 2.0,
57
+ ) -> dict[str, Any]:
58
+ """Poll until terminal status reached.
59
+
60
+ Raises CiteflowTimeoutError if `timeout` seconds elapse first.
61
+ Webhooks are preferred for long-running flows; use this only when
62
+ synchronous callers need the result.
63
+ """
64
+ deadline = time.monotonic() + timeout
65
+ while True:
66
+ audit = self.get(audit_id)
67
+ status = audit.get("status")
68
+ if status not in ("queued", "processing"):
69
+ return audit
70
+ if time.monotonic() + interval > deadline:
71
+ raise CiteflowTimeoutError(audit_id, timeout)
72
+ time.sleep(interval)
@@ -0,0 +1,20 @@
1
+ """Balance resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ._http import HttpClient
8
+
9
+
10
+ class BalanceResource:
11
+ def __init__(self, http: HttpClient) -> None:
12
+ self._http = http
13
+
14
+ def retrieve(self) -> dict[str, Any]:
15
+ envelope = self._http.request(
16
+ method="GET",
17
+ path="/balance",
18
+ auto_idempotency=False,
19
+ )
20
+ return envelope["data"]
@@ -0,0 +1,29 @@
1
+ """Billing resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ._http import HttpClient
8
+
9
+
10
+ class BillingResource:
11
+ def __init__(self, http: HttpClient) -> None:
12
+ self._http = http
13
+
14
+ def create_topup(
15
+ self,
16
+ *,
17
+ tier: str,
18
+ success_url: str,
19
+ cancel_url: str,
20
+ ) -> dict[str, Any]:
21
+ # Each call legitimately mints a fresh Stripe Checkout Session;
22
+ # auto Idempotency-Key would actively confuse partners.
23
+ envelope = self._http.request(
24
+ method="POST",
25
+ path="/billing/topup",
26
+ body={"tier": tier, "success_url": success_url, "cancel_url": cancel_url},
27
+ auto_idempotency=False,
28
+ )
29
+ return envelope["data"]
@@ -0,0 +1,44 @@
1
+ """Top-level Citeflow client.
2
+
3
+ Usage:
4
+
5
+ from citeflow import Citeflow
6
+ client = Citeflow(api_key="ckf_…")
7
+ audit = client.audits.create(url="https://example.com", type="seo")
8
+ result = client.audits.wait_for_completion(audit["audit_id"])
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Optional
14
+
15
+ import requests
16
+
17
+ from ._http import HttpClient
18
+ from .audits import AuditsResource
19
+ from .balance import BalanceResource
20
+ from .billing import BillingResource
21
+ from .webhooks import WebhooksResource
22
+
23
+
24
+ class Citeflow:
25
+ def __init__(
26
+ self,
27
+ *,
28
+ api_key: str,
29
+ base_url: str = "https://citeflow.io/api/v1",
30
+ timeout: float = 30.0,
31
+ max_retries: int = 3,
32
+ session: Optional[requests.Session] = None,
33
+ ) -> None:
34
+ self._http = HttpClient(
35
+ api_key=api_key,
36
+ base_url=base_url,
37
+ timeout=timeout,
38
+ max_retries=max_retries,
39
+ session=session,
40
+ )
41
+ self.audits = AuditsResource(self._http)
42
+ self.balance = BalanceResource(self._http)
43
+ self.billing = BillingResource(self._http)
44
+ self.webhooks = WebhooksResource(self._http)
@@ -0,0 +1,72 @@
1
+ """Exception hierarchy.
2
+
3
+ All API-level errors raise CiteflowError carrying the stable `code`
4
+ attribute. Branch on `code`, not on HTTP status — codes are versioned
5
+ with the API path (`/api/v1`).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Optional
11
+
12
+
13
+ class CiteflowError(Exception):
14
+ """Raised on any non-2xx response from the CiteFlow API."""
15
+
16
+ def __init__(
17
+ self,
18
+ *,
19
+ code: str,
20
+ status: int,
21
+ message: str,
22
+ request_id: str,
23
+ doc_url: str,
24
+ retry_after: Optional[int] = None,
25
+ required: Optional[int] = None,
26
+ ) -> None:
27
+ super().__init__(message)
28
+ self.code = code
29
+ self.status = status
30
+ self.request_id = request_id
31
+ self.doc_url = doc_url
32
+ self.retry_after = retry_after
33
+ self.required = required
34
+
35
+ @classmethod
36
+ def from_api_response(cls, status: int, body: dict[str, Any]) -> "CiteflowError":
37
+ err = body.get("error", {})
38
+ return cls(
39
+ code=err.get("code", "INTERNAL_ERROR"),
40
+ status=status,
41
+ message=err.get("message", "Unknown error"),
42
+ request_id=body.get("request_id", "unknown"),
43
+ doc_url=err.get("doc_url", "https://docs.citeflow.io/errors"),
44
+ retry_after=err.get("retryAfter"),
45
+ required=err.get("required"),
46
+ )
47
+
48
+ def __repr__(self) -> str:
49
+ return f"CiteflowError(code={self.code!r}, status={self.status}, request_id={self.request_id!r})"
50
+
51
+
52
+ class CiteflowNetworkError(Exception):
53
+ """Raised on transport-layer failures (DNS, timeout, connection reset)."""
54
+
55
+ def __init__(self, message: str, cause: Optional[BaseException] = None) -> None:
56
+ super().__init__(message)
57
+ self.__cause__ = cause
58
+
59
+
60
+ class CiteflowTimeoutError(Exception):
61
+ """`wait_for_completion` exhausted its budget before reaching a terminal status."""
62
+
63
+ def __init__(self, audit_id: str, timeout_seconds: float) -> None:
64
+ super().__init__(
65
+ f"Audit {audit_id} did not reach terminal state within {timeout_seconds}s"
66
+ )
67
+ self.audit_id = audit_id
68
+ self.timeout_seconds = timeout_seconds
69
+
70
+
71
+ class CiteflowSignatureError(Exception):
72
+ """Webhook signature verification failed."""
@@ -0,0 +1,118 @@
1
+ """Webhook verification + replay."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ import time
10
+ from typing import Any, Mapping, Union
11
+
12
+ from ._http import HttpClient
13
+ from .errors import CiteflowSignatureError
14
+
15
+ ID_HEADER = "x-citeflow-webhook-id"
16
+ TIMESTAMP_HEADER = "x-citeflow-webhook-timestamp"
17
+ SIGNATURE_HEADER = "x-citeflow-webhook-signature"
18
+
19
+ HeaderInput = Mapping[str, Union[str, list[str]]]
20
+
21
+
22
+ def _get(headers: HeaderInput, name: str) -> Union[str, None]:
23
+ """Case-insensitive header read across dict / Flask / FastAPI shapes."""
24
+ target = name.lower()
25
+ for key, value in headers.items():
26
+ if key.lower() == target:
27
+ if isinstance(value, list):
28
+ return value[0] if value else None
29
+ return value
30
+ return None
31
+
32
+
33
+ def verify_webhook_signature(
34
+ *,
35
+ raw_body: Union[str, bytes],
36
+ headers: HeaderInput,
37
+ secret: str,
38
+ tolerance_seconds: int = 300,
39
+ ) -> None:
40
+ """Verify a CiteFlow webhook signature.
41
+
42
+ Raises CiteflowSignatureError on any mismatch / drift / missing header.
43
+ During the 7-day secret-rotation grace window the header may carry
44
+ comma-separated `v1=` values; any matching value succeeds.
45
+ """
46
+ event_id = _get(headers, ID_HEADER)
47
+ timestamp = _get(headers, TIMESTAMP_HEADER)
48
+ signature = _get(headers, SIGNATURE_HEADER)
49
+
50
+ if not event_id or not timestamp or not signature:
51
+ raise CiteflowSignatureError(
52
+ f"Missing webhook headers (id={bool(event_id)}, "
53
+ f"ts={bool(timestamp)}, sig={bool(signature)})"
54
+ )
55
+
56
+ try:
57
+ ts_num = int(timestamp)
58
+ except ValueError as exc:
59
+ raise CiteflowSignatureError("Webhook timestamp is not a number") from exc
60
+
61
+ skew = abs(int(time.time()) - ts_num)
62
+ if skew > tolerance_seconds:
63
+ raise CiteflowSignatureError(
64
+ f"Webhook timestamp drift {skew}s exceeds tolerance {tolerance_seconds}s"
65
+ )
66
+
67
+ body_bytes = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
68
+ signed_payload = f"{event_id}.{ts_num}.".encode("utf-8") + body_bytes
69
+ expected = base64.b64encode(
70
+ hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).digest()
71
+ ).decode("ascii")
72
+
73
+ candidates = [
74
+ part.strip()[3:]
75
+ for part in signature.split(",")
76
+ if part.strip().startswith("v1=")
77
+ ]
78
+ if not candidates:
79
+ raise CiteflowSignatureError("No v1= signature values present in header")
80
+
81
+ for cand in candidates:
82
+ if hmac.compare_digest(cand, expected):
83
+ return
84
+ raise CiteflowSignatureError("Webhook signature mismatch")
85
+
86
+
87
+ def parse_webhook_event(
88
+ *,
89
+ raw_body: str,
90
+ headers: HeaderInput,
91
+ secret: str,
92
+ tolerance_seconds: int = 300,
93
+ ) -> dict[str, Any]:
94
+ """Verify + JSON-parse in one call. Returns the event envelope dict."""
95
+ verify_webhook_signature(
96
+ raw_body=raw_body,
97
+ headers=headers,
98
+ secret=secret,
99
+ tolerance_seconds=tolerance_seconds,
100
+ )
101
+ return json.loads(raw_body)
102
+
103
+
104
+ class WebhooksResource:
105
+ def __init__(self, http: HttpClient) -> None:
106
+ self._http = http
107
+
108
+ def replay(self, delivery_id: str) -> dict[str, Any]:
109
+ envelope = self._http.request(
110
+ method="POST",
111
+ path=f"/webhooks/deliveries/{delivery_id}/replay",
112
+ auto_idempotency=False,
113
+ )
114
+ return envelope["data"]
115
+
116
+ # Mirror the Node SDK's static helpers for symmetry.
117
+ verify = staticmethod(verify_webhook_signature)
118
+ parse_and_verify = staticmethod(parse_webhook_event)