v1 · Read + Write · No SDK required

SolarKnock Reporting API

Pull every knock, lead, appointment, and stat from your organization into your own data warehouse, CRM, or BI tool — and push knocks back in from third-party apps (AI transcription, field tablets, custom dialers). One header, one endpoint per resource, cursor-paginated reads, idempotent writes.

Overview

The Reporting API exposes the source data behind every screen in SolarKnock — the same rows that power your leaderboard, your stats sheet, your manager dashboard, and your lead pipeline. You get the records, not the aggregates, so your warehouse can roll them up however you like.

Base URL: https://app.solarknock.com/api/v1/reporting

Reads (every GET endpoint below) are enabled by default for every organization. Writes (POST + PATCH /knocks) require an org admin to explicitly enable them under Profile → API & Integrations — turned off by default so a stale read token cannot accidentally start mutating data.

Authentication

Every request carries an API key as a Bearer token. Keys look like sk_live_… and are scoped to exactly one organization — there is no ?orgId= parameter on any endpoint, ever.

🔑 Getting a key

Sign in to app.solarknock.com as an org adminAccount tab → Reporting API keyGenerate.

The key is shown exactly once. We store only its hash, so it can never be retrieved or emailed to you later — copy it into your secret manager immediately. Lost a key? Generate another; each one is independent.

Send it on every request:

Authorization Header
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxx

Send the key exactly as issued, including the sk_live_ prefix — it is hashed verbatim, so a trimmed or altered key returns 401 INVALID_KEY.

⚠️ Treat your key like a password

Anyone holding a key can read every knock, lead, and homeowner contact in your organization. Never embed one in client-side code.

Your organization can hold several keys at once, each with its own label — one per consumer (warehouse job, BI tool, internal agent). Revoking one key does not affect the others, so you can rotate a single integration without taking the rest down. A revoked key stops working immediately.

Metric Definitions

These are the exact definitions the in-app dashboard, leaderboard, and map use. If you roll the API data up any other way, your numbers will not match what your managers see inside SolarKnock.

MetricDefinition
KnockOne row from /knocks — one door visit. Never deduplicated by address: knocking the same house twice is two knocks. Marker pins with source: "lead" are auto-generated lead-parity pins and are excluded from /knocks for you — you never have to filter them out yourself.
ConversationA knock whose status is not one of not_home, failed_knock, renter, never_knock — a door where a decision-maker could actually be pitched. This is a denylist; see the callout below.
LeadA row from /leads. Not a knock whose status is new_lead.
AppointmentA row from /appointments. Not a knock whose status is set_appointment — that is a door disposition, a different object.
DealA lead whose status is won. Not the closed_deal knock disposition.
📐 Conversation is a DENYLIST, not an allowlist

A conversation is any knock whose status is not not_home, failed_knock, renter, or never_knock. Everything else counts — including no_decision_maker and closed_deal — and any new disposition we add in future counts as a conversation by default.

For clarity, the positive list as of today is: not_interested, new_lead, set_appointment, closed_deal, follow_up, no_decision_maker. Implement the deny check rather than hard-coding that positive list, so your numbers don't silently drift when a disposition is added.

Making Requests

There is no single parameter set that works on every endpoint — each one accepts its own list. The tables below are authoritative. On the list endpoints, any parameter not in that endpoint's list returns 400 INVALID_PARAM naming the offending parameter and listing what the endpoint does accept.

Common parameters

ParameterTypeDefaultDescription
limitinteger500Page size. Maximum 1000.
cursorstringOpaque pagination cursor returned by the previous page.
include_deletedbooleanfalseInclude soft-deleted rows. They come back with a non-null deletedAt.
sinceISO 8601Return rows where updated_at > since. A change feed, not a creation feed — see Dates & Time Zones.
untilISO 8601Upper bound on updated_at. Use to make a sync window deterministic.

What each endpoint accepts

EndpointAccepted query parameters
/orgNone — returns a single object, not a list. Parameters are ignored.
/userslimit, cursor
/knockslimit, cursor, include_deleted, since, until, user_id, status, knock_date_from, knock_date_to, county_fips
/leadslimit, cursor, include_deleted, since, until, user_id, status, created_from, created_to
/appointmentslimit, cursor, include_deleted, since, until, closer_id, setter_id, status, date_from, date_to
/daily-statslimit, cursor, user_id, date_from, date_to
/usage-dayslimit, cursor, user_id
/countiesNone — not paginated; returns the full aggregate in one response. Parameters are ignored.

Worth reading twice: only /knocks, /leads and /appointments support since/until. /daily-stats has no updated_at filter — window it with date_from/date_to. /usage-days has no time filter at all. /users has no updated_at, so it has no incremental mode — re-pull it in full. /counties takes no parameters and is not paginated.

