API documentation
JSON over HTTPS. Authentication is per-firm via firm_api_key. Issued during licensing onboarding — contact support if yours is missing or revoked.
Authentication
Every endpoint requires firm_api_key — either in the JSON body (POST routes) or as a URL query parameter (GET routes). Keys resolve to a row in firms.settings.api_key; unknown keys return 404 Unknown firm_api_key. Treat the key as a bearer secret; rotation requires emailing support.
Rate limit
/api/leads/ingest is rate-limited to 60 requests per 60 seconds per firm. Excess requests return 429 Too Many Requests with a Retry-Afterheader in seconds. The limit is enforced after API-key resolution, so an unknown key can't burn quota for a known firm.
HMAC webhook signing (optional)
When firms.settings.hmac_secret is provisioned, every webhook POST to /api/leads/ingest must carry an X-Webhook-Signature header containing sha256=<hex> where the digest is HMAC-SHA256(secret, raw_body). Mismatched or missing signatures return 401 Unauthorized. Comparison is timing-safe.
# bash example — assumes BODY is the raw JSON string you'll POST
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')"
curl -X POST https://lead-scorer-sigma.vercel.app/api/leads/ingest \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: $SIG" \
-d "$BODY"If hmac_secret is not set on the firm row, the header is ignored. Opt-in by emailing support to provision a secret.
POST /api/leads/ingest
Primary webhook. Accepts a single lead, persists the claimant + lead rows, scores synchronously through the engine, persists the full LeadScore, and returns the operator-relevant fields. Returns 202 Acceptedon both successful score and best-effort scoring failure — lead persistence is the contract; a scoring exception never fails the ingest (we don't want upstream brokers retrying and producing duplicate leads).
Request body
{
"firm_api_key": "sb_apikey_…", // REQUIRED
"first_name": "Jane", // REQUIRED
"last_name": "Doe", // REQUIRED
"phone": "5125551234", // REQUIRED, US 10-digit (E.164 also accepted)
"consent_at": "2026-05-20T14:00:00Z", // REQUIRED, ISO timestamp ≤ 72h old
// Identity + attribution — all optional
"email": "jane@example.com",
"state": "TX", // 2-letter; falls back to area-code inference
"source_url": "https://example.com/landing",
"trustedform_cert_id": "00abc123…", // logged for audit
// Rich intake — all optional, dropped silently if malformed
"age": 55,
"alleged_onset_date": "2024-06-01",
"last_worked_date": "2024-05-20",
"unable_to_work_12mo": true,
"currently_working": false,
"monthly_earnings_since_stop": 0,
"work_5_of_last_10_years": true,
"already_receiving_ssdi": false,
"has_attorney": false,
"under_doctor_care": true,
"prior_denial_count_lifetime": 1,
"material_change_since_prior_denial": true,
"case_stage": "reconsideration", // initial | reconsideration | hearing | appeals-council
"condition": "stage IV pancreatic cancer with liver metastases", // freeform
"condition_family": "neoplastic",
"monthly_benefit_estimate": 2400,
"diagnosis_confirmed": true,
"imaging_done": true,
"current_medications": ["gemcitabine", "morphine"], // or "med1;med2"
"prior_representation_active": false,
"prior_representation_status": "never",
"insured_status": "presumed_insured",
"dli_date": "2027-12-31"
}Success response (202)
{
"ok": true,
"lead_id": "f5a0…",
"qualification_score_id": "9c12…", // permalink at /scores/<id>
"band": "pursue",
"headline": "APPROVE_WITH_CAVEATS",
"composite_score": 0.726,
"composite_ci_low": 0.681,
"composite_ci_high": 0.771,
"expected_legal_fee_usd": 4250,
"status": "received"
}Best-effort failure response (202)
If the scoring step throws but the lead was persisted, you still get a 202 — the lead is durable and a backfill cron will retry the score.
{
"ok": true,
"lead_id": "f5a0…",
"status": "received_score_pending",
"score_error": "ssa-public-cache: cold start, no fallback for region 'XX'"
}Error responses
curl
curl -X POST https://lead-scorer-sigma.vercel.app/api/leads/ingest \
-H "Content-Type: application/json" \
-d '{
"firm_api_key": "sb_apikey_…",
"first_name": "Jane",
"last_name": "Doe",
"phone": "5125551234",
"consent_at": "2026-05-20T14:00:00Z",
"state": "TX",
"age": 55,
"alleged_onset_date": "2024-06-01",
"unable_to_work_12mo": true,
"currently_working": false,
"work_5_of_last_10_years": true,
"case_stage": "initial",
"condition": "stage IV pancreatic cancer with liver metastases",
"diagnosis_confirmed": true,
"monthly_benefit_estimate": 2600
}'POST /api/score
Stateless batch scorer. Accepts an array of LeadInput objects (the full broker schema, not the thin webhook subset), returns an array of LeadScore. No database writes. Used by the homepage CSV upload flow; available directly for any pipeline that doesn't want persistence.
Request body
{
"leads": [
{
"lead_id": "broker-1",
"state": "TX",
"age": 55,
"unable_to_work_12mo": true,
"currently_working": false,
"work_5_of_last_10_years": true,
"alleged_onset_date": "2024-06-01",
"primary_condition_freeform": "degenerative disc disease",
"condition_family": "musculoskeletal",
"case_stage": "initial",
"monthly_benefit_estimate": 2200
}
],
"firm_api_key": "sb_apikey_…", // OPTIONAL — required only if persist:true
"persist": true // OPTIONAL — default false; when true,
// every scored row writes a claimant +
// lead + qualification_scores entry
}Response (200)
Same array shape, each entry a full LeadScore: composite, CI, three sub-probabilities, top risk factors, engine telemetry, financial estimates, recommendation, reasoning. Per-row error isolation — a malformed lead returns error alongside the partial score.
When persist:true succeeds, each successful row also gets a qualification_score_id that resolves to the permalink at /scores/<id>. Per-row persistence failures don't fail the batch — the score is still returned, just without an id.
POST /api/outcomes/upload
Multipart form upload of an outcome CSV. Required columns: qualification_score_id, observation_stage, predicted_p_kind, observed_outcome, observed_at. Writes a firm_outcome_uploads audit row + one calibration_observations row per accepted CSV row. Form fields:
form fields:
firm_api_key (text) REQUIRED
file (file) REQUIRED — CSV, ≤ 5 MB, ≤ 20,000 rows
uploaded_by (text) optional — surfaced in audit log
notes (text) optional — surfaced in audit log
response (200):
{
"ok": true,
"upload_id": "…",
"row_count_total": 100,
"row_count_accepted": 97,
"row_count_written": 97,
"row_count_rejected": 3,
"write_errors_count": 0,
"rejected_rows": [{ "index": 4, "reason": "…" }, …]
}Web UI at /outcomes wraps the same endpoint.
GET / POST /api/firms/threshold
Read the active threshold for a given kind, or append-write a new value. Kinds: composite, p_reviewer_approve, p_ssa_initial, p_alj_approve. Values must be in [0, 1].
GET https://lead-scorer-sigma.vercel.app/api/firms/threshold?firm_api_key=sb_apikey_…&kind=composite
→ 200 { id, kind, value, effective_from, changed_by, reason, replaces_id }
→ 404 { error } if no threshold set yet
POST https://lead-scorer-sigma.vercel.app/api/firms/threshold
body: {
"firm_api_key": "sb_apikey_…",
"kind": "composite",
"value": 0.65, // [0, 1]
"changed_by": "nick", // optional
"reason": "tightening Q3" // optional
}
→ 200 { id, kind, value, effective_from, replaces_id }GET /api/firms/threshold/history
Newest-first time-series of threshold changes for a (firm, kind). Used by /settings/thresholds.
GET https://lead-scorer-sigma.vercel.app/api/firms/threshold/history?firm_api_key=sb_apikey_…&kind=composite&limit=20
→ 200 {
"kind": "composite",
"entries": [
{ "id": "…", "value": 0.65, "effective_from": "…", "changed_by": "nick", "reason": "…", "replaces_id": "…" },
…
]
}limit ranges 1–100, default 20.
Engine version stamping
Every persisted score is tagged with engine_version (semver). Calibration models are loaded per-firm × per-engine-version, so an engine bump invalidates the prior calibration. The methodology page at /methodology segments reliability metrics by engine version.
Current engine version: 0.1.0