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 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.
@@ -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,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,6 @@
1
+ """Official Python client for the Custfind API."""
2
+
3
+ from .client import Custfind, CustfindError, __version__
4
+ from .webhooks import WebhookVerificationError, verify_webhook
5
+
6
+ __all__ = ["Custfind", "CustfindError", "WebhookVerificationError", "verify_webhook"]
@@ -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
+ 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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()