SAMPLE-ONLY SANDBOX · No live scraping, AI qualification, verified contacts, payments or webhook delivery.

Vantrill API — Developer Specification

Version: 0.1 draft · 6 November 2026 (America/Chicago / CST)

Eagle eye for leads. Submit a company website, niche and location, or a keyword. Vantrill is designed to return structured company/contact records with qualification scores and the evidence behind them, so an agent can make a useful decision rather than consume a raw scrape.

Implementation status — read first

This document distinguishes the intended API contract from the initial sandbox skeleton. The sample-default implementation and guarded, operator-only Monid source beta are publicly deployed. Landing, docs, signup persistence and API authentication have been checked; the production collection contract remains a draft. The production API hostname is not active. General production collection, customer billing and webhook capabilities below remain a draft contract; the narrowly scoped Monid source beta is described separately below.

Paths in this specification are relative to the selected base. /targets means /v1/targets on the intended API host and /api/v1/targets on the temporary app host.

1. Authentication and key handling

Send an API key on every data API request. Public, read-only discovery at GET /api/v1 and GET /api/v1/providers does not require a key:

Authorization: Bearer <VANTRILL_API_KEY>
Accept: application/json
Content-Type: application/json

Key namespaces:

Prefix Meaning Initial release
vt_test_ Private operator preview; sample by default; no customer production credits Explicit Monid beta jobs may incur guarded source costs; not self-service
vt_live_ Production; billable work within approved quotas Not issued or accepted

Never put keys in query strings, browser-side code, analytics, screenshots or logs. Use a server-side environment variable or secret manager. Store key hashes, not plaintext; compare hashes in constant time. Associate production keys with an account, scopes and revocation status. The one-key sandbox accepts only the exact configured private demo key and must reject all other keys, including arbitrary strings with the correct prefix.

The demo test key is cryptographically generated and saved only in /workspace/vantrill/TEST_KEY.txt, with owner-only file permissions; its SHA-256 hash is configured server-side as VANTRILL_TEST_KEY_HASH. This file is private local operator material, is not committed, must not be deployed, and must not be pasted into chat. A test-key prefix is not an authorization mechanism by itself.

Planned scopes: targets:write, targets:read, leads:read, usage:read, webhooks:write. Jobs and records, including Monid beta results, are scoped to the owning account. Random IDs are not access control.

Missing, invalid or revoked keys return 401 unauthorized. A valid key without the necessary scope returns 403 forbidden. Production keys in the sample-only skeleton return 401 with a message that production access is not enabled.

2. Transport and common response format

HTTPS only. JSON request/response bodies; UTF-8. Timestamp fields use RFC 3339 with an explicit offset (normally UTC in machine responses); displayed times should be converted to the viewer's timezone. IDs are opaque strings (tgt_…, lead_…, evt_…, wh_…). Clients must not parse their contents.

Object response:

{
  "data": {
    "id": "tgt_example",
    "object": "target",
    "status": "queued",
    "sample": true
  },
  "meta": {
    "request_id": "req_example",
    "mode": "sandbox",
    "sample": true,
    "api_version": "2026-11-06"
  }
}

List response:

{
  "data": [],
  "pagination": {
    "next_cursor": null,
    "has_more": false,
    "limit": 25
  },
  "meta": {
    "request_id": "req_example",
    "mode": "sandbox",
    "sample": true,
    "api_version": "2026-11-06"
  }
}

Return X-Request-Id on all responses. Use Cache-Control: no-store for authenticated API responses and signup submissions. Do not expose provider credentials or internal error stacks.

Error response