⚠️ Query parameters are snake_case; row fields are camelCase

The two vocabularies do not match, and guessing the wrong one used to be silently wrong. The rep who logged a knock comes back on the row as createdBy, but the filter for it is user_id. On /knocks, /leads and /appointments — which return the raw row shape (createdBy, knockDate, updatedAt, deletedAt) — the mapping to remember is: row createdBy → filter user_id.

Filters are always snake_case, on every endpoint. Unknown parameters are no longer ignored — they return 400 INVALID_PARAM naming the parameter, so a typo fails loudly instead of quietly returning unfiltered data.

Accepted aliases

The guesses integrators actually reach for are accepted and applied (the filter really takes effect) rather than rejected:

You sendApplied asWhere
created_by, createdBy, userIduser_idEvery endpoint that accepts user_id
startDate, start_date, date_fromknock_date_from/knocks
endDate, end_date, date_toknock_date_to/knocks
startDate, start_date, date_fromcreated_from/leads
endDate, end_date, date_tocreated_to/leads
startDate, start_datedate_from/appointments, /daily-stats
endDate, end_datedate_to/appointments, /daily-stats

Aliases are a convenience, not the contract — write new integrations against the canonical names above. per_page and page_size are accepted as aliases for limit. Offset position parameters (page, offset, skip) cannot be honored by a cursor API: a first-page value (page=1, offset=0, skip=0) is accepted and ignored with a Warning response header, and any other value returns 400. See Pagination.

Response envelope

Every list response shares the same shape:

JSON Response
{
  "data": [ /* rows for this page */ ],
  "pagination": {
    "next_cursor": "eyJpZCI6IjAxMjM0NTY3...=",
    "has_more": true
  },
  "meta": {
    "org_id": "f7a...",
    "fetched_at": "2026-05-01T17:32:14Z",
    "row_count": 500
  }
}

When has_more is false, next_cursor is null. Your sync loop is simply: while (cursor) { fetch(cursor) }.

Dates & Time Zones

Getting this wrong is the most common cause of "the API doesn't match my dashboard". There are two kinds of date field here and they are not interchangeable.

FieldWhat it isUse it for
knock_dateThe calendar day on the rep's device when the knock was recorded — device-local, not UTC.All daily rollups. This is the authoritative day field; group by it.
created_at / updated_atUTC instants.Ordering and incremental sync only. Never day rollups.
⚠️ Never bucket days by created_at

Door-knocking happens in the evening. For a team working after 5pm local, the UTC day boundary falls mid-shift — so grouping by created_at misfiles a large share of every night's knocks onto the following day. Group by knock_date and your numbers line up with the in-app leaderboard.

Pagination & Incremental Sync

This API is cursor-paginated only. Set the page size with limit (max 1000), then follow pagination.next_cursor as ?cursor= until it comes back null.

There is no offset-based paging. per_page / page_size are accepted as aliases for limit. A position parameter that names the first page — page=1, offset=0, skip=0 — is accepted and ignored, and the response carries a Warning: 299 header telling you to move to cursors. Any other value (page=2, offset=500, …) returns 400 INVALID_PARAM: it cannot be honored, and silently ignoring it would re-serve the first page forever while has_more stayed true.

A page that returns exactly limit rows always yields a next_cursor, so when the row count divides evenly into your page size the final call returns an empty data array with a null cursor. That is normal termination, not an error — don't treat an empty page as a failure.

Cursors are stable under concurrent writes — a row inserted mid-sync won't cause the next page to skip a record. The cursor encodes (updated_at, id) and the server returns rows ordered the same way, so ties on updated_at are broken deterministically by id.

For nightly syncs, save the timestamp at which your job started (not finished) and use it as the next run's since. This guarantees no rows fall in the gap between your read and a row's next update.

Errors

Errors come back in a uniform envelope:

JSON
{
  "error": {
    "code": "INVALID_KEY",
    "message": "API key not recognized."
  }
}
HTTPCodeWhen it happens
401UNAUTHENTICATEDMissing Authorization header.
401INVALID_KEYHeader present but no organization matches.
403REPORTING_DISABLEDYour key is valid, but an admin has turned the Reporting API off for this organization. Not retryable and not fixed by generating a new key — an org admin re-enables it in the app.
400INVALID_CURSORCursor does not decode. Note a cursor is not bound to the endpoint that issued it — replaying a /leads cursor on /knocks decodes fine and silently skips rows. Keep one cursor per endpoint.
400INVALID_PARAMBad date format, unknown enum value, or a query parameter this endpoint doesn't accept. The message names the offending parameter.
429RATE_LIMITEDYou exceeded a rate limit — honor Retry-After. See Rate Limits.
500INTERNAL_ERRORSomething broke on our end. Retry with backoff.

