Skip to content

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.

ItemValue
Python3.10 or newer
Current version0.3.0

There are two packages. discolike is the SDK. discolike-cli is the command-line tool, published separately and pulled in by the cli extra.

Terminal window
pip install discolike
# SDK plus the `discolike` command-line tool
pip install "discolike[cli]"

The CLI is documented separately in Getting Started and Command Reference.

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:

OrderSourceHow to set it
1The auth argumentDiscolike(auth=OAuthCredential(...)) or Discolike(auth=ApiKeyCredential(api_key="dk_..."))
2The api_key argumentDiscolike(api_key="dk_...")
3The DISCOLIKE_API_KEY environment variableexport DISCOLIKE_API_KEY=dk_...
4The CLI config file, holding either an OAuth session or an API keydiscolike 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.

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 AuthenticationError like 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 your OAuthCredential instance 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_KEY
environment variable, pass api_key=..., or run `discolike auth login`. Create a key at
https://app.discolike.com/account/management/keys

That 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.

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
ParameterTypeDefaultDescription
api_keystr | NoneNoneYour DiscoLike API key. When None, the credential is resolved from the environment or the CLI config file.
authApiKeyCredential | OAuthCredential | NoneNoneAn 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_urlstr"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.
timeoutfloat60.0Per-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_retriesint3Number 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.

client.with_options(*, timeout: float) -> Discolike

Returns 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 call
client.discover(DiscoverParams(icp_prompt="B2B SaaS in fintech")) # default 60s timeout

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()
# equivalent
client = Discolike()
try:
usage = client.account.usage()
finally:
client.close()

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.

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 Discolike
from 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+ | 701

Counting the market before you pull it costs nothing but a request:

from discolike import Discolike
from 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.

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 pydantic
from 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 50

Naming 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:

RuleEffect
Unset fields are omittedA 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 keptJSON-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 throughThe 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 Discolike
from 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)

Both print the same thing:

stripe.com Stripe 10001+
adyen.com Adyen 5001-10000

These are the differences:

SyncAsync
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 Jobreturns AsyncJob
EmailJob / EmailBatchAsyncEmailJob / 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 asyncio
from discolike import AsyncDiscolike
from 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 701
adyen.com 458
checkout.com 641

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:

MethodTask 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 Discolike
from 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.

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, DiscolikeError
from 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 = None

Branch on a specific class when the recovery differs, and back off on RateLimitError using the server’s own hint:

import time
from discolike import Discolike, RateLimitError
from 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:

MethodRetried statusesRetried exceptions
GET, DELETE429, 502, 503, 504any transport failure: connect errors, read timeouts, write errors, protocol errors
POST, PUT, PATCH429 onlyconnection 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().

Every namespace and method is on the Reference page.

TaskEntry pointSection
Check requests, records, and spendclient.account.*account
Look up firmographics, score, growth, or page text for a domainclient.companies.*companies
Search, look up, or match peopleclient.contacts.*contacts
Run an LLM research pass over companies or personasclient.discogen.*discogen
Find lookalike companies from seeds or a text ICPclient.discover(), client.count()discovery
Find a work email from a name and a domainclient.email.*email
Enrich a domain list, segment it, or validate an ICPclient.append(), client.segment(), client.segment_file(), client.validate_icp()enrich
Bring your own LLM provider keysclient.llm_providers.*llm_providers
Resolve a messy company name to a domainclient.match.*match
Save, reuse, and exclude result setsclient.queries.*queries
Bring your own web search provider keysclient.search_providers.*search_providers
Read a request or response model’s fields, a job handle, or an exceptionDiscolikeRequest, CompanyProfile, Job, DiscolikeErrorTypes

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().