{
  "error": {
    "code": "validation_error",
    "message": "Provide exactly one target type: website, niche, or keyword.",
    "details": [{"field": "type", "issue": "unsupported_value"}],
    "request_id": "req_example"
  },
  "meta": {"mode": "sandbox", "sample": true}
}
HTTP Code Meaning
400 invalid_json Body is not valid JSON
401 unauthorized Missing or invalid API key
403 forbidden Key lacks scope or feature access
404 not_found Record does not exist for this account
405 method_not_allowed Unsupported HTTP method; include Allow
409 idempotency_conflict Same idempotency key, different body
409 target_not_ready Target is still queued/running
410 cursor_expired Cursor can no longer be used; restart pagination
413 payload_too_large Request exceeds 16 KiB
415 unsupported_media_type JSON endpoint received a non-JSON body
422 validation_error Field validation failed
429 rate_limit_exceeded Per-minute limit; honor Retry-After
429 quota_exceeded Credit or active-job quota exhausted; no surprise charges
500 internal_error Unexpected failure; report request ID
501 not_implemented Documented future feature, not implemented in sandbox
503 service_unavailable Temporary dependency/configuration failure

details is optional and must never echo secrets or personal data unnecessarily. Error codes are stable; message text may improve. Retry only transient errors (429, 503 and selected 5xx), with exponential backoff plus jitter. Use idempotency keys when retrying a POST. Do not retry validation/auth failures unchanged.

3. Targets and asynchronous jobs

A target is the durable asynchronous job resource; its id is also the job ID. A production worker will perform scraping and qualification after request acceptance. HTTP acceptance is not a guarantee of leads.

Lifecycle:

queued → running → completed
   └────────┴────→ failed

completed may contain zero leads. A failed target has a stable failure code and a sanitized reason. Partial usable results, if present, must be explicitly disclosed with partial: true; do not silently call a failed run completed. The initial sandbox persists targets in Postgres and simulates queued (first two seconds), running (two to five seconds), then completed (after five seconds). No external calls occur for default sample jobs. Explicit Monid jobs use a real, guarded source adapter with durable failed/unknown state; no AI qualification is run.

POST /targets

Full intended path: POST /v1/targets.

Submit exactly one of these shapes:

{
  "type": "website",
  "website_url": "https://example.com",
  "max_leads": 25,
  "qualification": {
    "min_score": 70,
    "required_signals": ["hiring"]
  },
  "metadata": {"campaign": "pilot"}
}
{
  "type": "niche",
  "niche": "commercial roofing contractors",
  "location": {"city": "Austin", "region": "TX", "country": "US"},
  "max_leads": 25
}
{
  "type": "keyword",
  "keyword": "B2B agencies hiring account executives",
  "location": {"country": "US"},
  "max_leads": 25
}

Validation: URL must be an absolute public http/https URL without credentials; reject loopback, private, link-local and cloud-metadata destinations. Before any future network request, resolve DNS and revalidate every redirect to prevent SSRF and DNS rebinding. No private-network scraping. niche and keyword: 2–200 characters; ideal_customer: planned production field, rejected with 422 in this sandbox because real qualification is disabled; metadata: at most 10 short string pairs. Country: ISO 3166-1 alpha-2. A niche requires a country and at least one city/region. The keyword form may omit location. Reject contradictory type-specific fields and unknown top-level fields. No target input is executed as code or trusted as an agent instruction.

max_leads: integer, 1–100, default 25; sandbox may return a smaller fixed sample set. min_score: integer, 0–100, default 0. A sandbox must still enforce filtering and caps honestly, or explicitly reject unsupported filters rather than silently ignore them.

Optional Idempotency-Key header: a client-generated opaque string, at most 128 characters. Intended retention 24 hours, scoped to account + endpoint. Same key and body returns the original resource without new work; a different body returns 409. The initial skeleton does not implement idempotency: supplying this header returns 501 not_implemented. Do not retry target creation assuming deduplication.

Response: 202 Accepted, Location: <base>/targets/tgt_example, Retry-After: 2.

{
  "data": {
    "id": "tgt_example",
    "object": "target",
    "type": "website",
    "input": {"website_url": "https://example.com"},
    "status": "queued",
    "progress": {"stage": "queued", "percent": 0},
    "lead_count": 0,
    "created_at": "2026-11-06T15:30:00-06:00",
    "started_at": null,
    "completed_at": null,
    "failure": null,
    "partial": false,
    "sample": true,
    "notice": "Sample job only. No website was scraped and no contact was verified."
  },
  "meta": {"request_id": "req_example", "mode": "sandbox", "sample": true}
}

