custfind 1.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- custfind-1.1.0/LICENSE +21 -0
- custfind-1.1.0/PKG-INFO +84 -0
- custfind-1.1.0/README.md +70 -0
- custfind-1.1.0/custfind/__init__.py +6 -0
- custfind-1.1.0/custfind/client.py +334 -0
- custfind-1.1.0/custfind/webhooks.py +42 -0
- custfind-1.1.0/custfind.egg-info/PKG-INFO +84 -0
- custfind-1.1.0/custfind.egg-info/SOURCES.txt +11 -0
- custfind-1.1.0/custfind.egg-info/dependency_links.txt +1 -0
- custfind-1.1.0/custfind.egg-info/top_level.txt +1 -0
- custfind-1.1.0/pyproject.toml +21 -0
- custfind-1.1.0/setup.cfg +4 -0
- custfind-1.1.0/tests/test_client.py +182 -0
custfind-1.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Parevo
|
|
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.
|
custfind-1.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: custfind
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Official Python client for the Custfind API: lead searches, email outreach, CRM and webhooks.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Documentation, https://custfind.com/developers
|
|
7
|
+
Project-URL: Source, https://github.com/parevo/custfind-python
|
|
8
|
+
Project-URL: Issues, https://github.com/parevo/custfind-python/issues
|
|
9
|
+
Keywords: custfind,leads,lead-generation,api,sdk
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# custfind
|
|
16
|
+
|
|
17
|
+
Official Python client for the [Custfind API](https://custfind.com/developers): start lead searches, read scored leads, write and send emails, move deals and receive webhooks. Standard library only; Python 3.9+.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install custfind
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from custfind import Custfind
|
|
25
|
+
|
|
26
|
+
cf = Custfind() # reads CUSTFIND_API_KEY
|
|
27
|
+
|
|
28
|
+
search = cf.searches.create(
|
|
29
|
+
sector="Dental clinic",
|
|
30
|
+
location="Kadıköy, Istanbul",
|
|
31
|
+
limit=25, # caps the credits this search reserves
|
|
32
|
+
external_customer_id="acme-42", # optional: bill your own customer
|
|
33
|
+
)
|
|
34
|
+
cf.searches.wait_for_completion(search["job_id"])
|
|
35
|
+
for lead in cf.searches.all_leads(search["job_id"]):
|
|
36
|
+
print(lead["name"], lead.get("email"), lead.get("score"))
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Safe retries
|
|
40
|
+
|
|
41
|
+
Every write is sent with an `Idempotency-Key` that stays the same across retries, and network errors, `429` (respecting `Retry-After`), `5xx` and `409 idempotency_in_progress` are retried up to `max_retries` (3). Any other `409` is final and raised at once. Every write method takes `idempotency_key=`. A timed-out search is never started twice. Pass your own key to make retries across restarts safe too:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
cf.searches.create(idempotency_key=f"search-{order_id}", sector="Dental clinic", location="Izmir", limit=50)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Errors
|
|
48
|
+
|
|
49
|
+
Failed calls raise `CustfindError` with `.status` and the API's `.code` (`api_requires_paid_plan`, `customer_limit_reached`, `insufficient_scope`, …).
|
|
50
|
+
|
|
51
|
+
## Webhooks
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from flask import Flask, request, abort
|
|
55
|
+
from custfind import verify_webhook, WebhookVerificationError
|
|
56
|
+
|
|
57
|
+
@app.post("/webhooks/custfind")
|
|
58
|
+
def custfind_webhook():
|
|
59
|
+
try:
|
|
60
|
+
event = verify_webhook(request.get_data(), request.headers.get("Custfind-Signature"), SECRET)
|
|
61
|
+
except WebhookVerificationError:
|
|
62
|
+
abort(400)
|
|
63
|
+
if event["type"] == "search.completed":
|
|
64
|
+
...
|
|
65
|
+
return "", 200
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Resources
|
|
69
|
+
|
|
70
|
+
`searches`, `leads`, `outreach`, `pipelines`, `deals`, `webhooks`, `customers`, `sender_accounts`, plus `me()`, `balance()` and `usage()`. See the [API reference](https://custfind.com/developers).
|
|
71
|
+
|
|
72
|
+
## Contributing
|
|
73
|
+
|
|
74
|
+
This repository is a read-only mirror of `sdks/python` in Custfind's main repository, updated on every release. Issues are welcome here; pull requests are read and ported by hand, then closed, since the next release overwrites this repository.
|
|
75
|
+
|
|
76
|
+
## Developing
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
python -m unittest discover -s tests -t .
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Changelog
|
|
83
|
+
|
|
84
|
+
- **1.1.0** — Only `409 idempotency_in_progress` is retried (a final 409 used to be retried until it gave up). Read timeouts and dropped connections are retried on Python 3.9 too. Every write takes `idempotency_key=`. `searches.leads`/`all_leads` take every lead filter; new `leads.all`. `outreach.send` takes an aware `datetime`; `outreach.rewrite` needs a tone or an instruction. Plain-text error bodies are kept in the message.
|
custfind-1.1.0/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# custfind
|
|
2
|
+
|
|
3
|
+
Official Python client for the [Custfind API](https://custfind.com/developers): start lead searches, read scored leads, write and send emails, move deals and receive webhooks. Standard library only; Python 3.9+.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install custfind
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from custfind import Custfind
|
|
11
|
+
|
|
12
|
+
cf = Custfind() # reads CUSTFIND_API_KEY
|
|
13
|
+
|
|
14
|
+
search = cf.searches.create(
|
|
15
|
+
sector="Dental clinic",
|
|
16
|
+
location="Kadıköy, Istanbul",
|
|
17
|
+
limit=25, # caps the credits this search reserves
|
|
18
|
+
external_customer_id="acme-42", # optional: bill your own customer
|
|
19
|
+
)
|
|
20
|
+
cf.searches.wait_for_completion(search["job_id"])
|
|
21
|
+
for lead in cf.searches.all_leads(search["job_id"]):
|
|
22
|
+
print(lead["name"], lead.get("email"), lead.get("score"))
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Safe retries
|
|
26
|
+
|
|
27
|
+
Every write is sent with an `Idempotency-Key` that stays the same across retries, and network errors, `429` (respecting `Retry-After`), `5xx` and `409 idempotency_in_progress` are retried up to `max_retries` (3). Any other `409` is final and raised at once. Every write method takes `idempotency_key=`. A timed-out search is never started twice. Pass your own key to make retries across restarts safe too:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
cf.searches.create(idempotency_key=f"search-{order_id}", sector="Dental clinic", location="Izmir", limit=50)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Errors
|
|
34
|
+
|
|
35
|
+
Failed calls raise `CustfindError` with `.status` and the API's `.code` (`api_requires_paid_plan`, `customer_limit_reached`, `insufficient_scope`, …).
|
|
36
|
+
|
|
37
|
+
## Webhooks
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from flask import Flask, request, abort
|
|
41
|
+
from custfind import verify_webhook, WebhookVerificationError
|
|
42
|
+
|
|
43
|
+
@app.post("/webhooks/custfind")
|
|
44
|
+
def custfind_webhook():
|
|
45
|
+
try:
|
|
46
|
+
event = verify_webhook(request.get_data(), request.headers.get("Custfind-Signature"), SECRET)
|
|
47
|
+
except WebhookVerificationError:
|
|
48
|
+
abort(400)
|
|
49
|
+
if event["type"] == "search.completed":
|
|
50
|
+
...
|
|
51
|
+
return "", 200
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Resources
|
|
55
|
+
|
|
56
|
+
`searches`, `leads`, `outreach`, `pipelines`, `deals`, `webhooks`, `customers`, `sender_accounts`, plus `me()`, `balance()` and `usage()`. See the [API reference](https://custfind.com/developers).
|
|
57
|
+
|
|
58
|
+
## Contributing
|
|
59
|
+
|
|
60
|
+
This repository is a read-only mirror of `sdks/python` in Custfind's main repository, updated on every release. Issues are welcome here; pull requests are read and ported by hand, then closed, since the next release overwrites this repository.
|
|
61
|
+
|
|
62
|
+
## Developing
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
python -m unittest discover -s tests -t .
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Changelog
|
|
69
|
+
|
|
70
|
+
- **1.1.0** — Only `409 idempotency_in_progress` is retried (a final 409 used to be retried until it gave up). Read timeouts and dropped connections are retried on Python 3.9 too. Every write takes `idempotency_key=`. `searches.leads`/`all_leads` take every lead filter; new `leads.all`. `outreach.send` takes an aware `datetime`; `outreach.rewrite` needs a tone or an instruction. Plain-text error bodies are kept in the message.
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import http.client
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any, Callable, Dict, Iterator, List, Optional, Union
|
|
9
|
+
from urllib import error, parse, request
|
|
10
|
+
|
|
11
|
+
__version__ = "1.1.0"
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://api.custfind.com"
|
|
14
|
+
FINAL_STATUSES = ("completed", "failed", "cancelled")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CustfindError(Exception):
|
|
18
|
+
"""An error response from the Custfind API, or a request that never got one."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, status: int, code: str, message: Optional[str] = None, retry_after: Optional[int] = None):
|
|
21
|
+
self.status = status # 0 when there was no response
|
|
22
|
+
self.code = code
|
|
23
|
+
self.retry_after = retry_after
|
|
24
|
+
super().__init__(message or (f"Custfind API error {status}: {code}" if status else f"Custfind request failed: {code}"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _q(value: str) -> str:
|
|
28
|
+
return parse.quote(str(value), safe="")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Custfind:
|
|
32
|
+
"""The Custfind API client.
|
|
33
|
+
|
|
34
|
+
Every write carries an Idempotency-Key that stays the same across its
|
|
35
|
+
retries, so a retried search never starts twice or reserves credits twice.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
api_key: Optional[str] = None,
|
|
41
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
42
|
+
timeout: float = 30.0,
|
|
43
|
+
max_retries: int = 3,
|
|
44
|
+
):
|
|
45
|
+
self.api_key = api_key or os.environ.get("CUSTFIND_API_KEY")
|
|
46
|
+
if not self.api_key:
|
|
47
|
+
raise ValueError("Custfind: pass api_key or set CUSTFIND_API_KEY")
|
|
48
|
+
self.base_url = base_url.rstrip("/")
|
|
49
|
+
self.timeout = timeout
|
|
50
|
+
self.max_retries = max_retries
|
|
51
|
+
self.searches = Searches(self)
|
|
52
|
+
self.leads = Leads(self)
|
|
53
|
+
self.outreach = Outreach(self)
|
|
54
|
+
self.pipelines = Pipelines(self)
|
|
55
|
+
self.deals = Deals(self)
|
|
56
|
+
self.webhooks = Webhooks(self)
|
|
57
|
+
self.customers = Customers(self)
|
|
58
|
+
self.sender_accounts = SenderAccounts(self)
|
|
59
|
+
|
|
60
|
+
def me(self) -> Dict[str, Any]:
|
|
61
|
+
return self.request("GET", "/v1/me")
|
|
62
|
+
|
|
63
|
+
def balance(self) -> Dict[str, Any]:
|
|
64
|
+
return self.request("GET", "/v1/balance")
|
|
65
|
+
|
|
66
|
+
def usage(self, **params: Any) -> Dict[str, Any]:
|
|
67
|
+
"""from, to, group_by ("customer" | "api_key" | "none"), external_customer_id."""
|
|
68
|
+
return self.request("GET", "/v1/usage", query=params)
|
|
69
|
+
|
|
70
|
+
def request(
|
|
71
|
+
self,
|
|
72
|
+
method: str,
|
|
73
|
+
path: str,
|
|
74
|
+
query: Optional[Dict[str, Any]] = None,
|
|
75
|
+
body: Any = None,
|
|
76
|
+
idempotency_key: Optional[str] = None,
|
|
77
|
+
) -> Any:
|
|
78
|
+
url = self.base_url + path
|
|
79
|
+
if query:
|
|
80
|
+
clean = {k: v for k, v in query.items() if v is not None and v != ""}
|
|
81
|
+
if clean:
|
|
82
|
+
url += "?" + parse.urlencode(clean)
|
|
83
|
+
headers = {
|
|
84
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
85
|
+
"Accept": "application/json",
|
|
86
|
+
"User-Agent": f"custfind-python/{__version__}",
|
|
87
|
+
}
|
|
88
|
+
data = None
|
|
89
|
+
if body is not None:
|
|
90
|
+
headers["Content-Type"] = "application/json"
|
|
91
|
+
data = json.dumps(body).encode()
|
|
92
|
+
if method != "GET":
|
|
93
|
+
headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
|
|
94
|
+
|
|
95
|
+
attempt = 0
|
|
96
|
+
while True:
|
|
97
|
+
req = request.Request(url, data=data, method=method, headers=headers)
|
|
98
|
+
try:
|
|
99
|
+
with request.urlopen(req, timeout=self.timeout) as resp:
|
|
100
|
+
raw = resp.read()
|
|
101
|
+
if resp.status == 204 or not raw:
|
|
102
|
+
return None
|
|
103
|
+
return json.loads(raw)
|
|
104
|
+
except error.HTTPError as exc:
|
|
105
|
+
retry_after = _int(exc.headers.get("Retry-After"))
|
|
106
|
+
try:
|
|
107
|
+
raw = exc.read() or b""
|
|
108
|
+
except (OSError, http.client.HTTPException):
|
|
109
|
+
raw = b""
|
|
110
|
+
finally:
|
|
111
|
+
exc.close()
|
|
112
|
+
code, detail = f"http_{exc.code}", None
|
|
113
|
+
try:
|
|
114
|
+
payload = json.loads(raw)
|
|
115
|
+
if isinstance(payload, dict):
|
|
116
|
+
code = payload.get("error") or code
|
|
117
|
+
detail = payload.get("detail")
|
|
118
|
+
except ValueError:
|
|
119
|
+
# A few routes answer in plain text ("invalid json body").
|
|
120
|
+
detail = raw.decode("utf-8", "replace").strip() or None
|
|
121
|
+
# A 409 is retried only while an earlier attempt of the same
|
|
122
|
+
# write is still running; any other 409 ("invalid job state")
|
|
123
|
+
# is final, and a retry would only get it replayed.
|
|
124
|
+
retryable = exc.code == 429 or exc.code >= 500 or (exc.code == 409 and code == "idempotency_in_progress")
|
|
125
|
+
if retryable and attempt < self.max_retries:
|
|
126
|
+
time.sleep(retry_after if retry_after is not None else _backoff(attempt))
|
|
127
|
+
attempt += 1
|
|
128
|
+
continue
|
|
129
|
+
message = f"Custfind API error {exc.code}: {code} ({detail})" if detail else None
|
|
130
|
+
raise CustfindError(exc.code, code, message, retry_after=retry_after) from None
|
|
131
|
+
except (OSError, http.client.HTTPException) as exc:
|
|
132
|
+
# URLError, timeouts (socket.timeout is not a TimeoutError on
|
|
133
|
+
# 3.9), dropped connections and truncated responses.
|
|
134
|
+
if attempt < self.max_retries:
|
|
135
|
+
time.sleep(_backoff(attempt))
|
|
136
|
+
attempt += 1
|
|
137
|
+
continue
|
|
138
|
+
raise CustfindError(0, str(getattr(exc, "reason", exc))) from None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _int(value: Optional[str]) -> Optional[int]:
|
|
142
|
+
try:
|
|
143
|
+
return int(value) if value is not None else None
|
|
144
|
+
except ValueError:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _backoff(attempt: int) -> float:
|
|
149
|
+
return min(8.0, 0.5 * 2**attempt) + random.random() * 0.25
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _paginate(fetch: Callable[[int], Dict[str, Any]], page_size: int) -> Iterator[Dict[str, Any]]:
|
|
153
|
+
page = 1
|
|
154
|
+
while True:
|
|
155
|
+
res = fetch(page)
|
|
156
|
+
items = res.get("items") or []
|
|
157
|
+
yield from items
|
|
158
|
+
if not items or page * page_size >= res.get("total", 0):
|
|
159
|
+
return
|
|
160
|
+
page += 1
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class _Resource:
|
|
164
|
+
def __init__(self, client: Custfind):
|
|
165
|
+
self._c = client
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class Searches(_Resource):
|
|
169
|
+
def create(self, idempotency_key: Optional[str] = None, **params: Any) -> Dict[str, Any]:
|
|
170
|
+
"""sector, location, limit, external_customer_id, search_mode, country_code, … Set limit: it caps the credits reserved."""
|
|
171
|
+
return self._c.request("POST", "/v1/searches", body=params, idempotency_key=idempotency_key)
|
|
172
|
+
|
|
173
|
+
def get(self, search_id: str) -> Dict[str, Any]:
|
|
174
|
+
return self._c.request("GET", f"/v1/searches/{_q(search_id)}")
|
|
175
|
+
|
|
176
|
+
def list(self, limit: Optional[int] = None) -> List[Dict[str, Any]]:
|
|
177
|
+
return self._c.request("GET", "/v1/searches", query={"limit": limit})
|
|
178
|
+
|
|
179
|
+
def cancel(self, search_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
180
|
+
return self._c.request("POST", f"/v1/searches/{_q(search_id)}/cancel", idempotency_key=idempotency_key)
|
|
181
|
+
|
|
182
|
+
def leads(self, search_id: str, page: int = 1, page_size: int = 25, **filters: Any) -> Dict[str, Any]:
|
|
183
|
+
"""One page of a search's leads; takes the same filters as leads.list."""
|
|
184
|
+
filters.pop("job_id", None)
|
|
185
|
+
return self._c.request("GET", f"/v1/searches/{_q(search_id)}/leads", query={"page": page, "page_size": page_size, **filters})
|
|
186
|
+
|
|
187
|
+
def all_leads(self, search_id: str, page_size: int = 100, **filters: Any) -> Iterator[Dict[str, Any]]:
|
|
188
|
+
"""Every lead of a search, page by page. Iterate a finished search: while one runs, new leads can shift the pages."""
|
|
189
|
+
return _paginate(lambda page: self.leads(search_id, page=page, page_size=page_size, **filters), page_size)
|
|
190
|
+
|
|
191
|
+
def wait_for_completion(self, search_id: str, interval: float = 3.0, timeout: float = 1800.0) -> Dict[str, Any]:
|
|
192
|
+
"""Poll until the search completes, fails or is cancelled."""
|
|
193
|
+
deadline = time.monotonic() + timeout
|
|
194
|
+
while True:
|
|
195
|
+
search = self.get(search_id)
|
|
196
|
+
if search.get("status") in FINAL_STATUSES:
|
|
197
|
+
return search
|
|
198
|
+
if time.monotonic() + interval > deadline:
|
|
199
|
+
raise CustfindError(0, "wait_timeout", f"search {search_id} still {search.get('status')}")
|
|
200
|
+
time.sleep(interval)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class Leads(_Resource):
|
|
204
|
+
def list(self, **params: Any) -> Dict[str, Any]:
|
|
205
|
+
"""page, page_size (max 100), q, job_id, category, contact (email|phone|both|website|none),
|
|
206
|
+
min_score, max_score, min_rating, max_rating, region, language, source,
|
|
207
|
+
outreach_status (none|pending|sent|opened|failed), unsubscribed (yes|no), stage_id, tag_id,
|
|
208
|
+
sort (score_desc, rating_desc, created_asc, name, …; newest first when omitted)."""
|
|
209
|
+
return self._c.request("GET", "/v1/leads", query=params)
|
|
210
|
+
|
|
211
|
+
def all(self, page_size: int = 100, **filters: Any) -> Iterator[Dict[str, Any]]:
|
|
212
|
+
"""Every lead matching the filters, page by page."""
|
|
213
|
+
filters.pop("page", None)
|
|
214
|
+
return _paginate(lambda page: self.list(page=page, page_size=page_size, **filters), page_size)
|
|
215
|
+
|
|
216
|
+
def get(self, lead_id: str) -> Dict[str, Any]:
|
|
217
|
+
"""The lead with its enrichment, emails and calls: {"lead", "enrichment", "outreach", "calls"}."""
|
|
218
|
+
return self._c.request("GET", f"/v1/leads/{_q(lead_id)}")
|
|
219
|
+
|
|
220
|
+
def outreach(self, lead_id: str) -> List[Dict[str, Any]]:
|
|
221
|
+
return self._c.request("GET", f"/v1/leads/{_q(lead_id)}/outreach")
|
|
222
|
+
|
|
223
|
+
def write_draft(self, lead_id: str, regenerate: bool = False, idempotency_key: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
224
|
+
"""Write the lead's draft if missing; regenerate=True rewrites it (one AI edit)."""
|
|
225
|
+
return self._c.request("POST", f"/v1/leads/{_q(lead_id)}/outreach", body={"regenerate": regenerate}, idempotency_key=idempotency_key)
|
|
226
|
+
|
|
227
|
+
def crm(self, lead_id: str) -> Dict[str, Any]:
|
|
228
|
+
"""The lead's deal, notes, tasks, tags, emails and meetings."""
|
|
229
|
+
return self._c.request("GET", f"/v1/leads/{_q(lead_id)}/crm")
|
|
230
|
+
|
|
231
|
+
def add_note(self, lead_id: str, body: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
232
|
+
return self._c.request("POST", f"/v1/leads/{_q(lead_id)}/notes", body={"body": body}, idempotency_key=idempotency_key)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class Outreach(_Resource):
|
|
236
|
+
def list(self, **params: Any) -> Dict[str, Any]:
|
|
237
|
+
"""page, page_size, job_id, status (draft|scheduled|sent|failed), channel, q,
|
|
238
|
+
sort (created_at_asc|sent_at_desc|sent_at_asc|lead_name_asc|lead_name_desc|engagement_score_desc).
|
|
239
|
+
The result has "items", "total", "page", "page_size" and "stats"."""
|
|
240
|
+
return self._c.request("GET", "/v1/outreach", query=params)
|
|
241
|
+
|
|
242
|
+
def get(self, message_id: str) -> Dict[str, Any]:
|
|
243
|
+
return self._c.request("GET", f"/v1/outreach/{_q(message_id)}")
|
|
244
|
+
|
|
245
|
+
def update(self, message_id: str, message: str, email_subject: Optional[str] = None, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
246
|
+
body: Dict[str, Any] = {"message": message}
|
|
247
|
+
if email_subject is not None:
|
|
248
|
+
body["email_subject"] = email_subject
|
|
249
|
+
return self._c.request("PATCH", f"/v1/outreach/{_q(message_id)}", body=body, idempotency_key=idempotency_key)
|
|
250
|
+
|
|
251
|
+
def rewrite(
|
|
252
|
+
self, message_id: str, tone: Optional[str] = None, instruction: Optional[str] = None, idempotency_key: Optional[str] = None
|
|
253
|
+
) -> Dict[str, Any]:
|
|
254
|
+
"""Rewrite with AI by tone (professional|short|casual) or instruction; one AI edit, refunded on failure."""
|
|
255
|
+
if not tone and not instruction:
|
|
256
|
+
raise ValueError("Custfind: rewrite needs a tone or an instruction")
|
|
257
|
+
body = {k: v for k, v in {"tone": tone, "instruction": instruction}.items() if v}
|
|
258
|
+
return self._c.request("POST", f"/v1/outreach/{_q(message_id)}/rewrite", body=body, idempotency_key=idempotency_key)
|
|
259
|
+
|
|
260
|
+
def send(
|
|
261
|
+
self, message_id: str, scheduled_at: Union[str, datetime.datetime, None] = None, idempotency_key: Optional[str] = None
|
|
262
|
+
) -> Dict[str, Any]:
|
|
263
|
+
"""Send now, or at scheduled_at (an aware datetime or an RFC 3339 string)."""
|
|
264
|
+
if isinstance(scheduled_at, datetime.datetime):
|
|
265
|
+
if scheduled_at.tzinfo is None:
|
|
266
|
+
raise ValueError("Custfind: scheduled_at needs a timezone")
|
|
267
|
+
scheduled_at = scheduled_at.astimezone(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
268
|
+
body = {"scheduled_at": scheduled_at} if scheduled_at else {}
|
|
269
|
+
return self._c.request("POST", f"/v1/outreach/{_q(message_id)}/send", body=body, idempotency_key=idempotency_key)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class Pipelines(_Resource):
|
|
273
|
+
def list(self) -> List[Dict[str, Any]]:
|
|
274
|
+
return self._c.request("GET", "/v1/pipelines")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class Deals(_Resource):
|
|
278
|
+
def create(self, lead_id: str, idempotency_key: Optional[str] = None, **params: Any) -> Dict[str, Any]:
|
|
279
|
+
"""stage_id, title, value_cents, currency, assigned_user_id. Without stage_id the deal starts in the default pipeline's first stage."""
|
|
280
|
+
return self._c.request("POST", "/v1/deals", body={"lead_id": lead_id, **params}, idempotency_key=idempotency_key)
|
|
281
|
+
|
|
282
|
+
def update(self, deal_id: str, idempotency_key: Optional[str] = None, **params: Any) -> Dict[str, Any]:
|
|
283
|
+
"""stage_id, pipeline_id, title, value_cents, currency, assigned_user_id."""
|
|
284
|
+
return self._c.request("PATCH", f"/v1/deals/{_q(deal_id)}", body=params, idempotency_key=idempotency_key)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class Webhooks(_Resource):
|
|
288
|
+
def create(self, url: str, events: List[str], description: str = "", idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
289
|
+
"""Register an endpoint; the returned "secret" is shown only now."""
|
|
290
|
+
body = {"url": url, "events": events, "description": description}
|
|
291
|
+
return self._c.request("POST", "/v1/webhooks", body=body, idempotency_key=idempotency_key)
|
|
292
|
+
|
|
293
|
+
def list(self) -> List[Dict[str, Any]]:
|
|
294
|
+
return self._c.request("GET", "/v1/webhooks")
|
|
295
|
+
|
|
296
|
+
def update(self, endpoint_id: str, idempotency_key: Optional[str] = None, **params: Any) -> Dict[str, Any]:
|
|
297
|
+
"""enabled, events, url, description."""
|
|
298
|
+
return self._c.request("PATCH", f"/v1/webhooks/{_q(endpoint_id)}", body=params, idempotency_key=idempotency_key)
|
|
299
|
+
|
|
300
|
+
def delete(self, endpoint_id: str, idempotency_key: Optional[str] = None) -> None:
|
|
301
|
+
self._c.request("DELETE", f"/v1/webhooks/{_q(endpoint_id)}", idempotency_key=idempotency_key)
|
|
302
|
+
|
|
303
|
+
def send_test(self, endpoint_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
304
|
+
return self._c.request("POST", f"/v1/webhooks/{_q(endpoint_id)}/test", idempotency_key=idempotency_key)
|
|
305
|
+
|
|
306
|
+
def deliveries(self, endpoint_id: str, limit: Optional[int] = None) -> List[Dict[str, Any]]:
|
|
307
|
+
return self._c.request("GET", f"/v1/webhooks/{_q(endpoint_id)}/deliveries", query={"limit": limit})
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class Customers(_Resource):
|
|
311
|
+
def list(self) -> List[Dict[str, Any]]:
|
|
312
|
+
return self._c.request("GET", "/v1/customers")
|
|
313
|
+
|
|
314
|
+
def get(self, customer_id: str) -> Dict[str, Any]:
|
|
315
|
+
return self._c.request("GET", f"/v1/customers/{_q(customer_id)}")
|
|
316
|
+
|
|
317
|
+
def set_limit(self, customer_id: str, monthly_credits: int, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
318
|
+
body = {"monthly_credits": monthly_credits}
|
|
319
|
+
return self._c.request("PUT", f"/v1/customers/{_q(customer_id)}/limit", body=body, idempotency_key=idempotency_key)
|
|
320
|
+
|
|
321
|
+
def remove_limit(self, customer_id: str, idempotency_key: Optional[str] = None) -> None:
|
|
322
|
+
self._c.request("DELETE", f"/v1/customers/{_q(customer_id)}/limit", idempotency_key=idempotency_key)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class SenderAccounts(_Resource):
|
|
326
|
+
def list(self, external_customer_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
327
|
+
return self._c.request("GET", "/v1/sender-accounts", query={"external_customer_id": external_customer_id})
|
|
328
|
+
|
|
329
|
+
def create(self, idempotency_key: Optional[str] = None, **params: Any) -> Dict[str, Any]:
|
|
330
|
+
"""name, email, smtp_host, smtp_port, smtp_user, smtp_pass, external_customer_id (optional)."""
|
|
331
|
+
return self._c.request("POST", "/v1/sender-accounts", body=params, idempotency_key=idempotency_key)
|
|
332
|
+
|
|
333
|
+
def delete(self, account_id: str, idempotency_key: Optional[str] = None) -> None:
|
|
334
|
+
self._c.request("DELETE", f"/v1/sender-accounts/{_q(account_id)}", idempotency_key=idempotency_key)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Dict, Optional, Union
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WebhookVerificationError(Exception):
|
|
9
|
+
"""A webhook delivery that did not verify."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def verify_webhook(
|
|
13
|
+
raw_body: Union[bytes, str],
|
|
14
|
+
signature_header: Optional[str],
|
|
15
|
+
secret: str,
|
|
16
|
+
tolerance_seconds: int = 300,
|
|
17
|
+
) -> Dict[str, Any]:
|
|
18
|
+
"""Verify a delivery and return its event.
|
|
19
|
+
|
|
20
|
+
Pass the raw request body exactly as received (before JSON parsing), the
|
|
21
|
+
Custfind-Signature header and your endpoint's whsec_ secret.
|
|
22
|
+
"""
|
|
23
|
+
body = raw_body.encode() if isinstance(raw_body, str) else raw_body
|
|
24
|
+
timestamp = 0
|
|
25
|
+
signatures = []
|
|
26
|
+
for part in (signature_header or "").split(","):
|
|
27
|
+
key, _, value = part.strip().partition("=")
|
|
28
|
+
if key == "t":
|
|
29
|
+
try:
|
|
30
|
+
timestamp = int(value)
|
|
31
|
+
except ValueError:
|
|
32
|
+
timestamp = 0
|
|
33
|
+
elif key == "v1" and value:
|
|
34
|
+
signatures.append(value)
|
|
35
|
+
if not timestamp or not signatures:
|
|
36
|
+
raise WebhookVerificationError("malformed Custfind-Signature header")
|
|
37
|
+
if abs(time.time() - timestamp) > tolerance_seconds:
|
|
38
|
+
raise WebhookVerificationError("timestamp outside tolerance")
|
|
39
|
+
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
|
|
40
|
+
if not any(hmac.compare_digest(expected, s) for s in signatures):
|
|
41
|
+
raise WebhookVerificationError("signature mismatch")
|
|
42
|
+
return json.loads(body)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: custfind
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Official Python client for the Custfind API: lead searches, email outreach, CRM and webhooks.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Documentation, https://custfind.com/developers
|
|
7
|
+
Project-URL: Source, https://github.com/parevo/custfind-python
|
|
8
|
+
Project-URL: Issues, https://github.com/parevo/custfind-python/issues
|
|
9
|
+
Keywords: custfind,leads,lead-generation,api,sdk
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# custfind
|
|
16
|
+
|
|
17
|
+
Official Python client for the [Custfind API](https://custfind.com/developers): start lead searches, read scored leads, write and send emails, move deals and receive webhooks. Standard library only; Python 3.9+.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install custfind
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from custfind import Custfind
|
|
25
|
+
|
|
26
|
+
cf = Custfind() # reads CUSTFIND_API_KEY
|
|
27
|
+
|
|
28
|
+
search = cf.searches.create(
|
|
29
|
+
sector="Dental clinic",
|
|
30
|
+
location="Kadıköy, Istanbul",
|
|
31
|
+
limit=25, # caps the credits this search reserves
|
|
32
|
+
external_customer_id="acme-42", # optional: bill your own customer
|
|
33
|
+
)
|
|
34
|
+
cf.searches.wait_for_completion(search["job_id"])
|
|
35
|
+
for lead in cf.searches.all_leads(search["job_id"]):
|
|
36
|
+
print(lead["name"], lead.get("email"), lead.get("score"))
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Safe retries
|
|
40
|
+
|
|
41
|
+
Every write is sent with an `Idempotency-Key` that stays the same across retries, and network errors, `429` (respecting `Retry-After`), `5xx` and `409 idempotency_in_progress` are retried up to `max_retries` (3). Any other `409` is final and raised at once. Every write method takes `idempotency_key=`. A timed-out search is never started twice. Pass your own key to make retries across restarts safe too:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
cf.searches.create(idempotency_key=f"search-{order_id}", sector="Dental clinic", location="Izmir", limit=50)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Errors
|
|
48
|
+
|
|
49
|
+
Failed calls raise `CustfindError` with `.status` and the API's `.code` (`api_requires_paid_plan`, `customer_limit_reached`, `insufficient_scope`, …).
|
|
50
|
+
|
|
51
|
+
## Webhooks
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from flask import Flask, request, abort
|
|
55
|
+
from custfind import verify_webhook, WebhookVerificationError
|
|
56
|
+
|
|
57
|
+
@app.post("/webhooks/custfind")
|
|
58
|
+
def custfind_webhook():
|
|
59
|
+
try:
|
|
60
|
+
event = verify_webhook(request.get_data(), request.headers.get("Custfind-Signature"), SECRET)
|
|
61
|
+
except WebhookVerificationError:
|
|
62
|
+
abort(400)
|
|
63
|
+
if event["type"] == "search.completed":
|
|
64
|
+
...
|
|
65
|
+
return "", 200
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Resources
|
|
69
|
+
|
|
70
|
+
`searches`, `leads`, `outreach`, `pipelines`, `deals`, `webhooks`, `customers`, `sender_accounts`, plus `me()`, `balance()` and `usage()`. See the [API reference](https://custfind.com/developers).
|
|
71
|
+
|
|
72
|
+
## Contributing
|
|
73
|
+
|
|
74
|
+
This repository is a read-only mirror of `sdks/python` in Custfind's main repository, updated on every release. Issues are welcome here; pull requests are read and ported by hand, then closed, since the next release overwrites this repository.
|
|
75
|
+
|
|
76
|
+
## Developing
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
python -m unittest discover -s tests -t .
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Changelog
|
|
83
|
+
|
|
84
|
+
- **1.1.0** — Only `409 idempotency_in_progress` is retried (a final 409 used to be retried until it gave up). Read timeouts and dropped connections are retried on Python 3.9 too. Every write takes `idempotency_key=`. `searches.leads`/`all_leads` take every lead filter; new `leads.all`. `outreach.send` takes an aware `datetime`; `outreach.rewrite` needs a tone or an instruction. Plain-text error bodies are kept in the message.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
custfind/__init__.py
|
|
5
|
+
custfind/client.py
|
|
6
|
+
custfind/webhooks.py
|
|
7
|
+
custfind.egg-info/PKG-INFO
|
|
8
|
+
custfind.egg-info/SOURCES.txt
|
|
9
|
+
custfind.egg-info/dependency_links.txt
|
|
10
|
+
custfind.egg-info/top_level.txt
|
|
11
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
custfind
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "custfind"
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "Official Python client for the Custfind API: lead searches, email outreach, CRM and webhooks."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = []
|
|
13
|
+
keywords = ["custfind", "leads", "lead-generation", "api", "sdk"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Documentation = "https://custfind.com/developers"
|
|
17
|
+
Source = "https://github.com/parevo/custfind-python"
|
|
18
|
+
Issues = "https://github.com/parevo/custfind-python/issues"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools]
|
|
21
|
+
packages = ["custfind"]
|
custfind-1.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import unittest
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
9
|
+
|
|
10
|
+
from custfind import Custfind, CustfindError, WebhookVerificationError, verify_webhook
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Server:
|
|
14
|
+
def __init__(self, respond):
|
|
15
|
+
self.seen = []
|
|
16
|
+
outer = self
|
|
17
|
+
|
|
18
|
+
class Handler(BaseHTTPRequestHandler):
|
|
19
|
+
def _handle(self):
|
|
20
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
21
|
+
body = self.rfile.read(length).decode() if length else ""
|
|
22
|
+
outer.seen.append({"method": self.command, "path": self.path, "headers": dict(self.headers), "body": body})
|
|
23
|
+
status, payload, extra = respond(len(outer.seen))
|
|
24
|
+
self.send_response(status)
|
|
25
|
+
extra = dict(extra or {})
|
|
26
|
+
self.send_header("Content-Type", extra.pop("Content-Type", "application/json"))
|
|
27
|
+
for k, v in extra.items():
|
|
28
|
+
self.send_header(k, v)
|
|
29
|
+
if payload is None:
|
|
30
|
+
data = b""
|
|
31
|
+
elif isinstance(payload, str):
|
|
32
|
+
data = payload.encode()
|
|
33
|
+
else:
|
|
34
|
+
data = json.dumps(payload).encode()
|
|
35
|
+
self.send_header("Content-Length", str(len(data)))
|
|
36
|
+
self.end_headers()
|
|
37
|
+
self.wfile.write(data)
|
|
38
|
+
|
|
39
|
+
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _handle
|
|
40
|
+
|
|
41
|
+
def log_message(self, *args):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
self.httpd = HTTPServer(("127.0.0.1", 0), Handler)
|
|
45
|
+
self.url = f"http://127.0.0.1:{self.httpd.server_address[1]}"
|
|
46
|
+
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
|
|
47
|
+
|
|
48
|
+
def close(self):
|
|
49
|
+
self.httpd.shutdown()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ClientTest(unittest.TestCase):
|
|
53
|
+
def test_request_shape(self):
|
|
54
|
+
s = _Server(lambda n: (202, {"job_id": "j1", "status": "queued"}, None))
|
|
55
|
+
cf = Custfind(api_key="cf_live_test", base_url=s.url)
|
|
56
|
+
res = cf.searches.create(sector="Dental clinic", location="Istanbul", limit=25)
|
|
57
|
+
self.assertEqual(res["job_id"], "j1")
|
|
58
|
+
first = s.seen[0]
|
|
59
|
+
self.assertEqual((first["method"], first["path"]), ("POST", "/v1/searches"))
|
|
60
|
+
self.assertEqual(first["headers"]["Authorization"], "Bearer cf_live_test")
|
|
61
|
+
self.assertTrue(first["headers"].get("Idempotency-Key"))
|
|
62
|
+
self.assertEqual(json.loads(first["body"]), {"sector": "Dental clinic", "location": "Istanbul", "limit": 25})
|
|
63
|
+
cf.leads.list(min_score=70, contact="email")
|
|
64
|
+
self.assertEqual(s.seen[1]["path"], "/v1/leads?min_score=70&contact=email")
|
|
65
|
+
self.assertNotIn("Idempotency-Key", s.seen[1]["headers"])
|
|
66
|
+
s.close()
|
|
67
|
+
|
|
68
|
+
def test_retries_keep_the_idempotency_key(self):
|
|
69
|
+
plan = {1: (503, {"error": "x"}, None), 2: (429, {"error": "rate_limited"}, {"Retry-After": "0"})}
|
|
70
|
+
s = _Server(lambda n: plan.get(n, (202, {"job_id": "j2"}, None)))
|
|
71
|
+
cf = Custfind(api_key="k", base_url=s.url, max_retries=3)
|
|
72
|
+
self.assertEqual(cf.searches.create(sector="x")["job_id"], "j2")
|
|
73
|
+
self.assertEqual(len(s.seen), 3)
|
|
74
|
+
self.assertEqual(len({r["headers"]["Idempotency-Key"] for r in s.seen}), 1)
|
|
75
|
+
s.close()
|
|
76
|
+
|
|
77
|
+
def test_error_code(self):
|
|
78
|
+
s = _Server(lambda n: (402, {"error": "customer_limit_reached"}, None))
|
|
79
|
+
cf = Custfind(api_key="k", base_url=s.url)
|
|
80
|
+
with self.assertRaises(CustfindError) as ctx:
|
|
81
|
+
cf.searches.create(sector="x", external_customer_id="acme")
|
|
82
|
+
self.assertEqual((ctx.exception.status, ctx.exception.code), (402, "customer_limit_reached"))
|
|
83
|
+
self.assertEqual(len(s.seen), 1)
|
|
84
|
+
s.close()
|
|
85
|
+
|
|
86
|
+
def test_path_encoding_and_204(self):
|
|
87
|
+
s = _Server(lambda n: (204, None, None))
|
|
88
|
+
cf = Custfind(api_key="k", base_url=s.url)
|
|
89
|
+
self.assertIsNone(cf.customers.remove_limit("acme/eu 1"))
|
|
90
|
+
self.assertEqual(s.seen[0]["path"], "/v1/customers/acme%2Feu%201/limit")
|
|
91
|
+
s.close()
|
|
92
|
+
|
|
93
|
+
def test_wait_for_completion(self):
|
|
94
|
+
statuses = ["queued", "processing", "completed"]
|
|
95
|
+
s = _Server(lambda n: (200, {"id": "j", "status": statuses[min(n - 1, 2)]}, None))
|
|
96
|
+
cf = Custfind(api_key="k", base_url=s.url)
|
|
97
|
+
self.assertEqual(cf.searches.wait_for_completion("j", interval=0.01)["status"], "completed")
|
|
98
|
+
self.assertEqual(len(s.seen), 3)
|
|
99
|
+
s.close()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_conflict_retried_only_while_in_progress(self):
|
|
103
|
+
plan = {
|
|
104
|
+
1: (409, {"error": "idempotency_in_progress"}, {"Retry-After": "0"}),
|
|
105
|
+
2: (200, {"id": "j"}, None),
|
|
106
|
+
}
|
|
107
|
+
s = _Server(lambda n: plan.get(n, (409, {"error": "invalid job state"}, None)))
|
|
108
|
+
cf = Custfind(api_key="k", base_url=s.url)
|
|
109
|
+
self.assertEqual(cf.searches.cancel("j")["id"], "j")
|
|
110
|
+
self.assertEqual(len(s.seen), 2)
|
|
111
|
+
self.assertEqual(s.seen[0]["headers"]["Idempotency-Key"], s.seen[1]["headers"]["Idempotency-Key"])
|
|
112
|
+
with self.assertRaises(CustfindError) as ctx:
|
|
113
|
+
cf.searches.cancel("j")
|
|
114
|
+
self.assertEqual((ctx.exception.status, ctx.exception.code), (409, "invalid job state"))
|
|
115
|
+
self.assertEqual(len(s.seen), 3, "a final 409 is not retried")
|
|
116
|
+
s.close()
|
|
117
|
+
|
|
118
|
+
def test_plain_text_error_is_kept(self):
|
|
119
|
+
s = _Server(lambda n: (400, "invalid json body\n", {"Content-Type": "text/plain"}))
|
|
120
|
+
cf = Custfind(api_key="k", base_url=s.url)
|
|
121
|
+
with self.assertRaises(CustfindError) as ctx:
|
|
122
|
+
cf.deals.create("lead-1", idempotency_key="deal-1")
|
|
123
|
+
self.assertEqual(ctx.exception.code, "http_400")
|
|
124
|
+
self.assertIn("invalid json body", str(ctx.exception))
|
|
125
|
+
self.assertEqual(s.seen[0]["headers"]["Idempotency-Key"], "deal-1")
|
|
126
|
+
s.close()
|
|
127
|
+
|
|
128
|
+
def test_network_error_is_retried_then_raised(self):
|
|
129
|
+
cf = Custfind(api_key="k", base_url="http://127.0.0.1:1", max_retries=1)
|
|
130
|
+
with self.assertRaises(CustfindError) as ctx:
|
|
131
|
+
cf.me()
|
|
132
|
+
self.assertEqual(ctx.exception.status, 0)
|
|
133
|
+
|
|
134
|
+
def test_all_leads_filters_and_pages(self):
|
|
135
|
+
pages = {1: {"items": [{"id": "a"}, {"id": "b"}], "total": 3}, 2: {"items": [{"id": "c"}], "total": 3}}
|
|
136
|
+
s = _Server(lambda n: (200, pages[n], None))
|
|
137
|
+
cf = Custfind(api_key="k", base_url=s.url)
|
|
138
|
+
ids = [lead["id"] for lead in cf.searches.all_leads("j1", page_size=2, min_score=60, sort="score_desc")]
|
|
139
|
+
self.assertEqual(ids, ["a", "b", "c"])
|
|
140
|
+
self.assertEqual(s.seen[1]["path"], "/v1/searches/j1/leads?page=2&page_size=2&min_score=60&sort=score_desc")
|
|
141
|
+
s.close()
|
|
142
|
+
|
|
143
|
+
def test_send_takes_an_aware_datetime(self):
|
|
144
|
+
s = _Server(lambda n: (202, {"id": "m"}, None))
|
|
145
|
+
cf = Custfind(api_key="k", base_url=s.url)
|
|
146
|
+
at = datetime.datetime(2026, 3, 1, 9, 0, tzinfo=datetime.timezone(datetime.timedelta(hours=3)))
|
|
147
|
+
cf.outreach.send("m", scheduled_at=at)
|
|
148
|
+
self.assertEqual(json.loads(s.seen[0]["body"]), {"scheduled_at": "2026-03-01T06:00:00Z"})
|
|
149
|
+
with self.assertRaises(ValueError):
|
|
150
|
+
cf.outreach.send("m", scheduled_at=datetime.datetime(2026, 3, 1, 9, 0))
|
|
151
|
+
with self.assertRaises(ValueError):
|
|
152
|
+
cf.outreach.rewrite("m")
|
|
153
|
+
self.assertEqual(len(s.seen), 1)
|
|
154
|
+
s.close()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class WebhookTest(unittest.TestCase):
|
|
158
|
+
def test_verifies_a_signature_made_by_the_api(self):
|
|
159
|
+
# Signed by the API's shared.WebhookSignatureValue.
|
|
160
|
+
body = b'{"id":"evt_1","type":"search.completed","created_at":"2023-11-14T22:13:20Z","organization_id":"org_1","data":{"id":"job_1","status":"completed","leads_found":12}}'
|
|
161
|
+
header = "t=1700000000,v1=09714fde5144a52b4b8218ce4fbd186b9cb1fce94a75f5396bfbc54f6fbee486"
|
|
162
|
+
event = verify_webhook(body, header, "whsec_test", tolerance_seconds=10**10)
|
|
163
|
+
self.assertEqual(event["data"]["leads_found"], 12)
|
|
164
|
+
|
|
165
|
+
def test_verify(self):
|
|
166
|
+
secret = "whsec_test"
|
|
167
|
+
body = json.dumps({"id": "evt", "type": "search.completed", "data": {"id": "j"}}).encode()
|
|
168
|
+
t = int(time.time())
|
|
169
|
+
sig = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
|
|
170
|
+
self.assertEqual(verify_webhook(body, f"t={t},v1={sig}", secret)["type"], "search.completed")
|
|
171
|
+
for bad_body, header, key in [
|
|
172
|
+
(body + b" ", f"t={t},v1={sig}", secret),
|
|
173
|
+
(body, f"t={t},v1={sig}", "whsec_other"),
|
|
174
|
+
(body, f"t={t - 3600},v1={sig}", secret),
|
|
175
|
+
(body, "garbage", secret),
|
|
176
|
+
]:
|
|
177
|
+
with self.assertRaises(WebhookVerificationError):
|
|
178
|
+
verify_webhook(bad_body, header, key)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
unittest.main()
|