# API: a channel partner's lead becomes a SurgePV project automatically

Journey for requirements register rows **A15** (Luminous M-Partner pushes leads and auto-creates projects; Excite wants CRM integration), **A20** (Tata: direct lead-to-project, same need as A15), **D11** (ISS-8 / ISS-13 unscoped CRUD, which enterprise buyers will ask about), and ideas-triage **#8** (public API as the Luminous contract; "OpenAPI contract in one week, build after Luminous signs"). Row **A17** (Excite: grid dependency data and custom static pages in proposals) is not an integration need and is not covered here.

**Org used:** *Luminous Solar Partners* (Gurugram), a SurgePV organisation run by Luminous's design desk. Luminous's channel partners enter leads in M-Partner and Luminous's own designers do the designs, which is how the old tool worked. This is the aggregator option the brief allowed, picked over Suryatech Solar because the M-Partner lead (Jyoti Power Solutions, Lucknow, MP-48213, from the shared data sheet) has to land in the org that owns the M-Partner key. Admin: Ankit Verma. Designers in the round-robin pool: Kavya Rao, Rohan Mehta, Sneha Iyer. All names, phone numbers and ids are invented.

**Timeline:** setup on 22 Oct (key, mapping, webhook test). MP-48213 arrives 24 Oct 14:02 and becomes *Srivastava Residence, Indira Nagar*. On 25 Oct at 09:30 Ankit rotates the webhook signing secret. Two leads are held for review (09:41, 09:58). Kavya generates the proposal at 10:41 (8 × Waaree 580 Wp = 4.64 kWp). Three deliveries fail with 401 until Luminous updates its secret at 11:12. A manual retry delivers at 11:14.

## What the journey proves

1. The smallest thing that closes Luminous is one org-scoped API key, one `POST /v1/projects`, one `GET /v1/projects/{id}`, one webhook with two events, a delivery log and a mapping screen. No CRM.
2. A lead becomes a normal Project in its first status, `CUSTOMER_INQUIRY` (`design-studio-BE/core/constant.py`), assigned to a designer, with provenance shown and source fields locked. The designer starts designing and never retypes anything (API04).
3. Bad leads are held, not created. A lead with an address that does not geocode, or a mobile number that matches an open project, gets a `202 needs_review` and waits for an Admin (API08). Address and coordinates are required: `address_line_1`, `latitude` and `longitude` are non-null on `projects/models/project.py`.
4. Failure is visible and recoverable. A 401 after a secret rotation is logged with request, response and cause, and the retry re-sends the same event id (API06).
5. The docs page is the contract Luminous signs against (API07). Excite and Tata can use the same contract.

## Screens

| ID | Screen | State |
|---|---|---|
| API01 | Settings > Integrations | Empty / first run. Only API keys, Webhooks and M-Partner are Available; Zoho, HubSpot and Salesforce are Planned |
| API02 | Create API key | Happy path. Key shown once with copy button and warning; hashed storage listed |
| API03 | M-Partner field mapping | Happy path. 7 lead fields mapped to Project fields; defaults; hold rules |
| API04 | Project created from MP-48213 | Happy path, designer view. Provenance banner, locked source fields, next step |
| API05 | Webhook settings | Happy path. 2 events, signing secret, test event with sample payload, 200 OK |
| API06 | Delivery log | Error + retry: 401 after secret rotation, manual retry 200; loading: a retry still sending |
| API07 | Public developer docs (`.site`) | The contract. Auth, rate limits, both endpoints, webhook, errors |
| API08 | Needs review | Error / blocked: duplicate mobile and an address that does not geocode, held instead of created. Admin-only actions |
| API09 | M-Partner app on a phone | Phone variant, illustration of Luminous's app reacting to SurgePV's webhooks (SurgePV does not build it) |

The brief asked for 7 to 8 screens. API09 is the extra one: CONTRACT.md requires a phone variant, and the person on a phone in this journey is the channel partner, not anyone inside SurgePV.

## The contract (v1, OpenAPI-style summary)

