API reference
One REST API for everything the dashboard and the MCP server can do: run a job from a sentence, read the leads, export a CSV, and get a signed webhook when it lands. JSON in, JSON out, bearer keys, cursor pagination.
Machine-readable spec: openapi.json. You need an API key from Settings; it is shown once.
Sign up for a keyOverview and base URL
All routes live under https://outreach-nlfc.onrender.com/v1. Requests and responses are JSON (Content-Type: application/json). Lists are cursor paginated: pass ?cursor= from the previous nextCursor and an optional limit (max 100). Timestamps are ISO 8601 in UTC. Every response carries a X-Request-Id; include it when you write to support.
Jobs are asynchronous. A job goes through parsing → routing → scraping → normalizing → enriching → delivering → delivered, usually in two to fifteen minutes. Either poll GET /v1/jobs/:id honouring Retry-After, or subscribe an endpoint to the job.delivered webhook.
Authentication
Send your key as a bearer token: Authorization: Bearer lp_live_…. Keys are created in Settings → API keys and shown once. Each key has scopes and a daily credit cap, and is either live (lp_live_, real sources, spends credits) or test (lp_test_, the sandbox).
curl https://outreach-nlfc.onrender.com/v1/credits -H "Authorization: Bearer lp_live_…"Scopes
leads:read— Read job status, leads and CSV exports.jobs:write— Create, clarify and cancel jobs.credits:read— Read balance and ledger.webhooks:write— Manage outbound webhook endpoints.
Buying credits, subscribing to a plan, opening the billing portal and managing keys are dashboard-only: they need a signed-in browser session, not an API key. A key without the required scope gets 403 INSUFFICIENT_SCOPE.
Rate limits
Limits come from your plan and are enforced from the same table shown here. Two per-minute buckets apply to API keys: one for the whole organization (requestsPerMinOrg) and one per key (requestsPerMinKey), so one integration cannot starve another. Dashboard traffic has its own bucket, so an integration can never lock you out of the app.
| Limit | Free | Starter | Growth | Scale |
|---|---|---|---|---|
| Requests per minute (organization)Across every API key of the org | 30/min | 120/min | 600/min | 2,000/min |
| Requests per minute (per API key)So one integration cannot starve another | 30/min | 60/min | 300/min | 1,000/min |
| Job creations per minutePOST /v1/jobs, REST and MCP | 2/min | 5/min | 20/min | 60/min |
| Jobs running at onceNon-terminal jobs; 429 CONCURRENT_JOBS_LIMIT beyond this | 1 | 2 | 5 | 15 |
| API keysActive keys, live and test | 2 | 5 | 20 | 50 |
| Webhook endpointsOutbound webhook endpoints | 1 | 3 | 10 | 25 |
Headers
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset(unix seconds) on every authenticated response, for the most constrained bucket.429 RATE_LIMITEDcarriesRetry-Afterin seconds. Sleep for it; do not hammer.POST /v1/jobsis additionally limited byjobCreatesPerMin. More thanconcurrentJobsnon-terminal jobs →429 CONCURRENT_JOBS_LIMIT.
Compare plans on the pricing page; upgrade from Settings → Billing.
Quickstart
The whole flow in four calls — there is no separate estimate step. POST /v1/jobs requires an Idempotency-Key header (any unique string per intended job); a replay with the same key returns the same job with Idempotent-Replayed: true instead of creating another one. maxCredits is optional: leave it out and the job is capped at your available balance (and the key's remaining daily cap), so the plan scales down instead of failing. The API answers 402 only when nothing can be spent.
# 1. Create the job straight from the prompt. Idempotency-Key makes retries safe.
# The job parses the prompt, picks sources and estimates the cost itself; credits are held once
# routed, capped at maxCredits (optional) or at your available balance.
curl -s https://outreach-nlfc.onrender.com/v1/jobs \
-H "Authorization: Bearer $LEADMIND_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"prompt":"50 dental clinics in Dubai with a website and phone","maxCredits":50}'
# → 202 { "id": "66f1…", "status": "parsing", "maxCredits": 50, "creditsHeld": 0, "retryAfterSeconds": 5, ... }
# 2. Poll until status is delivered / failed / cancelled / awaiting_clarification.
# Once routed, the response carries "routing" (estimated leads, sources) and "creditsHeld".
# Sleep for the Retry-After header instead of a fixed timer (or use a webhook).
curl -si https://outreach-nlfc.onrender.com/v1/jobs/$JOB_ID -H "Authorization: Bearer $LEADMIND_KEY"
# HTTP/1.1 200 OK
# Retry-After: 5
# X-RateLimit-Limit: 60
# X-RateLimit-Remaining: 57
# X-RateLimit-Reset: 1789000000
# 3. Read the leads (cursor paginated).
curl -s "https://outreach-nlfc.onrender.com/v1/jobs/$JOB_ID/leads?limit=100" -H "Authorization: Bearer $LEADMIND_KEY"
# 4. Or grab a CSV link (valid 24 h).
curl -s https://outreach-nlfc.onrender.com/v1/jobs/$JOB_ID/export -H "Authorization: Bearer $LEADMIND_KEY"
# → { "url": "https://…/export.csv?sig=…", "expiresAt": "…" }If the job lands in awaiting_clarification, read clarifyingQuestions and answer with POST /v1/jobs/:id/clarify. Use a test key first to run the same flow against fixture data.
Endpoints
A concise map. Field-level detail, request bodies and example responses are in the OpenAPI document.
Jobs
| POST | /v1/jobs | jobs:write | Create a job from a prompt; it parses, routes and holds credits itself. Requires Idempotency-Key; maxCredits optional (defaults to your balance). |
| GET | /v1/jobs | leads:read | List jobs, newest first. Cursor paginated (?cursor&limit&status). |
| GET | /v1/jobs/:id | leads:read | Job status, progress, ETA and costs. Non-terminal jobs carry Retry-After. |
| POST | /v1/jobs/:id/clarify | jobs:write | Answer clarifying questions on a job in awaiting_clarification. |
| POST | /v1/jobs/:id/cancel | jobs:write | Cancel a running job; the hold is released. |
| GET | /v1/jobs/:id/leads | leads:read | Delivered leads with scores and reasons. Cursor paginated. |
| GET | /v1/jobs/:id/export | leads:read | A signed CSV download URL, valid for 24 hours. |
Credits
| GET | /v1/credits | credits:read | Balance (available, held, spent today) and the ledger. Cursor paginated. |
| POST | /v1/credits/checkout | dashboard | Start a Stripe Checkout for a pack. Dashboard session only. |
Billing
| GET | /v1/plans | public | Every plan with prices, included leads and limits. |
| GET | /v1/billing | credits:read | Current plan, subscription state and this period’s usage. |
| POST | /v1/billing/subscribe | dashboard | Start a Stripe Checkout for a plan. Dashboard session only. |
| POST | /v1/billing/portal | dashboard | Open the Stripe customer portal. Dashboard session only. |
Webhook endpoints
| GET | /v1/webhook-endpoints | webhooks:write | List endpoints. |
| POST | /v1/webhook-endpoints | webhooks:write | Create an endpoint; the response carries the signing secret once. |
| PATCH | /v1/webhook-endpoints/:id | webhooks:write | Change url, events, description or enabled. |
| DELETE | /v1/webhook-endpoints/:id | webhooks:write | Delete an endpoint and drop pending deliveries. |
| POST | /v1/webhook-endpoints/:id/rotate-secret | webhooks:write | Issue a new secret; the old one stops validating immediately. |
| POST | /v1/webhook-endpoints/:id/test | webhooks:write | Queue a test: true event. Returns 202 with the delivery id. |
| GET | /v1/webhook-endpoints/:id/deliveries | webhooks:write | Delivery log with attempts, status codes and errors. Cursor paginated. |
API keys
| GET | /v1/apikeys | dashboard | List keys (prefix only). |
| POST | /v1/apikeys | dashboard | Create a live or test key. The full key is returned once. |
| DELETE | /v1/apikeys/:id | dashboard | Revoke a key. |
Sources
| GET | /v1/sources | public | Available data sources with coverage and cost hints. |
Errors
Every error uses one envelope. The HTTP status matches the code; the message is safe to show to a user.
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Retry after 12 seconds.",
"requestId": "req_01J…",
"details": { "bucket": "key", "limit": 60 }
}
}| Code | Status | When |
|---|---|---|
| IDEMPOTENCY_KEY_REQUIRED | 400 | POST /v1/jobs without an Idempotency-Key header. |
| SANDBOX_ONLY | 400 | The action is unavailable with a test key (buying credits, managing webhooks, MCP). |
| WEBHOOK_URL_INVALID | 400 | The endpoint URL is not https, not public, or otherwise not allowed. |
| INSUFFICIENT_CREDITS | 402 | The balance is empty, or maxCredits was set above what the balance can cover once the job is routed. |
| API_KEY_DAILY_CAP_EXCEEDED | 402 | The key has spent its daily credit cap. |
| INSUFFICIENT_SCOPE | 403 | The key lacks the scope the route needs. |
| PLAN_LIMIT_EXCEEDED | 403 | Creating another API key or webhook endpoint would exceed the plan. |
| RATE_LIMITED | 429 | A per-minute or per-day bucket is empty; see Retry-After. |
| CONCURRENT_JOBS_LIMIT | 429 | More jobs are running than the plan allows. |
Also expected: 400 VALIDATION_ERROR with details per field, 401 UNAUTHENTICATED, 404 NOT_FOUND, and 5xx with a requestId to quote.
Sandbox
Create a test key in Settings and you get lp_test_…. It accepts the same calls as a live key — POST /v1/jobs, status, list, cancel, leads, export and credits — but runs against fixture data:
- A sandbox job walks parsing → routing → scraping → normalizing → enriching → delivering → delivered over about 30 seconds, so your polling and retry logic get exercised.
- Leads are demo Dubai dental clinics; the export links to
/v1/demo/export.csv. - The balance is a fixed 1,000 credits and nothing is ever charged.
- No webhooks are emitted for sandbox jobs. Use “Send test” on an endpoint in Settings to see a signed delivery.
- Test keys do not work on MCP and cannot buy credits or manage webhooks (
400 SANDBOX_ONLY).
Rate limits apply to test keys as they do to live ones, so the sandbox is also the place to see the headers.
Webhooks
Register an https endpoint in Settings → Webhooks (or with the webhooks:write scope) and LeadMind POSTs a JSON event whenever something you subscribed to happens. The signing secret is shown once at creation and on rotation.
Events
job.deliveredjob.failedjob.awaiting_clarificationjob.cancelledcredits.charged
Payload
Every body is { id, type, createdAt, test, data }. For job.* events data is { job, links }; for credits.charged it is { jobId, amount, channel, job, links }. test is true for deliveries fired from the “Send test” button.
{
"id": "evt_01J8…",
"type": "job.delivered",
"createdAt": "2026-09-10T09:14:02.000Z",
"test": false,
"data": {
"job": {
"id": "66f1a2b3c4d5e6f7a8b9c0d1",
"prompt": "50 dental clinics in Dubai with a website and phone",
"status": "delivered",
"channel": "api",
"leadsDelivered": 47,
"creditsCharged": 47,
"errorCode": null,
"errorMessage": null,
"clarifyingQuestions": [],
"hasCsv": true,
"createdAt": "2026-09-10T09:11:40.000Z",
"deliveredAt": "2026-09-10T09:14:01.000Z"
},
"links": {
"job": "https://outreach-nlfc.onrender.com/v1/jobs/66f1a2b3c4d5e6f7a8b9c0d1",
"leads": "https://outreach-nlfc.onrender.com/v1/jobs/66f1a2b3c4d5e6f7a8b9c0d1/leads",
"export": "https://outreach-nlfc.onrender.com/v1/jobs/66f1a2b3c4d5e6f7a8b9c0d1/export",
"dashboard": "https://www.leadmindai.app/jobs/66f1a2b3c4d5e6f7a8b9c0d1"
}
}
}
// credits.charged carries { jobId, amount, channel, job, links } instead.Headers and signature
X-LeadMind-Signature: t=<unix seconds>,v1=<hex>wherev1 = HMAC-SHA256(secret, "<t>.<raw body>").X-LeadMind-Event— the event type;X-LeadMind-Delivery— the delivery id (use it to dedupe retries).- Reject the request if
|now − t| > 300seconds, and compare signatures in constant time.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// Keep the raw body: the signature covers the exact bytes we received.
app.post('/hooks/leadmind', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-LeadMind-Signature') ?? ''; // "t=1789000000,v1=<hex>"
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return res.status(400).end(); // replay window
const rawBody = req.body.toString('utf8');
const expected = crypto.createHmac('sha256', process.env.LEADMIND_WEBHOOK_SECRET).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(String(parts.v1 ?? ''), 'hex');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(400).end();
const event = JSON.parse(rawBody);
// Acknowledge fast, then do the work; anything but 2xx within 10 s is retried.
res.status(204).end();
if (event.type === 'job.delivered') fetchLeads(event.data.links.leads);
});Delivery and retries
- Respond with any 2xx within 10 seconds. Do the work after acknowledging.
- Anything else (timeout, non-2xx, connection error) is retried at 30s, 2m, 10m, 30m, 2h after the first attempt — 6 attempts in total — then the delivery is marked
dead. - 20 consecutive dead deliveries switch the endpoint off with a
disabledReason. Fix the receiver and re-enable it in Settings. - URLs must be https and publicly reachable (http is accepted only for localhost in development). Private and link-local addresses are rejected with
WEBHOOK_URL_INVALID. - Deliveries can arrive out of order and, rarely, more than once. Key your handling on
id.
Integrations
Claude, ChatGPT and Gemini connect over MCP instead of REST — see Connect your AI assistant, which also covers n8n, Zapier, Google Sheets, Clay and WhatsApp agents. Pair any of them with a job.delivered webhook and the workflow resumes the moment a list lands.