Partner API · v1.0 · 25 Oct 2026

Send a lead, get a project. Get told when the proposal is ready.

Two endpoints and one webhook. Built for partner apps like Luminous M-Partner that collect leads in the field and want a designer to take it from there.

This is the contract we sign against.

Version 1.0 is attached to partner order forms as a schedule. Required fields stay required. v1 only gets additive changes (new optional fields, new event types). Anything that breaks a v1 client ships as v2 with 6 months' notice.

Authentication

Send Authorization: Bearer <key> on every call. An Admin creates keys in Settings › Integrations › API keys. The key is shown once; SurgePV stores only a hash.

A key belongs to one organisation and acts as that organisation, not as a user. It can call the endpoints on this page and nothing else: no settings, equipment, users or shared reference data. Base URL https://api.surgepv.com/v1, HTTPS only. Sandbox: https://sandbox-api.surgepv.com/v1 with spv_test_ keys.

ScopeAllows
projects:createPOST /v1/projects
proposals:readGET /v1/projects/{id} and ?external_id=

Rate limits

60 requests per minute and 5,000 per day, per key. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. Over the limit you get 429 with Retry-After in seconds. Bodies up to 64 KB.

POST/v1/projects

Create a project from a lead

Creates a project in status Customer Inquiry, assigns the next designer by round-robin and applies the design profile set in the mapping. If the address does not geocode, the mobile matches an open project, or the load is over 100 kW, the lead is held for review and no project is made.

Request
POST https://api.surgepv.com/v1/projects
Authorization: Bearer spv_live_7Kq2…9f2c
Idempotency-Key: MP-48213
Content-Type: application/json

{
  "external_id": "MP-48213",
  "customer": {
    "name": "Anil Srivastava",
    "mobile": "+919415031827",
    "email": null
  },
  "site": {
    "address_line_1": "B-14, Sector 21, Indira Nagar",
    "city": "Lucknow",
    "state": "Uttar Pradesh",
    "pincode": "226016",
    "latitude": null, "longitude": null
  },
  "sanctioned_load_kw": 5,
  "monthly_bill_inr": 3950,
  "partner": { "id": "LUM-UP-0417",
                "name": "Jyoti Power Solutions" }
}
201 Created
HTTP/1.1 201 Created
Location: /v1/projects/prj_8f31c2

{
  "id": "prj_8f31c2",
  "external_id": "MP-48213",
  "status": "customer_inquiry",
  "name": "Srivastava Residence, Indira Nagar",
  "owner": "Kavya Rao",
  "design_profile": "Residential IN",
  "location": { "latitude": 26.8826, "longitude": 80.9995,
                "source": "geocoded" },
  "created_at": "2026-10-24T14:02:11+05:30"
}

Same external_id again, same body → 200 OK, same project.
Same external_id, different body → 409 external_id_conflict.
202 Accepted, held for review
HTTP/1.1 202 Accepted

{
  "status": "needs_review",
  "review_id": "rev_5Hc2",
  "external_id": "MP-48297",
  "reasons": [ { "code": "address_not_geocoded",
      "field": "site" } ]
}
FieldTypeRequiredNotes
external_idstring, ≤ 64YesYour lead id. Unique per organisation. Makes retries safe.
customer.namestring, ≤ 255YesBecomes customer_name.
customer.mobileE.164 stringYes+91 assumed if 10 digits. Matched against open projects.
customer.emailstringNoBecomes customer_email_id.
site.address_line_1, city, state, pincodestringsYesGeocoded. If it does not resolve to one point, the lead is held (202).
site.latitude, site.longitudedecimalNoIf sent, used instead of geocoding.
sanctioned_load_kwnumber, 0–1000NoAbove 100 kW the lead is held for review.
monthly_bill_inrnumberNoFills average monthly bill on Energy.
partner.id, partner.namestringsYesShown on the project and echoed in webhooks.
GET/v1/projects/{id}

Get a project and its latest proposal

Use it to catch up if you missed a webhook. Only returns projects your key created. proposal.ready is false until a designer generates one.

GET https://api.surgepv.com/v1/projects/prj_8f31c2
or  /v1/projects?external_id=MP-48213
Authorization: Bearer spv_live_7Kq2…9f2c

HTTP/1.1 200 OK
{
  "id": "prj_8f31c2",
  "external_id": "MP-48213",
  "status": "proposal_shared",
  "owner": "Kavya Rao",
  "proposal": {
    "ready": true,
    "system_kwp": 4.64,
    "url": "https://app.surgepv.com/share/p/…",
    "url_expires_at": "2026-11-24T10:41:30+05:30"
  }
}
Polling is fine for reconciliation, not as the main path. Once a day per open lead is plenty; the webhook is the main path.
WEBHOOKPOST to your URL

Webhooks

Set one endpoint per organisation in Settings › Integrations › Webhooks. Only projects your key created are sent. Answer 2xx within 10 seconds; anything else is retried after 1 min, 5 min, 30 min, 2 h, 6 h and 12 h, then marked failed. The same event keeps the same id on every retry, so drop ones you have seen.

EventWhen
proposal.readyA designer generates a proposal. Carries system size and a 30-day share link.
project.status_changedStatus moves, with from and to (customer_inquiry, engineering_design, proposal_shared … lost).
POST {your endpoint}
X-SurgePV-Event: proposal.ready
X-SurgePV-Delivery: dlv_01JB8R4C2N
X-SurgePV-Signature: t=1792905090,v1=5d1c…77aa

{
  "id": "evt_01JB8Q2K7M",
  "type": "proposal.ready",
  "created_at": "2026-10-25T10:41:30+05:30",
  "data": {
    "project_id": "prj_8f31c2",
    "external_id": "MP-48213",
    "partner_id": "LUM-UP-0417",
    "status": "proposal_shared",
    "system_kwp": 4.64,
    "proposal_url": "https://app.surgepv.com/share/p/…",
    "proposal_url_expires_at": "2026-11-24T10:41:30+05:30"
  }
}
# Python: check the signature before trusting the body
t, v1 = parse(header)            # t=..., v1=...
expected = hmac_sha256(secret, f"{t}.{raw_body}")
ok = compare_digest(expected, v1) and abs(now() - int(t)) < 300

Signature: HMAC-SHA256 with your signing secret over t + "." + raw body. Reject if older than 5 minutes.

Errors

Errors are JSON: {"error": "<code>", "message": "…", "fields": {…}}.

HTTPerrorMeaning
400validation_failedA required field is missing or malformed. Body lists each field.
401invalid_keyKey missing, wrong, expired or revoked.
403missing_scopeKey lacks the scope, e.g. proposals:read for GET.
404not_foundNo such project in your organisation. Another organisation's id answers the same way.
409external_id_conflictexternal_id already used with a different body.
413payload_too_largeBody over 64 KB.
429rate_limitedOver the limit. Wait for Retry-After seconds.
5xxserver_errorSafe to retry with the same Idempotency-Key.

Changelog

1.0 · 25 Oct 2026. First version: POST /v1/projects, GET /v1/projects/{id}, webhooks proposal.ready and project.status_changed.

Demo data