form4api 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.
form4api-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Form4API
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,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: form4api
3
+ Version: 0.1.0
4
+ Summary: Python client for the Form4API — real-time SEC Form 4 insider trading data
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://form4api.com
7
+ Project-URL: Documentation, https://form4api.com/docs
8
+ Project-URL: Repository, https://github.com/theodor90/form4api-py.git
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: httpx>=0.27
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=8; extra == "dev"
15
+ Requires-Dist: pytest-httpx>=0.30; extra == "dev"
16
+ Requires-Dist: respx>=0.21; extra == "dev"
17
+ Dynamic: license-file
18
+
19
+ # form4api
20
+
21
+ Python client for [Form4API](https://form4api.com) — real-time SEC Form 4 insider trading data.
22
+
23
+ Supports Python 3.11+. Uses `httpx` for both sync and async HTTP.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pip install form4api
29
+ ```
30
+
31
+ ## Sync quickstart
32
+
33
+ ```python
34
+ from form4api import Form4ApiClient
35
+
36
+ client = Form4ApiClient("YOUR_API_KEY")
37
+
38
+ # Recent open-market purchases at Apple
39
+ txns = client.transactions.list(ticker="AAPL", code="P", per_page=5)
40
+ for t in txns:
41
+ print(t.insider_name, t.shares_amount, "@", t.price_per_share)
42
+
43
+ # Company overview
44
+ company = client.companies.get("MSFT")
45
+ print(company.name, company.active_insiders, "active insiders")
46
+
47
+ # Insider detail
48
+ insider = client.insiders.get("0001234567")
49
+ print(insider.name, insider.officer_title)
50
+
51
+ # Cluster-buy signals (Business plan)
52
+ signals = client.signals.list(ticker="NVDA")
53
+ for sig in signals:
54
+ if sig.is_cluster_buy:
55
+ print(sig.company_name, sig.insider_count, "buyers")
56
+ ```
57
+
58
+ ## Async quickstart
59
+
60
+ ```python
61
+ import asyncio
62
+ from form4api import AsyncForm4ApiClient
63
+
64
+ async def main():
65
+ async with AsyncForm4ApiClient("YOUR_API_KEY") as client:
66
+ txns = await client.transactions.list(ticker="AAPL", per_page=5)
67
+ for t in txns:
68
+ print(t.insider_name, t.shares_amount, "@", t.price_per_share)
69
+
70
+ asyncio.run(main())
71
+ ```
72
+
73
+ ## Resources
74
+
75
+ | Resource | Methods |
76
+ |---|---|
77
+ | `client.transactions` | `.list(**params)`, `.paginate(**params)` |
78
+ | `client.insiders` | `.get(cik)`, `.transactions(cik, **params)` |
79
+ | `client.companies` | `.get(ticker)`, `.insiders(ticker)` |
80
+ | `client.signals` | `.list(**params)` — Business plan |
81
+ | `client.webhooks` | `.create(url, event_types)`, `.list()`, `.delete(id)`, `.events(**params)` |
82
+
83
+ ## Error handling
84
+
85
+ ```python
86
+ from form4api import Form4ApiClient, AuthError, PlanError, RateLimitError, NotFoundError
87
+
88
+ client = Form4ApiClient("YOUR_API_KEY")
89
+
90
+ try:
91
+ signals = client.signals.list()
92
+ except PlanError as e:
93
+ print(f"Upgrade to {e.required_plan}")
94
+ except RateLimitError as e:
95
+ print(f"Retry after {e.retry_after}s")
96
+ except AuthError:
97
+ print("Invalid API key")
98
+ except NotFoundError:
99
+ print("Resource not found")
100
+ ```
101
+
102
+ ## License
103
+
104
+ MIT
@@ -0,0 +1,86 @@
1
+ # form4api
2
+
3
+ Python client for [Form4API](https://form4api.com) — real-time SEC Form 4 insider trading data.
4
+
5
+ Supports Python 3.11+. Uses `httpx` for both sync and async HTTP.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install form4api
11
+ ```
12
+
13
+ ## Sync quickstart
14
+
15
+ ```python
16
+ from form4api import Form4ApiClient
17
+
18
+ client = Form4ApiClient("YOUR_API_KEY")
19
+
20
+ # Recent open-market purchases at Apple
21
+ txns = client.transactions.list(ticker="AAPL", code="P", per_page=5)
22
+ for t in txns:
23
+ print(t.insider_name, t.shares_amount, "@", t.price_per_share)
24
+
25
+ # Company overview
26
+ company = client.companies.get("MSFT")
27
+ print(company.name, company.active_insiders, "active insiders")
28
+
29
+ # Insider detail
30
+ insider = client.insiders.get("0001234567")
31
+ print(insider.name, insider.officer_title)
32
+
33
+ # Cluster-buy signals (Business plan)
34
+ signals = client.signals.list(ticker="NVDA")
35
+ for sig in signals:
36
+ if sig.is_cluster_buy:
37
+ print(sig.company_name, sig.insider_count, "buyers")
38
+ ```
39
+
40
+ ## Async quickstart
41
+
42
+ ```python
43
+ import asyncio
44
+ from form4api import AsyncForm4ApiClient
45
+
46
+ async def main():
47
+ async with AsyncForm4ApiClient("YOUR_API_KEY") as client:
48
+ txns = await client.transactions.list(ticker="AAPL", per_page=5)
49
+ for t in txns:
50
+ print(t.insider_name, t.shares_amount, "@", t.price_per_share)
51
+
52
+ asyncio.run(main())
53
+ ```
54
+
55
+ ## Resources
56
+
57
+ | Resource | Methods |
58
+ |---|---|
59
+ | `client.transactions` | `.list(**params)`, `.paginate(**params)` |
60
+ | `client.insiders` | `.get(cik)`, `.transactions(cik, **params)` |
61
+ | `client.companies` | `.get(ticker)`, `.insiders(ticker)` |
62
+ | `client.signals` | `.list(**params)` — Business plan |
63
+ | `client.webhooks` | `.create(url, event_types)`, `.list()`, `.delete(id)`, `.events(**params)` |
64
+
65
+ ## Error handling
66
+
67
+ ```python
68
+ from form4api import Form4ApiClient, AuthError, PlanError, RateLimitError, NotFoundError
69
+
70
+ client = Form4ApiClient("YOUR_API_KEY")
71
+
72
+ try:
73
+ signals = client.signals.list()
74
+ except PlanError as e:
75
+ print(f"Upgrade to {e.required_plan}")
76
+ except RateLimitError as e:
77
+ print(f"Retry after {e.retry_after}s")
78
+ except AuthError:
79
+ print("Invalid API key")
80
+ except NotFoundError:
81
+ print("Resource not found")
82
+ ```
83
+
84
+ ## License
85
+
86
+ MIT
@@ -0,0 +1,30 @@
1
+ from form4api._client import AsyncForm4ApiClient, Form4ApiClient
2
+ from form4api._errors import AuthError, Form4ApiError, NotFoundError, PlanError, RateLimitError
3
+ from form4api._types import (
4
+ Company,
5
+ Insider,
6
+ InsiderSignal,
7
+ Transaction,
8
+ WebhookCreated,
9
+ WebhookEvent,
10
+ WebhookSubscription,
11
+ )
12
+ from form4api._webhook_utils import verify_webhook
13
+
14
+ __all__ = [
15
+ "Form4ApiClient",
16
+ "AsyncForm4ApiClient",
17
+ "Form4ApiError",
18
+ "AuthError",
19
+ "PlanError",
20
+ "NotFoundError",
21
+ "RateLimitError",
22
+ "Transaction",
23
+ "Insider",
24
+ "Company",
25
+ "InsiderSignal",
26
+ "WebhookCreated",
27
+ "WebhookEvent",
28
+ "WebhookSubscription",
29
+ "verify_webhook",
30
+ ]
@@ -0,0 +1,219 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import re
5
+ import time
6
+ from typing import Any, TypeVar
7
+
8
+ import httpx
9
+
10
+ from form4api._errors import AuthError, Form4ApiError, NotFoundError, PlanError, RateLimitError
11
+ from form4api.resources._companies import CompaniesResource
12
+ from form4api.resources._insiders import InsidersResource
13
+ from form4api.resources._signals import SignalsResource
14
+ from form4api.resources._transactions import TransactionsResource
15
+ from form4api.resources._webhooks import WebhooksResource
16
+
17
+ DEFAULT_BASE_URL = "https://api.form4api.com"
18
+ _RETRY_DELAYS = [0.5, 1.0, 2.0]
19
+
20
+ T = TypeVar("T")
21
+
22
+
23
+ def _camel_to_snake(name: str) -> str:
24
+ s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
25
+ return re.sub(r"([a-z\d])([A-Z])", r"\1_\2", s).lower()
26
+
27
+
28
+ def _normalise(obj: Any) -> Any:
29
+ if isinstance(obj, dict):
30
+ return {_camel_to_snake(k): _normalise(v) for k, v in obj.items()}
31
+ if isinstance(obj, list):
32
+ return [_normalise(i) for i in obj]
33
+ return obj
34
+
35
+
36
+ class Form4ApiClient:
37
+ """Synchronous client for the Form4API."""
38
+
39
+ def __init__(
40
+ self,
41
+ api_key: str,
42
+ *,
43
+ base_url: str = DEFAULT_BASE_URL,
44
+ max_retries: int = 2,
45
+ timeout: float = 30.0,
46
+ ) -> None:
47
+ self._api_key = api_key
48
+ self._base_url = base_url.rstrip("/")
49
+ self._max_retries = max_retries
50
+ self._http = httpx.Client(
51
+ timeout=timeout,
52
+ headers={"X-Api-Key": api_key},
53
+ )
54
+ self.transactions = TransactionsResource(self)
55
+ self.insiders = InsidersResource(self)
56
+ self.companies = CompaniesResource(self)
57
+ self.signals = SignalsResource(self)
58
+ self.webhooks = WebhooksResource(self)
59
+
60
+ def __enter__(self) -> Form4ApiClient:
61
+ return self
62
+
63
+ def __exit__(self, *_: object) -> None:
64
+ self.close()
65
+
66
+ def close(self) -> None:
67
+ self._http.close()
68
+
69
+ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
70
+ url = self._base_url + path
71
+ last_exc: Exception | None = None
72
+
73
+ for attempt in range(self._max_retries + 1):
74
+ if attempt > 0:
75
+ time.sleep(_RETRY_DELAYS[min(attempt - 1, len(_RETRY_DELAYS) - 1)])
76
+ try:
77
+ res = self._http.request(method, url, **kwargs)
78
+ if res.status_code < 500:
79
+ return res
80
+ if attempt == self._max_retries:
81
+ return res
82
+ last_exc = None
83
+ except httpx.TransportError as exc:
84
+ if attempt == self._max_retries:
85
+ raise
86
+ last_exc = exc
87
+
88
+ raise last_exc # type: ignore[misc]
89
+
90
+ def _get(self, path: str, params: dict[str, str] | None = None) -> Any:
91
+ res = self._request("GET", path, params=params)
92
+ return _normalise(self._parse(res))
93
+
94
+ def _post(self, path: str, body: Any = None) -> Any:
95
+ res = self._request("POST", path, json=body)
96
+ return _normalise(self._parse(res))
97
+
98
+ def _delete(self, path: str) -> None:
99
+ res = self._request("DELETE", path)
100
+ if not res.is_success and res.status_code != 204:
101
+ self._raise(res)
102
+
103
+ def _parse(self, res: httpx.Response) -> Any:
104
+ if res.is_success:
105
+ return res.json()
106
+ self._raise(res)
107
+
108
+ def _raise(self, res: httpx.Response) -> None:
109
+ try:
110
+ body = res.json()
111
+ except Exception:
112
+ body = {}
113
+ error = body.get("error", {}) if isinstance(body, dict) else {}
114
+ code = error.get("code") if isinstance(error, dict) else None
115
+ message = (error.get("message") if isinstance(error, dict) else None) or f"HTTP {res.status_code}"
116
+
117
+ if res.status_code == 401:
118
+ raise AuthError(message, code)
119
+ if res.status_code == 402:
120
+ raise PlanError(message, body.get("requiredPlan") if isinstance(body, dict) else None)
121
+ if res.status_code == 404:
122
+ raise NotFoundError(message, code)
123
+ if res.status_code == 429:
124
+ retry_after = res.headers.get("Retry-After")
125
+ raise RateLimitError(message, int(retry_after) if retry_after else None)
126
+ raise Form4ApiError(message, res.status_code, code)
127
+
128
+
129
+ class AsyncForm4ApiClient:
130
+ """Async client for the Form4API."""
131
+
132
+ def __init__(
133
+ self,
134
+ api_key: str,
135
+ *,
136
+ base_url: str = DEFAULT_BASE_URL,
137
+ max_retries: int = 2,
138
+ timeout: float = 30.0,
139
+ ) -> None:
140
+ self._api_key = api_key
141
+ self._base_url = base_url.rstrip("/")
142
+ self._max_retries = max_retries
143
+ self._http = httpx.AsyncClient(
144
+ timeout=timeout,
145
+ headers={"X-Api-Key": api_key},
146
+ )
147
+ self.transactions = TransactionsResource(self) # type: ignore[arg-type]
148
+ self.insiders = InsidersResource(self) # type: ignore[arg-type]
149
+ self.companies = CompaniesResource(self) # type: ignore[arg-type]
150
+ self.signals = SignalsResource(self) # type: ignore[arg-type]
151
+ self.webhooks = WebhooksResource(self) # type: ignore[arg-type]
152
+
153
+ async def __aenter__(self) -> AsyncForm4ApiClient:
154
+ return self
155
+
156
+ async def __aexit__(self, *_: object) -> None:
157
+ await self.close()
158
+
159
+ async def close(self) -> None:
160
+ await self._http.aclose()
161
+
162
+ async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
163
+ url = self._base_url + path
164
+ last_exc: Exception | None = None
165
+
166
+ for attempt in range(self._max_retries + 1):
167
+ if attempt > 0:
168
+ await asyncio.sleep(_RETRY_DELAYS[min(attempt - 1, len(_RETRY_DELAYS) - 1)])
169
+ try:
170
+ res = await self._http.request(method, url, **kwargs)
171
+ if res.status_code < 500:
172
+ return res
173
+ if attempt == self._max_retries:
174
+ return res
175
+ last_exc = None
176
+ except httpx.TransportError as exc:
177
+ if attempt == self._max_retries:
178
+ raise
179
+ last_exc = exc
180
+
181
+ raise last_exc # type: ignore[misc]
182
+
183
+ async def _get(self, path: str, params: dict[str, str] | None = None) -> Any:
184
+ res = await self._request("GET", path, params=params)
185
+ return _normalise(self._parse(res))
186
+
187
+ async def _post(self, path: str, body: Any = None) -> Any:
188
+ res = await self._request("POST", path, json=body)
189
+ return _normalise(self._parse(res))
190
+
191
+ async def _delete(self, path: str) -> None:
192
+ res = await self._request("DELETE", path)
193
+ if not res.is_success and res.status_code != 204:
194
+ self._raise(res)
195
+
196
+ def _parse(self, res: httpx.Response) -> Any:
197
+ if res.is_success:
198
+ return res.json()
199
+ self._raise(res)
200
+
201
+ def _raise(self, res: httpx.Response) -> None:
202
+ try:
203
+ body = res.json()
204
+ except Exception:
205
+ body = {}
206
+ error = body.get("error", {}) if isinstance(body, dict) else {}
207
+ code = error.get("code") if isinstance(error, dict) else None
208
+ message = (error.get("message") if isinstance(error, dict) else None) or f"HTTP {res.status_code}"
209
+
210
+ if res.status_code == 401:
211
+ raise AuthError(message, code)
212
+ if res.status_code == 402:
213
+ raise PlanError(message, body.get("requiredPlan") if isinstance(body, dict) else None)
214
+ if res.status_code == 404:
215
+ raise NotFoundError(message, code)
216
+ if res.status_code == 429:
217
+ retry_after = res.headers.get("Retry-After")
218
+ raise RateLimitError(message, int(retry_after) if retry_after else None)
219
+ raise Form4ApiError(message, res.status_code, code)
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class Form4ApiError(Exception):
5
+ def __init__(self, message: str, status_code: int, error_code: str | None = None) -> None:
6
+ super().__init__(message)
7
+ self.status_code = status_code
8
+ self.error_code = error_code
9
+
10
+
11
+ class AuthError(Form4ApiError):
12
+ def __init__(self, message: str, error_code: str | None = None) -> None:
13
+ super().__init__(message, 401, error_code)
14
+
15
+
16
+ class PlanError(Form4ApiError):
17
+ def __init__(self, message: str, required_plan: str | None = None) -> None:
18
+ super().__init__(message, 402, "PLAN_REQUIRED")
19
+ self.required_plan = required_plan
20
+
21
+
22
+ class NotFoundError(Form4ApiError):
23
+ def __init__(self, message: str, error_code: str | None = None) -> None:
24
+ super().__init__(message, 404, error_code)
25
+
26
+
27
+ class RateLimitError(Form4ApiError):
28
+ def __init__(self, message: str, retry_after: int | None = None) -> None:
29
+ super().__init__(message, 429, "RATE_LIMIT_EXCEEDED")
30
+ self.retry_after = retry_after
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class Transaction:
8
+ ticker: str
9
+ company_name: str
10
+ insider_name: str
11
+ insider_cik: str
12
+ accession_number: str
13
+ security_title: str
14
+ transaction_code: str
15
+ shares_amount: float
16
+ price_per_share: float | None
17
+ shares_owned_after: float | None
18
+ direct_indirect: str | None
19
+ is_derivative: bool
20
+ transaction_date: str
21
+ period_of_report: str
22
+
23
+
24
+ @dataclass
25
+ class Insider:
26
+ cik: str
27
+ name: str
28
+ is_director: bool
29
+ is_officer: bool
30
+ is_ten_percent_owner: bool
31
+ officer_title: str | None
32
+ total_filings: int
33
+
34
+
35
+ @dataclass
36
+ class Company:
37
+ cik: str
38
+ name: str
39
+ ticker: str | None
40
+ exchange: str | None
41
+ total_filings: int
42
+ active_insiders: int
43
+
44
+
45
+ @dataclass
46
+ class InsiderSignal:
47
+ ticker: str | None
48
+ company_name: str
49
+ signal_date: str
50
+ buy_sell_ratio: float
51
+ is_cluster_buy: bool
52
+ is_cluster_sell: bool
53
+ insider_count: int
54
+
55
+
56
+ @dataclass
57
+ class WebhookCreated:
58
+ subscription_id: int
59
+ url: str
60
+ event_types: list[str]
61
+ secret: str
62
+ created_at: str
63
+ warning: str | None
64
+
65
+
66
+ @dataclass
67
+ class WebhookSubscription:
68
+ subscription_id: int
69
+ url: str
70
+ event_types: list[str]
71
+ created_at: str
72
+ is_active: bool
73
+
74
+
75
+ @dataclass
76
+ class WebhookEvent:
77
+ delivery_id: int
78
+ subscription_id: int
79
+ event_type: str
80
+ attempt_count: int
81
+ delivered_at: str | None
82
+ next_retry_at: str | None
83
+ last_status_code: int | None
84
+ is_dead: bool
85
+ payload: str
86
+
87
+
88
+
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+
6
+
7
+ def verify_webhook(payload: str | bytes, signature: str, secret: str) -> bool:
8
+ """Return True if the X-Insider-Signature header matches the payload.
9
+
10
+ Usage::
11
+
12
+ body = request.get_data(as_text=True)
13
+ sig = request.headers["X-Insider-Signature"]
14
+ if not verify_webhook(body, sig, WEBHOOK_SECRET):
15
+ abort(401)
16
+ """
17
+ if isinstance(payload, str):
18
+ payload = payload.encode()
19
+ expected = "sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
20
+ return hmac.compare_digest(expected, signature)
21
+
22
+
23
+
@@ -0,0 +1,16 @@
1
+ from form4api.resources._companies import CompaniesResource
2
+ from form4api.resources._insiders import InsidersResource
3
+ from form4api.resources._signals import SignalsResource
4
+ from form4api.resources._transactions import TransactionsResource
5
+ from form4api.resources._webhooks import WebhooksResource
6
+
7
+ __all__ = [
8
+ "CompaniesResource",
9
+ "InsidersResource",
10
+ "SignalsResource",
11
+ "TransactionsResource",
12
+ "WebhooksResource",
13
+ ]
14
+
15
+
16
+
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from form4api._types import Company, Insider
6
+
7
+ if TYPE_CHECKING:
8
+ from form4api._client import Form4ApiClient
9
+
10
+
11
+ class CompaniesResource:
12
+ def __init__(self, client: Form4ApiClient) -> None:
13
+ self._client = client
14
+
15
+ def get(self, ticker: str) -> Company:
16
+ data = self._client._get(f"/v1/companies/{ticker}")
17
+ return Company(**data)
18
+
19
+ def insiders(self, ticker: str) -> list[Insider]:
20
+ data = self._client._get(f"/v1/companies/{ticker}/insiders")
21
+ return [Insider(**item) for item in data]
22
+
23
+
24
+
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from form4api._types import Insider, Transaction
6
+
7
+ if TYPE_CHECKING:
8
+ from form4api._client import Form4ApiClient
9
+
10
+
11
+ class InsidersResource:
12
+ def __init__(self, client: Form4ApiClient) -> None:
13
+ self._client = client
14
+
15
+ def get(self, cik: str) -> Insider:
16
+ data = self._client._get(f"/v1/insiders/{cik}")
17
+ return Insider(**data)
18
+
19
+ def transactions(
20
+ self,
21
+ cik: str,
22
+ *,
23
+ from_date: str | None = None,
24
+ to_date: str | None = None,
25
+ page: int = 1,
26
+ per_page: int = 50,
27
+ ) -> list[Transaction]:
28
+ params: dict[str, str] = {"page": str(page), "per_page": str(per_page)}
29
+ if from_date is not None:
30
+ params["from"] = from_date
31
+ if to_date is not None:
32
+ params["to"] = to_date
33
+ data = self._client._get(f"/v1/insiders/{cik}/transactions", params)
34
+ return [Transaction(**item) for item in data]
35
+
36
+
37
+
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from form4api._types import InsiderSignal
6
+
7
+ if TYPE_CHECKING:
8
+ from form4api._client import Form4ApiClient
9
+
10
+
11
+ class SignalsResource:
12
+ def __init__(self, client: Form4ApiClient) -> None:
13
+ self._client = client
14
+
15
+ def list(
16
+ self,
17
+ *,
18
+ ticker: str | None = None,
19
+ page: int = 1,
20
+ per_page: int = 100,
21
+ ) -> list[InsiderSignal]:
22
+ params: dict[str, str] = {"page": str(page), "per_page": str(per_page)}
23
+ if ticker is not None:
24
+ params["ticker"] = ticker
25
+ data = self._client._get("/v1/signals", params)
26
+ return [InsiderSignal(**item) for item in data]
27
+
28
+
29
+
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Generator
4
+ from typing import TYPE_CHECKING
5
+
6
+ from form4api._types import Transaction
7
+
8
+ if TYPE_CHECKING:
9
+ from form4api._client import Form4ApiClient
10
+
11
+
12
+ class TransactionsResource:
13
+ def __init__(self, client: Form4ApiClient) -> None:
14
+ self._client = client
15
+
16
+ def list(
17
+ self,
18
+ *,
19
+ ticker: str | None = None,
20
+ cik: str | None = None,
21
+ insider_cik: str | None = None,
22
+ code: str | None = None,
23
+ from_date: str | None = None,
24
+ to_date: str | None = None,
25
+ page: int = 1,
26
+ per_page: int = 50,
27
+ ) -> list[Transaction]:
28
+ params: dict[str, str] = {"page": str(page), "per_page": str(per_page)}
29
+ if ticker is not None:
30
+ params["ticker"] = ticker
31
+ if cik is not None:
32
+ params["cik"] = cik
33
+ if insider_cik is not None:
34
+ params["insider_cik"] = insider_cik
35
+ if code is not None:
36
+ params["code"] = code
37
+ if from_date is not None:
38
+ params["from"] = from_date
39
+ if to_date is not None:
40
+ params["to"] = to_date
41
+ data = self._client._get("/v1/transactions", params)
42
+ return [Transaction(**item) for item in data]
43
+
44
+ def paginate(
45
+ self,
46
+ *,
47
+ ticker: str | None = None,
48
+ cik: str | None = None,
49
+ insider_cik: str | None = None,
50
+ code: str | None = None,
51
+ from_date: str | None = None,
52
+ to_date: str | None = None,
53
+ per_page: int = 50,
54
+ ) -> Generator[list[Transaction], None, None]:
55
+ page = 1
56
+ while True:
57
+ batch = self.list(
58
+ ticker=ticker, cik=cik, insider_cik=insider_cik,
59
+ code=code, from_date=from_date, to_date=to_date,
60
+ page=page, per_page=per_page,
61
+ )
62
+ if not batch:
63
+ break
64
+ yield batch
65
+ if len(batch) < per_page:
66
+ break
67
+ page += 1
68
+
69
+
70
+
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from form4api._types import WebhookCreated, WebhookEvent, WebhookSubscription
6
+
7
+ if TYPE_CHECKING:
8
+ from form4api._client import Form4ApiClient
9
+
10
+
11
+ class WebhooksResource:
12
+ def __init__(self, client: Form4ApiClient) -> None:
13
+ self._client = client
14
+
15
+ def create(self, url: str, event_types: list[str]) -> WebhookCreated:
16
+ data = self._client._post("/v1/webhooks", {"url": url, "eventTypes": event_types})
17
+ return WebhookCreated(**data)
18
+
19
+ def list(self) -> list[WebhookSubscription]:
20
+ data = self._client._get("/v1/webhooks")
21
+ return [WebhookSubscription(**item) for item in data]
22
+
23
+ def delete(self, subscription_id: int) -> None:
24
+ self._client._delete(f"/v1/webhooks/{subscription_id}")
25
+
26
+ def events(self, *, since: str | None = None) -> list[WebhookEvent]:
27
+ params: dict[str, str] = {}
28
+ if since is not None:
29
+ params["since"] = since
30
+ data = self._client._get("/v1/webhooks/events", params or None)
31
+ return [WebhookEvent(**item) for item in data]
32
+
33
+
34
+
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: form4api
3
+ Version: 0.1.0
4
+ Summary: Python client for the Form4API — real-time SEC Form 4 insider trading data
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://form4api.com
7
+ Project-URL: Documentation, https://form4api.com/docs
8
+ Project-URL: Repository, https://github.com/theodor90/form4api-py.git
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: httpx>=0.27
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=8; extra == "dev"
15
+ Requires-Dist: pytest-httpx>=0.30; extra == "dev"
16
+ Requires-Dist: respx>=0.21; extra == "dev"
17
+ Dynamic: license-file
18
+
19
+ # form4api
20
+
21
+ Python client for [Form4API](https://form4api.com) — real-time SEC Form 4 insider trading data.
22
+
23
+ Supports Python 3.11+. Uses `httpx` for both sync and async HTTP.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pip install form4api
29
+ ```
30
+
31
+ ## Sync quickstart
32
+
33
+ ```python
34
+ from form4api import Form4ApiClient
35
+
36
+ client = Form4ApiClient("YOUR_API_KEY")
37
+
38
+ # Recent open-market purchases at Apple
39
+ txns = client.transactions.list(ticker="AAPL", code="P", per_page=5)
40
+ for t in txns:
41
+ print(t.insider_name, t.shares_amount, "@", t.price_per_share)
42
+
43
+ # Company overview
44
+ company = client.companies.get("MSFT")
45
+ print(company.name, company.active_insiders, "active insiders")
46
+
47
+ # Insider detail
48
+ insider = client.insiders.get("0001234567")
49
+ print(insider.name, insider.officer_title)
50
+
51
+ # Cluster-buy signals (Business plan)
52
+ signals = client.signals.list(ticker="NVDA")
53
+ for sig in signals:
54
+ if sig.is_cluster_buy:
55
+ print(sig.company_name, sig.insider_count, "buyers")
56
+ ```
57
+
58
+ ## Async quickstart
59
+
60
+ ```python
61
+ import asyncio
62
+ from form4api import AsyncForm4ApiClient
63
+
64
+ async def main():
65
+ async with AsyncForm4ApiClient("YOUR_API_KEY") as client:
66
+ txns = await client.transactions.list(ticker="AAPL", per_page=5)
67
+ for t in txns:
68
+ print(t.insider_name, t.shares_amount, "@", t.price_per_share)
69
+
70
+ asyncio.run(main())
71
+ ```
72
+
73
+ ## Resources
74
+
75
+ | Resource | Methods |
76
+ |---|---|
77
+ | `client.transactions` | `.list(**params)`, `.paginate(**params)` |
78
+ | `client.insiders` | `.get(cik)`, `.transactions(cik, **params)` |
79
+ | `client.companies` | `.get(ticker)`, `.insiders(ticker)` |
80
+ | `client.signals` | `.list(**params)` — Business plan |
81
+ | `client.webhooks` | `.create(url, event_types)`, `.list()`, `.delete(id)`, `.events(**params)` |
82
+
83
+ ## Error handling
84
+
85
+ ```python
86
+ from form4api import Form4ApiClient, AuthError, PlanError, RateLimitError, NotFoundError
87
+
88
+ client = Form4ApiClient("YOUR_API_KEY")
89
+
90
+ try:
91
+ signals = client.signals.list()
92
+ except PlanError as e:
93
+ print(f"Upgrade to {e.required_plan}")
94
+ except RateLimitError as e:
95
+ print(f"Retry after {e.retry_after}s")
96
+ except AuthError:
97
+ print("Invalid API key")
98
+ except NotFoundError:
99
+ print("Resource not found")
100
+ ```
101
+
102
+ ## License
103
+
104
+ MIT
@@ -0,0 +1,20 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ form4api/__init__.py
5
+ form4api/_client.py
6
+ form4api/_errors.py
7
+ form4api/_types.py
8
+ form4api/_webhook_utils.py
9
+ form4api.egg-info/PKG-INFO
10
+ form4api.egg-info/SOURCES.txt
11
+ form4api.egg-info/dependency_links.txt
12
+ form4api.egg-info/requires.txt
13
+ form4api.egg-info/top_level.txt
14
+ form4api/resources/__init__.py
15
+ form4api/resources/_companies.py
16
+ form4api/resources/_insiders.py
17
+ form4api/resources/_signals.py
18
+ form4api/resources/_transactions.py
19
+ form4api/resources/_webhooks.py
20
+ tests/test_client.py
@@ -0,0 +1,6 @@
1
+ httpx>=0.27
2
+
3
+ [dev]
4
+ pytest>=8
5
+ pytest-httpx>=0.30
6
+ respx>=0.21
@@ -0,0 +1 @@
1
+ form4api
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "form4api"
7
+ version = "0.1.0"
8
+ description = "Python client for the Form4API — real-time SEC Form 4 insider trading data"
9
+ requires-python = ">=3.11"
10
+ dependencies = ["httpx>=0.27"]
11
+ license = "MIT"
12
+ readme = "README.md"
13
+
14
+ [project.urls]
15
+ Homepage = "https://form4api.com"
16
+ Documentation = "https://form4api.com/docs"
17
+ Repository = "https://github.com/theodor90/form4api-py.git"
18
+
19
+ [project.optional-dependencies]
20
+ dev = ["pytest>=8", "pytest-httpx>=0.30", "respx>=0.21"]
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["."]
24
+ include = ["form4api*"]
25
+
26
+ [tool.pytest.ini_options]
27
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,270 @@
1
+ import pytest
2
+ import httpx
3
+ import respx
4
+
5
+ from form4api import (
6
+ Form4ApiClient,
7
+ AuthError,
8
+ NotFoundError,
9
+ PlanError,
10
+ RateLimitError,
11
+ Form4ApiError,
12
+ Transaction,
13
+ Insider,
14
+ Company,
15
+ InsiderSignal,
16
+ verify_webhook,
17
+ )
18
+
19
+ BASE = "https://api.form4api.com"
20
+
21
+ TX = {
22
+ "ticker": "AAPL",
23
+ "companyName": "Apple Inc.",
24
+ "insiderName": "Cook Timothy D",
25
+ "insiderCik": "0001214156",
26
+ "accessionNumber": "0001234567-26-000001",
27
+ "securityTitle": "Common Stock",
28
+ "transactionCode": "P",
29
+ "sharesAmount": 1000.0,
30
+ "pricePerShare": 212.45,
31
+ "sharesOwnedAfter": 5000.0,
32
+ "directIndirect": "D",
33
+ "isDerivative": False,
34
+ "transactionDate": "2026-01-15T00:00:00Z",
35
+ "periodOfReport": "2026-01-15T00:00:00Z",
36
+ }
37
+
38
+ INSIDER = {
39
+ "cik": "0001214156",
40
+ "name": "Cook Timothy D",
41
+ "isDirector": False,
42
+ "isOfficer": True,
43
+ "isTenPercentOwner": False,
44
+ "officerTitle": "CEO",
45
+ "totalFilings": 42,
46
+ }
47
+
48
+ COMPANY = {
49
+ "cik": "0000320193",
50
+ "name": "Apple Inc.",
51
+ "ticker": "AAPL",
52
+ "exchange": "NASDAQ",
53
+ "totalFilings": 100,
54
+ "activeInsiders": 12,
55
+ }
56
+
57
+ SIGNAL = {
58
+ "ticker": "AAPL",
59
+ "companyName": "Apple Inc.",
60
+ "signalDate": "2026-01-15",
61
+ "buySellRatio": 2.5,
62
+ "isClusterBuy": True,
63
+ "isClusterSell": False,
64
+ "insiderCount": 4,
65
+ }
66
+
67
+
68
+ @pytest.fixture
69
+ def client():
70
+ with Form4ApiClient("test-key", max_retries=0) as c:
71
+ yield c
72
+
73
+
74
+ # ── transactions ───────────────────────────────────────────────────────────────
75
+
76
+ @respx.mock
77
+ def test_transactions_list_returns_typed_objects(client):
78
+ respx.get(f"{BASE}/v1/transactions").mock(return_value=httpx.Response(200, json=[TX]))
79
+ results = client.transactions.list()
80
+ assert len(results) == 1
81
+ assert isinstance(results[0], Transaction)
82
+ assert results[0].ticker == "AAPL"
83
+ assert results[0].company_name == "Apple Inc."
84
+ assert results[0].insider_cik == "0001214156"
85
+ assert results[0].transaction_code == "P"
86
+
87
+
88
+ @respx.mock
89
+ def test_transactions_list_sends_filters(client):
90
+ route = respx.get(f"{BASE}/v1/transactions").mock(return_value=httpx.Response(200, json=[]))
91
+ client.transactions.list(ticker="AAPL", code="P", from_date="2026-01-01", per_page=10)
92
+ assert route.called
93
+ qs = dict(route.calls[0].request.url.params)
94
+ assert qs["ticker"] == "AAPL"
95
+ assert qs["code"] == "P"
96
+ assert qs["from"] == "2026-01-01"
97
+ assert qs["per_page"] == "10"
98
+
99
+
100
+ @respx.mock
101
+ def test_transactions_paginate_stops_on_short_page(client):
102
+ respx.get(f"{BASE}/v1/transactions").mock(side_effect=[
103
+ httpx.Response(200, json=[TX]),
104
+ httpx.Response(200, json=[]),
105
+ ])
106
+ pages = list(client.transactions.paginate(per_page=1))
107
+ assert len(pages) == 1
108
+ assert pages[0][0].ticker == "AAPL"
109
+
110
+
111
+ # ── insiders ──────────────────────────────────────────────────────────────────
112
+
113
+ @respx.mock
114
+ def test_insiders_get_returns_typed_object(client):
115
+ respx.get(f"{BASE}/v1/insiders/0001214156").mock(return_value=httpx.Response(200, json=INSIDER))
116
+ result = client.insiders.get("0001214156")
117
+ assert isinstance(result, Insider)
118
+ assert result.cik == "0001214156"
119
+ assert result.is_officer is True
120
+ assert result.total_filings == 42
121
+
122
+
123
+ @respx.mock
124
+ def test_insiders_transactions_returns_list(client):
125
+ respx.get(f"{BASE}/v1/insiders/0001214156/transactions").mock(
126
+ return_value=httpx.Response(200, json=[TX])
127
+ )
128
+ results = client.insiders.transactions("0001214156")
129
+ assert len(results) == 1
130
+ assert isinstance(results[0], Transaction)
131
+
132
+
133
+ # ── companies ─────────────────────────────────────────────────────────────────
134
+
135
+ @respx.mock
136
+ def test_companies_get_returns_typed_object(client):
137
+ respx.get(f"{BASE}/v1/companies/AAPL").mock(return_value=httpx.Response(200, json=COMPANY))
138
+ result = client.companies.get("AAPL")
139
+ assert isinstance(result, Company)
140
+ assert result.ticker == "AAPL"
141
+ assert result.total_filings == 100
142
+ assert result.active_insiders == 12
143
+
144
+
145
+ @respx.mock
146
+ def test_companies_insiders_returns_list(client):
147
+ respx.get(f"{BASE}/v1/companies/AAPL/insiders").mock(
148
+ return_value=httpx.Response(200, json=[INSIDER])
149
+ )
150
+ results = client.companies.insiders("AAPL")
151
+ assert len(results) == 1
152
+ assert isinstance(results[0], Insider)
153
+
154
+
155
+ # ── signals ───────────────────────────────────────────────────────────────────
156
+
157
+ @respx.mock
158
+ def test_signals_list_returns_typed_objects(client):
159
+ respx.get(f"{BASE}/v1/signals").mock(return_value=httpx.Response(200, json=[SIGNAL]))
160
+ results = client.signals.list(ticker="AAPL")
161
+ assert len(results) == 1
162
+ assert isinstance(results[0], InsiderSignal)
163
+ assert results[0].is_cluster_buy is True
164
+ assert results[0].buy_sell_ratio == 2.5
165
+
166
+
167
+ # ── error handling ────────────────────────────────────────────────────────────
168
+
169
+ @respx.mock
170
+ def test_401_raises_auth_error(client):
171
+ respx.get(f"{BASE}/v1/transactions").mock(
172
+ return_value=httpx.Response(401, json={"error": {"code": "INVALID_API_KEY", "message": "Bad key"}})
173
+ )
174
+ with pytest.raises(AuthError) as exc:
175
+ client.transactions.list()
176
+ assert exc.value.status_code == 401
177
+ assert exc.value.error_code == "INVALID_API_KEY"
178
+
179
+
180
+ @respx.mock
181
+ def test_402_raises_plan_error(client):
182
+ respx.get(f"{BASE}/v1/signals").mock(
183
+ return_value=httpx.Response(402, json={"error": {"code": "PLAN_REQUIRED", "message": "Upgrade"}, "requiredPlan": "Business"})
184
+ )
185
+ with pytest.raises(PlanError) as exc:
186
+ client.signals.list()
187
+ assert exc.value.required_plan == "Business"
188
+
189
+
190
+ @respx.mock
191
+ def test_404_raises_not_found_error(client):
192
+ respx.get(f"{BASE}/v1/insiders/0000000000").mock(
193
+ return_value=httpx.Response(404, json={"error": {"code": "NOT_FOUND", "message": "Not found"}})
194
+ )
195
+ with pytest.raises(NotFoundError):
196
+ client.insiders.get("0000000000")
197
+
198
+
199
+ @respx.mock
200
+ def test_429_raises_rate_limit_error_with_retry_after(client):
201
+ respx.get(f"{BASE}/v1/transactions").mock(
202
+ return_value=httpx.Response(
203
+ 429,
204
+ headers={"Retry-After": "42"},
205
+ json={"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "Slow down"}},
206
+ )
207
+ )
208
+ with pytest.raises(RateLimitError) as exc:
209
+ client.transactions.list()
210
+ assert exc.value.retry_after == 42
211
+
212
+
213
+ @respx.mock
214
+ def test_500_raises_form4_api_error(client):
215
+ respx.get(f"{BASE}/v1/transactions").mock(return_value=httpx.Response(500, json={}))
216
+ with pytest.raises(Form4ApiError) as exc:
217
+ client.transactions.list()
218
+ assert exc.value.status_code == 500
219
+
220
+
221
+ # ── retries ───────────────────────────────────────────────────────────────────
222
+
223
+ @respx.mock
224
+ def test_retries_on_5xx_then_succeeds():
225
+ with Form4ApiClient("test-key", max_retries=1) as client:
226
+ respx.get(f"{BASE}/v1/transactions").mock(side_effect=[
227
+ httpx.Response(503, json={}),
228
+ httpx.Response(200, json=[TX]),
229
+ ])
230
+ results = client.transactions.list()
231
+ assert len(results) == 1
232
+
233
+
234
+ @respx.mock
235
+ def test_no_retry_on_4xx():
236
+ call_count = 0
237
+
238
+ def handler(_):
239
+ nonlocal call_count
240
+ call_count += 1
241
+ return httpx.Response(401, json={"error": {"code": "INVALID_API_KEY", "message": "Bad"}})
242
+
243
+ with Form4ApiClient("test-key", max_retries=2) as client:
244
+ respx.get(f"{BASE}/v1/transactions").mock(side_effect=handler)
245
+ with pytest.raises(AuthError):
246
+ client.transactions.list()
247
+
248
+ assert call_count == 1 # no retries on 401
249
+
250
+
251
+ # ── webhook verification ──────────────────────────────────────────────────────
252
+
253
+ def test_verify_webhook_valid_signature():
254
+ import hashlib, hmac as _hmac
255
+ payload = b'{"type":"TransactionFiled"}'
256
+ secret = "mysecret"
257
+ sig = "sha256=" + _hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
258
+ assert verify_webhook(payload, sig, secret) is True
259
+
260
+
261
+ def test_verify_webhook_invalid_signature():
262
+ assert verify_webhook(b"payload", "sha256=badhash", "mysecret") is False
263
+
264
+
265
+ def test_verify_webhook_accepts_str_payload():
266
+ import hashlib, hmac as _hmac
267
+ payload = '{"type":"TransactionFiled"}'
268
+ secret = "mysecret"
269
+ sig = "sha256=" + _hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
270
+ assert verify_webhook(payload, sig, secret) is True