LightSkye Platform

Webhook API Reference

Send leads, appointments and landing page events from any external system into LightSkye using HTTP webhooks authenticated by an API key, then check how they are doing with the read-only source stats API.

Building with an AI agent?

The full contract is available as JSON at /docs/llms.json (no key needed): every field, limit, error, rule and a step-by-step guide for agents. With your API key, GET /api/v1/docs?format=json returns the same contract plus what is specific to your key, including the source tags your source may send.

Authentication

Every request must include your API key in the x-api-key header. Keys are issued from the Admin panel and scoped to specific endpoints.

POST /api/v1/leads
x-api-key: lsk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
leadsCan send to /api/v1/leads and /api/v1/leads/events
appointmentsCan only send to /api/v1/appointments
bothCan send to all three endpoints

Rate Limiting

Each API key has a per-minute rate limit configured at creation time. When exceeded, the server returns 429 Too Many Requests. Check the response headers for limit info.

HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0

{ "error": "Rate limit exceeded", "retry_after_seconds": 60 }
POST

/api/v1/leads

Submit a lead. The endpoint accepts either a single lead object or a JSON array of up to 100 leads in one request. Each lead is deduplicated and routed independently. At least one of email, phone, or first_name is required per lead.

Every field is optional, but at least one of email, phone, or first_name must be present. Unknown fields are ignored. Fields fall into three groups: contact (synced to the HubSpot contact when a routing rule syncs to HubSpot), qualification signals (drive routing & scoring rules), and metadata.

Contact fields → HubSpot contact

{
  "first_name": "Jane",             // string
  "last_name":  "Smith",            // string
  "email":      "jane@email.com",   // string
  "phone":      "+15551234567",     // string
  "mobile_phone": "+15557654321",   // string → HubSpot mobilephone

  // Address
  "contact_address": "123 Main St", // max 300
  "contact_city":    "Austin",      // max 100
  "contact_state":   "TX",          // max 50
  "contact_zip":     "78701",       // max 20
  "country":         "USA",         // max 100

  // Profile / solar enrichment (→ HubSpot contact custom properties)
  "company":          "Acme Co",    // → company
  "job_title":        "Owner",      // → jobtitle
  "website":          "acme.com",   // → website
  "electric_company": "SCE",        // → electric_company

  // Referral attribution (plain text, passed through as-is)
  "referral_code":    "LS-4821",    // → referral_code
  "sales_rep":        "Jane Doe",   // → sales_rep (who referred, NOT owner)

  // Free-text intake body: the customer's message plus any answers with no
  // signal of their own. Rendered as a note on the HubSpot contact.
  "notes": "Wants a call after 5pm.\n\nSurvey answers\n• Roof type: Metal"
}

Two more HubSpot contact properties are derived from the qualification signals below: utilityProvider → utility, billAmount → electric_bill_average, isHomeowner → do_you_own_your_home. The contact's source and lead stage are set by the matched Sync to HubSpot routing rule, not by the webhook.

Qualification signals → routing & scoring

{
  "isHomeowner":      true,         // boolean
  "intentConfirmed":  true,         // boolean
  "utilityProvider":  "SCE",        // string
  "billAmount":       250,          // number
  "billOver100":      true,         // boolean | null
  "creditOver650":    true,         // boolean | null
  "creditRange":      "Good",       // string | null
  "roofCondition":    "Good",       // "Good" | "Workable" | "Poor" | "Unknown" | null
  "interestLevel":    "High",       // "High" | "Medium" | "Low" | null
  "priorSolarConsideration": false, // boolean | null
  "openToTreeTrimming":      true,  // boolean | "yes" | "no" | "not_applicable" (no trees) | null
  "preferredCallbackWindow": "Mornings", // string | null

  // Classification / scoring (usually computed upstream by the CRM)
  "crm_type":       "A",            // "A" | "B"
  "crm_score":      82,             // 0-100
  "classification": "Type A - ...", // string (sets crm_type if crm_type omitted)
  "score":          82,             // 0-100 (alias accepted)
  "scoreBreakdown": { "intent": 30 }// object of numbers
}

Metadata (optional)