Base URL `https://api.surgepv.com/v1` (sandbox `https://sandbox-api.surgepv.com/v1`, `spv_test_` keys). Auth: `Authorization: Bearer spv_live_…`. Rate limit 60 requests/min and 5,000/day per key, 64 KB bodies.

| Operation | Scope | Request | Responses | Notes |
|---|---|---|---|---|
| `POST /v1/projects` (createProjectFromLead) | `projects:create` | Headers: `Idempotency-Key` (use `external_id`). Body: `external_id`* string ≤ 64; `customer.name`* ; `customer.mobile`* E.164; `customer.email`; `site.address_line_1`*, `site.city`*, `site.state`*, `site.pincode`*; `site.latitude`, `site.longitude`; `sanctioned_load_kw` 0 to 1000; `monthly_bill_inr`; `partner.id`*, `partner.name`* | `201` project `{id, external_id, status: "customer_inquiry", name, owner, design_profile, location{latitude, longitude, source}, created_at}`. `200` same body if the same `external_id` and body are sent again. `202` `{status: "needs_review", review_id, external_id, reasons[{code, field}]}`. `400 validation_failed`, `401 invalid_key`, `403 missing_scope`, `409 external_id_conflict`, `413`, `429 rate_limited` + `Retry-After` | Creates a Project in the key's organisation only. Status Customer Inquiry; owner by round-robin from that organisation's designer pool; design profile from the mapping. Held if: address does not geocode to one point (`address_not_geocoded`), mobile matches an open project (`duplicate_mobile`), load over 100 kW (`load_over_limit`) |
| `GET /v1/projects/{id}` and `GET /v1/projects?external_id=` (getProject) | `proposals:read` | Path `id` or query `external_id` | `200` `{id, external_id, status, owner, proposal{ready, system_kwp, url, url_expires_at}}`. `404 not_found` for missing ids **and** ids that belong to another organisation or were not created by this key | For reconciliation when a webhook is missed. Not the main path |
| Webhook `POST {partner URL}` | n/a (outbound) | Headers `X-SurgePV-Event`, `X-SurgePV-Delivery`, `X-SurgePV-Signature: t=…,v1=HMAC-SHA256(secret, t + "." + raw body)`. Body `{id, type, created_at, data{project_id, external_id, partner_id, status, system_kwp, proposal_url, proposal_url_expires_at}}` | Partner answers `2xx` within 10 s | Events: `proposal.ready`, `project.status_changed` (`from`, `to`). Only projects created by the integration's key. Retries after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h (7 attempts), then failed and Admins emailed. Same event `id` on every retry |

\* required. Versioning rule written on API07: v1 gets additive changes only; breaking changes ship as v2 with 6 months' notice.

Mapping to existing model fields: `customer_name`, `customer_mobile_number`, `customer_email_id`, `address_line_1`, `city`, `state`, `pincode`, `latitude`, `longitude` on `projects.Project`; `sanction_load_kw` on `recommender/models/recommendation.py`; `average_monthly_cost` on `master_data.ProjectConsumptionDetail`. New: `Project.external_id`, `Project.source_partner`, `Project.created_by_api_key`.

## Needs backend work

Nothing here exists in `design-studio-BE` today. There is no API key model, no webhook model or sender, no integration settings and no `external_id` on Project, and `core/throttles.py` has only per-user and per-IP throttles.

- **ApiKey** model: organisation FK, name, prefix, last4, SHA-256 hash, scopes, expires_at, revoked_at, created_by. A separate DRF authentication class used only by the `/v1/` views (API02).
- **IntegrationMapping** per organisation: field map, default status, designer pool with a round-robin cursor, default DesignDefault, hold rules. Plus geocoding to fill latitude and longitude (API03).
- **Project fields** `external_id` (unique per organisation), `source_partner`, `created_by_api_key`, per-field lock with an Admin unlock, and activity entries (API04).
- **WebhookEndpoint** + emission on proposal generation and status change + a Celery delivery task with HMAC signing and retries (API05).
- **WebhookDelivery** and **ApiRequestLog**, org-scoped, 30-day retention, manual retry (API06).
- **OpenAPI file** for `/v1` tested against the serializers, a per-key throttle, and Idempotency-Key handling (API07).
- **LeadReview** model with a duplicate-mobile check and attach / create / reject actions (API08).

