Getting Started
discolike is the official Python client for the DiscoLike API. It wraps every REST endpoint in a typed resource namespace, handles authentication and retries, and speaks Pydantic in both directions: requests are typed models validated before anything is sent, and responses are typed models instead of raw dictionaries.
Every method, request model, and response model is listed in the Reference. This page covers what you need before any of that: install, authentication, the client, and the four behaviours (request models, async, jobs, and errors) that apply across the whole surface.
Requirements
Section titled “Requirements”| Item | Value |
|---|---|
| Python | 3.10 or newer |
| Current version | 0.3.0 |
Install
Section titled “Install”There are two packages. discolike is the SDK. discolike-cli is the command-line tool, published separately and pulled in by the cli extra.
pip install discolike
# SDK plus the `discolike` command-line toolpip install "discolike[cli]"uv add discolike
# SDK plus the `discolike` command-line tooluv add "discolike[cli]"The CLI is documented separately in Getting Started and Command Reference.
Authentication
Section titled “Authentication”The client authenticates with either an API key or an OAuth session. The quickest route on a workstation is discolike auth login from the CLI, which logs you in through the browser and saves the session where the SDK finds it. For servers and CI, create an API key at app.discolike.com/account/management/keys and pass it in. The client resolves a credential from these sources in order and uses the first that yields one:
| Order | Source | How to set it |
|---|---|---|
| 1 | The auth argument | Discolike(auth=OAuthCredential(...)) or Discolike(auth=ApiKeyCredential(api_key="dk_...")) |
| 2 | The api_key argument | Discolike(api_key="dk_...") |
| 3 | The DISCOLIKE_API_KEY environment variable | export DISCOLIKE_API_KEY=dk_... |
| 4 | The CLI config file, holding either an OAuth session or an API key | discolike auth login or discolike auth login --method api_key |
auth takes one of the two credential types exported from the package root, ApiKeyCredential or OAuthCredential, and wins over everything else, including a DISCOLIKE_API_KEY in the environment.
The config file lives at $XDG_CONFIG_HOME/discolike/config.json, defaulting to ~/.config/discolike/config.json. It is what discolike auth login writes, and it is created with mode 0600. It has one of two shapes, selected by auth_method:
{"auth_method": "api_key", "api_key": "dk_..."}{ "auth_method": "oauth", "oauth": { "access_token": "eyJ...", "refresh_token": "...", "expires_at": 1756426800.0, "client_id": "...", "token_endpoint": "https://auth.discolike.com/oauth/2.1/token" }, "oauth_client": { "client_id": "...", "redirect_uri": "http://127.0.0.1:51234/callback", "issuer": "https://auth.discolike.com/oauth/2.1" }}oauth_client is the CLI’s own OAuth client registration; the SDK ignores it. A missing, unreadable, or non-JSON config file is treated as empty rather than as an error, and so is an oauth section with a missing or malformed field.
OAuth sessions
Section titled “OAuth sessions”An OAuthCredential sends Authorization: Bearer <access_token> instead of the X-discolike-key header. Access tokens are short-lived, so the client keeps the session alive on its own:
- When the access token is within 60 seconds of
expires_at, the client refreshes it before sending the request. - If a request comes back 401 anyway, the client refreshes once and replays that request once. A second 401 is raised as
AuthenticationErrorlike any other. - A refresh rotates both tokens, and the new pair replaces the credential in memory. When the credential came from the config file, the pair is also written back there, so the next process starts from the rotated tokens; before refreshing, the client re-reads the file and adopts a newer session if another process has already rotated it, rather than refreshing with a stale refresh token.
- A credential passed as
auth=is never written to disk. Refreshed tokens stay in memory, and yourOAuthCredentialinstance is left untouched.
A refresh the authorization server rejects, typically because the refresh token itself has expired or been revoked, raises AuthenticationError with the message OAuth session expired; run `discolike auth login` from the call that triggered it.
If no source yields a credential, the constructor raises AuthenticationError at construction time, before any request is sent:
>>> from discolike import Discolike>>> Discolike()discolike._exceptions.AuthenticationError: No API key found. Set the DISCOLIKE_API_KEYenvironment variable, pass api_key=..., or run `discolike auth login`. Create a key athttps://app.discolike.com/account/management/keysThat construction-time AuthenticationError carries status_code=None, since no HTTP response was involved. A key or token the server rejects produces an AuthenticationError with status_code=401 on the first request instead.
Key creation, rotation, and the header and bearer-token details are covered in Authentication.
The client
Section titled “The client”Discolike and AsyncDiscolike are the only two entry points. Both take the same keyword-only arguments, and both are cheap to construct: the constructor resolves your credential and builds an HTTP client, but sends no request.
from discolike import AsyncDiscolike, Discolike
Discolike( *, api_key: str | None = None, auth: ApiKeyCredential | OAuthCredential | None = None, base_url: str = "https://api.discolike.com/v1", timeout: float = 60.0, max_retries: int = 3,)
AsyncDiscolike(...) # identical keywords| Parameter | Type | Default | Description |
|---|---|---|---|
api_key | str | None | None | Your DiscoLike API key. When None, the credential is resolved from the environment or the CLI config file. |
auth | ApiKeyCredential | OAuthCredential | None | None | An explicit credential. Takes precedence over api_key, the environment, and the config file. An OAuthCredential given here is refreshed in memory only and never written to disk. |
base_url | str | "https://api.discolike.com/v1" | Root URL every request is joined onto. It must include the API version segment. Change it to point at a staging or self-hosted deployment. |
timeout | float | 60.0 | Per-request timeout in seconds, applied to the connect, read, write, and pool phases alike. It covers each individual HTTP attempt rather than the total across retries. |
max_retries | int | 3 | Number of retries after the first attempt, so the default allows 4 total attempts. 0 disables retrying. |
These five arguments are the entire configuration surface. They are set once per client and apply to every call made through it. To override the timeout for individual calls without a second client, use with_options.
Per-call timeout with with_options
Section titled “Per-call timeout with with_options”client.with_options(*, timeout: float) -> DiscolikeReturns a lightweight client view that shares this client’s connection pool but applies a different timeout to every call made through it. The parent client is untouched, and closing a view never closes the shared pool; only closing the parent does.
from discolike.requests import AppendParams, DiscoverParams
client = Discolike()
result = client.with_options(timeout=300).append(AppendParams(query_id=["a1b2c3"], dataset=["bizdata"])) # one slow callclient.discover(DiscoverParams(icp_prompt="B2B SaaS in fintech")) # default 60s timeoutLifecycle
Section titled “Lifecycle”Closing a client releases its connection pool. Use the context manager and you cannot forget:
from discolike import Discolike
with Discolike() as client: usage = client.account.usage()
# equivalentclient = Discolike()try: usage = client.account.usage()finally: client.close()import asynciofrom discolike import AsyncDiscolike
async def main() -> None: async with AsyncDiscolike() as client: usage = await client.account.usage()
# equivalent client = AsyncDiscolike() try: usage = await client.account.usage() finally: await client.aclose()
asyncio.run(main())Construct one client for the life of your process or request handler and reuse it. Building a client per call throws away connection reuse and repeats the TLS handshake every time; a client that is never closed keeps its connection pool open, and on the async side leaves the event loop with open sockets at shutdown.
Your first call
Section titled “Your first call”Fetch the business data profile for a single domain. With DISCOLIKE_API_KEY set, the client needs no arguments at all. The request is a model from discolike.requests, one per method:
from discolike import Discolikefrom discolike.requests import CompaniesDataParams
with Discolike() as client: profile = client.companies.data(CompaniesDataParams(domain="stripe.com")) print(profile.name, "|", profile.employees, "|", profile.score)Stripe | 10001+ | 701Counting the market before you pull it costs nothing but a request:
from discolike import Discolikefrom discolike.requests import CountParams
with Discolike() as client: total = client.count(CountParams(category=["CYBERSECURITY"], country=["DE"])) print(total.to_dict()){"count": 9220}Every response model subclasses DiscolikeModel, which is configured with extra="allow". Fields the API returns that the model does not declare are kept rather than dropped: read them as ordinary attributes, through .model_extra, or through .to_dict(), which returns the full response as a plain JSON-safe mapping and always includes them.
Request models
Section titled “Request models”Every method that sends parameters takes a single request model as its first positional argument. The models live in discolike.requests, are generated from the platform’s OpenAPI spec, and validate locally: a bad enum value, an out-of-range number, or a missing required field raises pydantic.ValidationError before any HTTP request is made, so it never costs a round trip or a credit.
import pydanticfrom discolike.requests import MatchCompanyParams
try: params = MatchCompanyParams(name="Acme", min_match_confidence=10)except pydantic.ValidationError as exc: print(exc.errors()[0]["msg"])# Input should be greater than or equal to 50Naming follows the route. Query-parameter routes use <Resource><Method>Params (CompaniesDataParams, MatchCompanyParams, ContactsSearchParams, DiscoverParams, CountParams, AppendParams, SegmentParams); JSON-body routes use the platform’s own schema names (FindEmailRequest, ContactFilters, DiscoGenProcessRequest, UpdateQueryRequest). Path parameters and file uploads are not part of the model; they stay keyword-only next to it:
from discolike.requests import MatchBulkParams, UpdateQueryRequest
client.queries.update(UpdateQueryRequest(query_name="New name"), query_id="q3")client.match.bulk(MatchBulkParams(name_column="company"), file="crm.csv")Three rules govern what reaches the wire, all inherited from DiscolikeRequest:
| Rule | Effect |
|---|---|
| Unset fields are omitted | A field you never set is not sent, so the server default governs. The defaults shown in the Reference are the model’s declared defaults and mirror the server’s |
An explicit None is kept | JSON-body routes send it as null, which is how LLMProviderUpdateRequest(api_key=None) keeps the stored key. Query-string routes drop it, since a query string has no null |
| Unknown fields pass through | The models are extra="allow", so a platform field the SDK does not know about yet can still be sent, by keyword or via Model.model_validate({...}) |
Methods that take only a path parameter (queries.delete, llm_providers.get, email.job), or nothing at all (account.usage, discogen.models), have no model and keep their keyword arguments.
AsyncDiscolike mirrors Discolike method for method: the same namespaces, method names, request models, and return models. Every resource class has an async twin, so the Reference is read as async by adding await.
from discolike import Discolikefrom discolike.requests import CompaniesDataParams
with Discolike() as client: for domain in ("stripe.com", "adyen.com"): company = client.companies.data(CompaniesDataParams(domain=domain)) print(company.domain, company.name, company.employees)import asynciofrom discolike import AsyncDiscolikefrom discolike.requests import CompaniesDataParams
async def main() -> None: async with AsyncDiscolike() as client: stripe, adyen = await asyncio.gather( client.companies.data(CompaniesDataParams(domain="stripe.com")), client.companies.data(CompaniesDataParams(domain="adyen.com")), ) for company in (stripe, adyen): print(company.domain, company.name, company.employees)
asyncio.run(main())Both print the same thing:
stripe.com Stripe 10001+adyen.com Adyen 5001-10000These are the differences:
| Sync | Async |
|---|---|
Discolike(...) | AsyncDiscolike(...), same keywords |
client.close() | await client.aclose() |
with Discolike() as client: | async with AsyncDiscolike() as client: |
client.companies.data(...) | await client.companies.data(...) |
returns Job | returns AsyncJob |
EmailJob / EmailBatch | AsyncEmailJob / AsyncEmailBatch |
The rehydration helpers (client.discogen.job(), client.email.job(), and client.email.batch()) make no request, so they stay synchronous on the async client and are called rather than awaited.
One AsyncDiscolike is safe to share across coroutines in a single event loop; do not share one across event loops. Retry sleeps use asyncio.sleep, so a retry never blocks the loop.
import asynciofrom discolike import AsyncDiscolikefrom discolike.requests import CompaniesScoreParams
DOMAINS = ["stripe.com", "adyen.com", "checkout.com"]MAX_CONCURRENCY = 2
async def main() -> None: limit = asyncio.Semaphore(MAX_CONCURRENCY) async with AsyncDiscolike() as client: async def score(domain: str) -> tuple[str, int | None]: async with limit: result = await client.companies.score(CompaniesScoreParams(domain=domain)) return domain, result.score
for domain, value in await asyncio.gather(*(score(d) for d in DOMAINS)): print(domain, value)
asyncio.run(main())stripe.com 701adyen.com 458checkout.com 641Jobs and polling
Section titled “Jobs and polling”Operations that run longer than a single HTTP request return a job handle instead of a result. Eight methods return a Job on Discolike and an AsyncJob on AsyncDiscolike:
| Method | Task family |
|---|---|
client.match.bulk() | bulkmatch |
client.contacts.bulk_match() | contactmatch |
client.contacts.generate() | discogen |
client.discogen.process() | discogen |
client.discogen.process_personas() | discogen |
client.validate_icp() | discogen |
client.segment() | segment |
client.segment_file() | segment |
The task family is the path segment used when polling. Each of these reads task_id from the submit response.
wait() polls until the task reaches a terminal status and returns the JobStatus:
from discolike import Discolikefrom discolike.requests import SegmentParams
with Discolike() as client: job = client.segment(SegmentParams(domains="stripe.com,adyen.com")) status = job.wait(poll_interval=10, on_poll=lambda s: print(s.status, s.progress)) print(status.results)Terminal statuses are completed, failed, and cancelled. wait() returns the status on completed and on cancelled (a cancelled task returns with empty results), and raises JobFailedError on failed, so check status.status before using the results. On timeout it raises JobTimeoutError; the task keeps running server-side, so calling wait() again on the same handle resumes.
Email finding uses its own handle types, EmailJob and EmailBatch, with their own identifiers and completed / failed as their terminal statuses.
Full signatures, polling defaults, cancellation, and rehydration are in Types.
Errors and retries
Section titled “Errors and retries”Every error the SDK raises for an API problem derives from DiscolikeError, so a single except DiscolikeError catches all of them. Non-2xx responses are converted to a typed exception by status code; transport failures are converted to APIConnectionError. All exception classes are importable from the package root, and every one carries .status_code and .payload.
Invalid request values never reach this layer: constructing a request model with a bad value raises pydantic.ValidationError, which is not a DiscolikeError. The SDK’s own ValidationError is the server’s 400 or 422 answer.
from discolike import Discolike, DiscolikeErrorfrom discolike.requests import CompaniesDataParams
with Discolike() as client: try: data = client.companies.data(CompaniesDataParams(domain="stripe.com")) except DiscolikeError as exc: print(f"request failed ({exc.status_code}): {exc}") data = NoneBranch on a specific class when the recovery differs, and back off on RateLimitError using the server’s own hint:
import timefrom discolike import Discolike, RateLimitErrorfrom discolike.requests import CompaniesScoreParams
with Discolike() as client: for domain in ["stripe.com", "adyen.com", "checkout.com"]: while True: try: print(client.companies.score(CompaniesScoreParams(domain=domain)).score) break except RateLimitError as exc: time.sleep(exc.retry_after or 5.0)The full hierarchy is in Types.
Retries are built into the transport. The loop runs max_retries + 1 times, so the default is 4 total attempts. What is retried depends on the HTTP method:
| Method | Retried statuses | Retried exceptions |
|---|---|---|
GET, DELETE | 429, 502, 503, 504 | any transport failure: connect errors, read timeouts, write errors, protocol errors |
POST, PUT, PATCH | 429 only | connection errors only, where the request never reached the server |
Backoff between attempts is the numeric Retry-After header when the response carries one, and otherwise exactly 0.5 * 2 ** attempt: 0.5s, 1s, 2s for the default three retries, with no added jitter and no upper bound. Sleeps use time.sleep on the sync client and asyncio.sleep on the async one. After the last attempt the final response is converted to a typed exception: a persistent 429 becomes RateLimitError, a persistent 503 becomes ServerError, and an exhausted connect failure becomes APIConnectionError.
Job polling deadlines are a separate mechanism with their own timeout argument on wait().
Where to go next
Section titled “Where to go next”Every namespace and method is on the Reference page.
| Task | Entry point | Section |
|---|---|---|
| Check requests, records, and spend | client.account.* | account |
| Look up firmographics, score, growth, or page text for a domain | client.companies.* | companies |
| Search, look up, or match people | client.contacts.* | contacts |
| Run an LLM research pass over companies or personas | client.discogen.* | discogen |
| Find lookalike companies from seeds or a text ICP | client.discover(), client.count() | discovery |
| Find a work email from a name and a domain | client.email.* | |
| Enrich a domain list, segment it, or validate an ICP | client.append(), client.segment(), client.segment_file(), client.validate_icp() | enrich |
| Bring your own LLM provider keys | client.llm_providers.* | llm_providers |
| Resolve a messy company name to a domain | client.match.* | match |
| Save, reuse, and exclude result sets | client.queries.* | queries |
| Bring your own web search provider keys | client.search_providers.* | search_providers |
| Read a request or response model’s fields, a job handle, or an exception | DiscolikeRequest, CompanyProfile, Job, DiscolikeError | Types |
discover, count, validate_icp, append, segment, and segment_file sit directly on the client as convenience forwarders. Everything else lives under a namespace.
Paging is explicit: page through results by incrementing offset on client.discover(), client.contacts.search(), client.contacts.discover(), and client.queries.list().