{
  "idempotency_key": "unique-id",   // re-submitting the same key refreshes
                                    // the existing lead (cycle) instead of
                                    // inserting a duplicate
  "sourceTag":    "referral",       // one of YOUR source's tags (see Source tags)
  "campaignId":   "camp-123",       "campaignName": "Spring Solar",
  "agentId":      "agent-9",        "agentName":    "Dana R.",
  "qcNotes":      { "agentNotes": "…", "clientNotes": "…" },
  "starRatings":  { "callQuality": 5, "compliance": 5, "tonality": 4 },
  "callRecordingLinks": ["https://…/rec1.mp3"]
}

Success response (201) - single

{
  "id":         "uuid",
  "status":     "pending",
  "created_at": "2024-01-15T10:30:00Z"
}

Cycle-refresh response (200) - single

Returned when an idempotency_key matches an existing lead from the same source. The lead is refreshed and re-routed.

{
  "id":            "uuid",
  "status":        "pending",
  "created_at":    "2024-01-15T10:30:00Z",
  "cycle_refresh": true
}
BATCH

Sending many leads at once

Send a JSON array (max 100). Each lead is validated and ingested independently - one invalid lead does not reject the others. Every lead consumes one unit of the API key's per-minute rate-limit budget, so a 40-lead batch needs 40 units of remaining capacity or the whole batch is rejected with 429.

Request body - array

[
  { "first_name": "Jane", "email": "jane@email.com" },
  { "first_name": "John", "phone": "+15557654321" },
  { "email": "not-an-email" }        // will fail validation
]

Response - per-item results

Status is 201 when every lead succeeds, 207 when some succeed and some fail, and 422 when every lead fails. The results array preserves request order via index.

HTTP/1.1 207 Multi-Status

{
  "results": [
    { "index": 0, "id": "uuid", "status": "pending",
      "created_at": "2024-01-15T10:30:00Z" },
    { "index": 1, "id": "uuid", "status": "pending",
      "created_at": "2024-01-15T10:30:00Z" },
    { "index": 2, "error": "Validation failed",
      "details": [{ "path": ["email"], "message": "Invalid email address" }] }
  ],
  "summary": { "received": 3, "accepted": 2, "failed": 1 }
}
POST

/api/v1/appointments

Submit an appointment. LightSkye auto-assigns it to the next available sales rep based on rank and calendar availability.

Request body

{
  // Contact info
  "contact_first_name": "Jane",           // optional
  "contact_last_name":  "Smith",          // optional
  "contact_email":      "jane@email.com", // optional
  "contact_phone":      "+15551234567",   // optional
  "contact_address":    "123 Main St",    // optional
  "contact_city":       "Austin",         // optional
  "contact_state":      "TX",             // optional - 2-letter code (e.g. TX, CA)
  "contact_zip":        "78701",          // optional - max 10 characters

  // Appointment time (required)
  "requested_date":       "2024-02-01",   // YYYY-MM-DD
  "requested_time_start": "09:00",        // HH:MM (24h)
  "requested_time_end":   "10:00",        // HH:MM (24h)
  "requested_timezone":   "America/Chicago",

  // Optional
  "notes":            "Please call ahead.",
  "lead_id":          "uuid",            // link to an existing lead
  "idempotency_key":  "unique-id",       // prevents duplicate inserts
  "sourceTag":        "referral"         // one of YOUR source's tags (see Source tags)
}

Success response (201)

{
  "id":         "uuid",
  "status":     "pending",    // becomes "assigned" after auto-assignment
  "created_at": "2024-01-15T10:30:00Z"
}
POST

/api/v1/leads/events

Report something a lead you already sent did on the landing page: pressed "Call me now", asked to be texted other times, requested a callback, or booked on the calendar. The event is stored on the lead and a routing rule decides whether it is pushed to the HubSpot contact. The lead itself is not re-submitted or re-routed. One event per request; the lead must belong to your key's source (otherwise 404).

Request body

