leadsets 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- leadsets-0.1.0/.gitignore +16 -0
- leadsets-0.1.0/CHANGELOG.md +24 -0
- leadsets-0.1.0/LICENSE +21 -0
- leadsets-0.1.0/PKG-INFO +419 -0
- leadsets-0.1.0/README.md +388 -0
- leadsets-0.1.0/pyproject.toml +98 -0
- leadsets-0.1.0/src/leadsets/__init__.py +58 -0
- leadsets-0.1.0/src/leadsets/_base.py +171 -0
- leadsets-0.1.0/src/leadsets/_client.py +239 -0
- leadsets-0.1.0/src/leadsets/_exceptions.py +264 -0
- leadsets-0.1.0/src/leadsets/_models.py +104 -0
- leadsets-0.1.0/src/leadsets/_pagination.py +86 -0
- leadsets-0.1.0/src/leadsets/_version.py +1 -0
- leadsets-0.1.0/src/leadsets/py.typed +0 -0
- leadsets-0.1.0/src/leadsets/resources/__init__.py +0 -0
- leadsets-0.1.0/src/leadsets/resources/_compat.py +8 -0
- leadsets-0.1.0/src/leadsets/resources/companies.py +219 -0
- leadsets-0.1.0/src/leadsets/resources/emails.py +83 -0
- leadsets-0.1.0/src/leadsets/resources/jobs.py +54 -0
- leadsets-0.1.0/src/leadsets/resources/people.py +219 -0
- leadsets-0.1.0/src/leadsets/resources/scrape.py +111 -0
- leadsets-0.1.0/src/leadsets/resources/usage.py +45 -0
- leadsets-0.1.0/src/leadsets/types.py +457 -0
- leadsets-0.1.0/tests/__init__.py +0 -0
- leadsets-0.1.0/tests/conftest.py +101 -0
- leadsets-0.1.0/tests/test_async.py +97 -0
- leadsets-0.1.0/tests/test_client.py +377 -0
- leadsets-0.1.0/tests/test_errors.py +149 -0
- leadsets-0.1.0/tests/test_pagination.py +89 -0
- leadsets-0.1.0/tests/test_retries.py +151 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-11-11
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- First release of `leadsets`.
|
|
12
|
+
- Sync `Leadsets` and async `AsyncLeadsets` clients on httpx.
|
|
13
|
+
- `people.search`, `people.enrich`, `companies.search`, `companies.enrich`, `emails.verify`,
|
|
14
|
+
`scrape.company`, `scrape.jobs`, `jobs.get` and `usage.get`.
|
|
15
|
+
- Pagination with `search_all` (records) and `search_pages` (pages), sync and async, with
|
|
16
|
+
`max_items` and `max_pages`.
|
|
17
|
+
- Typed exceptions mapped from `error.type` and status, each with `request_id`.
|
|
18
|
+
- Automatic retries with exponential backoff and jitter on 429 (honouring `Retry-After`), 5xx,
|
|
19
|
+
timeouts and network errors.
|
|
20
|
+
- An auto-generated `Idempotency-Key` on every paid call, reused across retries.
|
|
21
|
+
- Typed parameters (`TypedDict`) and responses (dataclasses), with a `py.typed` marker.
|
|
22
|
+
- Parsed rate-limit headers on `last_response.rate_limit`.
|
|
23
|
+
|
|
24
|
+
[0.1.0]: https://github.com/leadsets/sdk-python/releases/tag/v0.1.0
|
leadsets-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mobylogix LLC
|
|
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.
|
leadsets-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: leadsets
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Leadsets B2B data API: people and company search, enrichment, email verification and web scraping.
|
|
5
|
+
Project-URL: Homepage, https://www.leadsets.ai/
|
|
6
|
+
Project-URL: Documentation, https://www.leadsets.ai/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/leadsets/sdk-python
|
|
8
|
+
Project-URL: Issues, https://github.com/leadsets/sdk-python/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/leadsets/sdk-python/blob/main/CHANGELOG.md
|
|
10
|
+
Author: Leadsets
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: api,b2b-data,company-search,email-verification,enrichment,lead-generation,leadsets,people-search,sales,sdk,web-scraping
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Requires-Dist: httpx<1,>=0.27
|
|
29
|
+
Requires-Dist: typing-extensions>=4.7; python_version < '3.12'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# Leadsets Python SDK
|
|
33
|
+
|
|
34
|
+
[](https://pypi.org/project/leadsets/)
|
|
35
|
+
[](https://pypi.org/project/leadsets/)
|
|
36
|
+
[](https://github.com/leadsets/sdk-python/actions/workflows/ci.yml)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
|
|
39
|
+
The official Python client for the [Leadsets](https://www.leadsets.ai/?utm_source=github&utm_medium=readme&utm_campaign=sdk-python&utm_content=intro) API.
|
|
40
|
+
|
|
41
|
+
Leadsets is B2B data for AI agents and the developers who build them. Search people and companies, enrich a person or a company, verify emails and scrape company websites and careers pages, over a REST API or an MCP server. Every record says why it matched, every field carries when it was last checked and a confidence score, and emails are only labelled verified when they are.
|
|
42
|
+
|
|
43
|
+
- Sync (`Leadsets`) and async (`AsyncLeadsets`) clients on [httpx](https://www.python-httpx.org/)
|
|
44
|
+
- Typed responses (dataclasses) and typed keyword arguments, with a `py.typed` marker
|
|
45
|
+
- Automatic retries with backoff on 429 (honouring `Retry-After`), 5xx and network errors
|
|
46
|
+
- An Idempotency-Key on every paid call, so a retry never counts records twice
|
|
47
|
+
- Cursor pagination with generators and async generators
|
|
48
|
+
- Python 3.10+
|
|
49
|
+
|
|
50
|
+
## Contents
|
|
51
|
+
|
|
52
|
+
- [Install](#install)
|
|
53
|
+
- [Quickstart](#quickstart)
|
|
54
|
+
- [Authentication](#authentication)
|
|
55
|
+
- [Resources](#resources)
|
|
56
|
+
- [Pagination](#pagination)
|
|
57
|
+
- [Errors and retries](#errors-and-retries)
|
|
58
|
+
- [Limits and usage](#limits-and-usage)
|
|
59
|
+
- [Idempotency](#idempotency)
|
|
60
|
+
- [Async usage](#async-usage)
|
|
61
|
+
- [Configuration](#configuration)
|
|
62
|
+
- [Typing](#typing)
|
|
63
|
+
- [MCP](#mcp)
|
|
64
|
+
- [Support](#support)
|
|
65
|
+
- [License](#license)
|
|
66
|
+
|
|
67
|
+
## Install
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install leadsets
|
|
71
|
+
# or
|
|
72
|
+
uv add leadsets
|
|
73
|
+
poetry add leadsets
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Quickstart
|
|
77
|
+
|
|
78
|
+
[Create a free account](https://www.leadsets.ai/signup?utm_source=github&utm_medium=readme&utm_campaign=sdk-python&utm_content=quickstart) and copy your API key. Keys start with `ls_live_`.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
export LEADSETS_API_KEY="ls_live_…"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Ten revenue leaders at US SaaS companies with 20–200 people:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
import os
|
|
88
|
+
from leadsets import Leadsets
|
|
89
|
+
|
|
90
|
+
ls = Leadsets(os.environ["LEADSETS_API_KEY"])
|
|
91
|
+
|
|
92
|
+
res = ls.people.search(
|
|
93
|
+
function=["revenue"],
|
|
94
|
+
seniority=["vp", "head"],
|
|
95
|
+
employees="20-200",
|
|
96
|
+
country="US",
|
|
97
|
+
industry="saas",
|
|
98
|
+
limit=10,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
print(res.items[0].match_reasons, res.usage)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Every response carries what the call counted and what is left (`usage`), plus a `request_id`. The [quickstart guide](https://www.leadsets.ai/docs/quickstart?utm_source=github&utm_medium=readme&utm_campaign=sdk-python&utm_content=quickstart) walks through the same call with cURL.
|
|
105
|
+
|
|
106
|
+
## Authentication
|
|
107
|
+
|
|
108
|
+
The API uses bearer keys. Pass the key to the constructor, or leave it out and the SDK reads the `LEADSETS_API_KEY` environment variable (or `LEADSETS_KEY`).
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
ls = Leadsets("ls_live_…") # explicit
|
|
112
|
+
ls = Leadsets() # from LEADSETS_API_KEY / LEADSETS_KEY
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Keep the key out of source control. A free key is available at [leadsets.ai/signup](https://www.leadsets.ai/signup?utm_source=github&utm_medium=readme&utm_campaign=sdk-python&utm_content=authentication).
|
|
116
|
+
|
|
117
|
+
## Resources
|
|
118
|
+
|
|
119
|
+
| Method | Endpoint | Uses |
|
|
120
|
+
| --- | --- | --- |
|
|
121
|
+
| `ls.people.search(**params)` | `POST /v1/people/search` | 1 search record per record returned |
|
|
122
|
+
| `ls.people.enrich(**params)` | `POST /v1/people/enrich` | 1 enrichment record when found (+1 for a personal email) |
|
|
123
|
+
| `ls.companies.search(**params)` | `POST /v1/companies/search` | 1 search record per record returned |
|
|
124
|
+
| `ls.companies.enrich(**params)` | `POST /v1/companies/enrich` | 1 enrichment record when found |
|
|
125
|
+
| `ls.emails.verify(email_or_emails)` | `POST /v1/emails/verify` | 1 enrichment record per email checked |
|
|
126
|
+
| `ls.scrape.company(**params)` | `POST /v1/scrape/company` | 1 scrape unit per page read |
|
|
127
|
+
| `ls.scrape.jobs(**params)` | `POST /v1/scrape/jobs` | 1 scrape unit per page read |
|
|
128
|
+
| `ls.jobs.get(job_id)` | `GET /v1/jobs/{id}` | nothing |
|
|
129
|
+
| `ls.usage.get()` | `GET /v1/usage` | nothing |
|
|
130
|
+
|
|
131
|
+
Every method also takes [request options](#per-request-options) as keyword arguments.
|
|
132
|
+
|
|
133
|
+
Responses are dataclasses: `res.items[0].email.status`. `obj.to_dict()` returns the raw JSON, and `obj["field"]` reads any field, including ones newer than your SDK version.
|
|
134
|
+
|
|
135
|
+
### People search
|
|
136
|
+
|
|
137
|
+
Filters: `title`, `seniority` (`c_level`, `vp`, `head`, `director`, `manager`, `ic`), `function` (`revenue`, `marketing`, `engineering`, `product`, `ops`, `finance`, `hr`), `company_domain`, `employees` (`"50-200"`, `"1000+"`, `"<50"`), `industry` (slugs such as `"saas"`), `country` (ISO 3166-1 alpha-2), `has_verified_email`, `max_age_days`, `exclude_stale`, `limit` (1–100, default 25) and `cursor`. A string or a list works wherever a list is accepted.
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
res = ls.people.search(
|
|
141
|
+
title=["VP Sales", "Head of Sales"], # fuzzy: "VP Sales" also finds "Vice President, Sales"
|
|
142
|
+
country=["US", "CA"],
|
|
143
|
+
employees="50-200",
|
|
144
|
+
has_verified_email=True,
|
|
145
|
+
limit=25,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
for p in res.items:
|
|
149
|
+
print(p.full_name, p.title, p.company.domain if p.company else None)
|
|
150
|
+
if p.email:
|
|
151
|
+
print(p.email.address, p.email.status, p.email.confidence, p.email.checked_at)
|
|
152
|
+
print(p.match_reasons) # ["title", "size", "country", "verified_email"]
|
|
153
|
+
print(p.sources["title"]) # FieldSource(seen_at=…, confidence=…)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Each `Person` has `person_id`, names, `title`, `seniority` and `function` (with finer `seniority_detail` and `function_detail`), `linkedin_url`, `location`, `company`, `email`, `refreshed_at`, `stale` and `sources`.
|
|
157
|
+
|
|
158
|
+
### People enrich
|
|
159
|
+
|
|
160
|
+
Look a person up by LinkedIn URL, by work email, or by name plus company domain.
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
res = ls.people.enrich(linkedin_url="https://www.linkedin.com/in/example")
|
|
164
|
+
ls.people.enrich(email="jane@example.com")
|
|
165
|
+
ls.people.enrich(full_name="Jane Doe", company_domain="example.com")
|
|
166
|
+
ls.people.enrich(first_name="Jane", last_name="Doe", company_domain="example.com", include_personal_email=True)
|
|
167
|
+
|
|
168
|
+
print(res.person.title, res.matched_by)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
No match raises `NotFoundError` and costs nothing.
|
|
172
|
+
|
|
173
|
+
### Companies search
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
res = ls.companies.search(
|
|
177
|
+
industry="saas",
|
|
178
|
+
employees="50-200",
|
|
179
|
+
country="US",
|
|
180
|
+
technology=["hubspot", "salesforce"], # matches any
|
|
181
|
+
limit=50,
|
|
182
|
+
)
|
|
183
|
+
print([(c.name, c.domain, c.employees) for c in res.items])
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Companies enrich
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
company = ls.companies.enrich(domain="example.com").company # a URL works too
|
|
190
|
+
ls.companies.enrich(linkedin_url="https://www.linkedin.com/company/example")
|
|
191
|
+
|
|
192
|
+
print(company.industry, company.naics, company.employee_range, company.revenue_range, company.technologies)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Email verification
|
|
196
|
+
|
|
197
|
+
One email or up to 100 per call. Each result has a `status` (`verified`, `catch_all`, `unknown`, `invalid`), a `confidence` and `checked_at`.
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
one = ls.emails.verify("jane@example.com")
|
|
201
|
+
many = ls.emails.verify(["jane@example.com", "sam@example.org"])
|
|
202
|
+
# or: ls.emails.verify(emails=[...])
|
|
203
|
+
|
|
204
|
+
print(many.summary) # verified, catch_all, unknown, invalid, not_checked
|
|
205
|
+
for r in many.results:
|
|
206
|
+
if r.status is None:
|
|
207
|
+
print(r.email, r.reason) # not checked, not counted
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Scrape a company website
|
|
211
|
+
|
|
212
|
+
A website as structured data: name, description, headings, key links (pricing, customers, careers, docs and more), social profiles and page text. `max_pages` (1–10, default 1) adds the about, pricing, customers, product and careers pages. Scraping is available on paid plans with scrape units.
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
site = ls.scrape.company(domain="example.com", max_pages=3)
|
|
216
|
+
print(site.name, site.key_links.get("pricing"), site.social.get("linkedin"))
|
|
217
|
+
print([p.url for p in site.pages], site.usage.scrape_units_left)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Scrape open roles
|
|
221
|
+
|
|
222
|
+
Open roles from a company's careers page or job board: title, link, team, location and posted date when shown. `max_pages` defaults to 3.
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
res = ls.scrape.jobs(domain="example.com")
|
|
226
|
+
print(res.count, res.careers_url)
|
|
227
|
+
for job in res.jobs:
|
|
228
|
+
print(job.title, job.team, job.location, job.url)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Async jobs
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
job = ls.jobs.get("job_123")
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Async jobs are not generally available yet. Until they are, `jobs.get` raises `NotFoundError`. Split large runs across windows instead.
|
|
238
|
+
|
|
239
|
+
### Usage
|
|
240
|
+
|
|
241
|
+
What's left in every meter and when each window resets. Free to call, so call it before a big run.
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
from leadsets.types import WindowMeters
|
|
245
|
+
|
|
246
|
+
u = ls.usage.get()
|
|
247
|
+
print(u.plan)
|
|
248
|
+
if isinstance(u.search, WindowMeters):
|
|
249
|
+
used, limit = u.search.records_5h
|
|
250
|
+
print(f"{limit - used} search records left; resets at {u.search.resets_at}")
|
|
251
|
+
print(u.concurrent_searches, u.scrape_units)
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
On the Free plan, `u.free` holds the one-time balances instead.
|
|
255
|
+
|
|
256
|
+
## Pagination
|
|
257
|
+
|
|
258
|
+
Searches return `next_cursor`. `search_all` follows it for you and yields one record at a time:
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
for person in ls.people.search_all(title=["VP Sales"], country="US", limit=100):
|
|
262
|
+
print(person.full_name)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Each record returned counts, so cap a run with `max_items`. The last page asks only for what's left, so you never pay for more than you take. `max_pages` caps the number of calls.
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
for company in ls.companies.search_all(industry="saas", max_items=250):
|
|
269
|
+
...
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
To read `usage` as you go, iterate pages instead:
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
for page in ls.people.search_pages(country="US", limit=100, max_pages=5):
|
|
276
|
+
print(len(page.items), page.usage.search_records_left_5h)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Breaking out of a loop stops fetching. To page by hand, pass `cursor=res.next_cursor` to the next `search` call.
|
|
280
|
+
|
|
281
|
+
## Errors and retries
|
|
282
|
+
|
|
283
|
+
Every API error has a status, a machine-readable `type`, a message and a `request_id`. The SDK raises a typed exception for each:
|
|
284
|
+
|
|
285
|
+
| Exception | Status | `type` |
|
|
286
|
+
| --- | --- | --- |
|
|
287
|
+
| `InvalidRequestError` | 400 | `invalid_request` (`param` names the field) |
|
|
288
|
+
| `AuthenticationError` | 401 | `unauthorized` |
|
|
289
|
+
| `FreeRecordsExhaustedError` | 402 | `free_records_exhausted` |
|
|
290
|
+
| `ScrapeUnitsExhaustedError` | 402 | `scrape_units_exhausted` |
|
|
291
|
+
| `PermissionDeniedError` | 403 | `forbidden` (`upgrade_url` when an upgrade would allow the call) |
|
|
292
|
+
| `NotFoundError` | 404 | `not_found` |
|
|
293
|
+
| `IdempotencyConflictError` | 409 | `idempotency_conflict` |
|
|
294
|
+
| `RateLimitError` | 429 | `window_full`, `weekly_limit`, `too_many_concurrent` |
|
|
295
|
+
| `InternalServerError` | 5xx | `internal_error` |
|
|
296
|
+
| `ServiceUnavailableError` | 503 | `unavailable` |
|
|
297
|
+
| `APIConnectionError` | none | the request got no answer |
|
|
298
|
+
| `APITimeoutError` | none | the request timed out |
|
|
299
|
+
|
|
300
|
+
`FreeRecordsExhaustedError` and `ScrapeUnitsExhaustedError` subclass `PaymentRequiredError`. Every HTTP error subclasses `APIError`, and everything subclasses `LeadsetsError`. `leadsets.ConnectionError` and `leadsets.TimeoutError` are aliases of the last two.
|
|
301
|
+
|
|
302
|
+
```python
|
|
303
|
+
from leadsets import APIError, NotFoundError, RateLimitError
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
ls.people.enrich(email="jane@example.com")
|
|
307
|
+
except NotFoundError:
|
|
308
|
+
pass # nothing to enrich; nothing was counted
|
|
309
|
+
except RateLimitError as e:
|
|
310
|
+
print(e.type, e.meter, e.resets_at, e.retry_after)
|
|
311
|
+
except APIError as e:
|
|
312
|
+
print(e.status_code, e.type, e.message, e.request_id)
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
**Retries.** The SDK retries 429, 5xx, timeouts and network errors up to `max_retries` times (default 2). It waits for `Retry-After` when the API sends one, and otherwise backs off exponentially with jitter (about 0.5 s, 1 s, 2 s, up to 8 s). A `Retry-After` longer than `max_retry_delay` (default 60 s), such as a weekly limit, is raised at once so a run doesn't hang. 400, 401, 402, 403, 404 and 409 are never retried.
|
|
316
|
+
|
|
317
|
+
## Limits and usage
|
|
318
|
+
|
|
319
|
+
- **Records count only when data comes back.** Empty results, misses and errors use no records. Every call counts one request toward its window, hits and misses alike.
|
|
320
|
+
- **Paid plans have rolling five-hour windows** for records and for requests, plus a **weekly** records window, kept separately for search and for enrichment. Scraping uses scrape units.
|
|
321
|
+
- **Free** has 1,000 enrichment records and 100 search records, one time, with no card needed. Paid plans start at $29/month. See [pricing](https://www.leadsets.ai/pricing?utm_source=github&utm_medium=readme&utm_campaign=sdk-python&utm_content=limits).
|
|
322
|
+
|
|
323
|
+
Every response has a `usage` object with what the call counted and what's left, and the SDK parses the rate-limit headers into `res.last_response.rate_limit`:
|
|
324
|
+
|
|
325
|
+
```python
|
|
326
|
+
res = ls.people.search(country="US")
|
|
327
|
+
res.usage # search_records_counted, search_records_left_5h, …
|
|
328
|
+
res.last_response.rate_limit # records_remaining, requests_remaining, weekly_remaining, free_remaining, retry_after
|
|
329
|
+
res.last_response.request_id # also res.request_id
|
|
330
|
+
res.last_response.headers # the raw httpx.Headers
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Read more in the [docs](https://www.leadsets.ai/docs?utm_source=github&utm_medium=readme&utm_campaign=sdk-python&utm_content=limits).
|
|
334
|
+
|
|
335
|
+
## Idempotency
|
|
336
|
+
|
|
337
|
+
The SDK sends an `Idempotency-Key` with every paid call (all POSTs) and reuses it across its own retries, so a retry never counts records twice. Pass your own key to make a call safe to repeat across processes, for example one key per CRM row:
|
|
338
|
+
|
|
339
|
+
```python
|
|
340
|
+
ls.people.enrich(email=row["email"], idempotency_key=f"crm-{row['id']}")
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
A repeat with the same key and body returns the first result (`res.last_response.idempotent_replayed` is `True`) and counts nothing. The same key with a different body raises `IdempotencyConflictError`. Keys last 24 hours. With `search_all`/`search_pages`, a key you pass is suffixed per page (`key`, `key-p2`, `key-p3` …).
|
|
344
|
+
|
|
345
|
+
## Async usage
|
|
346
|
+
|
|
347
|
+
`AsyncLeadsets` has the same resources and methods, awaited:
|
|
348
|
+
|
|
349
|
+
```python
|
|
350
|
+
import asyncio
|
|
351
|
+
from leadsets import AsyncLeadsets
|
|
352
|
+
|
|
353
|
+
async def main() -> None:
|
|
354
|
+
async with AsyncLeadsets() as ls:
|
|
355
|
+
sales, marketing = await asyncio.gather(
|
|
356
|
+
ls.people.search(function="revenue", seniority="vp", country="US", limit=10),
|
|
357
|
+
ls.people.search(function="marketing", seniority="head", country="US", limit=10),
|
|
358
|
+
)
|
|
359
|
+
async for company in ls.companies.search_all(industry="saas", max_items=50):
|
|
360
|
+
print(company.name)
|
|
361
|
+
|
|
362
|
+
asyncio.run(main())
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Use the clients as context managers (or call `close()` / `await aclose()`) to release connections. One client can be shared across threads or tasks.
|
|
366
|
+
|
|
367
|
+
## Configuration
|
|
368
|
+
|
|
369
|
+
```python
|
|
370
|
+
ls = Leadsets(
|
|
371
|
+
"ls_live_…", # default: LEADSETS_API_KEY or LEADSETS_KEY
|
|
372
|
+
base_url="https://api.leadsets.ai", # default; LEADSETS_BASE_URL also works
|
|
373
|
+
max_retries=2, # retries on 429, 5xx, timeouts and network errors
|
|
374
|
+
timeout=120.0, # seconds per attempt (float or httpx.Timeout)
|
|
375
|
+
max_retry_delay=60.0, # longest Retry-After to wait for, in seconds
|
|
376
|
+
default_headers={"X-My-App": "crm-sync"},
|
|
377
|
+
http_client=httpx.Client(proxy="http://proxy:8080"), # your own httpx client
|
|
378
|
+
)
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
### Per-request options
|
|
382
|
+
|
|
383
|
+
```python
|
|
384
|
+
ls.people.search(
|
|
385
|
+
country="US",
|
|
386
|
+
idempotency_key="run-42",
|
|
387
|
+
max_retries=5,
|
|
388
|
+
timeout=30.0,
|
|
389
|
+
extra_headers={"X-Trace-Id": "abc"},
|
|
390
|
+
)
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
## Typing
|
|
394
|
+
|
|
395
|
+
The package ships a `py.typed` marker. Search and enrich parameters are `TypedDict`s (`PeopleSearchParams`, `CompaniesSearchParams`, …) used as keyword arguments, so mypy and Pyright check filter names and values such as `seniority="vp"`. Responses are dataclasses in `leadsets.types`: `Person`, `Company`, `Email`, `EmailVerification`, `ScrapeCompanyResponse`, `Job`, `UsageResponse`, `Usage` and more. Fields the API may leave empty are `Optional`.
|
|
396
|
+
|
|
397
|
+
```python
|
|
398
|
+
from leadsets.types import PeopleSearchParams, Person
|
|
399
|
+
|
|
400
|
+
query: PeopleSearchParams = {"seniority": ["vp"], "function": ["marketing"]}
|
|
401
|
+
people: list[Person] = ls.people.search(**query).items
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
## MCP
|
|
405
|
+
|
|
406
|
+
Using an AI agent? Leadsets also runs a hosted MCP server at `https://mcp.leadsets.ai` with the same tools, so Claude, Cursor, ChatGPT and other MCP clients can call it with no code. See [Connect via MCP](https://www.leadsets.ai/docs/mcp?utm_source=github&utm_medium=readme&utm_campaign=sdk-python&utm_content=mcp).
|
|
407
|
+
|
|
408
|
+
## Support
|
|
409
|
+
|
|
410
|
+
- [Docs](https://www.leadsets.ai/docs?utm_source=github&utm_medium=readme&utm_campaign=sdk-python&utm_content=support) and the [quickstart](https://www.leadsets.ai/docs/quickstart?utm_source=github&utm_medium=readme&utm_campaign=sdk-python&utm_content=support)
|
|
411
|
+
- [Support](https://www.leadsets.ai/support?utm_source=github&utm_medium=readme&utm_campaign=sdk-python&utm_content=support): include the `request_id` from the response or error and we can see exactly what happened
|
|
412
|
+
- Bugs and feature requests: [GitHub issues](https://github.com/leadsets/sdk-python/issues)
|
|
413
|
+
- Security: see [SECURITY.md](SECURITY.md)
|
|
414
|
+
|
|
415
|
+
Contributions are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
416
|
+
|
|
417
|
+
## License
|
|
418
|
+
|
|
419
|
+
[MIT](LICENSE)
|