## Security notes (ISS-8, ISS-13 and why keys must be org-scoped)

- **ISS-8** (`CountryViewSet` with no permission override, so any signed-in user can change `conversion_factor` / `tax_rate`, and deleting a Country cascades to loans) and **ISS-13** (`DefaultApplianceViewSet` and `ElectricCarViewSet` are full CRUD over global reference data for any signed-in user) show that today, "authenticated" alone often means "can write shared data". If an API key were just another way to sign in as a user, every key would inherit those holes. A partner's integration bug, or a leaked key, could rewrite tax rates for every organisation.
- So a key is **not a user**. It is its own principal, bound to one organisation and to explicit scopes. Its authentication class is attached only to the `/v1/` views and is never added to `DEFAULT_AUTHENTICATION_CLASSES`, so the key cannot reach `master_data`, settings, users or equipment routes at all, whether or not ISS-8/ISS-13 are fixed first. API02 says this on screen: "Keys never reach settings or shared reference data."
- Every `/v1/` query is scoped to the key's organisation (reuse `core/scoping.py`, `OrgScopedViewSetMixin`). Another organisation's id answers 404 like a missing one, the rule already used for ISS-135 and ISS-89.
- **ISS-89** (project serializers accepting a foreign `assignee_id` / `project_status`) matters for round-robin: the designer pool must be checked against the key's organisation at mapping save time and again at assignment.
- Keys are shown once and stored as a SHA-256 hash with prefix and last 4 for display. Expiry is required (12 months by default). Revocation is immediate. The request log records the key prefix and never the full key; the existing telemetry redaction in `telemetry/services/writer.py` already masks `api_key` / `token` patterns and should cover the new headers.
- Webhooks only carry projects created by the integration's key. Payloads hold no customer phone or address, only ids, status, size and an expiring share link. The `202 needs_review` reply never includes another partner's data (API08).
- D11 still stands: fix ISS-8 and ISS-13 anyway. Enterprise buyers will read the API docs and ask about tenant isolation.

## Open questions

1. **Credit between partners.** When two partners send the same customer (API08), who keeps the lead? This is Luminous's rule. SurgePV should store both partner ids and not decide.
2. **Resolution back to M-Partner.** When an Admin attaches, creates or rejects a held lead, should a `lead.resolved` webhook fire, or is `GET ?external_id=` enough? Adding it later is an additive change.
3. **Secret rotation overlap.** API06 is the cost of instant rotation. Should we sign with both the old and new secret for 24 hours after a rotation?
4. **Geocoding provider and cost.** Which geocoder, and is "one match" enough, or should the designer confirm the roof pin before the project counts as ready?
5. **Licences.** Does an API-created project count against the org's licences or project quota, and does a held lead?
6. **Designer pool.** Round-robin, or least-open-projects? What happens when a designer is on leave?
7. **Field lock.** Can a designer correct a partner's typo (e.g. the address) without an Admin? If so, do we tell M-Partner (and which event)?
8. **MCP server** (ideas-triage #8, "design with your own AI"). Out of scope for the Luminous close. Should the same keys and scopes be reused later?
9. **Excite and Tata.** Same contract, or does Excite's CRM need (A15) push us to build the Zoho/HubSpot connectors marked Planned on API01?
10. **Data retention.** 30 days of request and response bodies includes customer names and phone numbers. Is that acceptable under DPDP, or should bodies be redacted after 7 days?

## Screenshot note

`chromium --headless=new --window-size=1440,900` gives an 813 px viewport on this machine, which leaves a blank strip at the bottom of the PNG. Shots were taken with the window 87 px taller than the manifest height and then cropped to exactly 1440 × `h` (390 × 844 for the phone), so the PNG sizes match the contract.