{
  // Which lead - one of these is required
  "idempotency_key": "form-8814",   // the key you sent to /api/v1/leads
  "lead_id":         "uuid",        // or the id /api/v1/leads returned

  // The event (required)
  "event_type": "call_me_now",      // call_me_now | text_me_times |
                                    // callback_requested | calendar_booked
  "occurred_at": "2026-09-24T15:04:05Z", // optional, ISO 8601

  // Optional
  "contact": { "first_name": "Jane", "phone": "+15551234567" }, // snapshot, goes into the HubSpot note
  "details": { "slot": "2026-09-25 14:00" }, // flat, max 20 keys
  "notes":   "Free text for the HubSpot note"
}

Success response (201)

{
  "id":             "uuid",
  "lead_id":        "uuid",
  "event_type":     "call_me_now",
  "routing_status": "unassigned",  // routing runs in the background
  "created_at":     "2026-09-24T15:04:06Z"
}

Repeat within 10 minutes (200)

If the same event_type for the same lead already arrived in the last 10 minutes, the request is treated as a repeat: nothing is stored, routed or counted against your rate limit, and you get the existing event back with its current status. For calendar_booked this only happens when details are identical; a booking with different details is a reschedule and is recorded as a new event.

{
  "id":             "uuid",        // the existing event
  "lead_id":        "uuid",
  "event_type":     "call_me_now",
  "routing_status": "assigned",    // its current status
  "created_at":     "2026-09-24T15:04:06Z",
  "deduplicated":   true
}

The full, always-current contract (every limit and error) is served at GET /api/v1/docs with your API key.

Source tags

A source tag says what kind of traffic a record is inside your one source, for example a referral versus a paid retarget. LightSkye sets up the tags for your source; each one can be handled on its own (its own HubSpot source and lead stage, its own slots). You only stamp the key.

  • Send at most one tag per record as sourceTag on /api/v1/leads or /api/v1/appointments. Leave it out when a record has no special kind.
  • Your source's tags are listed in GET /api/v1/docs (called with your key), under "Your source tags" or your_key.source_tags in the JSON.
  • Case and surrounding spaces are ignored.
  • An unknown or retired tag never rejects the record: it is accepted without a tag and noted in its history. A 201 does not prove the tag was recognized.
  • Tags are separate from campaignId, which works as before.
POST /api/v1/leads
x-api-key: lsk_live_xxxx

{ "first_name": "Jane", "phone": "+15551234567",
  "idempotency_key": "form-8814", "sourceTag": "referral" }

Error Codes

StatusMeaning
201Created - lead(s) accepted (all items in a batch succeeded)
207Multi-Status - batch partially succeeded; inspect each results[] item
400Invalid JSON body
401Missing or invalid API key / key is inactive
403Key does not have permission for this endpoint
404Events endpoint: no lead of your source matches idempotency_key / lead_id
422Validation failed (or empty / oversized batch) - check details
429Rate limit exceeded - batch exceeds remaining capacity; wait and retry
500Internal server error

Example

cURL

curl -X POST https://console.lightskye.com/api/v1/appointments \
  -H "Content-Type: application/json" \
  -H "x-api-key: lsk_live_xxxx" \
  -d '{
    "contact_first_name": "Jane",
    "contact_last_name":  "Smith",
    "contact_email": "jane@example.com",
    "contact_phone": "+15551234567",
    "requested_date": "2024-02-01",
    "requested_time_start": "09:00",
    "requested_time_end": "10:00",
    "requested_timezone": "America/Chicago",
    "idempotency_key": "ext-appt-9912"
  }'

Source stats API (read-only)

See how many leads and appointments you sent us and where each one stands in our CRM. This uses a separate, read-only stats key (starts with lss_), not your webhook key. Ask your LightSkye contact for one; a single key can cover one or several of your sources.

Authentication and conventions

GET /api/external/v1/source-stats
Authorization: Bearer lss_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  • Authenticate every request with your key: Authorization: Bearer lss_... (or x-api-key: lss_...). The key is read-only and only sees the sources it was issued for.
  • GET only. Responses are JSON (Cache-Control: no-store). Errors are { "error": "..." } with 400 (bad parameter), 401 (missing / invalid / revoked key), 404 (a source_id this key does not cover), 429 (rate limited, see Retry-After) or 500.
  • Rate limits: 60 requests per minute per key. Figures are refreshed at most every 5 minutes (records every 2 minutes), so polling faster returns the same numbers.
  • Dates are UTC. received_at is when we received the record.
  • CRM status is read from our CRM and grouped into the statuses below. Our CRM merges records for the same person, so a record sent by two sources shares one status.
  • At most crm_lookup.cap CRM records are checked per request; anything beyond reads as unchecked - narrow the date window to see them.
