{"name":"LightSkye Console inbound webhook API","version":"1.0.0","description":"Machine-readable contract for posting leads, appointments and landing page events into the LightSkye Console. Generated from the live validation schemas. This public copy needs no key; the per-key copy at GET /api/v1/docs adds what depends on your key (allowed endpoints, rate limit, and the source tags your source may send).","base_url":"https://console.lightskye.com","human_docs_url":"https://console.lightskye.com/docs","agent_guide":["Every request needs an API key issued by LightSkye, sent as the x-api-key header. There is no signup and no OAuth; ask your LightSkye contact for a key.","Before writing an integration, fetch https://console.lightskye.com/api/v1/docs?format=json with that key. It returns this same contract plus your_key: the source the key belongs to, its scope (which endpoints it may call), its per-minute rate limit and its source_tags.","Pick the endpoint by what you are sending: a person who wants to be contacted -> POST /api/v1/leads; a booked time slot -> POST /api/v1/appointments; something an already-sent lead did on a landing page -> POST /api/v1/leads/events.","Send an idempotency_key you control on every lead and appointment. Re-sending the same key refreshes the record instead of creating a second one, so retries are safe.","sourceTag is optional, one per record, and must be one of the keys listed in your_key.source_tags. An unknown tag is NOT an error: the record is accepted without a tag. Never invent tag keys; if the list is empty, leave sourceTag out.","Routing, HubSpot source / lead stage and slot placement are decided by LightSkye routing rules, not by the payload. Do not try to set them with extra fields; unknown fields are ignored.","A 201 means the record is durably stored; everything downstream runs in the background. Retry only on 429 (honor Retry-After) or 5xx; fix the payload on 4xx."],"authentication":{"scheme":"Static API key","header":"x-api-key: lsk_...","notes":["No session, no bearer token, no request signature.","The key is SHA-256 hashed and matched against the configured source rows, so one key maps to exactly one source, one scope and one vertical.","The source row must be active. A deactivated key returns 401.","Send Content-Type: application/json.","Keys are issued in the console (prefix lsk_ plus 48 random characters, shown once at creation). Nothing in a payload can request or override the scope or the vertical."]},"key_scopes":[{"scope":"leads","endpoints":["/api/v1/leads","/api/v1/leads/events"]},{"scope":"appointments","endpoints":["/api/v1/appointments"]},{"scope":"both","endpoints":["/api/v1/leads","/api/v1/appointments","/api/v1/leads/events"]}],"per_key_docs":{"url":"https://console.lightskye.com/api/v1/docs","formats":["markdown (default)","json (?format=json)"],"auth":"x-api-key header (same key as the webhooks)","adds":["your_key.source_name","your_key.scope","your_key.allowed_endpoints","your_key.rate_limit_per_minute","your_key.source_tags"]},"contract":{"vertical":"ls","name":"LightSkye residential intake","summary":"Your API key belongs to the residential LightSkye business. Leads and appointments you post run through the full console pipeline: routing rules, duplicate detection, CRM sync, rep assignment or the marketplace, depending on how your source is configured.","endpoints":[{"path":"/api/v1/leads","method":"POST","summary":"A residential lead.","description":"Accepts either a single lead object or a JSON array of up to 100 lead objects. The record is persisted first and the response returned immediately; routing, duplicate checking and any onward sync happen in the background, so a slow third party never slows your webhook.","allowedScopes":["leads","both"],"batching":"A JSON array of up to 100 objects is accepted. Each item is validated and ingested independently - one bad item does not sink its siblings - and each consumes one unit of your per-minute budget. An empty array or more than 100 items is a 422.","requestGroups":[{"title":"Contact and address","fields":[{"name":"first_name","type":"string","required":"conditional","limit":"max 255 chars","description":"Customer given name. One of email / phone / first_name is required."},{"name":"last_name","type":"string","required":"optional","limit":"max 255 chars","description":"Customer family name."},{"name":"email","type":"string","required":"conditional","limit":"valid email address","description":"Customer email. One of email / phone / first_name is required."},{"name":"phone","type":"string","required":"conditional","limit":"max 40 chars","description":"Customer phone, any format. Flagged (never rejected) when it is a number HubSpot would mark invalid. One of email / phone / first_name is required."},{"name":"contact_address","type":"string","required":"optional","limit":"max 300 chars","description":"Street address. Feeds address-based duplicate matching."},{"name":"contact_city","type":"string","required":"optional","limit":"max 100 chars","description":"City."},{"name":"contact_state","type":"string","required":"optional","limit":"max 50 chars","description":"State. Full names are accepted here and normalized to a 2-letter code."},{"name":"contact_zip","type":"string","required":"optional","limit":"max 20 chars","description":"ZIP code."}]},{"title":"Enrichment (all optional)","fields":[{"name":"company","type":"string","required":"optional","limit":"max 255 chars","description":"Company name."},{"name":"job_title","type":"string","required":"optional","limit":"max 255 chars","description":"Job title."},{"name":"website","type":"string","required":"optional","limit":"max 500 chars","description":"Website."},{"name":"mobile_phone","type":"string","required":"optional","limit":"max 40 chars","description":"Secondary mobile number."},{"name":"country","type":"string","required":"optional","limit":"max 100 chars","description":"Country."},{"name":"electric_company","type":"string","required":"optional","limit":"max 255 chars","description":"Electric utility company name."},{"name":"notes","type":"string","required":"optional","limit":"max 5000 chars","description":"Free-text intake body: the customer message plus any source-side answers that have no signal field of their own."},{"name":"referral_code","type":"string","required":"optional","limit":"max 100 chars","description":"Referral attribution, passed through verbatim."},{"name":"sales_rep","type":"string","required":"optional","limit":"max 255 chars","description":"The SOURCE \"who referred this person\" string. Not console rep ownership - that is assigned by the console, never by a payload."}]},{"title":"Identity and qualification signals","note":"Additional optional qualification-signal fields exist and are accepted on both endpoints. They are stored verbatim, matched by routing and pricing rules, and pushed onward where they have a destination. Sending them is optional and omitting them never fails a request.","fields":[{"name":"idempotency_key","type":"string","required":"optional","limit":"max 255 chars","description":"Your stable id for this submission. Re-sending the same key refreshes the existing record instead of creating a second one. Strongly recommended if you can ever re-send."},{"name":"sourceTag","type":"string | null","required":"optional","limit":"one tag per record","description":"One of the tag keys the LightSkye team set up for your source (the \"Your source tags\" list in this document). The console routes and labels the record differently per tag, for example a different HubSpot source or lead stage. Matching ignores case. An unknown or retired tag never rejects the record: it is accepted without a tag and noted in its history."},{"name":"crm_score","type":"integer","required":"optional","limit":"0 to 100","description":"Pre-computed lead quality score, if your system has one."},{"name":"crm_type","type":"\"A\" | \"B\"","required":"optional","limit":"","description":"Pre-computed lead classification."},{"name":"col","type":"number | null","required":"optional","limit":"0 to 100000, USD","description":"Cost of lead in dollars - what this record cost. Stored on the record and written to the HubSpot contact property \"COL\" on every contact push. Omit it if you do not price your leads."},{"name":"isHomeowner","type":"boolean","required":"optional","limit":"","description":"Homeowner gate."},{"name":"utilityProvider","type":"string","required":"optional","limit":"max 255 chars","description":"Utility provider name."},{"name":"billAmount","type":"number | null","required":"optional","limit":"","description":"Average monthly power bill in dollars."},{"name":"creditRange","type":"string | null","required":"optional","limit":"max 50 chars","description":"Credit band as your system labels it."},{"name":"roofCondition","type":"\"Good\" | \"Workable\" | \"Poor\" | \"Unknown\" | null","required":"optional","limit":"","description":"Roof condition bucket. Any unrecognized value is dropped rather than pushed onward, so it can never break a downstream sync."},{"name":"interestLevel","type":"\"High\" | \"Medium\" | \"Low\" | null","required":"optional","limit":"","description":"How interested the customer sounded."},{"name":"preferredCallbackWindow","type":"string | null","required":"optional","limit":"max 255 chars","description":"Leads endpoint only. When they asked to be called back."}]}],"responses":[{"status":201,"title":"Created (single object)","example":"{\n  \"id\": \"0d1e5b7a-3c22-4f10-9a44-8b2e1c7d5f90\",\n  \"status\": \"pending\",\n  \"created_at\": \"2026-08-08T15:04:11.284Z\"\n}","notes":["`status` is the lead processing status. A freshly ingested lead is \"pending\" until the background routing decision runs.","Response headers carry `X-RateLimit-Limit` and `X-RateLimit-Remaining`."]},{"status":200,"title":"Cycle refresh (single object, idempotency_key matched)","example":"{\n  \"id\": \"0d1e5b7a-3c22-4f10-9a44-8b2e1c7d5f90\",\n  \"status\": \"pending\",\n  \"created_at\": \"2026-08-08T15:04:11.284Z\",\n  \"cycle_refresh\": true\n}","notes":["The same idempotency_key was seen before, so the existing record was refreshed with your new payload instead of a second record being created."]},{"status":201,"title":"Batch (array body)","example":"{\n  \"results\": [\n    { \"index\": 0, \"id\": \"0d1e5b7a-...\", \"status\": \"pending\", \"created_at\": \"2026-08-08T15:04:11.284Z\" },\n    { \"index\": 1, \"error\": \"Validation failed\", \"details\": [ { \"path\": [\"email\"], \"message\": \"Invalid email address\" } ] }\n  ],\n  \"summary\": { \"received\": 2, \"accepted\": 1, \"failed\": 1 }\n}","notes":["HTTP status is 201 when every item succeeded, 207 when the batch was mixed, and 422 when every item failed.","A `results` item may also carry `cycle_refresh: true` (idempotency_key matched) or `deduplicated: true` (collapsed onto a recent same-contact record, see Idempotency)."]}]},{"path":"/api/v1/appointments","method":"POST","summary":"A residential appointment.","description":"One appointment per request. The booking is persisted, then the routing rules configured for your source decide what happens to it (rep pool, a slot, the marketplace, or manual review). The response always carries assignment_status so you can see where it landed.","allowedScopes":["appointments","both"],"batching":"Single object only. There is no batch contract on this endpoint; an array fails validation with a 422.","requestGroups":[{"title":"Contact, address and schedule","fields":[{"name":"contact_first_name","type":"string","required":"conditional","limit":"max 255 chars","description":"Customer given name. One of contact_email / contact_phone / contact_first_name is required."},{"name":"contact_last_name","type":"string","required":"optional","limit":"max 255 chars","description":"Customer family name."},{"name":"contact_email","type":"string","required":"conditional","limit":"valid email address","description":"Customer email. One of contact_email / contact_phone / contact_first_name is required."},{"name":"contact_phone","type":"string","required":"conditional","limit":"max 40 chars","description":"Customer phone. One of contact_email / contact_phone / contact_first_name is required."},{"name":"contact_address","type":"string","required":"optional","limit":"max 255 chars","description":"Street address."},{"name":"contact_city","type":"string","required":"optional","limit":"max 255 chars","description":"City."},{"name":"contact_state","type":"string","required":"optional","limit":"exactly 2 letters","description":"State as a 2-letter code (e.g. WV), uppercased on receipt. Full state names are reconciled to a code before validation, so sending \"West Virginia\" also works."},{"name":"contact_zip","type":"string","required":"optional","limit":"max 10 chars","description":"ZIP code."},{"name":"requested_date","type":"string","required":"required","limit":"exactly YYYY-MM-DD","description":"Appointment date."},{"name":"requested_time_start","type":"string","required":"required","limit":"exactly HH:MM (24-hour)","description":"Appointment start time, in requested_timezone."},{"name":"requested_time_end","type":"string","required":"required","limit":"exactly HH:MM (24-hour)","description":"Appointment end time, in requested_timezone."},{"name":"requested_timezone","type":"string","required":"optional","limit":"max 100 chars, IANA zone name","description":"Zone the times are written in. Defaults to America/New_York when omitted."},{"name":"modality","type":"\"In-Home\" | \"Virtual\"","required":"optional","limit":"","description":"How the appointment is held. In-Home bookings are additionally checked for travel feasibility."},{"name":"notes","type":"string","required":"optional","limit":"max 2000 chars","description":"Free-text booking notes."},{"name":"idempotency_key","type":"string","required":"optional","limit":"max 255 chars","description":"Your stable id for this booking. Re-sending the same key refreshes the existing appointment instead of creating a second one."},{"name":"sourceTag","type":"string | null","required":"optional","limit":"one tag per record","description":"One of the tag keys the LightSkye team set up for your source (the \"Your source tags\" list in this document). The console routes and labels the record differently per tag, for example a different HubSpot source or lead stage. Matching ignores case. An unknown or retired tag never rejects the record: it is accepted without a tag and noted in its history."},{"name":"pre_confirmed","type":"boolean","required":"optional","limit":"","description":"True when the customer has already confirmed with you. Skips the console customer-confirmation funnel."},{"name":"lead_id","type":"string (uuid)","required":"optional","limit":"","description":"Links this appointment to a previously posted lead."},{"name":"appointmentConsent","type":"boolean","required":"optional","limit":"","description":"Whether the customer consented to the appointment."},{"name":"crm_score","type":"integer","required":"optional","limit":"0 to 100","description":"Pre-computed quality score, if your system has one."},{"name":"crm_type","type":"\"A\" | \"B\"","required":"optional","limit":"","description":"Pre-computed classification."},{"name":"col","type":"number | null","required":"optional","limit":"0 to 100000, USD","description":"Cost of lead in dollars - what this record cost. Stored on the record and written to the HubSpot contact property \"COL\" on every contact push. Omit it if you do not price your leads."}]},{"title":"Qualification signals","note":"Additional optional qualification-signal fields exist and are accepted on both endpoints. They are stored verbatim, matched by routing and pricing rules, and pushed onward where they have a destination. Sending them is optional and omitting them never fails a request. The appointment endpoint accepts the same signal vocabulary as the leads endpoint (isHomeowner, utilityProvider, billAmount, creditRange, roofCondition, interestLevel and the rest).","fields":[]}],"requestNotes":["requested_date / requested_time_start / requested_time_end are a wall clock in requested_timezone. Do not pre-convert to UTC.","Address fields are reconciled before validation: common alias keys (address1, postal_code, a single combined full address string) and full state names are all understood. The original body is always stored verbatim for forensics."],"responses":[{"status":201,"title":"Created","example":"{\n  \"id\": \"7c1f4a90-51de-4a10-b2c4-6d0e9f2a8b31\",\n  \"status\": \"awaiting_confirmation\",\n  \"assignment_status\": \"awaiting_confirmation\",\n  \"created_at\": \"2026-08-08T15:04:11.284Z\"\n}","notes":["`status` and `assignment_status` always carry the same value; both are present for backwards compatibility.","The value reflects where the routing decision left the booking (for example pending, awaiting_confirmation, assigned, in_marketplace, needs_review, sent_to_hubspot when the source is configured to record the booking as a HubSpot contact only, or sent_as_text when a rule hands the booking to the team by text).","Response headers carry `X-RateLimit-Limit` and `X-RateLimit-Remaining`."]},{"status":200,"title":"Cycle refresh (idempotency_key matched)","example":"{\n  \"id\": \"7c1f4a90-51de-4a10-b2c4-6d0e9f2a8b31\",\n  \"status\": \"pending\",\n  \"assignment_status\": \"pending\",\n  \"created_at\": \"2026-08-08T15:04:11.284Z\",\n  \"cycle_refresh\": true\n}","notes":["The existing appointment was refreshed with your new payload instead of a second one being created."]},{"status":201,"title":"Created, but the requested slot was full","example":"{\n  \"id\": \"7c1f4a90-51de-4a10-b2c4-6d0e9f2a8b31\",\n  \"status\": \"needs_review\",\n  \"assignment_status\": \"needs_review\",\n  \"created_at\": \"2026-08-08T15:04:11.284Z\",\n  \"slot_overflow\": true,\n  \"slot\": {\n    \"date\": \"2026-08-19\",\n    \"time_start\": \"14:00\",\n    \"time_end\": \"15:00\",\n    \"capacity_total\": 3,\n    \"capacity_remaining\": 0\n  }\n}","notes":["The booking was accepted and is not lost, but the time you asked for had no capacity left, so it went to an operator rather than being auto-assigned.","Present only when the overflow actually happened. Absent on every other response."]}]},{"path":"/api/v1/leads/events","method":"POST","summary":"A landing-page event on an existing lead.","description":"Tells the console that a lead you already posted did something 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 against the lead and a routing rule decides whether it is pushed to the HubSpot contact. The lead itself is not re-submitted, re-routed or changed.","allowedScopes":["leads","both"],"batching":"Single object only. There is no batch contract on this endpoint; an array body is a 400. Send one request per event.","requestGroups":[{"title":"Which lead","note":"The lead must belong to the same source as your key. A key that resolves to no lead of your source is a 404.","fields":[{"name":"idempotency_key","type":"string","required":"conditional","limit":"1-255 chars","description":"The idempotency_key you sent when you posted the lead to /api/v1/leads. One of idempotency_key / lead_id is required."},{"name":"lead_id","type":"string (UUID)","required":"conditional","limit":"UUID","description":"The `id` the leads endpoint returned for the lead. One of idempotency_key / lead_id is required."}]},{"title":"The event","fields":[{"name":"event_type","type":"string (enum)","required":"required","limit":"one of the values under Enums","description":"What the lead did on the landing page: call_me_now (pressed \"Call me now\"), text_me_times (none of the offered times worked, asked to be texted other times), callback_requested (asked for a callback later), calendar_booked (picked a time on the landing page calendar)."},{"name":"occurred_at","type":"string (ISO 8601 date-time)","required":"optional","limit":"date-time with a Z or +hh:mm offset","description":"When the lead did it. Defaults to the time the console received the request."}]},{"title":"Contact snapshot (optional)","note":"What the landing page knows about the person at the time of the event. Stored on the event and included in the note on the HubSpot contact. The contact itself is created or refreshed from the lead record the console already holds.","fields":[{"name":"contact.first_name","type":"string","required":"optional","limit":"max 255 chars","description":"Given name as the landing page has it."},{"name":"contact.last_name","type":"string","required":"optional","limit":"max 255 chars","description":"Family name."},{"name":"contact.email","type":"string","required":"optional","limit":"valid email address","description":"Email."},{"name":"contact.phone","type":"string","required":"optional","limit":"max 40 chars","description":"Phone."},{"name":"contact.address","type":"string","required":"optional","limit":"max 300 chars","description":"Street address."},{"name":"contact.city","type":"string","required":"optional","limit":"max 100 chars","description":"City."},{"name":"contact.state","type":"string","required":"optional","limit":"max 50 chars","description":"State, as the lead webhook accepts it."},{"name":"contact.zip","type":"string","required":"optional","limit":"max 20 chars","description":"ZIP code."}]},{"title":"Details and notes (optional)","fields":[{"name":"details","type":"object of string | number | boolean","required":"optional","limit":"max 20 keys, key 1-64 chars, string values max 500 chars","description":"Flat key/value facts about the event, for example the booked slot or the times the lead asked about. Written into the note on the HubSpot contact, one line per key. No nested objects or arrays."},{"name":"notes","type":"string","required":"optional","limit":"max 4000 chars","description":"Free text shown in the HubSpot note."}]}],"requestNotes":["Residential sources only: a key whose source belongs to the B2B vertical gets a 403 here.","When both idempotency_key and lead_id are sent, idempotency_key is tried first and lead_id is the fallback.","Post the lead to /api/v1/leads first. An event for a lead the console has not received yet is a 404, not queued.","Repeats are collapsed: when the same event_type for the same lead already arrived within the last 10 minutes (for calendar_booked only when details are identical too; a booking with different details is a reschedule and is recorded as a new event), the request answers 200 with the existing event and nothing new is stored, routed or counted against your rate limit. See Idempotency."],"responses":[{"status":201,"title":"Created","example":"{\n  \"id\": \"5b9e2c14-7a3d-4e61-9f08-2c4d6a1b3e77\",\n  \"lead_id\": \"0d1e5b7a-3c22-4f10-9a44-8b2e1c7d5f90\",\n  \"event_type\": \"call_me_now\",\n  \"routing_status\": \"unassigned\",\n  \"created_at\": \"2026-09-24T15:04:11.284Z\"\n}","notes":["`routing_status` is always \"unassigned\" on a 201: the routing decision and the HubSpot push run in the background after the event is stored. There is no `deduplicated` key on a 201.","Response headers carry `X-RateLimit-Limit` and `X-RateLimit-Remaining`."]},{"status":200,"title":"Deduplicated (a repeat of a recent event)","example":"{\n  \"id\": \"5b9e2c14-7a3d-4e61-9f08-2c4d6a1b3e77\",\n  \"lead_id\": \"0d1e5b7a-3c22-4f10-9a44-8b2e1c7d5f90\",\n  \"event_type\": \"call_me_now\",\n  \"routing_status\": \"assigned\",\n  \"created_at\": \"2026-09-24T15:04:11.284Z\",\n  \"deduplicated\": true\n}","notes":["Returned when the same event_type for the same lead already arrived within the last 10 minutes (for calendar_booked only when details are identical too; a booking with different details is a reschedule and is recorded as a new event).","The body describes the EXISTING event: `id`, `created_at` and `routing_status` are its values, and `routing_status` is its current status (it may already be \"assigned\", meaning pushed to HubSpot).","Nothing is stored, routed or counted against your per-source rate limit."]}]}],"enums":[{"field":"modality","values":["In-Home","Virtual"],"note":"Appointments endpoint. Omitting it leaves the console default in place."},{"field":"roofCondition","values":["Good","Workable","Poor","Unknown"],"note":"Optional signal. Anything else is dropped rather than rejected."},{"field":"interestLevel","values":["High","Medium","Low"],"note":"Optional signal."},{"field":"crm_type","values":["A","B"],"note":"Optional pre-computed classification."},{"field":"event_type","values":["call_me_now","text_me_times","callback_requested","calendar_booked"],"note":"Events endpoint. Required. Anything else is a 422."}],"crossFieldRules":["Leads: at least one of email / phone / first_name must be present.","Appointments: at least one of contact_email / contact_phone / contact_first_name must be present.","Appointments: requested_date, requested_time_start and requested_time_end are all required, in exactly the documented formats.","Unknown extra fields are ignored by validation, and the raw body is always stored verbatim.","Events: at least one of idempotency_key / lead_id must be present."],"errors":[{"status":400,"body":"{ \"error\": \"Invalid JSON body\" }","when":"The body did not parse as JSON."},{"status":400,"body":"{ \"error\": \"Body must be a single JSON object (batches are not accepted)\" }","when":"Events endpoint only. The body was an array, null or not an object."},{"status":401,"body":"{ \"error\": \"Missing x-api-key header\" }","when":"No x-api-key header."},{"status":401,"body":"{ \"error\": \"Invalid API key\" }","when":"No source row matches the key hash."},{"status":401,"body":"{ \"error\": \"API key is inactive\" }","when":"The source row is deactivated."},{"status":403,"body":"{ \"error\": \"This API key is not authorized for the leads endpoint\" }","when":"The key scope excludes the endpoint. The appointments endpoint returns the appointments wording; the events endpoint accepts the same scopes as the leads endpoint."},{"status":404,"body":"{ \"error\": \"Lead not found for this source\" }","when":"Events endpoint only. Neither idempotency_key nor lead_id resolves to a lead of the source your key belongs to."},{"status":403,"body":"{ \"error\": \"Events are not available for this source\" }","when":"Events endpoint only. The key belongs to a B2B source."},{"status":422,"body":"{ \"error\": \"Validation failed\", \"details\": [ { \"path\": [\"email\"], \"message\": \"...\" } ] }","when":"Schema or cross-field failure on a single-object request. `details` names every offending field."},{"status":422,"body":"{ \"error\": \"Batch cannot be empty\" }","when":"Leads endpoint, an empty array was posted."},{"status":422,"body":"{ \"error\": \"Batch too large: 140 leads (max 100)\" }","when":"Leads endpoint, more than 100 items in one array."},{"status":422,"body":"{ \"results\": [...], \"summary\": { \"received\": 2, \"accepted\": 0, \"failed\": 2 } }","when":"Leads endpoint batch where every item failed. A mixed batch returns 207 with the same shape."},{"status":429,"body":"{ \"error\": \"Too many requests\" }","when":"Per-IP limit. Carries Retry-After."},{"status":429,"body":"{ \"error\": \"Rate limit exceeded\", \"retry_after_seconds\": 60 }","when":"Per-source limit. Carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining. A leads batch additionally carries `requested` and `available`."},{"status":500,"body":"{ \"error\": \"Internal server error\" }","when":"Unexpected failure. Safe to retry."}],"rateLimits":["Per IP: 30 requests per 60 seconds on the leads and appointments endpoints, 60 per 60 seconds on the events endpoint, applied before key authentication.","The events endpoint draws on the same per-source budget as your other calls; each newly stored event consumes one unit, a deduplicated repeat consumes none.","Per source: your key rate_limit_per_minute (see \"Your key\" above) per 60 seconds.","On the leads endpoint each item in a batch consumes one unit, and a batch is gated against the remaining capacity as a whole - a batch larger than what is left is refused rather than partially accepted.","Back off on Retry-After rather than retrying immediately."],"idempotency":["Send an `idempotency_key`. Re-posting the same key refreshes the existing record instead of creating a second one, and the response comes back 200 with `cycle_refresh: true`.","Leads only, as a safety net when no idempotency_key is sent: a lead from the same source matching an existing record on phone or email within the last 6 hours is treated as the same submission. It collapses onto that record (`deduplicated: true` in a batch result) and is not routed a second time.","That fallback exists for sources that fire several webhooks per contact. If your second post is a genuinely new enquiry, send an idempotency_key so it is never collapsed.","Events endpoint: there is no idempotency_key for an event (that field names the lead). Instead, when the same event_type for the same lead already arrived within the last 10 minutes (for calendar_booked only when details are identical too; a booking with different details is a reschedule and is recorded as a new event), the repeat answers 200 with `deduplicated: true` and the existing event, so retrying a timed-out event post is safe.","Retry 429 and 500. Any other 4xx will fail identically on retry: fix the payload."],"sections":[{"heading":"What happens after a 201","bullets":["The response is returned as soon as the record is durably stored. Everything downstream runs in the background, so a slow or unavailable third party never slows or fails your webhook.","Your source routing rules decide what happens next. A record that matches no rule is flagged for manual review rather than being dropped.","There is no callback to you by default. If your source is configured with a return URL, outcomes are posted back to it separately."]},{"heading":"Events: what happens after a 201","bullets":["The event is stored against the lead it names. Nothing re-submits the lead, so no duplicate handling, cycle refresh or lead routing runs, and the lead keeps its status.","A routing rule with the trigger \"On landing page event\" decides what happens to it. The only action such a rule can take is a push to the HubSpot contact of the lead, using the HubSpot source and lead stage options set on that rule. An event that matches no rule is stored and shown in the console, and nothing is pushed.","The push finds the HubSpot contact of the lead (creating it from the lead when there is none, refreshing it when found) and writes the event as a note on the contact: the action, when, the source, your details and notes, the contact snapshot and the lead details. A HubSpot workflow that should react can trigger on that note or on the lead stage / source the rule sets. No custom HubSpot property is involved.","No owner change, no HOT LEAD status, no duplicate flag, no staff notification and no text message come from an event."]},{"heading":"Source tags (sourceTag)","bullets":["A source tag is a short key that says what kind of traffic a record is, inside your one source (for example a referral versus a paid retarget). The LightSkye team creates the tags for your source in the console; you cannot create one from a payload.","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. Matching ignores case and surrounding spaces.","Each tag can be routed on its own: its own HubSpot contact source and lead stage, its own slots or its own destination. Routing is configured on the LightSkye side, never by the payload.","Your source's tags are listed under \"Your source tags\" in GET /api/v1/docs (called with your key). Only those keys are recognized.","An unknown or retired tag never rejects the record: it is accepted with no tag, routed like an untagged record and noted in its history. A 201 therefore does not prove the tag was recognized - check the tag list if routing looks wrong.","Tags are not shared between sources and are unrelated to `campaignId`, which still works as before."]},{"heading":"Rejected payloads are recorded","bullets":["A 422 on the appointments endpoint is written to the audit log with the offending field paths and a truncated copy of the raw body, because a rejected booking is otherwise invisible.","If you are debugging a field mapping, ask for those audit rows rather than guessing at what was received."]}],"examples":[{"title":"Single lead","description":"The common case.","command":"curl -X POST https://console.lightskye.com/api/v1/leads \\\n  -H \"x-api-key: lsk_YOUR_KEY_HERE\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"first_name\": \"Dana\",\n    \"last_name\": \"Whitfield\",\n    \"email\": \"dana@example.com\",\n    \"phone\": \"+13045550142\",\n    \"contact_address\": \"110 North Point Drive\",\n    \"contact_city\": \"Clendenin\",\n    \"contact_state\": \"WV\",\n    \"contact_zip\": \"25045\",\n    \"isHomeowner\": true,\n    \"billAmount\": 210,\n    \"notes\": \"Asked for a call this week.\",\n    \"idempotency_key\": \"form-2026-08-08-8814\"\n  }'"},{"title":"Appointment","description":"A booked residential appointment.","command":"curl -X POST https://console.lightskye.com/api/v1/appointments \\\n  -H \"x-api-key: lsk_YOUR_KEY_HERE\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"contact_first_name\": \"Dana\",\n    \"contact_last_name\": \"Whitfield\",\n    \"contact_email\": \"dana@example.com\",\n    \"contact_phone\": \"+13045550142\",\n    \"contact_address\": \"110 North Point Drive\",\n    \"contact_city\": \"Clendenin\",\n    \"contact_state\": \"WV\",\n    \"contact_zip\": \"25045\",\n    \"requested_date\": \"2026-08-19\",\n    \"requested_time_start\": \"14:00\",\n    \"requested_time_end\": \"15:00\",\n    \"requested_timezone\": \"America/New_York\",\n    \"modality\": \"Virtual\",\n    \"notes\": \"Confirmed the 2pm ET slot on the call.\",\n    \"idempotency_key\": \"crm-appt-88142\"\n  }'"},{"title":"Landing page event","description":"The lead posted as form-2026-08-08-8814 pressed \"Call me now\".","command":"curl -X POST https://console.lightskye.com/api/v1/leads/events   -H \"x-api-key: lsk_YOUR_KEY_HERE\"   -H \"Content-Type: application/json\"   -d '{\n    \"idempotency_key\": \"form-2026-08-08-8814\",\n    \"event_type\": \"call_me_now\",\n    \"occurred_at\": \"2026-09-24T15:04:05Z\",\n    \"details\": { \"page\": \"calendar\", \"offered_slots\": 6 }\n  }'"}]}}