Unknown parameters fail loudly

Any query parameter that isn't in the accepted list for that list endpoint (see What each endpoint accepts) returns 400 INVALID_PARAM naming it and listing the supported parameters, instead of being silently ignored. Typos and invented filter names surface immediately rather than returning unfiltered data that looks plausible. Recognized aliases are applied instead of rejected.

An offset position parameter with a non-first-page value (page=2, offset=500, skip=10) gets a 400 of its own, with a message pointing at cursor pagination; a first-page value is ignored with a Warning header instead. An out-of-vocabulary ?status= on /knocks or /leads is also a 400 INVALID_PARAM listing the valid statuses, rather than an empty 200. The same applies to a malformed date or timestamp, a repeated parameter, two spellings of the same filter, and a cursor that does not decode (400 INVALID_CURSOR).

Rate Limits

Several limiters stack, and they are not all keyed on your organization. The per-IP limiter is the one most integrations hit first, so pace against it.

LimiterBudgetKeyed onApplies to
Reporting per-IP60 requests / minuteClient IPEvery /api/v1/reporting request, applied before authentication — so rejected and 401 requests consume it too.
Reporting per-org120 in any 60-second window (≈60 sustained)OrganizationEvery authenticated reporting request.
Reporting writes30 requests / minuteOrganizationPOST and PATCH only, on top of the two above.
Global API100 requests / 15 minutesClient IPAll /api/ traffic without a browser session, including this API.

Practical guidance: pace your integration against the 60 requests/minute per IP budget, and keep longer-window throughput inside the global 100 requests / 15 minutes per IP. Running from a shared egress IP (a NAT gateway, a CI runner pool) means other traffic from that IP spends the same budget. At limit=1000, 60 requests/minute is still 60,000 rows in a burst minute — ample for any nightly sync we've seen.

Rate-limit headers

Responses carry the draft-6 standard headers — no X- prefix:

⚠️ Don't trust the advertised remaining count

Because the limiters stack, the RateLimit-Limit / RateLimit-Remaining values on a successful response reflect the per-org limiter (120), while the per-IP limiter (60/min) may bind first — you can be throttled with a healthy-looking RateLimit-Remaining. Pace to 60/min/IP, and on a 429 always honor Retry-After with exponential backoff rather than retrying against the advertised count.

Endpoints

GET /api/v1/reporting/org

Returns metadata about your organization — name, tier, subscription status, member count, and your invite codes. Useful as a sanity check that your key resolves to the org you expect.

curl
curl -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  https://app.solarknock.com/api/v1/reporting/org
GET /api/v1/reporting/users

Every member of your organization with their role, email, profile info, last login time, and closer status. One row per user.

Returned fields: id, name, email, role, is_closer, available_to_close, profile_picture_url, badge_photo_url, created_at, last_login_at, membership_status, joined_at, default_org_id.

GET /api/v1/reporting/knocks

Every knock pin in your organization — homeowner name, address, lat/lng, status, notes, disposition, follow-up date, the rep who knocked, and links to any lead or appointment that resulted. Each row includes actionsTaken (camelCase, like every row field on this endpoint) — a string array of actions the rep took at the door (price_estimate, proposal_given), independent of the disposition. Empty array when none were recorded.

Endpoint-specific filters:

🔀 Read/write asymmetry on status

GET ?status= takes the internal values listed above. POST /knocks takes the friendly disposition labels — Not Home, Not Interested, Lead, Appointment, Close — which map to not_home, not_interested, new_lead, set_appointment, closed_deal respectively. Filtering with a friendly label (?status=Appointment) or a value that isn't in the vocabulary (?status=appointment) returns 400 INVALID_PARAM listing the valid values.

curl — incremental pull
curl -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  "https://app.solarknock.com/api/v1/reporting/knocks?since=2026-04-30T00:00:00Z&limit=1000"
GET /api/v1/reporting/leads

Every lead in your organization except those created by the SitePilot sold-deal sync (source = 'sitepilot'), which are excluded — name, contact info, monthly bill, roof type, interest level, status, score, notes, system-size estimate, savings projection, and attachments. Each row includes actionsTaken (camelCase, like every row field on this endpoint) — a string array of actions recorded on the lead (price_estimate, proposal_given, qualified), independent of status. qualified moved from a lead status into an action: historical leads may still carry status: "qualified" and remain filterable, but new work records it here. Empty array when none were recorded.

Endpoint-specific filters:

The attachments field is a JSON array. Each attachment includes id, fileName, mimeType, size, uploadedAt, uploadedBy, and a download_url pointing at the attachment download endpoint below. The URL is permanent — its security comes from the same Bearer key, not the URL itself. Revoking a key stops that key from fetching these URLs; your other keys keep working.