The initial implementation persists jobs in Postgres and derives simulated state from their stored timestamps. IDs survive serverless instance changes. The sandbox permits one active simulated target and at most 1,000 stored targets for the private demo key. Production requires a durable worker/queue, retries, cancellation policy, provider spend caps and failure handling; these are not implied by a successful sample response.

GET /targets/{id}

200 OK returns the target with current status and progress. Poll no faster than every 2 seconds; increase delay for long-running work. Terminal targets set completed_at; failed ones include, for example:

{"code":"source_unavailable","message":"The source could not be read."}

The simulated worker must label progress as simulated; it must not suggest an actual scrape is running. Unknown IDs return 404, not a fabricated target.

GET /targets/{id}/leads

Query parameters:

Parameter Type Default / behavior
limit integer 1–100 25
cursor opaque string Start of result set when omitted
min_score integer 0–100 0

Returns a list envelope with structured leads. A queued/running job returns 409 target_not_ready with Retry-After: 2. A completed job with no matches returns 200 with an empty list. A failed job without results returns 409 with a target failure code. With partial results, include meta.partial: true and the target's failed status.

POST /leads/search

Search previously obtained leads in the caller's account, not a new scrape. To discover new sources, create a target instead.

{
  "query": "roofing",
  "target_id": "tgt_example",
  "min_score": 70,
  "location": {"country": "US"},
  "limit": 25,
  "cursor": null
}

All filter fields are optional; supported filters must be applied, including free-text matching. Returns 200 and the same list envelope. Initial sandbox searches only completed stored sample results (at most 1,000 targets), never an actual lead database. The fixed fixtures are commercial-roofing examples in Austin, TX, US; target niche/keyword text is stored but does not generate matching new records. Scores are fixed illustrative values. Location, max_leads, min_score and the supported required_signals filters (hiring, expansion) are applied. ideal_customer is rejected rather than evaluated. Website input is not fetched. A different target can still produce the same fictional companies; these are not discoveries. Unsupported filters return 422 with the relevant field.

GET /leads/{id}

Returns a single lead in an object envelope (200) or 404. Leads must belong to the caller's account; test and live datasets never mix.

4. Lead schema and qualification

Example — fictional, not a real lead:

{
  "id": "lead_sample_001",
  "object": "lead",
  "target_id": "tgt_example",
  "sample": true,
  "company": {"name": "Example Roofing Co", "industry": "Commercial roofing"},
  "website": "https://example.com",
  "contact": {"name": "Alex Example", "role": "Operations Manager"},
  "email": "alex@example.com",
  "email_status": "unverified",
  "phone": null,
  "socials": {"linkedin": null, "x": null, "instagram": null},
  "location": {"city": "Austin", "region": "TX", "country": "US"},
  "signals": [
    {
      "type": "hiring",
      "description": "Synthetic example of an active hiring signal",
      "source_url": "https://example.com/careers",
      "observed_at": "2026-11-06T15:30:00-06:00",
      "sample": true
    }
  ],
  "qualification": {
    "score": 84,
    "score_version": "sample-v1",
    "reasons": [
      {"code": "industry_fit", "description": "Synthetic match to requested niche", "points": 40},
      {"code": "location_fit", "description": "Synthetic match to requested location", "points": 25},
      {"code": "buying_signal", "description": "Synthetic hiring signal", "points": 19}
    ]
  },
  "source": {
    "provider": "sample_fixture",
    "url": "https://example.com",
    "sample": true
  },
  "scraped_at": null,
  "generated_at": "2026-11-06T15:30:00-06:00",
  "notice": "Fictional sample. Not scraped, enriched or verified; do not contact."
}

company.name, id, sample, qualification and source are required. Missing contact fields, website, phone, social URLs and location components are null, never guessed. Production scraped_at is the actual time the record was collected, not the time a sample fixture was generated. For sample data it is always null; use generated_at separately.