GET

/api/external/v1/source-stats

Totals per source: how many leads and appointments you sent, duplicates, and where they stand in our CRM.

ParamTypeDescription
fromYYYY-MM-DDFirst day received (UTC, inclusive). Default: 30 days before to.
toYYYY-MM-DDLast day received (UTC, inclusive). Default: today. Window may span at most 366 days.
source_iduuidLimit to one of the sources this key covers. Omit for all of them.

Response (200)

{
  "window": { "from": "2026-10-01", "to": "2026-10-09" },
  "generated_at": "2026-10-09T14:02:11.000Z",
  "sources": [
    {
      "source_id": "uuid",
      "name": "Your Source",
      "is_active": true,
      "leads": {
        "total": 128,
        "duplicates": 4,
        "in_crm": 121,
        "by_crm_status": { "new": 9, "in_progress": 61, "booked": 30, "won": 6,
                           "lost": 10, "unqualified": 5, "other": 0,
                           "not_in_crm": 7, "unknown": 0, "unchecked": 0 },
        "by_day": { "2026-10-01": 14, "2026-10-02": 17 }
      },
      "appointments": { "total": 22, "duplicates": 0, "in_crm": 22, "by_crm_status": { ... }, "by_day": { ... } }
    }
  ],
  "totals": { "leads": { ... }, "appointments": { ... } },
  "crm_lookup": { "checked": 143, "unknown": 0, "unchecked": 0, "cap": 3000 },
  "truncated": false
}
GET

/api/external/v1/source-stats/records

The individual records you sent, newest first, with the customer name and CRM status.

ParamTypeDescription
fromYYYY-MM-DDFirst day received (UTC, inclusive). Default: 30 days before to.
toYYYY-MM-DDLast day received (UTC, inclusive). Default: today. Window may span at most 366 days.
source_iduuidLimit to one of the sources this key covers. Omit for all of them.
kind"leads" | "appointments"Which records to list. Default: leads.
limitinteger 1-200Page size. Default: 50.
offsetinteger >= 0Rows to skip. Default: 0. Use has_more to know when to stop.

Response (200)

{
  "window": { "from": "2026-10-01", "to": "2026-10-09" },
  "kind": "leads",
  "limit": 50,
  "offset": 0,
  "has_more": true,
  "records": [
    {
      "id": "uuid",
      "kind": "lead",
      "source_id": "uuid",
      "external_id": "form-8814",        // the idempotency_key you sent
      "received_at": "2026-10-09T13:40:02Z",
      "first_name": "Jane",
      "last_name": "Smith",
      "duplicate": false,
      "crm_status": "booked",
      "crm_status_label": "Appointment booked",
      "appointment_date": null,          // appointments only
      "appointment_time": null
    }
  ]
}

CRM statuses

Every record carries one of these in crm_status, and by_crm_status counts them.

KeyLabelMeaning
newNewReceived and waiting for the first contact attempt.
in_progressIn progressBeing worked: contact attempts, callbacks, or a missed appointment being rebooked.
bookedAppointment bookedAn appointment is set or has taken place.
wonWonThe customer signed.
lostLostThe customer declined or asked not to be contacted.
unqualifiedNot qualifiedBad contact details, outside the service area, or does not qualify.
otherOtherAny other internal status.
not_in_crmNot in CRM yetReceived but not yet in the CRM (still in review, or a duplicate of an earlier record).
unknownUnknownThe CRM could not be read for this record just now. Try again later.
uncheckedNot checkedBeyond the per-request CRM lookup limit. Narrow the date window to check these.

Example

curl "https://console.lightskye.com/api/external/v1/source-stats?from=2026-10-01&to=2026-10-09" \
  -H "Authorization: Bearer lss_xxxx"

curl "https://console.lightskye.com/api/external/v1/source-stats/records?kind=appointments&limit=100" \
  -H "Authorization: Bearer lss_xxxx"

The same guide is served with your stats key at /api/external/v1/source-stats/docs (Markdown, or ?format=json).