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
Your manager invite code is your API key. Find it in the app under Profile → Manage Members → Manager Code. Send it as a Bearer token on every request:
Authorization: Bearer sk_BHMGR07
The sk_ prefix is optional — bare codes work too. Each key is scoped to exactly one organization; there is no ?orgId= parameter on any endpoint, ever.
Anyone with your manager code can read every knock, lead, and homeowner contact in your organization. Never embed it in client-side code. Rotate it from the app at any time — old keys stop working immediately.
Making Requests
Every list endpoint accepts the same five standard parameters. Endpoint-specific filters (like status on leads) are documented per endpoint.
| Parameter | Type | Default | Description |
|---|---|---|---|
since | ISO 8601 | — | Return rows where updated_at > since. Use this for incremental sync. |
until | ISO 8601 | — | Upper bound on updated_at. Use to make a sync window deterministic. |
cursor | string | — | Opaque pagination cursor returned by the previous page. |
limit | integer | 500 | Page size. Maximum 1000. |
include_deleted | boolean | false | Include soft-deleted rows. |
Response envelope
Every list response shares the same shape:
{
"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) }.
Pagination & Incremental Sync
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:
{
"error": {
"code": "INVALID_KEY",
"message": "API key not recognized or expired."
}
}
| HTTP | Code | When it happens |
|---|---|---|
401 | UNAUTHENTICATED | Missing Authorization header. |
401 | INVALID_KEY | Header present but no organization matches. |
402 | SUBSCRIPTION_EXPIRED | Your subscription is past due. Update payment to restore access. |
400 | INVALID_CURSOR | Cursor doesn't decode or is from a different endpoint. |
400 | INVALID_PARAM | Bad date format, unknown enum value, etc. |
429 | RATE_LIMITED | You exceeded your rate limit — see headers. |
500 | INTERNAL_ERROR | Something broke on our end. Retry with backoff. |
Rate Limits
The reporting API is rate-limited per organization, not per IP — your integration can run from anywhere.
- Default: 60 requests per minute
- Burst: Up to 120 in any 60-second window
- Every response includes
X-RateLimit-Limit,X-RateLimit-Remaining, andX-RateLimit-Resetheaders
At limit=1000, the default of 60 req/min lets you pull 60,000 rows/minute — more than enough for any nightly sync we've seen.
Endpoints
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 -H "Authorization: Bearer sk_BHMGR07" \ https://app.solarknock.com/api/v1/reporting/org
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, status, joined_at, default_org_id.
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 actions_taken — 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:
user_id=<uuid>— only this rep's knocksstatus=<value>— e.g.?status=appointmentknock_date_from=YYYY-MM-DD&knock_date_to=YYYY-MM-DD— calendar-day range against the knock date itselfcounty_fips=<5-digit>— pins inside one US county
curl -H "Authorization: Bearer sk_BHMGR07" \ "https://app.solarknock.com/api/v1/reporting/knocks?since=2026-04-30T00:00:00Z&limit=1000"
Every lead in your organization — name, contact info, monthly bill, roof type, interest level, status, score, notes, system-size estimate, savings projection, and attachments. Each row includes actions_taken — 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:
status=<value>—new,contacted,appointment,won,lost, etc.user_id=<uuid>— only this rep's leadscreated_from=YYYY-MM-DD&created_to=YYYY-MM-DD— calendar-day range against creation
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. Rotating the manager code revokes every URL at once.
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:
closer_id=<uuid>/setter_id=<uuid>status=<value>—pending,completed,cancelled, etc.date_from=YYYY-MM-DD&date_to=YYYY-MM-DD— appointment's own date
The raw rows that power your leaderboard. One row per (user_id, date) with knock count, leads generated, appointments set, deals closed, hours worked, and the timestamps of each individual knock that day.
Endpoint-specific filters:
user_id=<uuid>date_from=YYYY-MM-DD&date_to=YYYY-MM-DD
One row per (user, day) where the user opened the SolarKnock app. Useful for "active reps" calculations and attendance analytics.
Every US county your organization has knocked in, with renter-data availability status and a count of knocks per county.
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.
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 -H "Authorization: Bearer sk_BHMGR07" \ -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).
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.)
Enabling writes (operator / agent)
Until the Profile UI panel ships, write access is flipped via a one-line SQL update against the production database. Any agent or ops engineer with prod access can run this. Source of truth is the organizations.reporting_api_write_enabled column.
ssh root@143.110.232.40 "cd /var/www/solarknock && \ set -a; source .env; set +a; \ psql \\\"\$DATABASE_URL\\\" -c \\\"\ SELECT id, name, manager_code, reporting_api_enabled, reporting_api_write_enabled \ FROM organizations \ WHERE name ILIKE '%customer-name%' \ ORDER BY created_at;\\\""
Look at the rows: if there are duplicates (e.g. Foo Solar, Foo Solar (Old), Foo Solar (JD)), the bare name without a parenthetical suffix is the live customer org. The (Old) and (JD) variants are usually legacy or test accounts — do not enable those.
ssh root@143.110.232.40 "cd /var/www/solarknock && \ set -a; source .env; set +a; \ psql \\\"\$DATABASE_URL\\\" -c \\\"\ UPDATE organizations \ SET reporting_api_write_enabled = true \ WHERE id = '<org-uuid>' \ RETURNING id, name, manager_code, reporting_api_write_enabled;\\\""
Effect is immediate — the next API request from that org's manager_code will be accepted. To disable: same statement with SET reporting_api_write_enabled = false. There is no other side effect; the org keeps its read access either way.
Add a short note to ~/.claude/projects/-home-readystack/memory/ (project memory file) listing which orgs are write-enabled so future agents know without having to query the DB. Format: org_name (uuid) — write-enabled YYYY-MM-DD by <your-name> for <reason>.
Auth, idempotency, rate limit
- Auth: same Bearer header as reads.
- Idempotency: set
Idempotency-Key: <uuid>on every write. We store the key for 24h. Same key + same body → original 201 returned withmeta.idempotent_replay: true. Same key + different body →409 IDEMPOTENCY_KEY_REUSED. - Rate limit (writes): 30 requests / minute / org (separate budget from the 60/min read limit).
- Body size cap: 50 KB. Larger requests get
413 PAYLOAD_TOO_LARGE. - Address verification: server canonicalizes your
addressvia the USPS-grade Smarty service. Lat/lng on the resulting knock is the verified canonical lat/lng (often a few meters more accurate than client GPS). County FIPS is stamped automatically. - Auto-disable: if your integration produces ≥50 4xx responses in any rolling hour (or other suspicious patterns), the API auto-disables writes for your org. An admin must re-enable from the Profile panel after fixing the integration.
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
| Field | Type | Notes |
|---|---|---|
address | string | Free-form. Server canonicalizes via Smarty. |
latitude | number | -90..90. |
longitude | number | -180..180. |
disposition | enum | One of: Not Home, Not Interested, Lead, Appointment, Close. Maps to internal status (see below). |
knock_timestamp | ISO 8601 | When 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_id | UUID | SolarKnock user id of the rep who logged it. Must be a member of your org. |
user_email | string | Rep'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
| Field | Type | Notes |
|---|---|---|
homeowner_name | string | Max 200 chars. |
homeowner_phone | string | Free-form, no normalization. |
homeowner_email | string | — |
notes | string | AI transcription summary or rep notes. Max 5000. |
disposition_notes | string | Separate from notes. Max 5000. |
electric_bill_amount | number | Routes to knock and to the cascaded lead's monthly_bill. |
system_size_kw | number | Cascades to lead's estimated_system_size. |
roof_type | string | Cascades to lead. |
actions_taken | string[] | What the rep did at the door, independent of disposition. Knock actions: price_estimate, proposal_given. Unknown values are rejected. |
lead_actions_taken | string[] | 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_date | YYYY-MM-DD | — |
appointment_datetime | ISO 8601 | When disposition: "Appointment" and you also pass closer_id, an appointment row is created at this date/time. |
closer_id | UUID | Required to create an appointment. Must be an active org member with is_closer = true. |
id | UUID | Optional client-supplied id. Useful when you want to record the knock locally before the round-trip lands. |
meta | object | Pass-through metadata (AI confidence score, transcript id, app version). Max 8 KB serialized. |
Disposition → internal status mapping
disposition | Internal status on the returned row |
|---|---|
Not Home | not_home |
Not Interested | not_interested |
Lead | new_lead |
Appointment | set_appointment |
Close | closed_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)
{
"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 -X POST "https://app.solarknock.com/api/v1/reporting/knocks" \ -H "Authorization: Bearer sk_BHMGR07" \ -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 -X POST "https://app.solarknock.com/api/v1/reporting/knocks" \ -H "Authorization: Bearer sk_BHMGR07" \ -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)
| HTTP | Code | When |
|---|---|---|
400 | VALIDATION_FAILED | Bad lat/lng, missing required field, malformed knock_timestamp. |
400 | INVALID_STATUS | Unknown disposition or status. |
403 | WRITE_DISABLED | Writes not enabled for this org. |
403 | WRITE_DISABLED_AUTOMATIC | Auto-disabled by fail-to-ban. Admin must re-enable. |
404 | ASSIGNEE_NOT_FOUND | user_id doesn't match a member of this org. |
404 | CLOSER_NOT_FOUND | closer_id doesn't match a member. |
409 | DUPLICATE_KNOCK | Same fingerprint (org + rep + date + status + address) already exists in last 24h. Override with ?force=true. |
409 | IDEMPOTENCY_KEY_REUSED | Same Idempotency-Key with a different body. |
413 | PAYLOAD_TOO_LARGE | Body > 50 KB. |
422 | NOT_A_CLOSER | closer_id exists but is not a closer. |
429 | RATE_LIMITED | 30 writes/min/org exceeded. |
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 -X PATCH "https://app.solarknock.com/api/v1/reporting/knocks/01H8X8X8X8..." \ -H "Authorization: Bearer sk_BHMGR07" \ -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.
Recipe: Full Warehouse Pull
Run once when you set up the integration. Pulls every row from every endpoint into your warehouse.
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.
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 for endpoint in ["knocks", "leads", "appointments", "daily-stats"]: for row in pull(endpoint): # upsert(endpoint, row) 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:
- Stand up a small daily job (Airflow, GitHub Actions, cron) that runs the Python recipe above.
- Land the rows in Postgres / Snowflake / BigQuery / DuckDB / a parquet file in S3.
- 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.
Email support@solarknock.com. We'll help you scope out the warehouse setup and answer schema questions.