Liveness check. Returns { "ok": true }.
Overview
LeadRoute accepts leads via campaign webhooks, routes them to buyer distributions by schedule and balance rules, and exposes an admin API for the dashboard and automation scripts.
All JSON APIs use Content-Type: application/json unless noted.
Set PUBLIC_BASE_URL on the server when behind a reverse proxy so ingest URLs in responses match production.
Authentication
Dashboard (browser)
POST /api/admin/login with username and password sets an httpOnly session cookie (7 days).
The cookie is sent automatically on subsequent admin requests from the dashboard.
Scripts & curl
Protected admin routes accept either:
- Header
x-admin-key: YOUR_ADMIN_API_KEY - Header
Authorization: Bearer YOUR_ADMIN_API_KEY
Login, logout, and session endpoints do not require admin auth.
Lead ingest (campaign webhooks)
Campaign webhooks require ?token= on the URL (per-campaign secret). No session or API key. See Lead ingest for HTTP request and JSON body examples.
Health
Lead ingest
Accept a lead for a campaign. Body must be a JSON object (not an array).
| Header | Description |
|---|---|
Idempotency-Key |
Optional, recommended. Up to 128 chars. Duplicate keys within 7 days return the stored response. |
| Body field | Required | Notes |
|---|---|---|
phone | Yes | Non-empty string or number after trim |
firstName, lastName, email, source | No | Standard camelCase fields |
| custom fields | Per campaign | Configure in dashboard Fields or admin API. Keys are camelCase (e.g. monthlyElectricBill, utilityBillUpload). Required only if marked required on the campaign. |
Success (200) includes leadId, status (delivered, quarantine, or failed), and charge fields when applicable.
Webhook configuration
Copy the ingest URL from the dashboard campaign row — it includes campaignId and token. No API key or session cookie on ingest.
| Setting | Value |
|---|---|
| Method | POST |
| URL | $BASE/api/webhooks/campaigns/1?token=YOUR_CAMPAIGN_TOKEN |
| Content-Type | application/json |
| Idempotency-Key | Optional header — use a unique value per lead submission to dedupe retries |
HTTP request
POST /api/webhooks/campaigns/1?token=YOUR_CAMPAIGN_TOKEN HTTP/1.1
Host: $HOST
Content-Type: application/json
Idempotency-Key: lead-abc-123
{
"firstName": "Jane",
"lastName": "Doe",
"phone": "5551234567",
"email": "jane@example.com",
"source": "fb",
"homeowner": "Yes",
"monthlyElectricBill": "185",
"utilityBillUpload": "https://example.com/bill.pdf"
}
Request body (JSON only)
Use this in webhook tools that have a separate JSON body field.
{
"firstName": "Jane",
"lastName": "Doe",
"phone": "5551234567",
"email": "jane@example.com",
"source": "fb",
"homeowner": "Yes",
"monthlyElectricBill": "185",
"utilityBillUpload": "https://example.com/bill.pdf"
}
Example success response
{
"leadId": 42,
"status": "delivered",
"distributionId": 3,
"pricingMode": "pay_per_lead",
"amountCentsCharged": 100,
"amountUsdCharged": 1
}
Test from terminal (curl)
curl -X POST "$BASE/api/webhooks/campaigns/1?token=YOUR_CAMPAIGN_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: lead-abc-123" \
-d '{"firstName":"Jane","lastName":"Doe","phone":"5551234567","email":"jane@example.com","source":"fb"}'
Admin auth
| Body | Type | Notes |
|---|---|---|
username | string | Matches ADMIN_USERNAME |
password | string | Matches ADMIN_PASSWORD |
200: { "ok": true, "username": "…" } and sets session cookie. 401: invalid_credentials.
Clears the session cookie. Returns { "ok": true }.
200: { "ok": true, "username": "…" }. 401: not_authenticated.
Admin read
All routes below require session cookie or x-admin-key.
Full snapshot for the dashboard: accounts, campaigns (with ingest URLs), campaignCustomFields, buyers, distributions, links, leads, etc.
Daily aggregates for the analytics tab. days defaults to 30 when omitted or invalid.
Paginated credit ledger (newest first). limit max 200. Includes admin notes and system refunds.
Admin create
| Body | Required | Notes |
|---|---|---|
name | Yes | Non-empty, max 255 chars |
timezone | No | IANA timezone; default America/Chicago |
201 returns account with id, name, timezone. Optional — buyers and campaigns auto-use a default account when accountId is omitted.
| Body | Required | Notes |
|---|---|---|
accountId | No | Defaults to auto-created account |
name | Yes | Non-empty, max 255 chars |
201 returns buyer with id, balanceCents, balanceUsd.
| Body | Required | Notes |
|---|---|---|
accountId | No | Defaults to auto-created account |
name | Yes | Non-empty |
isActive | No | Default true |
distributionRoutingMode | No | priority (default), weighted_random, equal_random |
201 returns campaign with auto-generated webhookToken and ingestUrl.
curl -X POST "$BASE/api/admin/campaigns" \
-H "Content-Type: application/json" \
-H "x-admin-key: YOUR_ADMIN_API_KEY" \
-d '{"name":"Summer promo","isActive":true}'
Clone campaign configuration. Does not copy leads or delivery history.
| Body | Required | Notes |
|---|---|---|
name | No | Default: "{source name} (copy)" |
isActive | No | Default false |
copyRoutingLinks | No | Default true |
copyCustomFields | No | Default true |
201 returns new campaign with fresh webhookToken and ingestUrl.
Add an optional (or required) custom ingest field to a campaign. Manage from dashboard Campaigns → Fields.
| Body | Required | Notes |
|---|---|---|
fieldKey | Yes | camelCase, max 64 chars; cannot duplicate standard fields (phone, etc.) |
label | Yes | Display label |
dataType | No | text (default), number, boolean |
isRequired | No | Default false |
sortOrder | No | Integer; auto-increments if omitted |
409 field_key_exists if the key is already on the campaign.
Update label, dataType, isRequired, and/or sortOrder. fieldKey is immutable after create.
Remove a custom field definition. Past lead payloads in payload_json are unchanged.
| Body | Required | Notes |
|---|---|---|
buyerId | Yes | |
name | Yes | |
outboundWebhookUrl | Yes | Must start with http:// or https:// |
pricePerLeadUsd | PPL only | Whole dollars (required when pricingMode is pay_per_lead) |
pricePerLeadCents | PPL only | Multiple of 100 |
pricingMode | No | pay_per_lead (default) or paced_wallet |
paceDays | No | 1–365, default 30 (paced wallet daily budget = floor(balance ÷ pace days)) |
weekdaysOnly | No | Default false |
windowStartMinutes | No | 0–1440, default 0 |
windowEndMinutes | No | 0–1440, default 1440; must be > start |
dailyCap | No | Positive integer or omit |
timezone | No | IANA timezone; default America/Chicago |
isActive | No | Default true |
| Body | Required | Notes |
|---|---|---|
campaignId | Yes | |
distributionId | Yes | |
sortOrder | No | Default 0; used in priority routing |
weight | No | Integer ≥ 1, default 1 |
409 link_already_exists if the pair is already linked.
Admin update
Set distributionRoutingMode: priority | weighted_random | equal_random.
Update sortOrder and/or weight (integer ≥ 1) on a campaign↔distribution link.
Partial update. Any of: name, outboundWebhookUrl, pricingMode, paceDays, pricePerLeadUsd, pricePerLeadCents, weekdaysOnly, windowStartMinutes, windowEndMinutes, dailyCap (send null to clear), timezone, isActive.
Switching to paced_wallet clears fixed price. Switching to pay_per_lead requires a price.
Admin delete
Permanent deletes. Use isActive: false on campaigns/distributions to disable without removing history.
Removes the campaign, its routing links, custom fields, and all leads for that campaign.
Removes buyer, credit history, and distributions. Fails with buyer_has_balance if balance > 0.
Removes distribution and campaign links. Delivery history for past leads remains on lead records.
Admin actions
Add funds to a buyer balance.
| Body | Required | Notes |
|---|---|---|
amountUsd | One of | Positive whole dollars |
amountCents | One of | Positive, multiple of 100 |
note | No | Max 512 chars |
201 returns updated balanceCents / balanceUsd.
curl -X POST "$BASE/api/admin/buyers/1/credits" \
-H "Content-Type: application/json" \
-H "x-admin-key: YOUR_ADMIN_API_KEY" \
-d '{"amountUsd":500,"note":"Monthly top-up"}'
Settle paced wallet deliveries for a calendar day (buyer distribution timezone). Splits the snapshotted daily budget across successful paced deliveries with amount_cents_charged = 0, then deducts from buyer balance.
| Body | Required | Notes |
|---|---|---|
paceDate | Yes | YYYY-MM-DD in buyer paced distribution timezone |
buyerId | No | If omitted, settles all buyers with paced distributions |
409 already_settled if that buyer/day was settled. 404 pace_snapshot_not_found if no paced leads were routed that day.
Full lead detail for the dashboard View dialog: ingest payload (all fields), ingestResponse, campaign/distribution/buyer names, and deliveries.
Re-run routing for quarantined leads (no_eligible_distribution, insufficient_balance_before_delivery).
Optional body: { "distributionId": 123 } to force a specific distribution.
Error codes
Most errors return JSON { "error": "code" }. Some include a message field.
unauthorizedMissing or invalid admin authinvalid_credentialsLogin failedmissing_tokenIngest URL missing tokeninvalid_tokenWrong campaign tokencampaign_inactiveCampaign disabledinvalid_timezoneBad IANA timezoneinvalid_nameEmpty or missing nameaccount_not_foundInvalid accountIdbuyer_not_foundInvalid buyerIdbuyer_has_balanceBuyer delete blockedcampaign_not_foundInvalid campaignIdlink_already_existsDuplicate campaign↔distribution linkinternal_errorServer error (see logs)Notes
Routing modes
- priority — try links by
sortOrder; first eligible wins - weighted_random — random among eligible links, weighted by
weight - equal_random — uniform random among eligible links
Eligibility checks schedule (per-distribution timezone), buyer balance (PPL: balance ≥ price; paced: balance > 0), and daily cap.
Pay per lead: charge at delivery before webhook. Paced wallet: no charge at delivery; run POST /api/admin/settle-paced after the calendar day to split the daily budget across deliveries.
Charge & delivery
Buyer balance is deducted before the outbound webhook. Failed webhooks trigger a refund and lead status failed. Insufficient balance at charge time quarantines the lead without sending a webhook.
Outbound buyer webhook
Each distribution’s outboundWebhookUrl receives a POST with JSON:
{
"leadId": 42,
"campaignId": 1,
"campaignName": "Summer promo",
"distributionId": 3,
"distributionName": "West region",
"buyerId": 2,
"buyerName": "Acme Solar",
"payload": { "phone": "5551234567", "firstName": "Jane", … }
}
payload is the original campaign ingest body (standard + custom fields).
UI
Dashboard — browser admin for campaigns, routing, buyers, distributions, leads, and analytics.