GET /api/v1/reporting/appointments

Every appointment your team has set — date, time, type, status, the closer assigned, the setter who set it, and the lead/pin it relates to. Both Google Calendar event IDs are returned for reps who have calendar sync enabled.

Endpoint-specific filters:

GET /api/v1/reporting/daily-stats

Pre-aggregated per-(user, date) counters: knock count, leads generated, appointments set, deals closed, hours worked, and the timestamps of each individual knock that day. Convenient for a quick rollup — but it is a derived table, not the source of truth.

Endpoint-specific filters:

⚠️ /knocks is the source of truth, not /daily-stats

The authoritative per-knock record is /knocks — the in-app leaderboard reads knock rows directly, it does not read these counters. Treat /daily-stats as a convenience aggregate.

Day-bucket change (2026-07-31). These counters are now bucketed on the rep's local knock day, matching knock_date. Rows recorded before 2026-07-31 are still bucketed by UTC day, so for a team that knocks in the evening a series that spans that date contains two different conventions and will show a one-day step at the boundary. When you need calendar-accurate daily rollups — especially across that date — pull /knocks and group by knock_date, which is consistent throughout.

GET /api/v1/reporting/usage-days

One row per (user, day) where the user opened the SolarKnock app. Useful for "active reps" calculations and attendance analytics.

GET /api/v1/reporting/counties

Every US county your organization has knocked in, with renter-data availability status and a count of knocks per county.

GET /api/v1/reporting/attachments/:lead_id/:file_id

Streams the raw bytes of a lead attachment — power bills, drivers licenses, contract scans, photos. The :lead_id and :file_id values come from the download_url returned in each attachment object on the /leads endpoint.

Auth is the same Bearer key as every other reporting endpoint. The endpoint returns the file as an attachment download with the original filename and mime type preserved. Cross-org isolation is enforced — the lead must belong to the organization that owns the API key, otherwise you get a 404.

📎 Mirroring power bills into your CRM

For each lead returned by /leads, walk its attachments array. For each attachment, GET download_url with your Bearer header, save the bytes into your warehouse blob store (S3, GCS, local) keyed by (lead_id, file_id), and store the local path on the lead record. Re-fetching is idempotent.

curl — download an attachment
curl -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -o power_bill.pdf \
  https://app.solarknock.com/api/v1/reporting/attachments/f7a.../9b1...

Write Endpoints

Push knocks into SolarKnock from a third-party app (AI transcription, custom field tablet, voice-to-text dialer). One POST creates the knock and — when the disposition warrants — cascades into a lead and an appointment in a single round-trip. PATCH lets you update the disposition or notes on a knock you previously created (e.g. when an AI re-classifies after a follow-up call).

⚠️ Writes are opt-in

Your read API key works for writes too — but writes are disabled by default. An org admin must enable them under Profile → API & Integrations. Until then, every POST/PATCH returns 403 WRITE_DISABLED.

Customer self-serve: sign in to app.solarknock.com as an org admin → Profile → API & Integrations → toggle Enable API writes. (UI panel coming in Phase B of the Profile refactor — until then, contact support@solarknock.com to request enablement.)

Auth, idempotency, rate limit

POST /api/v1/reporting/knocks

Create a knock. Optionally cascades into a lead (when disposition is Lead, Appointment, or maps to follow_up) and an appointment (when disposition is Appointment and appointment_datetime + closer_id are present).

Required body fields

FieldTypeNotes
addressstringFree-form. Server canonicalizes via Smarty.
latitudenumber-90..90.
longitudenumber-180..180.
dispositionenumOne of: Not Home, Not Interested, Lead, Appointment, Close. Maps to internal status (see below).
knock_timestampISO 8601When the knock happened. Future timestamps are rejected. Knocks more than 30 days in the past are silently coerced to today.
Plus one of user_id or user_email:
user_idUUIDSolarKnock user id of the rep who logged it. Must be a member of your org.
user_emailstringRep's email. We look them up by email; if no match, we create a disabled placeholder user (no login until activated by an admin) and attribute the knock to them. Lets you start pushing knocks for reps before they've onboarded.

Optional body fields

