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. Calendar days use server ANALYTICS_TZ (default America/Denver). Response includes reportingTimezone, timezoneNote, and todaySummary.date in that zone.

Also returns todaySummary, profitTotals, profitPerDay, and profitByCampaign. Leads sold (deliveredCount) is successful first-time buyer deliveries (is_repush = 0, includes revenue share). Revenue is the sum of amount_cents_charged on those deliveries. Amount spent (costCents) is the sum of each lead’s campaign lead cost (acquisition_cost_cents, or current lead_cost_cents if unset). Profit is revenue minus amount spent.

GET /api/admin/buyers/:buyerId Admin

Buyer profile for the dashboard card: buyer (includes billingType, totalFundedCents, totalChargedCents), distributions, credits, charges (successful deliveries with amount charged, including leadId), and paceSettlements.

Optional query: creditsLimit, chargesLimit, paceLimit.

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

Paginated credit ledger (newest first). limit max 200. Each row includes creditKind (funding | refund).

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
billingTypeNoprepaid (default), at_cost, revenue_share

201 returns buyer with id, billingType, balanceCents, balanceUsd.

POST /api/admin/campaigns Admin
BodyRequiredNotes
accountIdNoDefaults to auto-created account
nameYesNon-empty
isActiveNoDefault true
distributionRoutingModeNopriority (default), weighted_random, equal_random
leadCostUsdNoWhole dollars, 0 or more. Default 0. Snapshotted onto each lead at delivery for profit.

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
copyFiltersNoDefault 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/campaigns/:campaignId/filters Admin

Add a quarantine filter rule. Manage from dashboard Campaigns → Filters. On ingest, if any active rule matches a top-level payload field, the lead is stored as quarantine with reason filtered_by_campaign_rule and is not routed to a buyer.

BodyRequiredNotes
nameYesDisplay label for the rule
fieldKeyYesTop-level ingest key (e.g. zip, address)
operatorNoequals, contains (default), starts_with, in_list
matchValueYesCompare string; for in_list use comma/newline-separated values
isActiveNoDefault true
sortOrderNoInteger; auto-increments if omitted

Matching is case-insensitive. First matching active rule wins (by sort order).

PATCH /api/admin/campaign-filters/:filterId Admin

Update name, fieldKey, operator, matchValue, isActive, and/or sortOrder. Dashboard Filters → Edit uses this.

DELETE /api/admin/campaign-filters/:filterId Admin

Remove a filter rule. Past quarantined leads 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/buyers/:buyerId Admin

Partial update. Body: billingType — prepaid, at_cost, or revenue_share.

prepaid — distribution price / paced wallet. at_cost — charge campaign lead cost at delivery; balance must cover it. revenue_share — no wallet charge; credits API returns 409 buyer_credits_not_allowed.

PATCH /api/admin/campaigns/:campaignId Admin

Set distributionRoutingMode (priority | weighted_random | equal_random) and/or leadCostUsd (whole dollars, 0 or more). Lead cost changes apply to future deliveries only.

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.

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

Unlinks a distribution from a campaign. Does not delete the campaign or the distribution — only the routing link row.

404 link_not_found if the link id does not exist.

Admin actions

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

Add to a buyer balance. Not allowed for revenue_share buyers (409 buyer_credits_not_allowed).

BodyRequiredNotes
amountUsdOne ofPositive whole dollars
amountCentsOne ofPositive, multiple of 100
creditKindNofunding (default) or refund. Refunds increase balance but are not counted in buyer totalFundedCents.
noteNoMax 512 chars

201 returns creditKind, 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 charges for a calendar day (buyer distribution timezone). Deducts the daily budget (floor(balance ÷ paceDays) from the day’s snapshot) even when there were 0 deliveries. If successful paced deliveries exist with amount_cents_charged = 0, the same budget is split across them. Creates a snapshot at settle time if none exists yet for that date.

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. Buyers with $0 balance are skipped for routing and settle with $0 charged.

Optional auto-run: set env SETTLE_PACED_CRON (e.g. 5 0 * * *) and SETTLE_PACED_TZ (default America/Denver). The server settles yesterday in that timezone when the cron fires.

GET /api/admin/leads Admin

Paginated lead list for the dashboard Leads tab.

QueryRequiredNotes
pageNoDefault 1
limitNoDefault 25, max 100
statusNoaccepted, routed, delivered, failed, quarantine
quarantineReasonNoExact match (e.g. filtered_by_campaign_rule)
campaignIdNoFilter to one campaign

Returns { leads, page, limit, total, totalPages }. Each lead includes id, campaignId, campaignName, status, quarantineReason, phonePreview, createdAt, and when routed/delivered/failed with a chosen distribution: buyerId, buyerName, chosenDistributionId, distributionName.

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.

POST /api/admin/leads/:leadId/repush Admin

Resend the stored ingest payload to the original buyer webhook (chosen_distribution_id). No buyer balance charge. Allowed when lead status is delivered or failed.

Appends a delivery row with is_repush (excluded from daily cap and revenue analytics). Optional body { "distributionId": 123 } must match the original 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; after the calendar day, settlement deducts the daily budget (including 0-lead days) and splits it across any successful deliveries. Enable SETTLE_PACED_CRON for automatic nightly settlement.

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.