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 +21 -0
- heyleads-1.0.0/PKG-INFO +73 -0
- heyleads-1.0.0/README.md +56 -0
- heyleads-1.0.0/heyleads/__init__.py +6 -0
- heyleads-1.0.0/heyleads/client.py +576 -0
- heyleads-1.0.0/heyleads/errors.py +39 -0
- heyleads-1.0.0/heyleads/webhooks.py +57 -0
- heyleads-1.0.0/heyleads.egg-info/PKG-INFO +73 -0
- heyleads-1.0.0/heyleads.egg-info/SOURCES.txt +12 -0
- heyleads-1.0.0/heyleads.egg-info/dependency_links.txt +1 -0
- heyleads-1.0.0/heyleads.egg-info/top_level.txt +1 -0
- heyleads-1.0.0/pyproject.toml +27 -0
- heyleads-1.0.0/setup.cfg +4 -0
- heyleads-1.0.0/tests/test_client.py +243 -0
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.
|
heyleads-1.0.0/PKG-INFO
ADDED
|
@@ -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.
|
heyleads-1.0.0/README.md
ADDED
|
@@ -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
|
+
|
|
@@ -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"]
|
heyleads-1.0.0/setup.cfg
ADDED
|
@@ -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()
|