FieldTypeNotes
homeowner_namestringMax 200 chars.
homeowner_phonestringFree-form, no normalization.
homeowner_emailstring
notesstringAI transcription summary or rep notes. Max 5000.
disposition_notesstringSeparate from notes. Max 5000.
electric_bill_amountnumberRoutes to knock and to the cascaded lead's monthly_bill.
system_size_kwnumberCascades to lead's estimated_system_size.
roof_typestringCascades to lead.
actions_takenstring[]What the rep did at the door, independent of disposition. Knock actions: price_estimate, proposal_given. Unknown values are rejected.
lead_actions_takenstring[]Actions recorded on the cascaded lead (only applies when a lead is created). Lead actions: price_estimate, proposal_given, qualified. Unknown values are rejected.
follow_up_dateYYYY-MM-DD
appointment_datetimeISO 8601When disposition: "Appointment" and you also pass closer_id, an appointment row is created at this date/time.
closer_idUUIDRequired to create an appointment. Must be an active org member with is_closer = true.
idUUIDOptional client-supplied id. Useful when you want to record the knock locally before the round-trip lands.
metaobjectPass-through metadata (AI confidence score, transcript id, app version). Max 8 KB serialized.

Disposition → internal status mapping

dispositionInternal status on the returned row
Not Homenot_home
Not Interestednot_interested
Leadnew_lead
Appointmentset_appointment
Closeclosed_deal

Power users: pass status directly to access internal-only states (follow_up, renter, no_decision_maker, never_knock, failed_knock). If both status and disposition are present, status wins.

Response (201 Created)

JSON
{
  "data": {
    "knock": { /* full knock_pins row */ },
    "lead": { /* lead row, or null */ },
    "appointment": { /* appointment row, or null */ }
  },
  "meta": {
    "org_id": "f7a...",
    "created_at": "2026-05-13T19:30:00Z",
    "source": "api",
    "assignee_created": false,
    "address_verified": true,
    "rdi": "Residential",
    "idempotent_replay": false
  }
}

Examples

curl — minimum payload
curl -X POST "https://app.solarknock.com/api/v1/reporting/knocks" \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Idempotency-Key: 4f8c8a52-3e02-4a1f-91a3-c6e3b9f1b30c" \
  -H "Content-Type: application/json" \
  -d '{
    "address": "123 Main St, Brooklyn NY 11201",
    "latitude": 40.7128,
    "longitude": -74.0060,
    "disposition": "Not Home",
    "knock_timestamp": "2026-05-13T15:42:00Z",
    "user_email": "jose@sunrise-solar.com"
  }'
curl — full payload (knock + lead + appointment)
curl -X POST "https://app.solarknock.com/api/v1/reporting/knocks" \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Idempotency-Key: 9f3a..." \
  -H "Content-Type: application/json" \
  -d '{
    "address": "123 Main St, Brooklyn NY 11201",
    "latitude": 40.7128,
    "longitude": -74.0060,
    "disposition": "Appointment",
    "knock_timestamp": "2026-05-13T15:42:00Z",
    "user_email": "jose@sunrise-solar.com",
    "homeowner_name": "Jane Doe",
    "homeowner_phone": "+15551234567",
    "homeowner_email": "jane@example.com",
    "notes": "Strong interest. Currently paying $310/mo to Con Ed.",
    "electric_bill_amount": 310,
    "system_size_kw": 8.4,
    "roof_type": "asphalt",
    "actions_taken": ["price_estimate", "proposal_given"],
    "lead_actions_taken": ["price_estimate", "proposal_given", "qualified"],
    "appointment_datetime": "2026-05-15T18:30:00Z",
    "closer_id": "u_closer_abc",
    "meta": {
      "ai_transcript_id": "tr_abc123",
      "ai_confidence": 0.94
    }
  }'

Error codes (POST)

HTTPCodeWhen
400VALIDATION_FAILEDBad lat/lng, missing required field, malformed knock_timestamp.
400INVALID_STATUSUnknown disposition or status.
403WRITE_DISABLEDWrites not enabled for this org.
403WRITE_DISABLED_AUTOMATICAuto-disabled by fail-to-ban. Admin must re-enable.
404ASSIGNEE_NOT_FOUNDuser_id doesn't match a member of this org.
404CLOSER_NOT_FOUNDcloser_id doesn't match a member.
409DUPLICATE_KNOCKSame fingerprint (org + rep + date + status + address) already exists in last 24h. Override with ?force=true.
409IDEMPOTENCY_KEY_REUSEDSame Idempotency-Key with a different body.
413PAYLOAD_TOO_LARGEBody > 50 KB.
422NOT_A_CLOSERcloser_id exists but is not a closer.
429RATE_LIMITED30 writes/min/org exceeded — or one of the stacked per-IP limits. Honor Retry-After.
PATCH /api/v1/reporting/knocks/:id

Partial update of an existing knock you created. Use this when the AI re-classifies after a follow-up call ("Not Home" → "Lead"), or to attach late-arriving notes / a refined bill amount.

Mutable: status or disposition, notes, disposition_notes, homeowner_name, homeowner_phone, homeowner_email, electric_bill_amount, actions_taken (replaces the array — send the full set), follow_up_date, meta (deep-merged onto existing).