qualification.score is an integer 0–100, not a probability of sale or email validity. Production score reasons must be evidence-backed and versioned. Proposed interpretable scoring: industry fit 0–40, location fit 0–25, evidence-backed buying signals 0–25, contact completeness 0–10. Return the reasons and points; the score must equal their sum. Scores from different scoring versions are not automatically comparable. Sandbox scores are illustrative only and must never be advertised as actual AI evaluation.

Production email_status: unverified, verified, invalid or unknown; only claim verified when an identified verification process supports it. A website domain does not prove an email pattern. Do not promise consent to outreach, deliverability or legal compliance just because a contact is listed.

Data providers — coming soon / guarded beta

GET /api/v1/providers is live, public and read-only. It returns only provider names, credential-presence booleans, implementation status and spend limits. It never contacts a provider, validates a key, reveals a credential, starts work or spends money. Presence is not proof that a key is valid or that an account has funds. Non-GET methods return 405.

{
  "data": {
    "object": "provider_list",
    "default_provider": "simulated",
    "live_execution_enabled": true,
    "spend_caps": {
      "currency":"USD", "per_job":0.50, "per_day":2.00,
      "daily_window":"rolling_24_hours_including_unresolved_holds"
    },
    "providers": [
      {"id":"simulated","configured":true,"status":"available","execution_enabled":true},
      {"id":"apify","configured":false,"status":"not_configured","execution_enabled":false},
      {"id":"monid","configured":true,"status":"beta_enabled","execution_enabled":true}
    ]
  },
  "meta": {"mode":"sandbox","sample":true}
}

Simulation stays the default. Existing target requests behave exactly as before. Only an authenticated operator request that explicitly adds "provider": "monid" opts in to the real-source beta. "provider": "simulated" is also accepted; apify and arbitrary provider/tool/input overrides are rejected. This is not production-key issuance or a paid customer plan. Do not submit an opt-in job merely to test the connection: it may spend.

Opt-in example — potentially billable; do not run without intending a real job:

{"type":"website","website_url":"https://example.com","provider":"monid","max_leads":1}

The website path selects People Data Labs /v5/company/enrich with the website hostname. Niche/keyword paths select Apollo /mixed_people/api_search, page 1, at most 25 records. Only these two data-only endpoints are allowlisted. No outreach, actor run or arbitrary agent action can be selected. qualification is rejected for Monid jobs: real scoring is not implemented. Completed beta targets expose the untrusted provider result as source_output, plus source/run provenance, with sample:false, meta.mode:"beta" and progress.simulated:false. They do not fabricate scored leads; /leads returns an empty list until a reviewed normalization/qualification layer exists. Default samples, filters, signed cursors, signup persistence and all existing routes remain supported.

All targets include additive provider and cost_ledger fields. Ledger amounts are integer MICRO_DOLLAR USD units (1 USD = 1,000,000). Samples have zero caps, spend and reservations. Monid jobs have a $0.50 per-request/job ceiling, persisted call entries, charged cost, billed units and result count. global_reserved shows the separate global job reservation. State is stored server-side in existing target JSON; no schema migration or new database service is required. This is source-cost accounting, not customer billing.

The server-only MONID_API_KEY is configured as a sensitive production-only Vercel variable. MONID_RUNS_ENABLED defaults to false in code; Jon explicitly enabled it for production. MONID_MAX_SPEND_PER_JOB=0.50 and MONID_MAX_SPEND_PER_DAY=2.00 are enforced server-side. Invalid, zero or higher-than-approved caps fail closed. The daily ceiling is global across accounts/instances and uses a stricter rolling 24-hour window: settled costs in the last 24 hours plus every unresolved hold, however old. PostgreSQL transaction advisory locks serialize reservations; the full job ceiling is reserved before acceptance. A killed or uncertain worker cannot silently release funds or be replayed automatically.

