# 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`. ## Operating limits and monthly search budget The Netcup deployment has no daily SMTP quota. It permits four concurrent verification tasks, with starts at least one second apart globally and five seconds apart per domain. A probe includes target checks and random-recipient controls; it sends no message. This is controlled pacing, not a guaranteed checks-per-day capacity. Company search reserves each request in SQLite before calling Brave. The authorized $50/month ceiling permits 10,000 search requests at the published $5/1,000 price, before credits and taxes. Existing requests this month count. The allowance resets at 00:00 UTC on the first of each calendar month and survives restarts. It applies to this Relay deployment, not unrelated use of the same provider account. Price changes require updating the rate and cap. See https://brave.com/search/api/. `/v1/account` reports `search_monthly_query_limit`, `search_monthly_budget_usd`, `search_budget_resets_at`, `usage.search.month` and SMTP pacing. `smtp_daily_probe_limit: null` means no daily quota. The former daily search and lifetime limits are retired. Set `SEARCH_MONTHLY_BUDGET_USD` in the server environment (default $5, deployed $50). `SMTP_DAILY_PROBE_LIMIT=0` disables the daily quota; pacing remains active. Budget-blocked bulk rows remain queued with `retry_at`; the job exposes `next_retry_at`. They resume automatically after the allowance resets, including after a restart. Other eligible rows can continue. Individual requests report the budget failure without claiming an invalid mailbox. Direct official-page research can still succeed when paid search is paused. Synchronous discovery has 150 seconds and bulk find rows have 15 minutes. ## 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. ## In-house verification (1.1.4) Decision-maker lookup already calls the same verifier as `/v1/verify` before returning `valid_email`. No separate verification request or third-party verification key is required. Check `verification.evidence.smtp` on inconclusive decision-maker results, or `evidence.smtp` on direct verification results, for the actual recipient/control outcomes. The verifier also recognizes Microsoft's documented `550 5.4.1 Recipient address rejected: Access denied` response **for random controls only**, on a Microsoft Exchange Online MX over certificate-validated TLS. It still requires two rejected random controls and two successful target checks. A target-only 5.4.1, other provider's 5.4.1, generic policy rejection, catch-all acceptance, or temporary failure does not become verified. If TLS fails before a mailbox answer, one alternate MX may be tried with the same certificate validation; encryption checks are never disabled. Leadership discovery includes managing attorneys, attorney directory links, middle initials and name suffixes. This does not qualify every attorney or partner as an owner, and does not establish who controls a verified mailbox. SMTP and Brave caps remain unchanged; synchronous searches can run out of time before trying all name patterns. Bulk jobs allow more time but share the same daily capacity. Release 1.1.5 reserves a homepage fetch even when search finds many blocked biographies. Each OR search branch repeats its company/domain scope. First-name-only guesses are checked immediately after `first.last` (published addresses still come first), reducing time spent on less useful patterns in this pilot's examples. This ordering was tuned on the comparison sample, not validated as a universal hit-rate improvement. A specific authenticated Microsoft directory rejection on a target stays `unknown` with `reason: recipient_directory_rejected`; the finder may continue to the next candidate. Generic policy blocks still stop guesses. Release 1.2.0 also reads explicit name/title cards preserved in search-indexed official directories when the live page fails. Such evidence is labelled `indexed_official_team_card` and `current_role_confirmed: false`. A readable live page takes precedence over indexed evidence. Arbitrary snippets and LinkedIn remain discovery hints. Partner, founding partner and senior partner count as leadership; ordinary attorneys do not. Release 1.2.1 reports explicit Spamhaus rejections as `smtp_ip_policy_blocked` with unknown mailbox status. It stops further guesses. The current Netcup IP has a confirmed PBL listing affecting Microsoft and Network Solutions; opening outbound port 25 alone does not clear it.