Immutable: id, org, created_by, created_at, knock_date, latitude, longitude, daily_knock_number, source. Anything not in the mutable list is silently ignored.

curl — flip a knock from Not Home → Lead
curl -X PATCH "https://app.solarknock.com/api/v1/reporting/knocks/01H8X8X8X8..." \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "disposition": "Lead",
    "notes": "Came back at 7pm. Husband is now interested. Asked us to set an appointment Thursday.",
    "follow_up_date": "2026-05-16"
  }'

Photo attachments — coming soon

POST /knocks/:id/photos (door / roof / utility-bill photo upload) is on the roadmap for v2. Until then, photo attachments are only supported on leads via the in-app UI.

Lead Lifecycle & Commission

After a deal is sold it moves through financing, permitting, install and payout — usually tracked in someone else's system. These endpoints let that system write progress back onto the SolarKnock lead, and record who gets paid what.

This is bookkeeping, not a workflow

There is no enforced order. You can set customer_installed while lender_approved is still blank, and nothing warns you or blocks the write. Milestones are a record of what happened, so a rep or an integration can post everything that has occurred since the last update in one call without waiting for a UI to unlock the next step.

Finding the lead

GET /api/v1/reporting/leads/by-address

Most external systems know an address, not a SolarKnock lead id. This resolves one to the other, and it is a dry run — same 200/404/409 envelope as a write, with no side effects. Use it to check your matching before you start writing.

Pass either address (free-form, we parse it) or street + optionally city / zip. Add match=loose to widen.

How matching resolves

SituationResult
Exactly one active lead at the address200 — resolved.
Several leads, but only one is still active200. Closed ones are counted in meta.superseded_leads, not hidden.
Two or more ACTIVE leads at the address409 MULTIPLE_ACTIVE_LEADS, listing the candidates. We will not guess. Mark the stale one lost in SolarKnock, then retry.
Loose match spanning genuinely different addresses (e.g. same street number and name in two towns)409 AMBIGUOUS_ADDRESS_MATCH with reason: loose_match_different_addresses. Supply city or zip to disambiguate.
No street number in the input404 with reason: no_street_number.
Nothing on that street404 with reason: no_bucket_match.
Only loose candidates exist, but you asked for exact404 with reason: only_loose_matches and loose_candidates_suppressed — never a silent miss.

Every 404 and 409 on a write is a no-op. We never apply part of a batch and then fail.

Reading milestones

GET /api/v1/reporting/leads/:leadId/milestones

Returns all seven milestones every time, whether or not they have been set. A milestone is done when it has an actual_date.

MilestoneMeaning
lending_application_finishedFinancing application submitted.
lender_approvedLender approved the customer.
project_in_queueProject accepted into the install queue.
equipment_orderedPanels/inverters ordered.
project_scheduledInstall date booked.
customer_installedSystem installed.
commission_paidThe date commission was paid out. This milestone records when; the amounts live on the commission endpoints below.

Writing milestones

PATCH /api/v1/reporting/leads/:leadId/milestones
PATCH /api/v1/reporting/leads/by-address/milestones

Send { "milestones": [ … ] }. Each entry takes milestone plus any of actual_date, target_date, comment, not_applicable, external_ref. Dates are YYYY-MM-DD. Omitted milestones are left alone — this is a patch, not a replace. Setting actual_date: null clears it.

A bad entry does not discard the good ones. Every entry comes back in meta.results with applied: true, or applied: false plus an error code (UNKNOWN_MILESTONE, INVALID_DATE). The response body is the full current state of all seven.

curl — post two milestones at once, by address
curl -X PATCH -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  https://app.solarknock.com/api/v1/reporting/leads/by-address/milestones \
  -d '{
    "street": "900 Writeback Way", "city": "Alphaville",
    "milestones": [
      { "milestone": "lender_approved",   "actual_date": "2026-07-20", "external_ref": "lender:CASE-1" },
      { "milestone": "project_scheduled", "target_date": "2026-09-15" }
    ]
  }'

Commission

Commission access is enabled per organization

Commission is redacted from /leads, and these endpoints return 403 COMMISSION_NOT_ENABLED, until commission access is enabled for your organization. There is no self-serve toggle for this one — email support@solarknock.com and we will enable it. Writing additionally requires API writes to be enabled (403 WRITE_DISABLED).

How commission is stored

Commission attaches to the lead, and it is per person — not one number per deal. Each person on a deal has up to two rows:

A row is identified by (lead, user, kind). Someone who is both the setter and the closer on a deal therefore has one projected row and one paid row, not two of each. The two kinds are independent — writing paid never disturbs projected.

