LightSkye Platform
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.
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.
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
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 }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.
{
"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.
{
"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
}{
"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"]
}{
"id": "uuid",
"status": "pending",
"created_at": "2024-01-15T10:30:00Z"
}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
}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.
[
{ "first_name": "Jane", "email": "jane@email.com" },
{ "first_name": "John", "phone": "+15557654321" },
{ "email": "not-an-email" } // will fail validation
]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 }
}Submit an appointment. LightSkye auto-assigns it to the next available sales rep based on rank and calendar availability.
{
// 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)
}{
"id": "uuid",
"status": "pending", // becomes "assigned" after auto-assignment
"created_at": "2024-01-15T10:30:00Z"
}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).
{
// 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"
}{
"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"
}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.
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.
sourceTag on /api/v1/leads or /api/v1/appointments. Leave it out when a record has no special kind.GET /api/v1/docs (called with your key), under "Your source tags" or your_key.source_tags in the JSON.201 does not prove the tag was recognized.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" }| Status | Meaning |
|---|---|
201 | Created - lead(s) accepted (all items in a batch succeeded) |
207 | Multi-Status - batch partially succeeded; inspect each results[] item |
400 | Invalid JSON body |
401 | Missing or invalid API key / key is inactive |
403 | Key does not have permission for this endpoint |
404 | Events endpoint: no lead of your source matches idempotency_key / lead_id |
422 | Validation failed (or empty / oversized batch) - check details |
429 | Rate limit exceeded - batch exceeds remaining capacity; wait and retry |
500 | Internal server error |
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"
}'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.
GET /api/external/v1/source-stats Authorization: Bearer lss_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Totals per source: how many leads and appointments you sent, duplicates, and where they stand in our CRM.
| Param | Type | Description |
|---|---|---|
from | YYYY-MM-DD | First day received (UTC, inclusive). Default: 30 days before to. |
to | YYYY-MM-DD | Last day received (UTC, inclusive). Default: today. Window may span at most 366 days. |
source_id | uuid | Limit to one of the sources this key covers. Omit for all of them. |
{
"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
}The individual records you sent, newest first, with the customer name and CRM status.
| Param | Type | Description |
|---|---|---|
from | YYYY-MM-DD | First day received (UTC, inclusive). Default: 30 days before to. |
to | YYYY-MM-DD | Last day received (UTC, inclusive). Default: today. Window may span at most 366 days. |
source_id | uuid | Limit to one of the sources this key covers. Omit for all of them. |
kind | "leads" | "appointments" | Which records to list. Default: leads. |
limit | integer 1-200 | Page size. Default: 50. |
offset | integer >= 0 | Rows to skip. Default: 0. Use has_more to know when to stop. |
{
"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
}
]
}Every record carries one of these in crm_status, and by_crm_status counts them.
| Key | Label | Meaning |
|---|---|---|
new | New | Received and waiting for the first contact attempt. |
in_progress | In progress | Being worked: contact attempts, callbacks, or a missed appointment being rebooked. |
booked | Appointment booked | An appointment is set or has taken place. |
won | Won | The customer signed. |
lost | Lost | The customer declined or asked not to be contacted. |
unqualified | Not qualified | Bad contact details, outside the service area, or does not qualify. |
other | Other | Any other internal status. |
not_in_crm | Not in CRM yet | Received but not yet in the CRM (still in review, or a duplicate of an earlier record). |
unknown | Unknown | The CRM could not be read for this record just now. Try again later. |
unchecked | Not checked | Beyond the per-request CRM lookup limit. Narrow the date window to check these. |
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).