Monid's /v1/run schema has no per-request maxCost field. The beta therefore accepts only simple fixed USD PER_CALL prices and requires an ACTIVE native Monid workspace run cap no greater than the job reservation before the only run POST. An optional MONID_RUN_CAP_CONTROL_ID pins that control; otherwise the client looks for a suitable cap on the first list page and fails closed if none is visible. The key needs controls:read in addition to runs:create. This extra guard is necessary because a local price estimate cannot constrain upstream billing if pricing changes. Upstream controls must remain active; Vantrill cannot police spending by another app using the same wallet.

Discover/inspect and run-status reads have bounded safe retries/timeouts. A paid POST is never automatically retried, including after a timeout or HTTP 429/5xx. HTTP 200 BLOCKED is not success. Actual billing.reportedCost and its monetary unit settle the ledger, not the quoted unit rate. Missing/ambiguous billing stays reserved and halts work; cap breaches are recorded honestly and halt the job. Free GET target polling can reconcile an accepted run with a known run ID. Unknown runs without IDs or interrupted workers need manual operator reconciliation; funds do not expire automatically.

Verification used free discover, inspect and wallet reads only: auth worked, balance was $0.00, held $0.00. No Monid run was made and no balance was debited. A funded balance, a suitable native run cap, the key scopes above, rights review and an explicit operator Monid job are still required for billable data. Provider terms, privacy and resale rights must be reviewed before real records are collected, retained, exported or resold.

Scraping adapters (future)

Vantrill's future worker can select Apify or Monid as a data-source layer, then normalize, deduplicate and qualify the result. Apify credentials and actor execution stay server-side. They are not customer-facing API parameters. Provider dataset IDs may be held internally for provenance but credentials, private source records and internal run URLs must not leak.

The current skeleton must not call Apify, run actors, consume credits or make any paid AI calls. Use sample fixtures with reserved example domains; do not copy real people's contact details into a demo. Production must review source permissions/terms, privacy, retention, suppression/deletion processes and relevant outreach laws before turning collection on.

5. Cursor pagination

Lists accept limit (1–100, default 25) and cursor. The server returns next_cursor plus has_more. Continue until has_more is false; never construct or decode a cursor in a client. Cursor is tied to the account, dataset snapshot, sort order and filters. Tampered or mismatched cursors return 422; expired ones return 410.

Default stable sort: creation time descending, then ID descending. Intended cursor validity: 24 hours. Do not claim a fixed validity in the sandbox unless it is enforced. Target sample lists are immutable. For sandbox search, cursors are tied to the exact matched ordered result IDs; adding matching targets invalidates an old cursor with 422 rather than silently shifting its offset. Restart a search if that occurs. A production snapshot-bound cursor is planned. No total-count field is promised.

6. Webhooks — proposed, not enabled

Intended events:

Planned management endpoints: POST /webhooks (HTTPS URL + event list), GET /webhooks, DELETE /webhooks/{id}. These are future endpoints; initial skeleton should return 501 not_implemented, with no URL fetch, registration or delivery. Do not accept a webhook URL in a target and silently discard it.

Proposed event payload:

{
  "id": "evt_example",
  "type": "target.completed",
  "created_at": "2026-11-06T15:30:00-06:00",
  "mode": "sandbox",
  "sample": true,
  "data": {"target_id": "tgt_example", "status": "completed", "lead_count": 1}
}

Production design: sign the exact raw request body using HMAC-SHA256 and a separate per-endpoint signing secret. Vantrill-Signature: t=<unix-seconds>,v1=<hex-digest> signs <timestamp>.<raw-body>. Verify in constant time, reject timestamps outside a five-minute tolerance, and deduplicate event IDs. API keys are not webhook signing secrets. Verify the target is public HTTPS, defend SSRF on DNS/redirects and do not follow unvalidated redirects.

Delivery is at least once and events may be out of order. Respond with a 2xx after durably storing the event, then process asynchronously. Proposed retries: exponential backoff with jitter for up to 24 hours; log attempts and expose failed deliveries before claiming production webhook support. This is a proposed policy, not a live SLA. Clients should reconcile by fetching the target and leads.