The role field on a row (setter / closer) is a display label only. It is filled in when that person is the lead's setter or closer, and is null otherwise. It never affects which row is which.

More than two people on a deal

Any active member of your organization can hold a share — a second setter, a third rep, a manager override. They do not have to be the lead's setter or closer. The only requirement is org membership; anyone else is rejected with USER_NOT_IN_ORG.

Setters and closers already have empty rows created for them automatically when they are assigned to the lead, so for those two you are filling in numbers rather than creating rows.

Amounts are stored exactly as you send them

percent and amount are recorded verbatim and are not validated against each other or against anything else. Percentages are not required to add up to 100, amount is not derived from percent, and neither is checked against a deal total. Negative amounts are accepted, so clawbacks can be recorded. This is a ledger for numbers a human decides — SolarKnock does not compute commission.

Reading commission

GET /api/v1/reporting/leads/:leadId/commission
GET /api/v1/reporting/leads/by-address/commission

Returns every split on the lead plus totals.projected and totals.paid. The by-address form accepts the same parameters and follows the same resolution rules as /leads/by-address.

Your API key authenticates an organization, so nothing is hidden here — you get every person's amounts. (The SolarKnock mobile app masks other people's amounts from an individual rep; that is a UI behaviour and does not apply to this API.)

JSON — response
{
  "data": {
    "lead_id": "a1b2c3…",
    // Every split on the lead, both kinds, ordered by kind then name.
    "splits": [
      { "id": "2a1ff167…",
        "user_id": "u1…", "name": "Sam Kimball", "email": "sam@acme.com",
        "role": "setter", "kind": "paid",
        "percent": null, "amount": null, "notes": null,
        "updated_at": "2026-08-14 18:22:10.477737+00" },
      { "id": "3ec82ac9…",
        "user_id": "u1…", "name": "Sam Kimball", "email": "sam@acme.com",
        "role": "setter", "kind": "projected",
        "percent": 40, "amount": 1200, "notes": null,
        "updated_at": "2026-08-14 18:22:10.574623+00" },
      { "id": "1ae982ba…",
        "user_id": "u2…", "name": "Landon Davenport", "email": "landon@acme.com",
        "role": "closer", "kind": "projected",
        "percent": 60, "amount": 1800, "notes": null,
        "updated_at": "2026-08-14 18:22:11.001204+00" }
    ],
    // Sum of `amount` per kind. Nulls count as zero.
    "totals": { "projected": 3000, "paid": 0 }
  },
  "meta": {}
}

Writing commission

PATCH /api/v1/reporting/leads/:leadId/commission
PATCH /api/v1/reporting/leads/by-address/commission

Send { "splits": [ … ] }. Each entry needs a person and a kind, plus whichever values you are setting.

FieldTypeNotes
One of user_email or user_id:
user_emailstringMatched case-insensitively. Must be an active member of your org.
user_idUUIDSolarKnock user id. Must be an active member of your org.
kindenumRequired. projected or paid.
percentnumber or nullStored verbatim. Not validated.
amountnumber or nullStored verbatim. Negatives allowed (clawbacks).
notesstringMax 5000 chars.

Omitted fields are left as they were — send { "amount": 1100 } and an existing percent and notes survive. Send null to clear a value. You must supply at least one of percent, amount or notes, or the entry comes back NOTHING_TO_APPLY.

Partial batches apply. Each entry is reported in meta.results; a rejected entry never discards the accepted ones.

Per-entry errorCause
USER_NOT_IN_ORGThe email or id is not an active member of your organization.
MISSING_USERNeither user_email nor user_id was supplied.
INVALID_KINDkind was not projected or paid.
INVALID_NUMBERpercent or amount was not a number or null.
NOTHING_TO_APPLYNo percent, amount or notes in the entry.
curl — split one deal between a setter and a closer
curl -X PATCH -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  https://app.solarknock.com/api/v1/reporting/leads/a1b2c3…/commission \
  -d '{
    "splits": [
      { "user_email": "setter@acme.com", "kind": "projected", "percent": 40, "amount": 1200 },
      { "user_email": "closer@acme.com", "kind": "projected", "percent": 60, "amount": 1800 }
    ]
  }'
curl — record what was actually paid, and stamp the date
# 1. the amounts
curl -X PATCH -H "Authorization: Bearer sk_live_xxx" -H "Content-Type: application/json" \
  https://app.solarknock.com/api/v1/reporting/leads/by-address/commission \
  -d '{
    "street": "900 Writeback Way", "city": "Alphaville",
    "splits": [
      { "user_email": "setter@acme.com", "kind": "paid", "amount": 1150 },
      { "user_email": "closer@acme.com", "kind": "paid", "amount": 1800 },
      { "user_email": "manager@acme.com", "kind": "paid", "amount": 250, "notes": "override" }
    ]
  }'

