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, Postgres-backed account sign-up/sign-in 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 includemeta.sample: trueandmeta.mode: "sandbox"; usage also includes both fields directly. - Customer
vt_live_keys are live: signed-in users create them in the dashboard and use them for paid lead searches (/searches,/balance) billed from prepaid credits at $0.10 per accepted unique business lead, or $0.25 for a lead with a contact person when you opt in withinclude_contacts. Live searches can optionally be AI-qualified against anideal_customerdescription for $0.02 per scored lead. See Live API — paid lead searches. - Contact verification and webhook delivery are not enabled, and the sandbox
/targetsroutes never run AI qualification. The sandbox/targetsroutes below remain sample-first. 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. - Account sign-up is live at
https://vantrill.com/signup; sign-in is athttps://vantrill.com/login. A workspace account does not issue a production API key or activate billing.
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_ |
Customer production key; paid lead searches billed from your prepaid credits | Self-service in the dashboard (API keys panel); up to 10 active keys per account |
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.
Live keys (vt_live_)
- Create, name and revoke keys in the dashboard under API keys. The full key (
vt_live_+ 40 hex characters) is shown exactly once at creation; afterwards only its prefix and last four characters are displayed. Lost keys cannot be recovered: revoke and create a new one. - Vantrill stores only a SHA-256 hash of each key and looks it up by that hash. Revocation takes effect immediately; a revoked key returns
401. last_used_atis updated at most once a minute per key.- Live keys and the
vt_test_sandbox key are separate worlds: live keys can only reach/balanceand/searches…(anything else is404), and the sandbox key cannot reach those routes. Live responses carrymeta.mode: "live"andmeta.sample: false. - Each live key has its own limit of 60 requests/minute, and each account may start at most 5 searches/minute. Invalid live keys share the per-IP invalid-auth bucket.
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, invalid or revoked API key |
| 402 | insufficient_credits |
Live search needs more prepaid credits than the account has |
| 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 |
| 409 | not_ready |
Live search is still running; honor Retry-After |
| 409 | search_failed |
Live search finished without results; nothing was charged |
| 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.
Live API — paid lead searches (vt_live_ keys)
Everything in this section requires Authorization: Bearer vt_live_… and is billed from the same prepaid credit balance as the dashboard. Buy credits in the dashboard; there is no subscription.
Pricing. You pay per accepted unique lead actually delivered (duplicates and permanently closed places are dropped before charging):
| What | Default price | When it applies |
|---|---|---|
| Business lead | $0.10 per lead | Every delivered lead without a contact person, and every lead when include_contacts is off |
| Lead with a contact person | $0.25 per lead | Only when you send include_contacts: true and that lead has a contact |
| AI qualification | +$0.02 per scored lead | Only when you send ideal_customer and the model actually scored that lead |
Current prices are returned by GET /balance and can change; a search always settles at the prices in effect when it was created (shown in its pricing.prices).
Holds. Starting a search places a hold for the worst case: max_leads × (include_contacts ? contact price : business price), plus max_leads × qualification price when ideal_customer is set and a qualification model is configured. When the search finishes you are charged only for what was delivered, never more than the hold, and the rest is released automatically. A search that returns nothing costs nothing. If your balance can't cover the hold, the search is refused with 402 insufficient_credits before any provider is called.
Optional contact people. Contact people (name, title, LinkedIn) come from an additional source that only runs when you send include_contacts: true. Leads where no contact is found are charged the business price. Without include_contacts, that source never runs and the contact price is never charged.
Optional AI qualification. Add ideal_customer to score every delivered lead against your description. Leads the model could not score are not charged for qualification. If no qualification model is configured, the search still runs without scoring (qualification.state: "unavailable"), the hold has no qualification part, and nothing extra is charged.
POST /searches
Body (same as the dashboard search form):
{
"keyword": "roofers",
"location": {"city": "Austin", "region": "TX", "country": "US"},
"max_leads": 25,
"ideal_customer": "Commercial roofing contractors with 10+ employees that work on warehouses",
"include_contacts": true
}
keyword 2–120 characters; location.country is a two-letter code and at least one of city/region is required; max_leads 1–500 (default 25); ideal_customer is optional, 10–500 characters (omit it or send "" to skip qualification); include_contacts is an optional boolean, default false (any other type returns 422; it appears in query only when true). Unknown fields return 422. Returns 202 with Location: /api/v1/searches/{id}:
{
"data": {
"object": "search",
"id": "srch_…",
"status": "running",
"query": {"keyword": "roofers", "location": {"city": "Austin", "region": "TX", "country": "US"}, "max_leads": 25, "ideal_customer": "Commercial roofing contractors with 10+ employees that work on warehouses", "include_contacts": true},
"created_at": "2026-11-06T18:00:00.000Z",
"completed_at": null,
"hold_usd": 6.75,
"charged_usd": null,
"lead_count": null,
"error": null,
"qualification": {"state": "pending", "provider": "anthropic"},
"pricing": {"prices": {"lead_price_usd": 0.1, "contact_lead_price_usd": 0.25, "qualify_price_usd": 0.02}, "charges": null},
"sources": {"google_maps": {"state": "running", "found": null}, "apollo": {"state": "running", "found": null}},
"lead_origin": null
},
"meta": {"request_id": "req_…", "mode": "live", "sample": false, "api_version": "2026-11-06"}
}
Errors: 402 insufficient_credits, 422 validation_error, 429 rate_limit_exceeded, 503 service_unavailable (live sources at capacity or not configured; no hold is kept). Idempotency-Key is not supported yet (501); if a create times out, check GET /searches before retrying.
GET /searches
Your 50 most recent searches, newest first, as {"data": [search, …], "meta": …}. Leads are not included.
GET /searches/{id}
One search. Polling this endpoint advances a running search; poll every 5 seconds (Retry-After: 5 is set while status is running). status is running, completed or failed. Searches belonging to other accounts return 404.
qualification is null when no ideal_customer was given. Otherwise state is pending (while running), done (at least one lead scored), failed (the model scored none; nothing charged for qualification), skipped (no leads to score) or unavailable (no qualification model configured). Finished qualifications also report provider, model, qualified and unqualified lead counts.
pricing.prices are the prices frozen when the search was created. Once the search has finished, pricing.charges breaks down charged_usd:
{"business_leads": 18, "business_price_usd": 0.1, "contact_leads": 7, "contact_price_usd": 0.25, "scored_leads": 25, "qualify_price_usd": 0.02, "total_usd": 4.05, "capped": false}
total_usd equals charged_usd; capped is true only if the total was limited to the hold. pricing is null for searches created before pricing tiers existed.
Recently verified data. Vantrill keeps businesses that earlier searches already found. When enough recently verified businesses (default: verified by a provider within the last 30 days) match the same keyword and location, the search completes immediately without a new provider run; otherwise they are merged with fresh provider results (fresh data wins on duplicates). You pay the same per-lead price either way. Such searches list a cache entry in sources (found = matching cached businesses, served = leads delivered only from cache), and completed searches report lead_origin: {"cache": 2, "providers": 23} (null while running). Cached contact persons are served only when the search sets include_contacts: true, and are then billed at the contact-lead price like fresh ones.
GET /searches/{id}/leads
Leads for a completed search: {"data": [lead, …], "search_id": "srch_…", "lead_count": 3, "meta": …}. Lead fields: name, website, phone, email, address, city, region, country, category, rating, reviews_count, contact (name, title, linkedin or null), sources (cache marks a lead served from recently verified data) and last_verified_at (when a provider last returned this business; check it for leads served from cache). Qualified searches add qualification to each lead: {"score": 82, "fit": "strong", "reasons": ["…"], "evidence": ["category", "region"]} (score 0–100, not a probability of sale; fit is strong 70–100, possible 40–69 or weak 0–39; at most 3 short reasons; evidence names the lead fields the reasons rely on), or null for a lead the model could not score. Qualified leads are ordered best score first, unscored leads last. Add ?format=csv for a CSV download with the same columns as the dashboard export — the lead fields (with last_verified as a date) plus score, fit and reasons (reasons joined by |; empty when unscored). Spreadsheet formulas are neutralized. Returns 409 not_ready while running and 409 search_failed for a failed search. Fetching leads again never charges again.
GET /balance
{"data": {"object": "balance", "currency": "USD", "balance_usd": 22.5, "lead_price_usd": 0.1, "contact_lead_price_usd": 0.25, "qualify_price_usd": 0.02}, "meta": {"mode": "live", "sample": false}}
balance_usd is your spendable balance; amounts held by running searches are already subtracted. Prices are the current ones for new searches: lead_price_usd per business lead, contact_lead_price_usd per lead with a contact person (only with include_contacts: true), and qualify_price_usd extra per scored lead when a search includes ideal_customer.
curl -s https://app.vantrill.com/api/v1/searches \
-H "Authorization: Bearer $VANTRILL_API_KEY" -H "Content-Type: application/json" \
-d '{"keyword":"roofers","location":{"city":"Austin","country":"US"},"max_leads":10}'
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:
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:
{
"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. Workspace accounts (separate from API keys)
Create an account at https://vantrill.com/signup, or sign in at https://vantrill.com/login. Both use the same self-hosted Better Auth email/password flow and redirect to https://app.vantrill.com/dashboard.
Users, hashed password credentials and revocable sessions live in portable Postgres tables via the server-only DATABASE_URL. Session cookies are Secure, HttpOnly, SameSite=Lax and scoped to .vantrill.com so the app host recognizes the same session. Email ownership verification and password-recovery email are not configured; no automatic email is sent. Use a unique password of at least 12 characters.
Each account has a private workspace. Sample jobs stay explicitly fictional; signed-in users can buy prepaid credits and create vt_live_ keys in the dashboard. The API never accepts a session cookie; it uses the key-based authorization contract above. URL-search history is private to the signed-in user and stores the associated sample job reference.
The legacy POST /api/signup endpoint is retired and returns 410 Gone, with links to account sign-up and sign-in. It accepts no new submissions. Previously stored records are retained privately; there is no public record-list endpoint.
12. Acceptance checks before announcing a live skeleton
- Verify the exact public landing URL and
/docsfrom outside the deployment account; neither should require a Vercel login. - Create a synthetic workspace account, confirm its Postgres persistence, verify sign-in/session isolation and signout, then remove only that synthetic test account. Confirm the retired endpoint returns 410 without a database write.
- Check missing/invalid key gives
401; approved test key gives202for a target; an unissuedvt_live_value is rejected. - Verify the same target can be fetched across requests and deployments as designed; queue state is not ephemeral process memory.
- Poll to completion, retrieve only labelled sample leads, check
scraped_at: null, filter behavior, pagination, unknown IDs and proper errors. - Verify per-key rate-limit headers, atomic enforcement and
429plusRetry-After; check usage reads real sandbox counters with billing disabled. - Webhook routes are labelled future/return
501; no delivery, Apify actor, paid model or payment call happens. - 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.
- Ensure the test key file and database credential never enter source control, static output, public docs or logs. Use server-only environment variables.
- Record the actual temporary URLs, storage location, working/stubbed matrix and verified results in a release report.
13. Current app hosting
The marketing site and account pages are live at https://vantrill.com; the signed-in workspace is https://app.vantrill.com/dashboard. Both hosts use the same Vercel app and shared session domain. www.vantrill.com redirects to the apex. Workspace, sample jobs and cost receipts persist in self-hosted Postgres via a verified-TLS connection.
The current API base remains /api/v1 on the app, with private-key authorization separate from workspace sessions. api.vantrill.com/v1 is still a proposed production base, not an active hostname. Customer vt_live_ keys are issued from the dashboard and are scoped to paid lead searches.
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. These sandbox limits apply to the vt_test_ key only; customer vt_live_ keys have their own limits (see Live keys). 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. Storage now uses the approved self-hosted Postgres instance; Vercel uses the existing team plan without buying any upgrade. Provider allowances still apply; no promise of unlimited traffic is made.
Get started ↗