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

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 Header
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.

⚠️ Treat your key like a password

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.

ParameterTypeDefaultDescription
sinceISO 8601Return rows where updated_at > since. Use this for incremental sync.
untilISO 8601Upper bound on updated_at. Use to make a sync window deterministic.
cursorstringOpaque pagination cursor returned by the previous page.
limitinteger500Page size. Maximum 1000.
include_deletedbooleanfalseInclude soft-deleted rows.

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) }.

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:

JSON
{
  "error": {
    "code": "INVALID_KEY",
    "message": "API key not recognized or expired."
  }
}
HTTPCodeWhen it happens
401UNAUTHENTICATEDMissing Authorization header.
401INVALID_KEYHeader present but no organization matches.
402SUBSCRIPTION_EXPIREDYour subscription is past due. Update payment to restore access.
400INVALID_CURSORCursor doesn't decode or is from a different endpoint.
400INVALID_PARAMBad date format, unknown enum value, etc.
429RATE_LIMITEDYou exceeded your rate limit — see headers.
500INTERNAL_ERRORSomething 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.

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

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_BHMGR07" \
  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, 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 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:

curl — incremental pull
curl -H "Authorization: Bearer sk_BHMGR07" \
  "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 — 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:

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.

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

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:

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_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).

⚠️ 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.)

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.

find an org by name (verify before flipping)
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.

enable writes for a specific org id
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.

📝 What to record after enabling

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

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_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 — full payload (knock + lead + appointment)
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)

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.
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_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.

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.

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

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:

  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.