7. Rate limits and usage

Rate-limit policy is account/key based, not process-local. A production or multi-instance sandbox must use atomic persistent counters. The key's minute bucket has an absolute Unix-second reset time.

Headers on successful requests and authenticated errors:

X-RateLimit-Limit: 30
X-RateLimit-Remaining: 29
X-RateLimit-Reset: <unix-seconds-at-next-minute-boundary>

When exhausted, return 429 rate_limit_exceeded, Retry-After: <seconds> and remaining 0. The reset header is an absolute epoch time; Retry-After is a duration. No special bypass should exist for sample API calls. Invalid-auth traffic needs a separate abuse limit and must not reveal whether a guessed key exists. Signup endpoint limits are separate from API quotas.

Proposed pricing and tier limits — draft only, NOT live offers

Proposed tier Proposed monthly price Proposed included lead credits Proposed API requests/min Proposed concurrent targets
Free sandbox $0 Synthetic sample data only 30 1 simulated
Starter $29 500 60 2
Growth $99 2,500 180 10
Scale Custom, undecided Negotiated Negotiated Negotiated

These are discussion numbers, not prices users can buy today. No checkout, subscriptions, automatic overages or payment collection are enabled. Real collection/AI costs have not been validated; pricing needs Jon's explicit approval and a unit-economics review. Free hosting is for the initial skeleton only and its provider limits must be verified before launch. No plan upgrade or general billable collection is authorized. Only an explicit operator Monid beta job can execute within the separately approved source caps.

Proposed credit policy: one credit per unique qualifying lead delivered to an account per 30-day window. No credits for empty/failed runs, duplicate retrievals, polling or sample data. Reserve against a target's approved cap, settle only actually delivered leads and release unused reservations; enforce a hard cap rather than surprise overages. This policy is unimplemented and must be finalized before issuing live keys.

GET /usage

{
  "data": {
    "object": "usage",
    "tier": "sandbox",
    "mode": "sandbox",
    "sample": true,
    "billing_enabled": false,
    "lead_credits_used": 0,
    "lead_credits_limit": null,
    "requests_this_minute": 1,
    "rate_limit_per_minute": 30,
    "active_targets": 0,
    "period_start": null,
    "period_end": null,
    "notice": "Sample-only access. No production lead credits or charges."
  },
  "meta": {"request_id": "req_example", "mode": "sandbox", "sample": true}
}

Request and target counts must be measured, not invented. A null billing period means billing is not active; a null credit limit in sandbox is not a promise of unlimited real leads. The example values above are illustrative.

8. Python example (requests)

Set VANTRILL_API_KEY privately and VANTRILL_BASE_URL to the verified temporary base after deployment. Never paste the key into the program or commit it. This example does not print lead contact details.

import os
import time
import requests

BASE = os.environ["VANTRILL_BASE_URL"].rstrip("/")
session = requests.Session()
session.headers.update({
    "Authorization": f"Bearer {os.environ['VANTRILL_API_KEY']}",
    "Accept": "application/json",
})

response = session.post(f"{BASE}/targets", json={
    "type": "niche",
    "niche": "commercial roofing contractors",
    "location": {"city": "Austin", "region": "TX", "country": "US"},
    "max_leads": 10,
}, timeout=30)
response.raise_for_status()
target = response.json()["data"]

deadline = time.monotonic() + 120
while target["status"] not in ("completed", "failed"):
    if time.monotonic() >= deadline:
        raise TimeoutError("Target is still processing; resume polling later")
    time.sleep(2)
    response = session.get(f"{BASE}/targets/{target['id']}", timeout=30)
    response.raise_for_status()
    target = response.json()["data"]

if target["status"] == "failed":
    raise RuntimeError(target.get("failure", {}).get("code", "target_failed"))

cursor = None
while True:
    params = {"limit": 25}
    if cursor:
        params["cursor"] = cursor
    response = session.get(
        f"{BASE}/targets/{target['id']}/leads", params=params, timeout=30
    )
    response.raise_for_status()
    page = response.json()
    for lead in page["data"]:
        print(lead["id"], lead["qualification"]["score"], lead["sample"])
    if not page["pagination"]["has_more"]:
        break
    cursor = page["pagination"]["next_cursor"]

