# Relay API guide ## Addresses and authentication Current native local server: `http://127.0.0.1:3000`. Local Docker deployment: `http://127.0.0.1:3010` when its container is started. Public deployment hostname: `https://finder.themailersystem.com`. See RUNNING.md for the latest deployment validation. All `/v1/` operations require: ```http Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` The live VPS key is in `data/vps-access.json` on the Mac (`token` field). For native local use, the separate key is in `data/local-access.txt`. For Docker, create a separate key using `docker compose exec relay node server/create-key.js create 'My integration'`. Keep the key in your integration's credentials store, not its URL or publicly served JavaScript. Keys can be listed and revoked with `node server/create-key.js list` and `node server/create-key.js revoke KEY_ID` on the appropriate server. Set `RELAY_BASE_URL` and `RELAY_API_KEY` in your client's environment before using these examples. Example addresses below are documentation inputs, not claimed real contacts. `example.com` explicitly does not accept mail. ## Find a person's email ```sh curl --max-time 200 "$RELAY_BASE_URL/v1/find" \ -H "Authorization: Bearer $RELAY_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"full_name":"Alex Morgan","domain":"example.com","fresh":true}' ``` Required: `domain` or `company_name`, and either `full_name`, or both `first_name` and `last_name`. Optional `fresh` is a boolean. The engine searches public company pages, prioritizes matching published addresses, and checks common name formats. It stops after a verified candidate, a domain-wide obstruction, or its work budget. Company-name input uses Brave Search and official-site metadata to resolve the domain; ambiguous matches return no domain. An optional `location` helps the search. Use `valid_email`. An HTTP 200 means processing completed, not that an email was found. Finder results can include `discovery_method`, `source_url`, `candidates_checked` and per-candidate `discovery` evidence. `identity_confirmed: false` means mailbox ownership has not been independently proven. ## Decision-maker lookup ```sh curl --max-time 200 "$RELAY_BASE_URL/v1/decision-maker" \ -H "Authorization: Bearer $RELAY_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"company_name":"Example Company","location":"London","role":"leadership"}' ``` Prefer `domain` when known. Roles: `leadership` (default), `marketing`, `sales`, `finance`, `technology`, `operations`, `hr`. This searches the public web, fetches official company pages and up to three company-issued press releases on supported newswire publishers, and pairs a person's name with an explicit senior title. Releases must identify the company and requested domain. Search snippets alone cannot establish their role. It makes at most eight official-page fetch attempts, three press-page attempts and checks three people. Each discovery lookup uses one Brave query, with at most two targeted follow-ups when official role evidence is absent; company-name resolution may add one query. Each search requests up to 20 results. Public LinkedIn-index results supply names to corroborate on official sources, never role proof, and LinkedIn itself is not crawled. Official homepage team sections, founder biographies, explicit company statements and structured founder/employee references are supported. Page reads are bounded to 2 MB. It does not render JavaScript-only directories or access private employee databases. A press-release result includes `role_evidence.published_at` when available and `current_role_confirmed: false`: a historical announcement does not establish current authority. Inaccessible sources return `research_sources_unavailable` with `email_status: "unknown"` and `research.page_errors`, including HTTP 403 where applicable. Found names remain visible when no email passes verification. Catch-all results include `verification` evidence and status `risky` while both top-level email fields stay null. The address itself is visible in the separately labelled `candidates` array. Published first-name addresses (including publicly obfuscated contact text) are checked before patterns. A successful result has `email_status: "valid"`, `valid_email`, `full_name`, `job_title`, `is_decision_maker: true`, and `role_evidence` with a source URL, text and timestamp. The boolean means the published title matches the requested seniority; it does not prove purchasing authority or that the mailbox belongs to that person. `identity_confirmed` remains false. Unconfirmed roles, catch-alls and unknown mailboxes produce null email fields. No assistant or generic-inbox fallback is used by this endpoint. Resolve a domain separately with `POST /v1/resolve-company` and `{"company_name":"Example Company","location":"London"}`. If several checked official websites match, `domain` is null and `resolution` is `ambiguous_company`. Bulk decision-maker input uses `POST /v1/jobs` with: ```json {"type":"find","rows":[{"domain":"example.com","decision_maker":true,"role":"leadership"}]} ``` Results exports are **verified-only by default**, in both JSON and CSV. Append `verified_only=false` only to inspect failures or unfinished rows. Diagnostic inputs can contain addresses you submitted, but unverified addresses are never promoted to the result's `email` or `valid_email`. ## Hostinger pilot limits The initial shared-VPS deployment uses one SMTP probe per minute, up to 100 probes per UTC day, and 100 Brave queries per UTC day, with a hard lifetime ceiling of 500 queries per database. This ceiling does not reset each month or when the container restarts. At Brave’s published $5/1,000 rate this is $2.50 before tax/credits; current prices can be checked at https://brave.com/search/api/. Do not reset usage counters or run extra independent copies under the same spending budget. A probe includes the target and catch-all controls. These are conservative application limits, not confirmation of Hostinger permission for bulk verification. Hostinger publishes a five-emails-per-minute sending limit; treatment of recipient-only checks still needs clarification. Checks wait for the configured probe spacing when the request deadline allows. Synchronous discovery has a 150-second work budget; bulk find rows have 15 minutes so more candidates can be checked safely. If the daily cap or request deadline is reached, a request returns no verified email with `smtp_budget_or_rate_limit`, or a search request returns HTTP 429. A bulk row can finish without a result when the limit is hit; this pilot does not defer rows until tomorrow. Do not submit a large list expecting every candidate to be checked. `/v1/account` reports the configured limits. Increasing a limit does not overcome mail-server restrictions or establish provider permission. ## Verify an address ```sh curl --max-time 200 "$RELAY_BASE_URL/v1/verify" \ -H "Authorization: Bearer $RELAY_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"email":"alex@example.com","fresh":true}' ``` Required: `email`. Optional: `fresh`. Key response fields: | Field | Meaning | | --- | --- | | `email` | Verified address only; otherwise null (same eligibility as `valid_email`) | | `valid_email` | Address only when status is `valid`; otherwise null | | `email_status` | `valid`, `invalid`, `risky`, or `unknown`; finders also return `not_found` | | `reason` | Machine-readable explanation, such as `catch_all`, `null_mx`, or `smtp_not_configured` | | `checked_at` | Time the evidence was obtained | | `cached` | Whether a stored result was returned | | `evidence` | Syntax, MX hosts, SMTP decisions and catch-all controls | `valid` requires the target to be accepted twice and two random control recipients to be explicitly rejected as missing. A catch-all domain is `risky`. Policy rejection, temporary errors and timeouts are inconclusive, not proof of invalidity. The engine never sends an email message. SMTP acceptance does not guarantee personal identity, future delivery or inbox placement. Cache: valid one hour, invalid 15 minutes, inconclusive one minute. `fresh: true` bypasses stored results. Concurrent requests for the same address can share an in-flight check. ## Published company addresses ```sh curl --max-time 200 "$RELAY_BASE_URL/v1/company" \ -H "Authorization: Bearer $RELAY_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"domain":"example.com"}' ``` Returns `valid_emails`, detailed `emails`, `published_candidates` and `website_warnings`. This scans public company pages; it is not a database of all employees. ## Bulk jobs ```sh curl "$RELAY_BASE_URL/v1/jobs" \ -H "Authorization: Bearer $RELAY_API_KEY" \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: contact-list-001' \ -d '{"type":"verify","rows":[{"email":"alex@example.com"}]}' ``` Use `type: "find"` with rows containing names and domains to find emails. HTTP 202 returns the job's `id`. Maximum 1,000 rows and 1 MB per request. Reuse the same Idempotency-Key and exact body when retrying creation; changed input with that key returns 409. ```sh # Poll this every five seconds until complete or cancelled. curl "$RELAY_BASE_URL/v1/jobs/$JOB_ID" \ -H "Authorization: Bearer $RELAY_API_KEY" # Retrieve verified results only. curl "$RELAY_BASE_URL/v1/jobs/$JOB_ID/results" \ -H "Authorization: Bearer $RELAY_API_KEY" # Download results as CSV. curl "$RELAY_BASE_URL/v1/jobs/$JOB_ID/results?format=csv" \ -H "Authorization: Bearer $RELAY_API_KEY" -o relay-results.csv ``` Jobs report `status`, `total`, `completed`, and `valid`. Statuses are `queued`, `running`, `complete`, and `cancelled`. `DELETE /v1/jobs/{id}` cancels queued work; an in-progress row can finish. `GET /v1/jobs` lists your key's latest 30 jobs. Another key cannot read or cancel them. Jobs survive restarts using the persistent database. Completed/cancelled jobs expire after 30 days. ## Endpoints and errors | Method | Path | Purpose | | --- | --- | --- | | GET | `/healthz` | Public process health and SMTP configuration | | GET | `/readyz` | Database/configuration readiness; 503 with SMTP disabled | | GET | `/openapi.json` | Machine-readable API specification | | GET | `/v1/account` | Authenticated capabilities and limits | | POST | `/v1/find` | Person and company lookup | | POST | `/v1/resolve-company` | Company name to official domain | | POST | `/v1/decision-maker` | Senior role research plus strict email verification | | POST | `/v1/verify` | Existing address verification | | POST | `/v1/company` | Public company inbox discovery | | POST / GET | `/v1/jobs` | Create / list jobs | | GET / DELETE | `/v1/jobs/{id}` | Progress / cancellation | | GET | `/v1/jobs/{id}/results` | JSON or CSV results | 400: invalid request. 401: missing, invalid or revoked key. 404: resource does not exist or is owned by a different key. 409: idempotency conflict. 413: request too large. 422: unsupported/missing identity input. 429: rate/queue limit. 500/503: internal failure or temporary capacity pressure. Use bounded backoff for 429/503; respect Retry-After when present. Never treat these errors as “mailbox invalid.” Initial limits: 60 requests per minute per key and direct IP, five active jobs per key, 10,000 queued/running rows globally, two simultaneous synchronous discovery requests, and four concurrent verifier tasks by default. Behind a reverse proxy, the direct-IP limit is shared by proxied callers because untrusted forwarded headers are not used. Allow 200 seconds for synchronous requests; prefer bulk for long lists. A bulk find row can take up to 15 minutes under conservative SMTP pacing. Bulk uses polling, not webhooks. ## AnyMail Finder compatibility Aliases: `/v5.1/verify-email`, `/v5.1/find-email/person`, `/v5.1/find-email/company`, `/v5.1/find-email/decision-maker`. Only the method/path overlap and documented inputs are supported. Billing/credits, response fields and coverage differ. LinkedIn-only requests return 422. Company-name resolution requires the configured Brave Search key. Do not assume this is a drop-in replacement for an existing AnyMail Finder integration. ## Check whether it really works A successful `/healthz` proves the HTTP process answers. `/readyz` additionally checks database access and whether SMTP is configured. Neither proves SMTP egress or accuracy. Before production, verify an operator-controlled real mailbox and a known missing address, check catch-all behaviour, and confirm the full name/domain lookup returns the intended person. See `RUNNING.md` for operating the service independently of a terminal. ## All discovered candidates (1.1.2) `/v1/find`, `/v1/decision-maker` and `/v1/company` include `candidates`, a bounded list of matching published addresses and actually checked guesses. `email` and `valid_email` at the top level retain their verified-only meaning; company `emails`/`valid_emails` and default bulk exports also remain verified-only. Do not treat presence in `candidates` as verification. ```json { "email": null, "valid_email": null, "email_status": "risky", "reason": "catch_all", "candidates": [{ "email": "jane@example.com", "email_status": "risky", "reason": "catch_all", "discovery_method": "published_address", "source_url": "https://example.com/team", "checked_at": "2026-09-14T23:00:00.000Z" }] } ``` Candidate statuses: `valid` (displayed as Verified), `risky` with `reason: catch_all` (Catch-all), other `risky` (Risky), `unknown`, `invalid`, and `not_checked`. A published address not probed because of time, pacing or an early stop is `not_checked`, even if another mailbox on its domain exhibited catch-all behaviour. `discovery_method` distinguishes `published_address` from `name_pattern`; guessed addresses are not described as found on a website. Published addresses are matched to the researched person's name formats; arbitrary coworkers are not substituted. The interface offers **Export verified** and **Export all with status**. For bulk API results, use `?verified_only=false` to retrieve candidates in JSON, or `?verified_only=false&format=csv` for one CSV row per candidate with status, reason and source. Default exports continue to exclude unverified candidates. Past stored jobs retain their original results; rerun a lookup for the new candidate data. `research.search_queries` records bounded query attempts and provider/budget failures. `research.discovery_hints` contains unconfirmed public-profile pointers; these do not qualify a person for email verification. Extra queries remain inside the existing lifetime/daily caps. Broader source coverage is not a guaranteed hit rate. Release 1.1.3 also recognizes explicit company origin stories and corporate pages on `website.`, alongside the root and `www` hosts. Arbitrary subdomains do not become trusted employee directories. A redirect to a different company domain is reported in `research.page_errors` as `external_redirect_requires_company_confirmation` with `redirected_to`; the engine does not silently change the requested email domain. Historical founding statements carry `current_role_confirmed: false`, even if fetched from a current webpage.