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.

Base URL

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

GET /health Public

Liveness check. Returns { "ok": true }.

Lead ingest

POST /api/webhooks/campaigns/:campaignId?token=… Token query param

Accept a lead for a campaign. Body must be a JSON object (not an array).

HeaderDescription
Idempotency-Key Optional, recommended. Up to 128 chars. Duplicate keys within 7 days return the stored response.
Body fieldRequiredNotes
phoneYesNon-empty string or number after trim
firstName, lastName, email, sourceNoStandard camelCase fields
custom fieldsPer campaignConfigure 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.

SettingValue
MethodPOST
URL $BASE/api/webhooks/campaigns/1?token=YOUR_CAMPAIGN_TOKEN
Content-Typeapplication/json
Idempotency-KeyOptional 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

POST /api/admin/login Public
BodyTypeNotes
usernamestringMatches ADMIN_USERNAME
passwordstringMatches ADMIN_PASSWORD

200: { "ok": true, "username": "…" } and sets session cookie. 401: invalid_credentials.

POST /api/admin/logout Public

Clears the session cookie. Returns { "ok": true }.

GET /api/admin/session Session cookie

200: { "ok": true, "username": "…" }. 401: not_authenticated.

Admin read

All routes below require session cookie or x-admin-key.

GET /api/admin/dashboard-data Admin

Full snapshot for the dashboard: accounts, campaigns (with ingest URLs), campaignCustomFields, buyers, distributions, links, leads, etc.

GET /api/admin/analytics?days=30 Admin

Daily aggregates for the analytics tab. days defaults to 30 when omitted or invalid.

GET /api/admin/buyers/:buyerId/credits?limit=50&offset=0 Admin

Paginated credit ledger (newest first). limit max 200. Includes admin notes and system refunds.

Admin create

POST /api/admin/accounts Admin
BodyRequiredNotes
nameYesNon-empty, max 255 chars
timezoneNoIANA timezone; default America/Chicago

201 returns account with id, name, timezone. Optional — buyers and campaigns auto-use a default account when accountId is omitted.

POST /api/admin/buyers Admin
BodyRequiredNotes
accountIdNoDefaults to auto-created account
nameYesNon-empty, max 255 chars

201 returns buyer with id, balanceCents, balanceUsd.

POST /api/admin/campaigns Admin
BodyRequiredNotes
accountIdNoDefaults to auto-created account
nameYesNon-empty
isActiveNoDefault true
distributionRoutingModeNopriority (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}'
POST /api/admin/campaigns/:campaignId/duplicate Admin

Clone campaign configuration. Does not copy leads or delivery history.

BodyRequiredNotes
nameNoDefault: "{source name} (copy)"
isActiveNoDefault false
copyRoutingLinksNoDefault true
copyCustomFieldsNoDefault true

201 returns new campaign with fresh webhookToken and ingestUrl.

POST /api/admin/campaigns/:campaignId/custom-fields Admin

Add an optional (or required) custom ingest field to a campaign. Manage from dashboard Campaigns → Fields.

BodyRequiredNotes
fieldKeyYescamelCase, max 64 chars; cannot duplicate standard fields (phone, etc.)
labelYesDisplay label
dataTypeNotext (default), number, boolean
isRequiredNoDefault false
sortOrderNoInteger; auto-increments if omitted

409 field_key_exists if the key is already on the campaign.

PATCH /api/admin/campaign-custom-fields/:fieldId Admin

Update label, dataType, isRequired, and/or sortOrder. fieldKey is immutable after create.

DELETE /api/admin/campaign-custom-fields/:fieldId Admin

Remove a custom field definition. Past lead payloads in payload_json are unchanged.

POST /api/admin/distributions Admin
BodyRequiredNotes
buyerIdYes
nameYes
outboundWebhookUrlYesMust start with http:// or https://
pricePerLeadUsdPPL onlyWhole dollars (required when pricingMode is pay_per_lead)
pricePerLeadCentsPPL onlyMultiple of 100
pricingModeNopay_per_lead (default) or paced_wallet
paceDaysNo1–365, default 30 (paced wallet daily budget = floor(balance ÷ pace days))
weekdaysOnlyNoDefault false
windowStartMinutesNo0–1440, default 0
windowEndMinutesNo0–1440, default 1440; must be > start
dailyCapNoPositive integer or omit
timezoneNoIANA timezone; default America/Chicago
isActiveNoDefault true
POST /api/admin/campaign-distributions Admin
BodyRequiredNotes
campaignIdYes
distributionIdYes
sortOrderNoDefault 0; used in priority routing
weightNoInteger ≥ 1, default 1

409 link_already_exists if the pair is already linked.

Admin update

PATCH /api/admin/campaigns/:campaignId Admin

Set distributionRoutingMode: priority | weighted_random | equal_random.

PATCH /api/admin/campaign-distributions/:linkId Admin

Update sortOrder and/or weight (integer ≥ 1) on a campaign↔distribution link.

PATCH /api/admin/distributions/:distributionId Admin

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.

DELETE /api/admin/campaigns/:campaignId Admin

Removes the campaign, its routing links, custom fields, and all leads for that campaign.

DELETE /api/admin/buyers/:buyerId Admin

Removes buyer, credit history, and distributions. Fails with buyer_has_balance if balance > 0.

DELETE /api/admin/distributions/:distributionId Admin

Removes distribution and campaign links. Delivery history for past leads remains on lead records.

Admin actions

POST /api/admin/buyers/:buyerId/credits Admin

Add funds to a buyer balance.

BodyRequiredNotes
amountUsdOne ofPositive whole dollars
amountCentsOne ofPositive, multiple of 100
noteNoMax 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"}'
POST /api/admin/settle-paced Admin

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.

BodyRequiredNotes
paceDateYesYYYY-MM-DD in buyer paced distribution timezone
buyerIdNoIf 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.

GET /api/admin/leads/:leadId Admin

Full lead detail for the dashboard View dialog: ingest payload (all fields), ingestResponse, campaign/distribution/buyer names, and deliveries.

POST /api/admin/leads/:leadId/retry-routing Admin

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 auth
invalid_credentialsLogin failed
missing_tokenIngest URL missing token
invalid_tokenWrong campaign token
campaign_inactiveCampaign disabled
invalid_timezoneBad IANA timezone
invalid_nameEmpty or missing name
account_not_foundInvalid accountId
buyer_not_foundInvalid buyerId
buyer_has_balanceBuyer delete blocked
campaign_not_foundInvalid campaignId
link_already_existsDuplicate campaign↔distribution link
internal_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.