# 2. the date it was paid — that lives on the commission_paid milestone
curl -X PATCH -H "Authorization: Bearer sk_live_xxx" -H "Content-Type: application/json" \
  https://app.solarknock.com/api/v1/reporting/leads/by-address/milestones \
  -d '{
    "street": "900 Writeback Way", "city": "Alphaville",
    "milestones": [ { "milestone": "commission_paid", "actual_date": "2026-08-14" } ]
  }'

Recipe: Full Warehouse Pull

Run once when you set up the integration. Pulls every row from every endpoint into your warehouse.

Node.js
const API_KEY = process.env.SOLARKNOCK_KEY;
const BASE = "https://app.solarknock.com/api/v1/reporting";

async function pullAll(endpoint) {
  let cursor = null, all = [];
  do {
    const url = `${BASE}/${endpoint}?limit=1000${cursor ? `&cursor=${cursor}` : ""}`;
    const resp = await fetch(url, { headers: { Authorization: `Bearer ${API_KEY}` }});
    const body = await resp.json();
    all.push(...body.data);
    cursor = body.pagination.next_cursor;
  } while (cursor);
  return all;
}

for (const ep of ["users", "knocks", "leads", "appointments", "daily-stats"]) {
  const rows = await pullAll(ep);
  // upsert into your warehouse / CSV / etc
  console.log(`${ep}: ${rows.length} rows`);
}

Recipe: Nightly Incremental Sync

Save the timestamp at which the job started, use it as the next run's since. The diff is small, the cursor pages through it, and you never miss a row even if updates land mid-sync.

⚠️ Three ways an incremental sync goes wrong

1. since is a change feed — upsert, never append. It selects on updated_at, so a knock from three weeks ago whose notes were edited yesterday legitimately reappears in tonight's pull. That is correct behavior. Key every write on the row's id and upsert; a consumer that appends will duplicate every edited row.

2. Bucket by knockDate, not by the pull date. Tonight's pull routinely contains rows from earlier days. Stamping them with the date you fetched them corrupts every daily rollup — see Dates & Time Zones.

3. Propagate deletes. Soft-deleted rows are hidden by default, so a naive sync leaves deleted knocks in your warehouse forever. On an incremental pull (since present) rows deleted after your watermark are returned so the tombstone reaches you — check deletedAt on every row and remove those from your warehouse. To backfill deletions that happened before your watermark, run a one-off full pull with include_deleted=true and no since, then reconcile by id — adding include_deleted=true to an incremental pull does not surface them, because the since filter still compares updatedAt against your watermark.

Python
import os, requests, datetime as dt
from pathlib import Path

API_KEY = os.environ["SOLARKNOCK_KEY"]
STATE   = Path("last_run.txt")
BASE    = "https://app.solarknock.com/api/v1/reporting"

since = STATE.read_text().strip() if STATE.exists() else "1970-01-01T00:00:00Z"
started = dt.datetime.utcnow().isoformat() + "Z"

def pull(endpoint):
    cursor = None
    while True:
        params = {"since": since, "limit": 1000}
        if cursor: params["cursor"] = cursor
        r = requests.get(f"{BASE}/{endpoint}", params=params,
                         headers={"Authorization": f"Bearer {API_KEY}"})
        body = r.json()
        yield from body["data"]
        cursor = body["pagination"]["next_cursor"]
        if not cursor: break

# since/until are only accepted on these three endpoints. /daily-stats has no
# updated_at filter (window it with date_from/date_to) and /users has none at all.
for endpoint in ["knocks", "leads", "appointments"]:
    for row in pull(endpoint):
        if row.get("deletedAt"):
            # delete(endpoint, row["id"]) — tombstone, drop it from the warehouse
            continue
        # upsert(endpoint, row) — keyed on row["id"], NEVER append
        # day-bucket knocks on row["knockDate"], never on today's date
        pass

STATE.write_text(started)  # save for next run

Recipe: Connect to Your BI Tool

Most BI tools (Looker, Metabase, Tableau, Hex, Mode) can hit a paginated REST endpoint directly, but they're happiest with a flat table they own. The pattern that scales:

  1. Stand up a small daily job (Airflow, GitHub Actions, cron) that runs the Python recipe above.
  2. Land the rows in Postgres / Snowflake / BigQuery / DuckDB / a parquet file in S3.
  3. Point your BI tool at the warehouse, not at this API directly.

This way your dashboards stay fast (no API round-trips per query), and you can join SolarKnock data with your own CRM, payroll, and financial data.

💬 Need help integrating?

Email support@solarknock.com. We'll help you scope out the warehouse setup and answer schema questions.