heyleads 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
heyleads-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HeyLeads
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,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: heyleads
3
+ Version: 1.0.0
4
+ Summary: Official Python client for the HeyLeads API (LinkedIn outreach): campaigns, leads, lists, inbox, webhooks. Standard library only.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://app.heyleads.so/developers
7
+ Project-URL: Documentation, https://app.heyleads.so/developers
8
+ Keywords: heyleads,linkedin,outreach,api,sdk
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Intended Audience :: Developers
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # heyleads (Python)
19
+
20
+ Python client for the [HeyLeads API](https://app.heyleads.so/developers). **Standard library only** (no `requests`, no dependencies); Python 3.9+.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install heyleads
26
+ ```
27
+
28
+ Create an API key in HeyLeads under **Settings → Integrations → API keys** (it starts with `hl_live_`) and keep it in an environment variable.
29
+
30
+ ```python
31
+ import os
32
+ from heyleads import HeyLeads, HeyLeadsError
33
+
34
+ hl = HeyLeads(api_key=os.environ["HEYLEAD_API_KEY"]) # talks to https://app.heyleads.so unless you pass base_url
35
+
36
+ campaign = hl.campaigns.create({"name": "Founders, Q4"}) # a draft: nothing is sent yet
37
+ hl.campaigns.add_leads(campaign["id"], rows=[{"fullName": "Ada Lovelace", "linkedinUrl": "https://www.linkedin.com/in/ada-lovelace"}]) # queued
38
+ hl.campaigns.launch(campaign["id"]) # running, not "sent": the engine paces every action within each account's limits
39
+
40
+ for lead in hl.leads.iterate(status="active"): # follows the cursor for you
41
+ print(lead["fullName"])
42
+
43
+ try:
44
+ hl.lists.create("")
45
+ except HeyLeadsError as e:
46
+ print(e.status, e.code, e.message, e.field_errors, e.request_id)
47
+ ```
48
+
49
+ ## Good to know
50
+
51
+ - **Dicts in, dicts out**, with the API's own camelCase keys (`fullName`, `linkedinUrl`, `nextCursor`), so nothing is lost in translation and the [API reference](https://app.heyleads.so/developers/reference) is the documentation for every field. Options that are Python keyword arguments are snake_case and sent as camelCase (`campaign_id` → `campaignId`; `from_` → `from`).
52
+ - **Queued, not sent.** There is no "send now": adding leads to a campaign is how HeyLeads contacts anyone, and a return means queued / running. People on the blacklist or marked do-not-contact are skipped and counted in the answer.
53
+ - **Errors** are `HeyLeadsError` with `status`, `code`, `message`, `request_id`, and, for a 422, `field_errors` / `issues`. A network failure or timeout has `status == 0`.
54
+ - **Retries.** A `429` is waited out using `Retry-After` (for any method: it was refused before it ran). Repeatable requests (`GET`, `PUT`, `DELETE`, and queued LinkedIn actions, which carry an `Idempotency-Key`) are also retried after a `502`/`503`/`504` or a network error; a `POST`/`PATCH` that may have run is **never** repeated. Tune with `max_retries` (default 2), `timeout` (30 s) and `max_retry_wait_seconds` (30: a longer `Retry-After` raises so you can decide).
55
+ - **Paging.** `leads.list` / `inbox.threads` return `{items, nextCursor, total}`; `leads.iterate`, `lists.iterate_leads` and `inbox.iterate_threads` walk every page.
56
+ - **Waiting for an action.** `hl.actions.wait(action["id"])` polls until it finishes (default up to 5 minutes); prefer webhooks in production.
57
+ - **Webhooks.** Verify a delivery against the **raw** body:
58
+
59
+ ```python
60
+ from heyleads import construct_webhook_event
61
+
62
+ @app.post("/heylead")
63
+ def hook():
64
+ try:
65
+ event = construct_webhook_event(os.environ["HEYLEAD_WEBHOOK_SECRET"], request.headers.get("x-heylead-signature"), request.get_data()) # raises ValueError if invalid or older than 5 minutes
66
+ except ValueError:
67
+ return "", 400
68
+ if event["event"] == "lead.positive_reply":
69
+ handle(event["data"])
70
+ return "", 200
71
+ ```
72
+
73
+ The method list mirrors `GET /api/v1/openapi.json` one to one (`heyleads.SDK_OPERATIONS` maps each operation id to its method, and a test fails if they drift). The tests run the client against the real API handlers over HTTP.
@@ -0,0 +1,56 @@
1
+ # heyleads (Python)
2
+
3
+ Python client for the [HeyLeads API](https://app.heyleads.so/developers). **Standard library only** (no `requests`, no dependencies); Python 3.9+.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install heyleads
9
+ ```
10
+
11
+ Create an API key in HeyLeads under **Settings → Integrations → API keys** (it starts with `hl_live_`) and keep it in an environment variable.
12
+
13
+ ```python
14
+ import os
15
+ from heyleads import HeyLeads, HeyLeadsError
16
+
17
+ hl = HeyLeads(api_key=os.environ["HEYLEAD_API_KEY"]) # talks to https://app.heyleads.so unless you pass base_url
18
+
19
+ campaign = hl.campaigns.create({"name": "Founders, Q4"}) # a draft: nothing is sent yet
20
+ hl.campaigns.add_leads(campaign["id"], rows=[{"fullName": "Ada Lovelace", "linkedinUrl": "https://www.linkedin.com/in/ada-lovelace"}]) # queued
21
+ hl.campaigns.launch(campaign["id"]) # running, not "sent": the engine paces every action within each account's limits
22
+
23
+ for lead in hl.leads.iterate(status="active"): # follows the cursor for you
24
+ print(lead["fullName"])
25
+
26
+ try:
27
+ hl.lists.create("")
28
+ except HeyLeadsError as e:
29
+ print(e.status, e.code, e.message, e.field_errors, e.request_id)
30
+ ```
31
+
32
+ ## Good to know
33
+
34
+ - **Dicts in, dicts out**, with the API's own camelCase keys (`fullName`, `linkedinUrl`, `nextCursor`), so nothing is lost in translation and the [API reference](https://app.heyleads.so/developers/reference) is the documentation for every field. Options that are Python keyword arguments are snake_case and sent as camelCase (`campaign_id` → `campaignId`; `from_` → `from`).
35
+ - **Queued, not sent.** There is no "send now": adding leads to a campaign is how HeyLeads contacts anyone, and a return means queued / running. People on the blacklist or marked do-not-contact are skipped and counted in the answer.
36
+ - **Errors** are `HeyLeadsError` with `status`, `code`, `message`, `request_id`, and, for a 422, `field_errors` / `issues`. A network failure or timeout has `status == 0`.
37
+ - **Retries.** A `429` is waited out using `Retry-After` (for any method: it was refused before it ran). Repeatable requests (`GET`, `PUT`, `DELETE`, and queued LinkedIn actions, which carry an `Idempotency-Key`) are also retried after a `502`/`503`/`504` or a network error; a `POST`/`PATCH` that may have run is **never** repeated. Tune with `max_retries` (default 2), `timeout` (30 s) and `max_retry_wait_seconds` (30: a longer `Retry-After` raises so you can decide).
38
+ - **Paging.** `leads.list` / `inbox.threads` return `{items, nextCursor, total}`; `leads.iterate`, `lists.iterate_leads` and `inbox.iterate_threads` walk every page.
39
+ - **Waiting for an action.** `hl.actions.wait(action["id"])` polls until it finishes (default up to 5 minutes); prefer webhooks in production.
40
+ - **Webhooks.** Verify a delivery against the **raw** body:
41
+
42
+ ```python
43
+ from heyleads import construct_webhook_event
44
+
45
+ @app.post("/heylead")
46
+ def hook():
47
+ try:
48
+ event = construct_webhook_event(os.environ["HEYLEAD_WEBHOOK_SECRET"], request.headers.get("x-heylead-signature"), request.get_data()) # raises ValueError if invalid or older than 5 minutes
49
+ except ValueError:
50
+ return "", 400
51
+ if event["event"] == "lead.positive_reply":
52
+ handle(event["data"])
53
+ return "", 200
54
+ ```
55
+
56
+ The method list mirrors `GET /api/v1/openapi.json` one to one (`heyleads.SDK_OPERATIONS` maps each operation id to its method, and a test fails if they drift). The tests run the client against the real API handlers over HTTP.
@@ -0,0 +1,6 @@
1
+ """The HeyLeads Python client. See README.md and https://<your-host>/developers."""
2
+ from .client import SDK_OPERATIONS, SDK_VERSION, HeyLeads
3
+ from .errors import HeyLeadsError
4
+ from .webhooks import construct_webhook_event, verify_webhook_signature
5
+
6
+ __all__ = ["HeyLeads", "HeyLeadsError", "SDK_OPERATIONS", "SDK_VERSION", "construct_webhook_event", "verify_webhook_signature"]
@@ -0,0 +1,576 @@
1
+ """The HeyLeads API client. Standard library only (no dependencies); Python 3.9+.
2
+
3
+ from heyleads import HeyLeads
4
+
5
+ hl = HeyLeads(api_key=os.environ["HEYLEAD_API_KEY"], base_url="https://app.heyleads.so")
6
+ campaign = hl.campaigns.create({"name": "Founders, Q4"}) # a draft: nothing is sent yet
7
+ hl.campaigns.add_leads(campaign["id"], rows=[{"fullName": "Ada Lovelace", "linkedinUrl": "https://www.linkedin.com/in/ada-lovelace"}]) # queued
8
+ hl.campaigns.launch(campaign["id"]) # running, not "sent": the engine paces every action within each account's limits
9
+
10
+ Requests and responses are plain dicts with the API's own camelCase keys (see the reference at /developers), so nothing is lost in translation. Options that are
11
+ Python keyword arguments are snake_case and sent as camelCase. The method list mirrors GET /api/v1/openapi.json one to one (a test fails if they drift).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import random
17
+ import time
18
+ import urllib.error
19
+ import urllib.parse
20
+ import urllib.request
21
+ import uuid
22
+ from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Tuple
23
+
24
+ from .errors import HeyLeadsError
25
+
26
+ SDK_VERSION = "1.0.0"
27
+ # Where the hosted HeyLeads API lives.
28
+ DEFAULT_BASE_URL = "https://app.heyleads.so"
29
+
30
+ # (status, headers, body) for one request. Replaceable, so tests and proxies can supply their own.
31
+ Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float], Tuple[int, Mapping[str, str], bytes]]
32
+
33
+ _IDEMPOTENT = {"GET", "PUT", "DELETE", "HEAD"}
34
+
35
+
36
+ def _quote(value: str) -> str:
37
+ return urllib.parse.quote(str(value), safe="")
38
+
39
+
40
+ def _camel(name: str) -> str:
41
+ head, *rest = name.split("_")
42
+ return head + "".join(p[:1].upper() + p[1:] for p in rest)
43
+
44
+
45
+ def _query_value(v: Any) -> str:
46
+ if isinstance(v, bool):
47
+ return "true" if v else "false"
48
+ return str(v)
49
+
50
+
51
+ def _default_transport(method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout: float) -> Tuple[int, Mapping[str, str], bytes]:
52
+ req = urllib.request.Request(url, data=body, headers=headers, method=method)
53
+ try:
54
+ with urllib.request.urlopen(req, timeout=timeout) as res: # noqa: S310 - the URL is the host the caller configured
55
+ return res.status, {k.lower(): v for k, v in res.headers.items()}, res.read()
56
+ except urllib.error.HTTPError as e:
57
+ return e.code, {k.lower(): v for k, v in (e.headers or {}).items()}, e.read()
58
+
59
+
60
+ class HeyLeads:
61
+ """The client. One instance per API key; safe to share between threads."""
62
+
63
+ def __init__(
64
+ self,
65
+ api_key: str,
66
+ base_url: Optional[str] = None,
67
+ max_retries: int = 2,
68
+ timeout: float = 30.0,
69
+ max_retry_wait_seconds: float = 30.0,
70
+ transport: Optional[Transport] = None,
71
+ sleep: Optional[Callable[[float], None]] = None,
72
+ ) -> None:
73
+ """
74
+ Args:
75
+ api_key: `hl_live_…`. Keep it on your server.
76
+ base_url: your HeyLeads host, e.g. `https://app.heyleads.so` (no `/api/v1`). Defaults to $HEYLEAD_BASE_URL, else https://app.heyleads.so.
77
+ max_retries: retries after a 429 or a temporary failure. Default 2 (so up to 3 attempts). 0 turns retrying off.
78
+ timeout: per-attempt timeout in seconds.
79
+ max_retry_wait_seconds: the longest the client waits on a single `Retry-After`. A longer ask raises immediately so you can decide.
80
+ """
81
+ if not isinstance(api_key, str) or not api_key.startswith("hl_live_"):
82
+ raise TypeError("HeyLeads: pass your API key as `api_key` (it starts with hl_live_).")
83
+ import os
84
+
85
+ # The hosted product by default; `base_url` (or HEYLEAD_BASE_URL) points it at another host.
86
+ base = (base_url or os.environ.get("HEYLEAD_BASE_URL") or DEFAULT_BASE_URL).strip()
87
+ if not base.lower().startswith(("http://", "https://")):
88
+ raise TypeError("HeyLeads: `base_url` must start with https:// (for example https://app.heyleads.so).")
89
+ self._api_key = api_key
90
+ self._root = base.rstrip("/") + "/api/v1"
91
+ self._max_retries = max(0, int(max_retries))
92
+ self._timeout = float(timeout)
93
+ self._max_wait = float(max_retry_wait_seconds)
94
+ self._transport = transport or _default_transport
95
+ self._sleep = sleep or time.sleep
96
+ self.accounts = Accounts(self)
97
+ self.actions = Actions(self)
98
+ self.campaigns = Campaigns(self)
99
+ self.leads = Leads(self)
100
+ self.lists = Lists(self)
101
+ self.blacklist = Blacklist(self)
102
+ self.members = Members(self)
103
+ self.inbox = Inbox(self)
104
+ self.analytics = AnalyticsApi(self)
105
+ self.webhooks = Webhooks(self)
106
+
107
+ def workspace(self) -> dict:
108
+ """The workspace this key acts in: name, plan, time zone, working hours and how much it holds."""
109
+ return self.request("GET", "/workspace")
110
+
111
+ def me(self) -> dict:
112
+ """Who this key is: the workspace it acts in and its effective role. A cheap way to check a key works."""
113
+ return self.request("GET", "/me")
114
+
115
+ # ── the one place a request is made ──────────────────────────────────────────────────────────────────────────
116
+
117
+ def request(
118
+ self,
119
+ method: str,
120
+ path: str,
121
+ query: Optional[Mapping[str, Any]] = None,
122
+ body: Any = None,
123
+ headers: Optional[Mapping[str, str]] = None,
124
+ repeatable: bool = False,
125
+ ) -> Any:
126
+ url = self._root + path
127
+ if query:
128
+ qs = urllib.parse.urlencode({k: _query_value(v) for k, v in query.items() if v is not None})
129
+ if qs:
130
+ url += "?" + qs
131
+ payload = None if body is None else json.dumps(body).encode("utf-8")
132
+ request_id = str(uuid.uuid4())
133
+ can_repeat = repeatable or method in _IDEMPOTENT
134
+ base_headers = {
135
+ "authorization": f"Bearer {self._api_key}",
136
+ "accept": "application/json",
137
+ "user-agent": f"heyleads-python/{SDK_VERSION}",
138
+ "x-request-id": request_id,
139
+ **({"content-type": "application/json"} if payload is not None else {}),
140
+ **{k.lower(): v for k, v in (headers or {}).items()},
141
+ }
142
+
143
+ attempt = 0
144
+ while True:
145
+ try:
146
+ status, res_headers, raw = self._transport(method, url, base_headers, payload, self._timeout)
147
+ except HeyLeadsError:
148
+ raise
149
+ except Exception as err: # noqa: BLE001 - any transport failure is a network error to the caller
150
+ # The request may or may not have reached the server, so only a repeatable request is retried.
151
+ if attempt < self._max_retries and can_repeat:
152
+ self._sleep(self._backoff(attempt))
153
+ attempt += 1
154
+ continue
155
+ timed_out = isinstance(err, TimeoutError) or "timed out" in str(err).lower()
156
+ raise HeyLeadsError(
157
+ 0,
158
+ "timeout" if timed_out else "network_error",
159
+ f"Timed out after {self._timeout:g} s" if timed_out else f"Network error: {err}",
160
+ ) from err
161
+
162
+ if 200 <= status < 300:
163
+ return None if status == 204 or not raw else json.loads(raw.decode("utf-8"))
164
+
165
+ error = _to_error(status, res_headers, raw)
166
+ delay = self._retry_delay(can_repeat, status, error, attempt)
167
+ if delay is None:
168
+ raise error
169
+ self._sleep(delay)
170
+ attempt += 1
171
+
172
+ def _backoff(self, attempt: int) -> float:
173
+ return min(8.0, 0.5 * 2**attempt) + random.random() * 0.25
174
+
175
+ def _retry_delay(self, repeatable: bool, status: int, error: HeyLeadsError, attempt: int) -> Optional[float]:
176
+ """Seconds to wait before retrying, or None to give up and raise."""
177
+ if attempt >= self._max_retries:
178
+ return None
179
+ # A feature the server has switched off will not come back on by waiting a few seconds.
180
+ if error.code == "actions_disabled":
181
+ return None
182
+ # 429: refused BEFORE it ran, so retrying is safe for every method. Our own 503 "unavailable" is also refused before it runs.
183
+ # Any other 5xx may have run: repeatable methods only.
184
+ safe = status == 429 or (status == 503 and error.code == "unavailable")
185
+ transient = status in (502, 503, 504)
186
+ if not safe and not (transient and repeatable):
187
+ return None
188
+ asked = error.retry_after_seconds
189
+ if asked is not None:
190
+ return None if asked > self._max_wait else float(asked)
191
+ return self._backoff(attempt)
192
+
193
+
194
+ def _to_error(status: int, headers: Mapping[str, str], raw: bytes) -> HeyLeadsError:
195
+ envelope: dict = {}
196
+ try:
197
+ parsed = json.loads(raw.decode("utf-8"))
198
+ if isinstance(parsed, dict):
199
+ envelope = parsed
200
+ except Exception: # noqa: BLE001 - not JSON (a proxy's error page): fall through to the generic message
201
+ pass
202
+ e = envelope.get("error") if isinstance(envelope.get("error"), dict) else {}
203
+ try:
204
+ header = float(headers.get("retry-after", ""))
205
+ except ValueError:
206
+ header = 0.0
207
+ return HeyLeadsError(
208
+ status,
209
+ e.get("code") or ("rate_limited" if status == 429 else "http_error"),
210
+ e.get("message") or f"HeyLeads API answered {status}",
211
+ e.get("requestId") or headers.get("x-request-id"),
212
+ e.get("fieldErrors"),
213
+ e.get("issues"),
214
+ e.get("retryAfterSeconds") if e.get("retryAfterSeconds") is not None else (header if header > 0 else None),
215
+ )
216
+
217
+
218
+ class _Resource:
219
+ def __init__(self, client: HeyLeads) -> None:
220
+ self._c = client
221
+
222
+
223
+ def _drop_none(d: Mapping[str, Any]) -> Dict[str, Any]:
224
+ return {k: v for k, v in d.items() if v is not None}
225
+
226
+
227
+ def _iso(d: Any) -> Any:
228
+ return d.isoformat() if hasattr(d, "isoformat") else d
229
+
230
+
231
+ def _pages(fetch: Callable[[Optional[str]], dict]) -> Iterator[dict]:
232
+ cursor: Optional[str] = None
233
+ while True:
234
+ page = fetch(cursor)
235
+ yield from page["items"]
236
+ cursor = page.get("nextCursor")
237
+ if not cursor:
238
+ return
239
+
240
+
241
+ class Accounts(_Resource):
242
+ def _queue(self, account_id: str, path: str, body: Dict[str, Any], not_before: Any, expires_in_seconds: Optional[int], idempotency_key: Optional[str]) -> dict:
243
+ """Queue a LinkedIn action. It is QUEUED, never sent immediately: a worker runs it under the account's daily limits, warm-up and working hours,
244
+ and drops it if it expires first. Every call carries an Idempotency-Key (yours, or one generated here), so a retry after a network error
245
+ cannot queue it twice. Wait for the outcome with `hl.actions.wait(action["id"])` or the action.* webhooks."""
246
+ return self._c.request(
247
+ "POST",
248
+ f"/accounts/{_quote(account_id)}/{path}",
249
+ body=_drop_none({**body, "notBefore": _iso(not_before), "expiresInSeconds": expires_in_seconds}),
250
+ headers={"idempotency-key": idempotency_key or str(uuid.uuid4())},
251
+ repeatable=True,
252
+ )
253
+
254
+ def send_connection_request(self, account_id: str, profile_url: str, note: Optional[str] = None, *, not_before: Any = None, expires_in_seconds: Optional[int] = None, idempotency_key: Optional[str] = None) -> dict:
255
+ """A connection request, with or without a note (up to 300 characters, sent exactly as written)."""
256
+ return self._queue(account_id, "connection-requests", _drop_none({"profileUrl": profile_url, "note": note}), not_before, expires_in_seconds, idempotency_key)
257
+
258
+ def send_message(self, account_id: str, profile_url: str, text: str, override_reply_guard: Optional[bool] = None, *, not_before: Any = None, expires_in_seconds: Optional[int] = None, idempotency_key: Optional[str] = None) -> dict:
259
+ """A message to someone the account is connected to. Nothing is sent into a conversation they already answered unless `override_reply_guard` is True."""
260
+ return self._queue(account_id, "messages", _drop_none({"profileUrl": profile_url, "text": text, "overrideReplyGuard": override_reply_guard}), not_before, expires_in_seconds, idempotency_key)
261
+
262
+ def visit_profile(self, account_id: str, profile_url: str, *, not_before: Any = None, expires_in_seconds: Optional[int] = None, idempotency_key: Optional[str] = None) -> dict:
263
+ return self._queue(account_id, "profile-visits", {"profileUrl": profile_url}, not_before, expires_in_seconds, idempotency_key)
264
+
265
+ def follow(self, account_id: str, profile_url: str, *, not_before: Any = None, expires_in_seconds: Optional[int] = None, idempotency_key: Optional[str] = None) -> dict:
266
+ return self._queue(account_id, "follows", {"profileUrl": profile_url}, not_before, expires_in_seconds, idempotency_key)
267
+
268
+ def like_latest_post(self, account_id: str, profile_url: str, *, not_before: Any = None, expires_in_seconds: Optional[int] = None, idempotency_key: Optional[str] = None) -> dict:
269
+ return self._queue(account_id, "post-likes", {"profileUrl": profile_url}, not_before, expires_in_seconds, idempotency_key)
270
+
271
+ def endorse_skills(self, account_id: str, profile_url: str, max: Optional[int] = None, *, not_before: Any = None, expires_in_seconds: Optional[int] = None, idempotency_key: Optional[str] = None) -> dict: # noqa: A002
272
+ return self._queue(account_id, "endorsements", _drop_none({"profileUrl": profile_url, "max": max}), not_before, expires_in_seconds, idempotency_key)
273
+
274
+ def list_actions(self, account_id: str, status: Optional[str] = None, type: Optional[str] = None, limit: Optional[int] = None, before: Optional[str] = None) -> dict: # noqa: A002
275
+ """This account's queued and finished actions, newest first. Page back with the returned `nextBefore` as `before`."""
276
+ return self._c.request("GET", f"/accounts/{_quote(account_id)}/actions", query={"status": status, "type": type, "limit": limit, "before": before})
277
+
278
+ def list(self) -> List[dict]:
279
+ return self._c.request("GET", "/accounts")["items"]
280
+
281
+ def get(self, id: str) -> dict: # noqa: A002
282
+ return self._c.request("GET", f"/accounts/{_quote(id)}")
283
+
284
+ def pause(self, id: str) -> dict: # noqa: A002
285
+ """Stops all automation on the account until resumed."""
286
+ return self._c.request("POST", f"/accounts/{_quote(id)}/pause")
287
+
288
+ def resume(self, id: str) -> dict: # noqa: A002
289
+ return self._c.request("POST", f"/accounts/{_quote(id)}/resume")
290
+
291
+ def set_tier(self, id: str, tier: str, sales_navigator: Optional[str] = None, recruiter: Optional[str] = None) -> dict: # noqa: A002
292
+ """States which LinkedIn plan the account has until detection exists (`source: "declared"`). A detected plan is never overwritten (409);
293
+ `tier="unknown"` clears a declaration. InMail steps refuse to launch from a sender whose plan cannot send them."""
294
+ return self._c.request("PUT", f"/accounts/{_quote(id)}/tier", body=_drop_none({"tier": tier, "salesNavigator": sales_navigator, "recruiter": recruiter}))
295
+
296
+ def disconnect(self, id: str) -> None: # noqa: A002
297
+ """Removes the account and its stored session from the workspace."""
298
+ self._c.request("DELETE", f"/accounts/{_quote(id)}")
299
+
300
+
301
+ class Actions(_Resource):
302
+ def get(self, id: str) -> dict: # noqa: A002
303
+ return self._c.request("GET", f"/actions/{_quote(id)}")
304
+
305
+ def cancel(self, id: str) -> dict: # noqa: A002
306
+ """Only a queued action can be cancelled; a running one is left to finish."""
307
+ return self._c.request("POST", f"/actions/{_quote(id)}/cancel")
308
+
309
+ def wait(self, id: str, timeout_seconds: float = 300.0, interval_seconds: float = 3.0) -> dict: # noqa: A002
310
+ """Polls until the action reaches a final status (succeeded, failed, cancelled, expired) and returns it, or raises a `timeout` HeyLeadsError.
311
+ An action can legitimately wait a long time (the account's working hours and daily limits come first), so prefer webhooks for production
312
+ and use this for scripts."""
313
+ deadline = time.monotonic() + timeout_seconds
314
+ while True:
315
+ action = self.get(id)
316
+ if action["status"] in ("succeeded", "failed", "cancelled", "expired"):
317
+ return action
318
+ if time.monotonic() + interval_seconds > deadline:
319
+ raise HeyLeadsError(0, "timeout", f"Action {id} is still {action['status']} after {timeout_seconds:g} s. It keeps its place in the queue.")
320
+ self._c._sleep(interval_seconds)
321
+
322
+
323
+ class Campaigns(_Resource):
324
+ def list(self, status: Optional[str] = None) -> List[dict]:
325
+ return self._c.request("GET", "/campaigns", query={"status": status})["items"]
326
+
327
+ def create(self, body: Mapping[str, Any]) -> dict:
328
+ """Starts as a draft: nothing is sent until you `launch` it."""
329
+ return self._c.request("POST", "/campaigns", body=dict(body))
330
+
331
+ def get(self, id: str) -> dict: # noqa: A002
332
+ return self._c.request("GET", f"/campaigns/{_quote(id)}")
333
+
334
+ def update(self, id: str, patch: Mapping[str, Any]) -> dict: # noqa: A002
335
+ return self._c.request("PATCH", f"/campaigns/{_quote(id)}", body=dict(patch))
336
+
337
+ def delete(self, id: str) -> None: # noqa: A002
338
+ self._c.request("DELETE", f"/campaigns/{_quote(id)}")
339
+
340
+ def save_graph(self, id: str, graph: Mapping[str, Any], expected_version: Optional[int] = None) -> dict: # noqa: A002
341
+ """Replaces the whole graph. Pass the `graphVersion` you last read as `expected_version` so a concurrent edit is rejected instead of overwritten."""
342
+ return self._c.request("PUT", f"/campaigns/{_quote(id)}/graph", body=_drop_none({"graph": dict(graph), "expectedVersion": expected_version}))
343
+
344
+ def duplicate(self, id: str) -> dict: # noqa: A002
345
+ return self._c.request("POST", f"/campaigns/{_quote(id)}/duplicate")
346
+
347
+ def launch(self, id: str) -> dict: # noqa: A002
348
+ """Hands the campaign to the engine. A return means RUNNING, not that anything was sent. Needs an admin key."""
349
+ return self._c.request("POST", f"/campaigns/{_quote(id)}/launch")
350
+
351
+ def pause(self, id: str) -> dict: # noqa: A002
352
+ return self._c.request("POST", f"/campaigns/{_quote(id)}/pause")
353
+
354
+ def resume(self, id: str) -> dict: # noqa: A002
355
+ return self._c.request("POST", f"/campaigns/{_quote(id)}/resume")
356
+
357
+ def split_tests(self, id: str) -> List[dict]: # noqa: A002
358
+ return self._c.request("GET", f"/campaigns/{_quote(id)}/split-tests")["items"]
359
+
360
+ def add_leads(self, campaign_id: str, rows: Optional[List[Mapping[str, Any]]] = None, lead_ids: Optional[List[str]] = None, **options: Any) -> dict:
361
+ """The only way to make HeyLeads contact someone. `rows` imports new leads and enrols them; `lead_ids` enrols existing ones. They are QUEUED:
362
+ the engine decides when each is contacted, within every account's safe limits. People on the blacklist or marked do-not-contact are skipped
363
+ and counted in the answer. Other options (`list_name`, `list_id`, `source`) are sent as the API's camelCase fields."""
364
+ body = _drop_none({"rows": rows, "leadIds": lead_ids, **{_camel(k): v for k, v in options.items()}})
365
+ return self._c.request("POST", f"/campaigns/{_quote(campaign_id)}/leads", body=body)
366
+
367
+ def stop_lead(self, campaign_id: str, lead_id: str) -> dict:
368
+ """Take ONE lead out of this campaign's sequence (nothing more is sent from it). Safe to repeat: a lead that already left answers
369
+ `changed: False`. Raises a 409 while the engine is mid-step on that lead."""
370
+ return self._c.request("POST", f"/campaigns/{_quote(campaign_id)}/leads/{_quote(lead_id)}/stop")
371
+
372
+
373
+ class Leads(_Resource):
374
+ @staticmethod
375
+ def _query(filters: Mapping[str, Any]) -> Dict[str, Any]:
376
+ return {_camel(k): v for k, v in filters.items()}
377
+
378
+ def list(self, **filters: Any) -> dict:
379
+ """One page of leads. Filters (snake_case): search, campaign_id, list_id, status, connection, reply_category, account_id, do_not_contact, sort, cursor, limit."""
380
+ return self._c.request("GET", "/leads", query=self._query(filters))
381
+
382
+ def iterate(self, **filters: Any) -> Iterator[dict]:
383
+ """Every matching lead, following the cursor for you: `for lead in hl.leads.iterate(status="active"): ...`"""
384
+ filters.pop("cursor", None)
385
+ return _pages(lambda cursor: self.list(**filters, cursor=cursor))
386
+
387
+ def get(self, id: str) -> dict: # noqa: A002
388
+ """The lead with its activity timeline and the ids of its conversations."""
389
+ return self._c.request("GET", f"/leads/{_quote(id)}")
390
+
391
+ def events(self, id: str, campaign_id: Optional[str] = None) -> List[dict]: # noqa: A002
392
+ return self._c.request("GET", f"/leads/{_quote(id)}/events", query={"campaignId": campaign_id})["items"]
393
+
394
+ def check_identities(self, public_ids: Optional[List[str]] = None, people: Optional[List[Mapping[str, Any]]] = None) -> dict:
395
+ """What the workspace already knows about some people (up to 5,000): lead or new, the lists they are in, do-not-contact, blacklisted. Never writes.
396
+ Send `public_ids` (profile URLs or bare identifiers) or `people` (dicts with `profileUrl` and optional `fullName` and `company`, for the blacklist check).
397
+ Returns `{items, invalid}`."""
398
+ return self._c.request("POST", "/leads/identities", body=_drop_none({"publicIds": public_ids, "people": [dict(p) for p in people] if people is not None else None}))
399
+
400
+ def classify_signals(self, items: List[Mapping[str, Any]], icp: Optional[str] = None) -> dict:
401
+ """Intent Lens: what up to 100 comments say about the people who left them, ranked. Each item is a dict with an `id`, the comment `text` (empty for someone who only reacted)
402
+ and optionally `headline`, `title` and `company`; `icp` is a sentence describing your ideal customer (with a headline, title or company it adds a `fit` score).
403
+ Returns `{items, engine}`; each item has `intent`, `heat`, `tier`, `fit`, `quote` (their own words, cleaned for a message), `by`, `seniority` and `function`. Reads only."""
404
+ return self._c.request("POST", "/leads/signals", body=_drop_none({"items": [dict(i) for i in items], "icp": icp}))
405
+
406
+ def lookup(self, profile_url: Optional[str] = None, email: Optional[str] = None) -> List[dict]:
407
+ """Find a lead by LinkedIn profile URL or by email (exactly one). A profile URL matches at most one lead; an email can match several."""
408
+ return self._c.request("GET", "/leads/lookup", query={"profileUrl": profile_url, "email": email})["items"]
409
+
410
+ def tag(self, lead_ids: List[str], add: Optional[List[str]] = None, remove: Optional[List[str]] = None) -> dict:
411
+ """Add and/or remove tags on many leads at once."""
412
+ return self._c.request("POST", "/leads/tags", body=_drop_none({"leadIds": lead_ids, "add": add, "remove": remove}))
413
+
414
+ def set_tags(self, id: str, tags: List[str]) -> List[str]: # noqa: A002
415
+ """Leave the lead with exactly these tags (an empty list clears them). Returns the tags as stored."""
416
+ return self._c.request("PUT", f"/leads/{_quote(id)}/tags", body={"tags": tags})["tags"]
417
+
418
+ def set_custom_fields(self, id: str, set: Optional[Mapping[str, str]] = None, unset: Optional[List[str]] = None) -> dict: # noqa: A002
419
+ """Set and/or remove plain custom fields (the values templates use as {{variables}}). Returns the lead's custom fields."""
420
+ return self._c.request("PATCH", f"/leads/{_quote(id)}/custom-fields", body=_drop_none({"set": dict(set) if set is not None else None, "unset": unset}))["customFields"]
421
+
422
+ def set_do_not_contact(self, id: str, value: bool) -> dict: # noqa: A002
423
+ """A do-not-contact lead is never contacted by any campaign."""
424
+ return self._c.request("POST", f"/leads/{_quote(id)}/do-not-contact", body={"value": value})
425
+
426
+
427
+ class Lists(_Resource):
428
+ def list(self) -> List[dict]:
429
+ return self._c.request("GET", "/lists")["items"]
430
+
431
+ def derive(self, operation: str, list_ids: List[str], name: Optional[str] = None, exclude_list_ids: Optional[List[str]] = None, dry_run: bool = False) -> dict:
432
+ """A NEW list from existing ones. `combine`: anyone in any of `list_ids`; `intersect`: in all of them; `exclude`: in the one list in `list_ids`
433
+ and in none of `exclude_list_ids`. The sources are never changed, and if nobody matches no list is created (`list` is None). `dry_run=True` only counts."""
434
+ return self._c.request("POST", "/lists/derive", body=_drop_none({"name": name, "operation": operation, "listIds": list_ids, "excludeListIds": exclude_list_ids, "dryRun": True if dry_run else None}))
435
+
436
+ def create(self, name: str) -> dict:
437
+ """An empty list to import into. (Importing with a `list_name` creates one for you.)"""
438
+ return self._c.request("POST", "/lists", body={"name": name})
439
+
440
+ def get(self, id: str) -> dict: # noqa: A002
441
+ return self._c.request("GET", f"/lists/{_quote(id)}")
442
+
443
+ def leads(self, id: str, **filters: Any) -> dict: # noqa: A002
444
+ """The leads in a list, one page at a time (pass `nextCursor` back as `cursor`)."""
445
+ return self._c.request("GET", f"/lists/{_quote(id)}/leads", query={_camel(k): v for k, v in filters.items()})
446
+
447
+ def iterate_leads(self, id: str, **filters: Any) -> Iterator[dict]: # noqa: A002
448
+ """Every lead in a list, following the cursor for you."""
449
+ filters.pop("cursor", None)
450
+ return _pages(lambda cursor: self.leads(id, **filters, cursor=cursor))
451
+
452
+ def add_leads(self, id: str, rows: List[Mapping[str, Any]], source: Optional[str] = None) -> dict: # noqa: A002
453
+ """Import new leads (up to 5,000 per call). People already in the workspace are skipped, not moved. Nothing is contacted."""
454
+ return self._c.request("POST", f"/lists/{_quote(id)}/leads", body=_drop_none({"rows": [dict(r) for r in rows], "source": source}))
455
+
456
+ def add_people(self, people: List[Mapping[str, Any]], list_id: Optional[str] = None, list_name: Optional[str] = None, post_url: Optional[str] = None, source: Optional[str] = None) -> dict:
457
+ """Add people (up to 500) to a list in one call: new ones are imported, existing leads are put in it too, blacklisted and do-not-contact people are
458
+ skipped and counted. Send `list_id` or `list_name`. Each person is a dict with `profileUrl` and optional `fullName`, `headline`, `company`,
459
+ `reactionType` and so on. Safe to repeat. Nothing is contacted."""
460
+ return self._c.request("POST", "/lists/add-people", body=_drop_none({"listId": list_id, "listName": list_name, "people": [dict(p) for p in people], "postUrl": post_url, "source": source}))
461
+
462
+ def delete_created_leads(self, id: str, lead_ids: List[str]) -> dict: # noqa: A002
463
+ """Take back leads that `add_people` CREATED (up to 500 ids), while each is still exactly what the import made it: made by you, source `extension:post-engagers`, under 15 minutes
464
+ old, only in this list, in no campaign, with no events, conversation, notes or tags, not do-not-contact. Each id is judged alone. Not a general delete.
465
+ Returns `{deleted, refused: [{leadId, reason}]}`."""
466
+ return self._c.request("DELETE", f"/lists/{_quote(id)}/created-leads", body={"leadIds": list(lead_ids)})
467
+
468
+ def remove_members(self, id: str, lead_ids: List[str]) -> dict: # noqa: A002
469
+ """Take leads out of this list again (up to 5,000 ids): the undo of `add_members`. A lead whose home list this is stays in it; no lead is deleted.
470
+ Safe to repeat. Returns `{removed, notInList, atHome, notFound}`."""
471
+ return self._c.request("DELETE", f"/lists/{_quote(id)}/members", body={"leadIds": list(lead_ids)})
472
+
473
+ def add_members(self, id: str, lead_ids: List[str]) -> dict: # noqa: A002
474
+ """Put leads that ALREADY exist (up to 5,000 ids) in this list as well; their home list is unchanged. Safe to repeat. Nothing is contacted.
475
+ Returns `{added, alreadyInList, notFound}`."""
476
+ return self._c.request("POST", f"/lists/{_quote(id)}/members", body={"leadIds": list(lead_ids)})
477
+
478
+
479
+ class Blacklist(_Resource):
480
+ def list(self, kind: str, search: Optional[str] = None) -> dict:
481
+ """People (`kind="lead"`) or companies (`kind="company"`) this workspace never contacts, newest first. Returns `{items, total, limit}`."""
482
+ return self._c.request("GET", "/blacklist", query={"kind": kind, "search": search})
483
+
484
+ def add(self, kind: str, entries: List[str], dry_run: bool = False) -> dict:
485
+ """Add up to 100 entries of one kind: a profile URL, email or full name (people), or a company page URL or name. Bad entries come back in
486
+ `invalid`, not stored. Leads who match are skipped when added to a campaign. `dry_run=True` stores nothing. Needs an admin key."""
487
+ return self._c.request("POST", "/blacklist", body=_drop_none({"kind": kind, "entries": entries, "dryRun": True if dry_run else None}))
488
+
489
+ def remove(self, id: str) -> None: # noqa: A002
490
+ """The person or company can be contacted again."""
491
+ self._c.request("DELETE", f"/blacklist/{_quote(id)}")
492
+
493
+
494
+ class Members(_Resource):
495
+ def list(self) -> List[dict]:
496
+ return self._c.request("GET", "/members")["items"]
497
+
498
+ def invite(self, email: str, role: str) -> dict:
499
+ """Emails the person an invitation. A key cannot grant `admin` (403): invite as `member` and promote in the dashboard."""
500
+ return self._c.request("POST", "/members", body={"email": email, "role": role})
501
+
502
+ def set_role(self, user_id: str, role: str) -> dict:
503
+ """Switch a teammate between `member` and `client_viewer`. A key cannot grant `admin`, or change an admin or the owner."""
504
+ return self._c.request("PATCH", f"/members/{_quote(user_id)}", body={"role": role})
505
+
506
+ def remove(self, user_id: str) -> None:
507
+ """Remove a member or client viewer. A key cannot remove an admin or the owner."""
508
+ self._c.request("DELETE", f"/members/{_quote(user_id)}")
509
+
510
+
511
+ class Inbox(_Resource):
512
+ def threads(self, **filters: Any) -> dict:
513
+ return self._c.request("GET", "/inbox/threads", query={_camel(k): v for k, v in filters.items()})
514
+
515
+ def iterate_threads(self, **filters: Any) -> Iterator[dict]:
516
+ filters.pop("cursor", None)
517
+ return _pages(lambda cursor: self.threads(**filters, cursor=cursor))
518
+
519
+ def thread(self, id: str) -> dict: # noqa: A002
520
+ return self._c.request("GET", f"/inbox/threads/{_quote(id)}")
521
+
522
+
523
+ class AnalyticsApi(_Resource):
524
+ def get(self, **query: Any) -> dict:
525
+ """Query (snake_case): days, from_, to, campaign_id, account_id. `from_` is sent as `from`."""
526
+ return self._c.request("GET", "/analytics", query={("from" if k == "from_" else _camel(k)): v for k, v in query.items()})
527
+
528
+
529
+ class Webhooks(_Resource):
530
+ def list(self) -> List[dict]:
531
+ return self._c.request("GET", "/webhooks")["items"]
532
+
533
+ def create(self, url: str, event_types: List[str], campaign_ids: Optional[List[str]] = None) -> dict:
534
+ """The returned `secret` is shown ONCE. Store it: it is what you verify signatures with."""
535
+ return self._c.request("POST", "/webhooks", body=_drop_none({"url": url, "eventTypes": event_types, "campaignIds": campaign_ids}))
536
+
537
+ def get(self, id: str) -> dict: # noqa: A002
538
+ return self._c.request("GET", f"/webhooks/{_quote(id)}")
539
+
540
+ def update(self, id: str, url: Optional[str] = None, event_types: Optional[List[str]] = None, campaign_ids: Optional[List[str]] = None, is_active: Optional[bool] = None) -> dict: # noqa: A002
541
+ return self._c.request("PATCH", f"/webhooks/{_quote(id)}", body=_drop_none({"url": url, "eventTypes": event_types, "campaignIds": campaign_ids, "isActive": is_active}))
542
+
543
+ def delete(self, id: str) -> None: # noqa: A002
544
+ self._c.request("DELETE", f"/webhooks/{_quote(id)}")
545
+
546
+ def deliveries(self, id: str, limit: Optional[int] = None, before: Optional[str] = None) -> dict: # noqa: A002
547
+ return self._c.request("GET", f"/webhooks/{_quote(id)}/deliveries", query={"limit": limit, "before": before})
548
+
549
+ def test(self, id: str) -> dict: # noqa: A002
550
+ """Queues a `webhook.test` event to this endpoint; see `deliveries` for the result."""
551
+ return self._c.request("POST", f"/webhooks/{_quote(id)}/test")
552
+
553
+ def rotate_secret(self, id: str) -> dict: # noqa: A002
554
+ """Issues a new secret (shown once); the old one stops working immediately."""
555
+ return self._c.request("POST", f"/webhooks/{_quote(id)}/rotate-secret")
556
+
557
+
558
+ # operationId (from the OpenAPI document) -> the SDK method that serves it. A test keeps this in step with the spec.
559
+ SDK_OPERATIONS: Dict[str, str] = {
560
+ "listAccounts": "accounts.list", "getAccount": "accounts.get", "setAccountTier": "accounts.set_tier", "pauseAccount": "accounts.pause", "resumeAccount": "accounts.resume", "disconnectAccount": "accounts.disconnect",
561
+ "listCampaigns": "campaigns.list", "createCampaign": "campaigns.create", "getCampaign": "campaigns.get", "updateCampaign": "campaigns.update", "deleteCampaign": "campaigns.delete",
562
+ "saveCampaignGraph": "campaigns.save_graph", "duplicateCampaign": "campaigns.duplicate", "launchCampaign": "campaigns.launch", "pauseCampaign": "campaigns.pause", "resumeCampaign": "campaigns.resume",
563
+ "getSplitTests": "campaigns.split_tests", "addLeadsToCampaign": "campaigns.add_leads", "stopLeadInCampaign": "campaigns.stop_lead",
564
+ "getMe": "me", "getWorkspace": "workspace",
565
+ "listMembers": "members.list", "inviteMember": "members.invite", "updateMemberRole": "members.set_role", "removeMember": "members.remove",
566
+ "listBlacklist": "blacklist.list", "addToBlacklist": "blacklist.add", "removeFromBlacklist": "blacklist.remove",
567
+ "deriveList": "lists.derive", "listLists": "lists.list", "createList": "lists.create", "getList": "lists.get", "listListLeads": "lists.leads", "addLeadsToList": "lists.add_leads", "addMembersToList": "lists.add_members", "removeMembersFromList": "lists.remove_members", "deleteCreatedLeads": "lists.delete_created_leads", "addPeopleToList": "lists.add_people",
568
+ "listLeads": "leads.list", "getLead": "leads.get", "getLeadEvents": "leads.events", "setDoNotContact": "leads.set_do_not_contact",
569
+ "checkLeadIdentities": "leads.check_identities", "classifyLeadSignals": "leads.classify_signals", "lookupLead": "leads.lookup", "tagLeads": "leads.tag", "setLeadTags": "leads.set_tags", "updateLeadCustomFields": "leads.set_custom_fields",
570
+ "listInboxThreads": "inbox.threads", "getInboxThread": "inbox.thread",
571
+ "getAnalytics": "analytics.get",
572
+ "queueConnectionRequest": "accounts.send_connection_request", "queueMessage": "accounts.send_message", "queueProfileVisit": "accounts.visit_profile", "queueFollow": "accounts.follow",
573
+ "queuePostLike": "accounts.like_latest_post", "queueEndorsement": "accounts.endorse_skills", "listAccountActions": "accounts.list_actions", "getAction": "actions.get", "cancelAction": "actions.cancel",
574
+ "listWebhooks": "webhooks.list", "createWebhook": "webhooks.create", "getWebhook": "webhooks.get", "updateWebhook": "webhooks.update", "deleteWebhook": "webhooks.delete",
575
+ "listWebhookDeliveries": "webhooks.deliveries", "testWebhook": "webhooks.test", "rotateWebhookSecret": "webhooks.rotate_secret",
576
+ }
@@ -0,0 +1,39 @@
1
+ """Anything the API (or the network) answered that is not a success."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Optional
5
+
6
+
7
+ class HeyLeadsError(Exception):
8
+ """Raised for every non-success answer.
9
+
10
+ Attributes:
11
+ status: HTTP status, or 0 when the request never got an answer (network error, timeout).
12
+ code: the API's error code: unauthenticated, forbidden, not_found, invalid, rate_limited, limit_reached, unavailable, unknown ...
13
+ request_id: quote this to HeyLeads support.
14
+ field_errors: field name -> message, for a 422 on bad input.
15
+ issues: campaign graph problems, for a 422 on a graph save or launch.
16
+ retry_after_seconds: for a 429, how long the API asked you to wait.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ status: int,
22
+ code: str,
23
+ message: str,
24
+ request_id: Optional[str] = None,
25
+ field_errors: Optional[dict] = None,
26
+ issues: Optional[list] = None,
27
+ retry_after_seconds: Optional[float] = None,
28
+ ) -> None:
29
+ super().__init__(message)
30
+ self.status = status
31
+ self.code = code
32
+ self.message = message
33
+ self.request_id = request_id
34
+ self.field_errors = field_errors
35
+ self.issues = issues
36
+ self.retry_after_seconds = retry_after_seconds
37
+
38
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
39
+ return f"HeyLeadsError(status={self.status}, code={self.code!r}, message={self.message!r}, request_id={self.request_id!r})"
@@ -0,0 +1,57 @@
1
+ """Verify a webhook delivery. Dependency-free, and byte-for-byte the algorithm in docs/api/webhooks.md:
2
+
3
+ x-heylead-signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" keyed with your whsec_ secret>
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import hmac
9
+ import json
10
+ import time
11
+ from typing import Any, Optional, Union
12
+
13
+
14
+ def verify_webhook_signature(
15
+ secret: str,
16
+ signature_header: Optional[str],
17
+ raw_body: Union[str, bytes],
18
+ now_seconds: Optional[float] = None,
19
+ tolerance_seconds: float = 300,
20
+ ) -> bool:
21
+ """True only if `signature_header` is a valid signature of the EXACT `raw_body` bytes you received, made with `secret`, and recent enough.
22
+
23
+ Pass the raw request body, never a re-serialised object: re-serialising changes the bytes.
24
+ """
25
+ if not signature_header:
26
+ return False
27
+ parts: dict = {}
28
+ for piece in signature_header.split(","):
29
+ i = piece.find("=")
30
+ if i > 0:
31
+ parts[piece[:i].strip()] = piece[i + 1 :].strip()
32
+ try:
33
+ t = float(parts.get("t", ""))
34
+ except ValueError:
35
+ return False
36
+ given = parts.get("v1")
37
+ now = time.time() if now_seconds is None else now_seconds
38
+ if not given or abs(now - t) > tolerance_seconds:
39
+ return False
40
+ body = raw_body if isinstance(raw_body, str) else raw_body.decode("utf-8")
41
+ # The timestamp is signed as the text the sender wrote, so use it as written (an integer, in practice).
42
+ t_text = parts["t"]
43
+ expected = hmac.new(secret.encode("utf-8"), f"{t_text}.{body}".encode("utf-8"), hashlib.sha256).hexdigest()
44
+ return hmac.compare_digest(expected, given)
45
+
46
+
47
+ def construct_webhook_event(
48
+ secret: str,
49
+ signature_header: Optional[str],
50
+ raw_body: Union[str, bytes],
51
+ now_seconds: Optional[float] = None,
52
+ tolerance_seconds: float = 300,
53
+ ) -> Any:
54
+ """Verify, then parse. Raises ValueError if the signature is bad, so an unverified body is never handed to your code as an event."""
55
+ if not verify_webhook_signature(secret, signature_header, raw_body, now_seconds, tolerance_seconds):
56
+ raise ValueError("Invalid HeyLeads webhook signature")
57
+ return json.loads(raw_body if isinstance(raw_body, str) else raw_body.decode("utf-8"))
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: heyleads
3
+ Version: 1.0.0
4
+ Summary: Official Python client for the HeyLeads API (LinkedIn outreach): campaigns, leads, lists, inbox, webhooks. Standard library only.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://app.heyleads.so/developers
7
+ Project-URL: Documentation, https://app.heyleads.so/developers
8
+ Keywords: heyleads,linkedin,outreach,api,sdk
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Intended Audience :: Developers
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # heyleads (Python)
19
+
20
+ Python client for the [HeyLeads API](https://app.heyleads.so/developers). **Standard library only** (no `requests`, no dependencies); Python 3.9+.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install heyleads
26
+ ```
27
+
28
+ Create an API key in HeyLeads under **Settings → Integrations → API keys** (it starts with `hl_live_`) and keep it in an environment variable.
29
+
30
+ ```python
31
+ import os
32
+ from heyleads import HeyLeads, HeyLeadsError
33
+
34
+ hl = HeyLeads(api_key=os.environ["HEYLEAD_API_KEY"]) # talks to https://app.heyleads.so unless you pass base_url
35
+
36
+ campaign = hl.campaigns.create({"name": "Founders, Q4"}) # a draft: nothing is sent yet
37
+ hl.campaigns.add_leads(campaign["id"], rows=[{"fullName": "Ada Lovelace", "linkedinUrl": "https://www.linkedin.com/in/ada-lovelace"}]) # queued
38
+ hl.campaigns.launch(campaign["id"]) # running, not "sent": the engine paces every action within each account's limits
39
+
40
+ for lead in hl.leads.iterate(status="active"): # follows the cursor for you
41
+ print(lead["fullName"])
42
+
43
+ try:
44
+ hl.lists.create("")
45
+ except HeyLeadsError as e:
46
+ print(e.status, e.code, e.message, e.field_errors, e.request_id)
47
+ ```
48
+
49
+ ## Good to know
50
+
51
+ - **Dicts in, dicts out**, with the API's own camelCase keys (`fullName`, `linkedinUrl`, `nextCursor`), so nothing is lost in translation and the [API reference](https://app.heyleads.so/developers/reference) is the documentation for every field. Options that are Python keyword arguments are snake_case and sent as camelCase (`campaign_id` → `campaignId`; `from_` → `from`).
52
+ - **Queued, not sent.** There is no "send now": adding leads to a campaign is how HeyLeads contacts anyone, and a return means queued / running. People on the blacklist or marked do-not-contact are skipped and counted in the answer.
53
+ - **Errors** are `HeyLeadsError` with `status`, `code`, `message`, `request_id`, and, for a 422, `field_errors` / `issues`. A network failure or timeout has `status == 0`.
54
+ - **Retries.** A `429` is waited out using `Retry-After` (for any method: it was refused before it ran). Repeatable requests (`GET`, `PUT`, `DELETE`, and queued LinkedIn actions, which carry an `Idempotency-Key`) are also retried after a `502`/`503`/`504` or a network error; a `POST`/`PATCH` that may have run is **never** repeated. Tune with `max_retries` (default 2), `timeout` (30 s) and `max_retry_wait_seconds` (30: a longer `Retry-After` raises so you can decide).
55
+ - **Paging.** `leads.list` / `inbox.threads` return `{items, nextCursor, total}`; `leads.iterate`, `lists.iterate_leads` and `inbox.iterate_threads` walk every page.
56
+ - **Waiting for an action.** `hl.actions.wait(action["id"])` polls until it finishes (default up to 5 minutes); prefer webhooks in production.
57
+ - **Webhooks.** Verify a delivery against the **raw** body:
58
+
59
+ ```python
60
+ from heyleads import construct_webhook_event
61
+
62
+ @app.post("/heylead")
63
+ def hook():
64
+ try:
65
+ event = construct_webhook_event(os.environ["HEYLEAD_WEBHOOK_SECRET"], request.headers.get("x-heylead-signature"), request.get_data()) # raises ValueError if invalid or older than 5 minutes
66
+ except ValueError:
67
+ return "", 400
68
+ if event["event"] == "lead.positive_reply":
69
+ handle(event["data"])
70
+ return "", 200
71
+ ```
72
+
73
+ The method list mirrors `GET /api/v1/openapi.json` one to one (`heyleads.SDK_OPERATIONS` maps each operation id to its method, and a test fails if they drift). The tests run the client against the real API handlers over HTTP.
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ heyleads/__init__.py
5
+ heyleads/client.py
6
+ heyleads/errors.py
7
+ heyleads/webhooks.py
8
+ heyleads.egg-info/PKG-INFO
9
+ heyleads.egg-info/SOURCES.txt
10
+ heyleads.egg-info/dependency_links.txt
11
+ heyleads.egg-info/top_level.txt
12
+ tests/test_client.py
@@ -0,0 +1 @@
1
+ heyleads
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "heyleads"
7
+ version = "1.0.0"
8
+ description = "Official Python client for the HeyLeads API (LinkedIn outreach): campaigns, leads, lists, inbox, webhooks. Standard library only."
9
+ requires-python = ">=3.9"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ keywords = ["heyleads", "linkedin", "outreach", "api", "sdk"]
13
+ readme = "README.md"
14
+ dependencies = []
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Operating System :: OS Independent",
19
+ "Intended Audience :: Developers",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://app.heyleads.so/developers"
24
+ Documentation = "https://app.heyleads.so/developers"
25
+
26
+ [tool.setuptools]
27
+ packages = ["heyleads"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,243 @@
1
+ """Unit tests with a fake transport: paths, methods, bodies, query strings, error mapping, retries, paging, webhooks. Run: python3 -I -m unittest discover -s tests"""
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+ import os
6
+ import sys
7
+ import unittest
8
+
9
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
10
+
11
+ from heyleads import HeyLeads, HeyLeadsError, SDK_OPERATIONS, construct_webhook_event, verify_webhook_signature # noqa: E402
12
+
13
+ KEY = "hl_live_" + "a" * 32
14
+ BASE = "https://app.heylead.test"
15
+
16
+
17
+ class Fake:
18
+ """Answers from a script of (status, headers, body) and records every request."""
19
+
20
+ def __init__(self, *script):
21
+ self.script = list(script)
22
+ self.calls = []
23
+
24
+ def __call__(self, method, url, headers, body, timeout):
25
+ self.calls.append({"method": method, "url": url, "headers": headers, "body": json.loads(body) if body else None, "timeout": timeout})
26
+ step = self.script.pop(0) if len(self.script) > 1 else self.script[0]
27
+ if isinstance(step, Exception):
28
+ raise step
29
+ status, headers, payload = step
30
+ return status, headers, (payload if isinstance(payload, bytes) else json.dumps(payload).encode())
31
+
32
+
33
+ def ok(payload=None, status=200):
34
+ return (status, {}, payload if payload is not None else {})
35
+
36
+
37
+ def err(status, code, message="no", extra=None, headers=None):
38
+ return (status, headers or {}, {"error": {"code": code, "message": message, "requestId": "req_1", **(extra or {})}})
39
+
40
+
41
+ def client(*script, **kw):
42
+ fake = Fake(*(script or (ok(),)))
43
+ slept = []
44
+ hl = HeyLeads(api_key=KEY, base_url=BASE, transport=fake, sleep=slept.append, **kw)
45
+ return hl, fake, slept
46
+
47
+
48
+ class Construction(unittest.TestCase):
49
+ def test_refuses_a_missing_or_malformed_key_and_a_missing_host(self):
50
+ with self.assertRaises(TypeError):
51
+ HeyLeads(api_key="", base_url=BASE)
52
+ with self.assertRaises(TypeError):
53
+ HeyLeads(api_key="sk-nope", base_url=BASE)
54
+ old = os.environ.pop("HEYLEAD_BASE_URL", None)
55
+ try:
56
+ with self.assertRaisesRegex(TypeError, "base_url"):
57
+ HeyLeads(api_key=KEY, base_url="app.heyleads.so")
58
+ self.assertTrue(HeyLeads(api_key=KEY)._root.startswith("https://app.heyleads.so/")) # the hosted product by default
59
+ finally:
60
+ if old is not None:
61
+ os.environ["HEYLEAD_BASE_URL"] = old
62
+
63
+ def test_sends_the_key_a_user_agent_and_a_request_id_and_tolerates_a_trailing_slash(self):
64
+ fake = Fake(ok({"items": []}))
65
+ HeyLeads(api_key=KEY, base_url=BASE + "///", transport=fake).accounts.list()
66
+ c = fake.calls[0]
67
+ self.assertEqual(c["url"], f"{BASE}/api/v1/accounts")
68
+ self.assertEqual(c["headers"]["authorization"], f"Bearer {KEY}")
69
+ self.assertTrue(c["headers"]["user-agent"].startswith("heyleads-python/"))
70
+ self.assertEqual(len(c["headers"]["x-request-id"]), 36)
71
+
72
+
73
+ class Requests(unittest.TestCase):
74
+ def test_paths_methods_bodies_and_query_strings(self):
75
+ hl, fake, _ = client(ok({"items": []}))
76
+ hl.accounts.list_actions("a/b", status="queued", limit=5)
77
+ self.assertEqual(fake.calls[-1]["url"], f"{BASE}/api/v1/accounts/a%2Fb/actions?status=queued&limit=5")
78
+ hl.campaigns.update("c1", {"name": "New"})
79
+ self.assertEqual((fake.calls[-1]["method"], fake.calls[-1]["body"]), ("PATCH", {"name": "New"}))
80
+ hl.leads.list(search="ada", do_not_contact=True, campaign_id="c1")
81
+ self.assertEqual(fake.calls[-1]["url"], f"{BASE}/api/v1/leads?search=ada&doNotContact=true&campaignId=c1")
82
+ hl.blacklist.add("lead", ["a@b.co"], dry_run=True)
83
+ self.assertEqual(fake.calls[-1]["body"], {"kind": "lead", "entries": ["a@b.co"], "dryRun": True})
84
+ hl.blacklist.add("lead", ["a@b.co"])
85
+ self.assertNotIn("dryRun", fake.calls[-1]["body"])
86
+ hl.lists.derive("exclude", ["A"], name="x", exclude_list_ids=["B"])
87
+ self.assertEqual(fake.calls[-1]["body"], {"name": "x", "operation": "exclude", "listIds": ["A"], "excludeListIds": ["B"]})
88
+ hl.accounts.set_tier("acc", "sales_navigator", sales_navigator="connected")
89
+ self.assertEqual((fake.calls[-1]["method"], fake.calls[-1]["body"]), ("PUT", {"tier": "sales_navigator", "salesNavigator": "connected"}))
90
+ hl.analytics.get(days=7, campaign_id="c")
91
+ self.assertEqual(fake.calls[-1]["url"], f"{BASE}/api/v1/analytics?days=7&campaignId=c")
92
+ hl.analytics.get(from_="2026-10-01", to="2026-10-07")
93
+ self.assertIn("from=2026-10-01&to=2026-10-07", fake.calls[-1]["url"])
94
+ hl.leads.lookup(profile_url="https://www.linkedin.com/in/ada")
95
+ self.assertIn("profileUrl=https%3A%2F%2Fwww.linkedin.com%2Fin%2Fada", fake.calls[-1]["url"])
96
+
97
+ def test_returns_the_unwrapped_pieces(self):
98
+ hl, _, _ = client(ok({"items": [{"id": "a"}]}))
99
+ self.assertEqual(hl.accounts.list(), [{"id": "a"}])
100
+ hl2, _, _ = client(ok({"tags": ["x"]}))
101
+ self.assertEqual(hl2.leads.set_tags("l", ["x"]), ["x"])
102
+ hl3, _, _ = client(ok({"customFields": {"k": "v"}}))
103
+ self.assertEqual(hl3.leads.set_custom_fields("l", set={"k": "v"}), {"k": "v"})
104
+
105
+ def test_204_is_none(self):
106
+ hl, fake, _ = client((204, {}, b""))
107
+ self.assertIsNone(hl.accounts.disconnect("a"))
108
+ self.assertEqual(fake.calls[0]["method"], "DELETE")
109
+
110
+ def test_queued_actions_carry_an_idempotency_key_yours_or_generated(self):
111
+ hl, fake, _ = client(ok({"id": "x"}, 201))
112
+ hl.accounts.send_message("a", "https://www.linkedin.com/in/ada", "Hi", idempotency_key="mine-1")
113
+ self.assertEqual(fake.calls[0]["headers"]["idempotency-key"], "mine-1")
114
+ self.assertEqual(fake.calls[0]["body"], {"profileUrl": "https://www.linkedin.com/in/ada", "text": "Hi"})
115
+ hl.accounts.visit_profile("a", "https://www.linkedin.com/in/ada", expires_in_seconds=60)
116
+ self.assertEqual(len(fake.calls[1]["headers"]["idempotency-key"]), 36)
117
+ self.assertEqual(fake.calls[1]["body"]["expiresInSeconds"], 60)
118
+
119
+ def test_paging_follows_the_cursor(self):
120
+ hl, fake, _ = client(ok({"items": [{"id": 1}, {"id": 2}], "nextCursor": "c2"}), ok({"items": [{"id": 3}], "nextCursor": None}))
121
+ self.assertEqual([x["id"] for x in hl.leads.iterate(status="active")], [1, 2, 3])
122
+ self.assertNotIn("cursor", fake.calls[0]["url"])
123
+ self.assertIn("cursor=c2", fake.calls[1]["url"])
124
+ self.assertIn("status=active", fake.calls[1]["url"])
125
+
126
+
127
+ class Errors(unittest.TestCase):
128
+ def test_maps_the_error_envelope(self):
129
+ hl, _, _ = client(err(422, "invalid", "Give it a name.", {"fieldErrors": {"name": "Give it a name."}}))
130
+ with self.assertRaises(HeyLeadsError) as cm:
131
+ hl.lists.create("")
132
+ e = cm.exception
133
+ self.assertEqual((e.status, e.code, e.request_id, e.field_errors), (422, "invalid", "req_1", {"name": "Give it a name."}))
134
+ self.assertEqual(str(e), "Give it a name.")
135
+
136
+ def test_a_non_json_error_page_still_raises_something_readable(self):
137
+ hl, _, _ = client((502, {}, b"<html>Bad gateway</html>"), max_retries=0)
138
+ with self.assertRaises(HeyLeadsError) as cm:
139
+ hl.accounts.list()
140
+ self.assertEqual((cm.exception.status, cm.exception.code), (502, "http_error"))
141
+
142
+ def test_network_errors_and_timeouts_raise_with_status_zero(self):
143
+ hl, _, _ = client(ConnectionError("down"), max_retries=0)
144
+ with self.assertRaises(HeyLeadsError) as cm:
145
+ hl.accounts.list()
146
+ self.assertEqual((cm.exception.status, cm.exception.code), (0, "network_error"))
147
+ hl2, _, _ = client(TimeoutError("slow"), max_retries=0)
148
+ with self.assertRaises(HeyLeadsError) as cm2:
149
+ hl2.accounts.list()
150
+ self.assertEqual(cm2.exception.code, "timeout")
151
+
152
+
153
+ class Retries(unittest.TestCase):
154
+ def test_a_429_is_waited_out_for_any_method_using_retry_after(self):
155
+ hl, fake, slept = client(err(429, "rate_limited", headers={"retry-after": "2"}, extra={"retryAfterSeconds": 2}), ok({"id": "c"}, 201))
156
+ self.assertEqual(hl.campaigns.create({"name": "x"}), {"id": "c"})
157
+ self.assertEqual(len(fake.calls), 2)
158
+ self.assertEqual(slept, [2.0])
159
+
160
+ def test_a_retry_after_longer_than_the_cap_raises_so_the_caller_decides(self):
161
+ hl, fake, _ = client(err(429, "rate_limited", extra={"retryAfterSeconds": 120}))
162
+ with self.assertRaises(HeyLeadsError) as cm:
163
+ hl.accounts.list()
164
+ self.assertEqual(cm.exception.retry_after_seconds, 120)
165
+ self.assertEqual(len(fake.calls), 1)
166
+
167
+ def test_a_503_is_retried_for_repeatable_requests_only(self):
168
+ hl, fake, _ = client(err(503, "x"), ok({"items": []}))
169
+ hl.accounts.list()
170
+ self.assertEqual(len(fake.calls), 2)
171
+ hl2, fake2, _ = client(err(503, "x"), ok({"id": "c"}))
172
+ with self.assertRaises(HeyLeadsError):
173
+ hl2.campaigns.create({"name": "x"}) # a POST that may have run is never repeated
174
+ self.assertEqual(len(fake2.calls), 1)
175
+
176
+ def test_a_queued_action_may_be_repeated_because_it_is_idempotent(self):
177
+ hl, fake, _ = client(ConnectionError("blip"), ok({"id": "a"}, 201))
178
+ hl.accounts.follow("acc", "https://www.linkedin.com/in/ada", idempotency_key="k")
179
+ self.assertEqual(len(fake.calls), 2)
180
+ self.assertEqual(fake.calls[0]["headers"]["idempotency-key"], fake.calls[1]["headers"]["idempotency-key"])
181
+
182
+ def test_retries_stop_at_max_retries_and_actions_disabled_is_never_retried(self):
183
+ hl, fake, _ = client(err(503, "x"), max_retries=1)
184
+ with self.assertRaises(HeyLeadsError):
185
+ hl.accounts.list()
186
+ self.assertEqual(len(fake.calls), 2)
187
+ hl2, fake2, _ = client(err(503, "actions_disabled"))
188
+ with self.assertRaises(HeyLeadsError):
189
+ hl2.accounts.list()
190
+ self.assertEqual(len(fake2.calls), 1)
191
+
192
+
193
+ class ActionsWait(unittest.TestCase):
194
+ def test_polls_until_final_then_returns(self):
195
+ hl, fake, slept = client(ok({"status": "queued"}), ok({"status": "running"}), ok({"status": "succeeded"}))
196
+ self.assertEqual(hl.actions.wait("a", interval_seconds=1, timeout_seconds=60)["status"], "succeeded")
197
+ self.assertEqual(len(fake.calls), 3)
198
+ self.assertEqual(slept, [1, 1])
199
+
200
+ def test_gives_up_with_a_timeout_error(self):
201
+ hl, _, _ = client(ok({"status": "queued"}))
202
+ with self.assertRaises(HeyLeadsError) as cm:
203
+ hl.actions.wait("a", interval_seconds=10, timeout_seconds=5)
204
+ self.assertEqual(cm.exception.code, "timeout")
205
+
206
+
207
+ class Webhooks(unittest.TestCase):
208
+ def sign(self, secret, t, body):
209
+ return f"t={t},v1=" + hmac.new(secret.encode(), f"{t}.{body}".encode(), hashlib.sha256).hexdigest()
210
+
211
+ def test_verifies_the_exact_body_and_rejects_everything_else(self):
212
+ body = '{"event":"lead.replied","data":{"id":"l"}}'
213
+ header = self.sign("whsec_x", 1_760_000_000, body)
214
+ self.assertTrue(verify_webhook_signature("whsec_x", header, body, now_seconds=1_760_000_100))
215
+ self.assertTrue(verify_webhook_signature("whsec_x", header, body.encode(), now_seconds=1_760_000_100))
216
+ self.assertFalse(verify_webhook_signature("whsec_x", header, body + " ", now_seconds=1_760_000_100))
217
+ self.assertFalse(verify_webhook_signature("whsec_other", header, body, now_seconds=1_760_000_100))
218
+ self.assertFalse(verify_webhook_signature("whsec_x", header, body, now_seconds=1_760_000_000 + 301))
219
+ self.assertFalse(verify_webhook_signature("whsec_x", header, body, now_seconds=1_760_000_000 - 301))
220
+ for bad in (None, "", "garbage", "t=abc,v1=00", "t=1760000000", "v1=00"):
221
+ self.assertFalse(verify_webhook_signature("whsec_x", bad, body, now_seconds=1_760_000_100), bad)
222
+
223
+ def test_construct_parses_only_after_verifying(self):
224
+ body = '{"event":"lead.replied","data":{"id":"l"}}'
225
+ header = self.sign("whsec_x", 1_760_000_000, body)
226
+ self.assertEqual(construct_webhook_event("whsec_x", header, body, now_seconds=1_760_000_001)["data"], {"id": "l"})
227
+ with self.assertRaises(ValueError):
228
+ construct_webhook_event("whsec_x", "t=1,v1=00", body, now_seconds=1)
229
+
230
+
231
+ class Surface(unittest.TestCase):
232
+ def test_every_mapped_method_exists_on_the_client(self):
233
+ hl, _, _ = client()
234
+ for op, path in SDK_OPERATIONS.items():
235
+ obj = hl
236
+ for part in path.split("."):
237
+ obj = getattr(obj, part, None)
238
+ self.assertIsNotNone(obj, f"{op} -> {path}")
239
+ self.assertTrue(callable(obj), f"{op} -> {path}")
240
+
241
+
242
+ if __name__ == "__main__":
243
+ unittest.main()