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

- Intended production base: `https://api.vantrill.com/v1` — reserved design, **not live or configured**.
- Initial temporary base: `https://vantrill.vercel.app/api/v1` — **live sample-only sandbox; private test key required**.
- Initial public docs: `https://vantrill.vercel.app/docs` — **live developer documentation**.
- The initial skeleton returns only clearly labelled synthetic sample records. Default sample targets and leads include `sample: true`; their API response envelopes include `meta.sample: true` and `meta.mode: "sandbox"`; usage also includes both fields directly.
- Apify execution, contact verification, AI qualification, general production API keys, customer payments and webhook delivery are **not enabled**. Explicit operator-only Monid jobs can return raw source data within the beta guards below; default targets remain fictional. No scrape may be implied by a sample response.
- An early-access request is not a paid plan or a production API account. No messages are sent automatically by the signup form.

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:

```http
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:

```json
{
  "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:

```json
{
  "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

```json
{
  "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:

```text
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:

```json
{
  "type": "website",
  "website_url": "https://example.com",
  "max_leads": 25,
  "qualification": {
    "min_score": 70,
    "required_signals": ["hiring"]
  },
  "metadata": {"campaign": "pilot"}
}
```

```json
{
  "type": "niche",
  "niche": "commercial roofing contractors",
  "location": {"city": "Austin", "region": "TX", "country": "US"},
  "max_leads": 25
}
```

```json
{
  "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`.

```json
{
  "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:

```json
{"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.

```json
{
  "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**:

```json
{
  "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`.

```json
{
  "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**:

```json
{"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:

- `lead.ready`: one qualified lead has become available.
- `target.completed`: a target reached completed status, including zero-result runs.
- `target.failed`: recommended additional terminal failure event.

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:

```json
{
  "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:

```http
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

```json
{
  "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.

```python
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.

```javascript
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**.

```json
{
  "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):

```javascript
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:

```json
{"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.
