REST API

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

400Invalid JSON body
400firm_api_key, first_name, last_name, phone, and consent_at are required
400phone must be a 10-digit US number
400consent_at must be a valid ISO timestamp
404Unknown firm_api_key
422Consent is Xh old — exceeds 72h freshness limit
500Failed to create claimant profile / Failed to create lead

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
  }'
Field coercion:all optional fields tolerate the broker's loose typing. Booleans accept true / "true" / "yes" / 1. Arrays accept ["a","b"] or a semicolon/comma-delimited string. Numbers accept stringified digits. Invalid values are dropped silently — the engine tolerates abstention better than wrong inputs. If you omit alleged_onset_date, the lead fast-rejects on no_alleged_onset_date; supply it.

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