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 key

Overview 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:readRead job status, leads and CSV exports.
  • jobs:writeCreate, clarify and cancel jobs.
  • credits:readRead balance and ledger.
  • webhooks:writeManage 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.

LimitFreeStarterGrowthScale
Requests per minute (organization)Across every API key of the org30/min120/min600/min2,000/min
Requests per minute (per API key)So one integration cannot starve another30/min60/min300/min1,000/min
Job creations per minutePOST /v1/jobs, REST and MCP2/min5/min20/min60/min
Jobs running at onceNon-terminal jobs; 429 CONCURRENT_JOBS_LIMIT beyond this12515
API keysActive keys, live and test252050
Webhook endpointsOutbound webhook endpoints131025

Headers

  • X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (unix seconds) on every authenticated response, for the most constrained bucket.
  • 429 RATE_LIMITED carries Retry-After in seconds. Sleep for it; do not hammer.
  • POST /v1/jobs is additionally limited by jobCreatesPerMin. More than concurrentJobs non-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/jobsjobs:writeCreate a job from a prompt; it parses, routes and holds credits itself. Requires Idempotency-Key; maxCredits optional (defaults to your balance).
GET/v1/jobsleads:readList jobs, newest first. Cursor paginated (?cursor&limit&status).
GET/v1/jobs/:idleads:readJob status, progress, ETA and costs. Non-terminal jobs carry Retry-After.
POST/v1/jobs/:id/clarifyjobs:writeAnswer clarifying questions on a job in awaiting_clarification.
POST/v1/jobs/:id/canceljobs:writeCancel a running job; the hold is released.
GET/v1/jobs/:id/leadsleads:readDelivered leads with scores and reasons. Cursor paginated.
GET/v1/jobs/:id/exportleads:readA signed CSV download URL, valid for 24 hours.

Credits

GET/v1/creditscredits:readBalance (available, held, spent today) and the ledger. Cursor paginated.
POST/v1/credits/checkoutdashboardStart a Stripe Checkout for a pack. Dashboard session only.

Billing

GET/v1/planspublicEvery plan with prices, included leads and limits.
GET/v1/billingcredits:readCurrent plan, subscription state and this period’s usage.
POST/v1/billing/subscribedashboardStart a Stripe Checkout for a plan. Dashboard session only.
POST/v1/billing/portaldashboardOpen the Stripe customer portal. Dashboard session only.

Webhook endpoints

GET/v1/webhook-endpointswebhooks:writeList endpoints.
POST/v1/webhook-endpointswebhooks:writeCreate an endpoint; the response carries the signing secret once.
PATCH/v1/webhook-endpoints/:idwebhooks:writeChange url, events, description or enabled.
DELETE/v1/webhook-endpoints/:idwebhooks:writeDelete an endpoint and drop pending deliveries.
POST/v1/webhook-endpoints/:id/rotate-secretwebhooks:writeIssue a new secret; the old one stops validating immediately.
POST/v1/webhook-endpoints/:id/testwebhooks:writeQueue a test: true event. Returns 202 with the delivery id.
GET/v1/webhook-endpoints/:id/deliverieswebhooks:writeDelivery log with attempts, status codes and errors. Cursor paginated.

API keys

GET/v1/apikeysdashboardList keys (prefix only).
POST/v1/apikeysdashboardCreate a live or test key. The full key is returned once.
DELETE/v1/apikeys/:iddashboardRevoke a key.

Sources

GET/v1/sourcespublicAvailable 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 }
  }
}
CodeStatusWhen
IDEMPOTENCY_KEY_REQUIRED400POST /v1/jobs without an Idempotency-Key header.
SANDBOX_ONLY400The action is unavailable with a test key (buying credits, managing webhooks, MCP).
WEBHOOK_URL_INVALID400The endpoint URL is not https, not public, or otherwise not allowed.
INSUFFICIENT_CREDITS402The balance is empty, or maxCredits was set above what the balance can cover once the job is routed.
API_KEY_DAILY_CAP_EXCEEDED402The key has spent its daily credit cap.
INSUFFICIENT_SCOPE403The key lacks the scope the route needs.
PLAN_LIMIT_EXCEEDED403Creating another API key or webhook endpoint would exceed the plan.
RATE_LIMITED429A per-minute or per-day bucket is empty; see Retry-After.
CONCURRENT_JOBS_LIMIT429More 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.delivered
  • job.failed
  • job.awaiting_clarification
  • job.cancelled
  • credits.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> where v1 = 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| > 300 seconds, 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.