For production, add retry/backoff with Retry-After, structured error handling and idempotency. This bounded polling example is an integration illustration, not a durable job runner.

9. JavaScript example (server-side fetch)

Node.js 20+; keep the key on the server, never in a client component.

const base = process.env.VANTRILL_BASE_URL?.replace(/\/$/, "");
const key = process.env.VANTRILL_API_KEY;
if (!base || !key) throw new Error("Configure Vantrill server environment");

async function api(path, { method = "GET", body } = {}) {
  const response = await fetch(`${base}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${key}`,
      Accept: "application/json",
      ...(body ? { "Content-Type": "application/json" } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
    signal: AbortSignal.timeout(30000),
    cache: "no-store",
  });
  const result = await response.json();
  if (!response.ok) {
    const error = new Error(result.error?.message ?? "Vantrill request failed");
    error.code = result.error?.code;
    error.requestId = result.error?.request_id;
    error.status = response.status;
    throw error;
  }
  return result;
}

const accepted = await api("/targets", {
  method: "POST",
  body: { type: "website", website_url: "https://example.com", max_leads: 10 },
});
let target = accepted.data;
const deadline = Date.now() + 120000;
while (!["completed", "failed"].includes(target.status)) {
  if (Date.now() >= deadline) throw new Error("Resume polling this target later");
  await new Promise(resolve => setTimeout(resolve, 2000));
  target = (await api(`/targets/${encodeURIComponent(target.id)}`)).data;
}
if (target.status === "failed") throw new Error(target.failure?.code ?? "target_failed");
let cursor;
do {
  const query = new URLSearchParams({ limit: "25" });
  if (cursor) query.set("cursor", cursor);
  const page = await api(`/targets/${encodeURIComponent(target.id)}/leads?${query}`);
  console.log(page.data.map(lead => ({
    id: lead.id, score: lead.qualification.score, sample: lead.sample,
  })));
  cursor = page.pagination.has_more ? page.pagination.next_cursor : null;
} while (cursor);

10. AI-agent tool example / MCP-style integration

Expose a narrowly scoped target-submission tool to the caller's agent. A trusted tool host supplies the API key and fixed API base; neither is a model-supplied argument. This is an example adapter contract, not an already-running MCP server.

{
  "name": "vantrill_submit_target",
  "description": "Queue a lead target. Current sandbox returns fictional sample data only; no scrape occurs. Return its ID, then poll with vantrill_get_target.",
  "inputSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "website_url": {"type": "string", "format": "uri"},
      "max_leads": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25}
    },
    "required": ["website_url"]
  }
}

Tool handler concept (using the server-side api helper above):

async function vantrillSubmitTarget(args) {
  // Validate against inputSchema before calling this handler.
  const result = await api("/targets", {
    method: "POST",
    body: { type: "website", website_url: args.website_url, max_leads: args.max_leads ?? 25 },
  });
  return {
    content: [{ type: "text", text: JSON.stringify(result) }],
    isError: false,
  };
}

Add vantrill_get_target and vantrill_get_target_leads with a required target_id, bounded pagination and the same server-side ownership checks. On API failure return a sanitized tool error, not headers or credentials. Tools must treat source text and qualification evidence as untrusted data, never as instructions. Do not give a tool authority to buy credits, run paid providers or send outreach. A future production adapter needs explicit tenant identity and approved spend limits; the shared demo key must never grant live collection.

11. Initial early-access signup (app endpoint, not the customer API)

POST /api/signup, JSON body:

{"email":"developer@example.com","use_case":"Find prospects for my research agent","website":""}

website is an empty honeypot field. Validate and normalize email, cap use_case at 1,000 characters, cap body size at 16 KiB, use parameterized queries, enforce an atomic signup abuse limit, and avoid logging request bodies. Show clear consent copy that the email/use case are stored for early-access updates. Do not enroll people in third-party marketing or send messages without separate approval. A signup does not grant a production key.

