Documentation
ContactCTL product documentation
The canonical reference for the contactctl CLI and its
behavior: commands, flags, credit costs, batch semantics, async runs, JSON shapes, exit
codes, and rate limits. Written to be executed verbatim — by you or by your agent.
Overview
ContactCTL is a CLI for AI agents that finds, verifies, and enriches B2B contact data
through waterfall enrichment across 20+ premium data sources. It installs as a single
binary with two names: contactctl and the short alias ctc. Both are identical; examples here use ctc.
The surface is value-first: every work verb takes positional values and routes on structural input shape — never on natural-language meaning. One
command per job, --json everywhere, deterministic exit codes, and no interactive prompts, ever.
find, phone, and whois charge only when a result is found. A clean miss
costs nothing.
Install & authenticate
Install globally via npm, then connect your account with the API key from the
dashboard. Keys look like ctc_live_… and are shown
once at creation.
npm install -g contactctl contactctl auth <api_key> # stores the key locally contactctl auth status # email, plan, balance, key prefix
contactctl auth <key> validates the key against
the API and writes it with file mode 0600 to ~/.config/contactctl/config.json. Environment
variables override the file — see Configuration. The full
install walkthrough, including a copy-paste block for handing to an agent, is on the install page.
verify — email deliverability
0.02 credits per verification · sub-second
ctc verify <email> # single ctc verify <file.csv> [out.csv] # batch; stdout if out omitted
Answers “can I send to this address?” with a status (deliverable, undeliverable, risky, unknown) and a
confidence score. Free local syntax and MX checks run first; only the full
verification is charged.
Verification charges per check — found or not — because the answer itself is the
product. Catch-all domains surface as status risky: they accept everything, so a catch-all “ok” is
weaker evidence than a directly confirmed mailbox.
Single-mode verify exits on its verdict — the result
prints first, then the exit code carries it: 0 deliverable, 2 undeliverable/no_mx,
3 risky/catch_all/unknown, 6 invalid syntax. So ctc verify "$EMAIL" && send branches correctly with no parsing. Batch rows are unaffected.
$ ctc verify jane.smith@acme.com --json
{
"email": "jane.smith@acme.com",
"status": "deliverable",
"confidence": 0.99,
"domain": "acme.com",
"mx_found": true,
"role_account": false,
"checked_at": "2026-05-21T10:14:04Z",
"cost": { "estimated_credits": 0.02, "actual_credits": 0.02 }
}find — work or personal email
1 credit per work email found · 3 credits per personal email found · a miss is free
ctc find <full name> <domain|company> # work email ctc find <full name> <domain> --personal # personal email instead ctc find <linkedin_url> # from a LinkedIn profile ctc find <file.csv> <out.csv> # batch; output required
The work-email hero command. The first positional is the full name; the second is shape-routed to a domain (token contains a dot, no spaces) or a company name (anything else). A single LinkedIn URL positional needs no company anchor.
Name split: the first whitespace token becomes first_name; the entire remainder becomes last_name ("miha von cacic" → first miha, last von cacic). A mononym leaves last_name empty. There is no “Last, First” comma
logic.
--personal requests the personal-email field (3 credits) instead of the work email — a single-field call, never both at once. A
bare name with no domain, company, or LinkedIn URL is rejected with exit 6; use search people for role-based
discovery.
find runs asynchronously: it
submits the enrichment, polls, and either prints the result or — past --timeout — prints a run-id you can reattach to.
phone — mobile phone
10 credits per number found · a miss is free
ctc phone <full name> <domain|company> ctc phone <linkedin_url> ctc phone <file.csv> <out.csv> # batch; output required
Mobile phone lookup is its own top-level verb so the 10 credits spend
stays explicit — an agent never stumbles into it through a flag. Input parsing,
name-split, shape routing, async behavior, and batch rules are identical to find.
whois — reverse email lookup
1 credit per profile found · a miss is free
ctc whois <email> ctc whois <file.csv> [out.csv] # batch; stdout if out omitted
Resolves the person and company behind a known email: name, title, company profile,
LinkedIn URL where available. This is enrichment of a contact point you already have
— not deliverability (that is verify) and not discovery (that is search). Runs asynchronously like find.
search — scoped people & company search
0.25 credits per search request
ctc search people --domain D | --company C
[--titles T] [--departments D] [--seniority S]
[--name N] [--location L] [--limit N]
ctc search companies [--industry I] [--headcount 11-200]
[--location L] [--type T] [--limit N]search people finds people at a named account — one of --domain or --company is required. search companies finds companies by explicit
attributes. Both return profiles, not email addresses; run find on a selected result when you need the email.
Filters combine with AND across fields and OR within a field
(--titles "CMO,VP Marketing" matches either title).
Both commands charge 0.25 credits per request regardless of result count,
reject batch files, and paginate with --limit and --offset.
ctc search people --domain acme.com \ --titles "VP Marketing,Head of Content" --limit 10 --json
lookalike — similar companies or people
0.35 credits per returned row
ctc lookalike <domain> # similar COMPANIES
ctc lookalike <linkedin_url> # similar PEOPLE
[--size 11-200] [--location L] [--limit N]Expands one known-good seed into similar records. The seed's shape decides the mode:
a domain returns companies, a LinkedIn URL returns people. Exactly one seed per call
— no comma lists, no batch. Charged per returned row, so --limit 25 costs at most 8.75 credits. --estimate prices the worst case as --limit × 0.35.
Filter introspection — free, local
ctc search filters # list all filter fields ctc search filters <field> # accepted values for one field ctc lookalike filters # lookalike filter fields
Search and lookalike filters take controlled vocabularies (industries, seniorities,
company types) — and guessing values silently weakens results. The filters commands print every field with its type
(enum, substring, range) and, per field, the accepted values one per line,
copy-friendly. They run entirely locally from data embedded in the binary: no
network, no key, no credits.
Validation is warn-only: an unrecognized enum value prints a closest-match suggestion to stderr and the request is still sent. An unknown field exits 6 with a suggestion.
$ ctc search filters seniorities Owner Founder C-level Partner VP Head Director Manager Senior
Utility commands
ctc instructions
The agent usage guide: roughly 60 lines covering the value-first surface, routing rules, batch behavior, costs, and exit codes. An agent reads it once per session and knows the whole tool — this is the zero-context-cost alternative to MCP tool schemas (why we chose that).
ctc usage
Credit balance (plan, top-up, total) and recent ledger entries — each with action,
credits, and status. Plan credits are spent first, then top-up credits, oldest first. Supports --json for monitoring scripts.
ctc auth · ctc auth status
auth <api_key> validates and stores the key; auth status reports account email, plan, balance,
and the key prefix (never the full key).
ctc version
Prints the version string. version --json emits a
deterministic struct with build and output-schema version for scripts that pin
behavior.
Universal flags
| Flag | Applies to | Effect |
|---|---|---|
| --json | every command | Deterministic JSON output, stable field order. |
| --estimate | every paid command | Print the credit cost and exit. Computed locally; zero spend, zero network. |
| --help | every command | Detailed usage for that command. |
| --max-cost N | find, phone, whois, verify | Hard spend cap in credits. Batch runs stop before exceeding it (exit 4). |
| --timeout D | find, phone, whois | Async wait cap, e.g. 30s or 5m (default 120s). On expiry: prints run-id, exit 9. |
| --run-id ID | find, phone, whois (single mode only) | Reattach to a previous async run — repeat the original positionals. Polls only; never re-bills. Batch invocations reject it (exit 6). |
| --dry-run | batch verbs | Preflight: rows detected, rows ready, missing fields, cost estimate. No spend. |
| --in F / --out F | verify, whois, find, phone | Explicit batch form; --in - reads stdin.
Mutually exclusive with positional files. |
| --personal | find only | Personal email (3 credits) instead of work email. |
| --limit N | search, lookalike | Result cap and pagination unit. |
Input shape routing
Routing is structural and deterministic, applied per positional. The CLI never interprets natural language — it matches shapes:
| Input shape | Routed as |
|---|---|
| Matches an email regex | |
| Is a URL or contains “linkedin.com” | LINKEDIN_URL |
| Contains a dot, no spaces | DOMAIN (preferred when ambiguous) |
| Anything else | COMPANY_NAME (2nd arg) · NAME (1st arg) |
So ctc find "Jane Smith" acme.com routes to domain, ctc find "Jane Smith" "Acme GmbH" routes to company
name, and ctc lookalike flips between company mode
(domain seed) and people mode (LinkedIn seed) on the same rule. On the batch-capable
verbs, file detection runs before shape routing.
Batch semantics
verify, whois, find, and phone are
batch-native: pass a file as the first positional and the verb processes it row by
row. CSV and TSV are supported.
The trigger is file-ness, never value-sniffing. A verb enters batch
mode when the first positional exists as a readable file, or ends in .csv/.tsv. A name
or domain is never a file, so find "Jane Smith" acme.com always stays single. A
path that looks like a data file but does not exist is a hard error (exit
6) — never a silent fall-through to single mode.
Output rules. The second positional is the output path. find and phone batch require it (unless --dry-run); verify and whois default to stdout. When input and output are the same path, ContactCTL overwrites in
place via write-temp-then-atomic-rename — a crash can never corrupt the input file.
Guarantees. Input row order is preserved. Output columns are
appended; input columns survive untouched. Rows are never silently dropped — errors
are reported per row. Runs stop before exceeding --max-cost. Large files are processed in chunks of
at most 100 rows per run.
ctc find leads.csv enriched.csv --max-cost 100 ctc find leads.csv leads.csv # safe in-place overwrite ctc verify list.csv # batch verify to stdout ctc whois --in - < emails.csv # explicit form, stdin ctc find leads.csv --dry-run # preflight, no spend
Recognized input columns (headers are matched case-, space-, and dash-insensitively;
synonyms in parentheses): first_name (first,
firstname), last_name (last, lastname), full_name (name, person, contact_name), company (company_name, organization), domain (company_domain, website), linkedin_url (linkedin, profile_url), and email (work_email). search and lookalike reject batch entirely — paginate with --limit.
Async runs & run-id
Enrichment (find, phone, whois) is
asynchronous under the hood. The CLI submits the job, receives a run-id, and polls
every 3 seconds until the result arrives or --timeout (default 120s) expires. On timeout it
prints the run-id and exits 9. Nothing is lost:
$ ctc find "Jane Smith" acme.com --timeout 30 status: in_progress run_id: 7c9e6679-7425-40de-963d-1a8f7d2e9b1c (reattach with --run-id 7c9e6679-7425-40de-963d-1a8f7d2e9b1c) # exit 9 $ ctc find "Jane Smith" acme.com --run-id 7c9e6679-7425-40de-963d-1a8f7d2e9b1c status: found work_email: jane.smith@acme.com (deliverable, confidence 0.98)
Reattach is single-mode only and repeats the original positionals with --run-id added: ctc find "<full name>" <domain|company> --run-id <id>, ctc find <linkedin_url> --run-id <id>, ctc phone "<name>" <domain> --run-id <id>, or ctc whois <email> --run-id <id>. With --run-id the CLI submits nothing new — it only polls GET /v1/runs/<id> — so reattaching never
re-bills. An unknown or expired run-id exits 6 with an “unknown run” error.
There is no batch reattach. Batch invocations reject --run-id with exit 6 (“--run-id reattach is
single-mode only”). When a batch run outlives --timeout (exit 9), the affected rows are written with status in_progress and the per-chunk run-id in the contactctl_detail column; recovering a full chunk
from those run-ids is not yet supported — re-run the unfinished rows as a new batch.
Credits are held when a run is submitted and settled only for rows where a result was found; holds on misses and failed runs are released in full.
Cost control
Three mechanisms, layered:
--estimateprints what an action would cost and exits — computed locally from the embedded price table, zero spend, zero network. For lookalikes it prices the worst case (--limit× 0.35).--dry-runadds batch readiness: rows detected, rows with enough input, rows missing fields, and the low/high cost estimate.--max-costis a hard cap enforced during the run — the command stops before exceeding it and exits 4.
And the structural one: find, phone, and whois charge only when a result is found. A clean miss costs nothing.
Credit costs
Credits are the only unit the CLI reports — command output never converts to EUR because top-up rates differ per plan. Plans and top-up pricing live on the pricing page.
| Action | Command | Credits | Billing |
|---|---|---|---|
| Email verification | ctc verify <email> | 0.02 credits | per verification |
| Work email find | ctc find <name> <domain> | 1 credit | per result found · miss is free |
| Personal email find | ctc find <name> <domain> --personal | 3 credits | per result found · miss is free |
| Mobile phone find | ctc phone <name> <domain> | 10 credits | per result found · miss is free |
| Reverse email lookup | ctc whois <email> | 1 credit | per result found · miss is free |
| People search | ctc search people ... | 0.25 credits | per search request |
| Company search | ctc search companies ... | 0.25 credits | per search request |
| Lookalike companies or people | ctc lookalike <seed> | 0.35 credits | per returned row |
| Filter introspection | ctc search filters | free | free, runs locally |
find, phone, and whois charge only when a result is found. A clean miss costs nothing.
JSON output shapes
Every command supports --json. Shapes are stable and
versioned via ctc version --json's schema field.
Abridged examples below; the HTTP API returns the same structures (see the API reference).
{
"status": "found",
"input": {
"first_name": "Jane", "last_name": "Smith",
"full_name": "Jane Smith", "domain": "acme.com"
},
"profile": {
"full_name": "Jane Smith", "title": "VP Growth",
"company": "Acme", "domain": "acme.com"
},
"contacts": [{
"type": "work_email",
"value": "jane.smith@acme.com",
"status": "deliverable",
"confidence": 0.88,
"catch_all": false,
"verified_at": "2026-05-21T10:14:04Z"
}],
"waterfall": [{
"provider": "contactctl",
"action": "waterfall_enrichment",
"status": "found",
"cost_credits": 1,
"latency_ms": 31000
}],
"cost": { "estimated_credits": 1, "actual_credits": 1 }
}The input object echoes the positionals exactly as
parsed (name split, plus the domain or company the second positional routed to);
empty fields are omitted. The requested field shows up as the contact's type (work_email, personal_email, or mobile_phone). On a miss, status is not_found, the command exits 2, and actual_credits is 0.
{ "error": { "code": "insufficient_credits", "message": "…" } }Batch CSV output keeps every input column untouched and appends six columns: contactctl_status, the value column named for the
verb (contactctl_email for verify, contactctl_profile for whois, contactctl_work_email for find — contactctl_personal_email with --personal — and contactctl_phone for phone), then contactctl_detail, contactctl_provider, contactctl_cost_credits, and a per-row contactctl_error.
Exit codes
Exit codes are part of the contract — scripts and agents branch on them without parsing output.
| Code | Meaning |
|---|---|
| 0 | Success with a usable result |
| 1 | General error |
| 2 | Completed, but no result found (the free miss) |
| 3 | Result found but below the confidence threshold |
| 4 | Budget exceeded — --max-cost hit or insufficient credits |
| 5 | Missing or invalid API key |
| 6 | Invalid input |
| 7 | Upstream error (including rate limit after retries are exhausted) |
| 8 | Compliance or suppression block |
| 9 | Timeout — run-id printed for reattach |
Single-mode verify additionally
exits on its verdict (the result prints first): 0 deliverable, 2 undeliverable/no_mx,
3 risky/catch_all/unknown, 6 invalid syntax. Batch verify exits per the normal batch semantics, never per-row
verdict.
Rate limits & backoff
Each API key is limited to 120 requests per minute across all endpoints. When a limit
is hit, the API responds with HTTP 429 and a retry_after_seconds value — and the CLI handles it
for you: it sleeps the indicated time (capped at 30s) and retries, up to 4 times
(roughly 90 seconds total). Your agent sees latency, not failures. Only after
retries are exhausted does the command exit 7.
Batch chunking (≤100 rows per run) keeps large files inside these limits automatically; no client-side throttling logic is required.
Configuration
| Setting | Purpose |
|---|---|
| ~/.config/contactctl/config.json | Written by contactctl auth with file mode
0600. Holds api_key and optional api_url. |
| CONTACTCTL_API_KEY | Overrides the config file — use in CI and ephemeral environments. |
| CONTACTCTL_API_URL | Overrides the API base URL (default https://contactctl.com/api). |
There is no provider-key configuration anywhere — ContactCTL is a managed service and one key covers the whole waterfall. Key management lives in the dashboard.
Related: the HTTP API reference, the CLI design page, and integrations.