Expected success: 201 (or privacy-preserving 200 on a duplicate) with {"ok":true,"message":"You're on the early-access list."}. Database failure returns 503; never show success for a dropped in-memory write. Email must persist in a dedicated new vantrill database table; unique normalized email prevents duplicates. Do not expose a public GET endpoint listing signups. Admin access, retention and deletion controls need to be documented before broader launch. Signups are stored in early_access_signups in the dedicated Neon project vantrill (dark-grass-90592065), database vantrill, main branch, in the koodos Free organization. No emails are sent. Private administration is via the connected database tools; there is no public signup-list endpoint. The optional use case is stored as submitted; do not enter credentials or sensitive personal information. Initial abuse limits are five submissions per IP per hour and 250 total submissions/day.

12. Acceptance checks before announcing a live skeleton

  1. Verify the exact public landing URL and /docs from outside the deployment account; neither should require a Vercel login.
  2. Submit a clearly identified synthetic signup and confirm its row in the new database. Check invalid email, duplicate handling, length/body limits and database-failure behavior.
  3. Check missing/invalid key gives 401; approved test key gives 202 for a target; an unissued vt_live_ value is rejected.
  4. Verify the same target can be fetched across requests and deployments as designed; queue state is not ephemeral process memory.
  5. Poll to completion, retrieve only labelled sample leads, check scraped_at: null, filter behavior, pagination, unknown IDs and proper errors.
  6. Verify per-key rate-limit headers, atomic enforcement and 429 plus Retry-After; check usage reads real sandbox counters with billing disabled.
  7. Webhook routes are labelled future/return 501; no delivery, Apify actor, paid model or payment call happens.
  8. Check mobile/desktop accessibility, form success/error states, code/docs consistency and original geometric eagle branding. No invented customers, testimonials, performance figures or production capabilities.
  9. Ensure the test key file and database credential never enter source control, static output, public docs or logs. Use server-only environment variables.
  10. Record the actual temporary URLs, storage location, working/stubbed matrix and verified results in a release report.

13. Moving to vantrill.com later

No domain has been registered, purchased or connected under this work. After Jon separately approves and completes purchase:

  1. Add vantrill.com and, optionally, www.vantrill.com to the new Vantrill Vercel project only.
  2. Use the exact apex and CNAME records Vercel displays for that project; do not guess IPs or overwrite unrelated MX/TXT records. Choose one canonical site hostname and redirect the other.
  3. Add api.vantrill.com to that same project or a dedicated future API project. If served from the app, configure host-aware routing from /v1/* to /api/v1/* so the intended base in this spec is truthful, while preserving the temporary base during migration.
  4. Set each DNS record per the project's current Vercel instructions, satisfy any ownership verification and wait for DNS plus managed TLS.
  5. Update examples/configuration, webhook URLs and allowed origins to the verified production host, then rerun signup/auth/API tests. Do not redirect API POSTs across hosts as a substitute for correct routing.
  6. Production scraping and paid access remain off until Jon explicitly approves provider budgets, the legal/data policy, live-key issuance and real worker infrastructure.

Initial sandbox safety limits

The private demo key has 30 requests/minute, one active simulated target, at most 1,000 stored targets, and a shared 2,000 authenticated API requests/day safety cap. Counters are atomic database UPSERTs, not process-local. Each target returns at most three fixed synthetic companies. There is no key issuance UI, customer account system, automatic scraping or production lead delivery. Invalid-key requests have a separate per-IP 30/minute abuse bucket. Website input rejects IP literals and obvious local hostnames but does not resolve DNS, because nothing is fetched; DNS and redirect validation is a prerequisite to any future network collection. Rate caps can make the preview unavailable rather than trigger paid collection. Neon is on the verified Free plan with additional small project quotas; Vercel uses the existing team plan without buying any upgrade. Provider allowances still apply; no promise of unlimited traffic is made.