# Partner API — Publisher Lead Integration

Machine-readable documentation for approved publishers and partners.

This document describes the **only** inbound publisher lead endpoint that
exists in this project: `POST /api/public/partner-lead`. The partner-facing
name is always **"Partner API"**. No response, header, log line, or audit row
from this endpoint ever contains a client/contractor name, an internal record
id, SQL text, an upstream provider message, or any brand other than "Partner
API".

> **Status:** Live inbound posting is **disabled by default**. A live post
> returns `403 publisher_not_active` until an administrator explicitly
> activates the publisher. While disabled, only `test=1` submissions are
> accepted, and an accepted test submission writes nothing but a privacy-safe
> audit row — no canonical lead, no client delivery, no billing, no CAPI, no
> notification. The separate endpoint `POST /api/public/lead-ingress` is **not**
> a publisher ingestion endpoint and is not documented here.

## Endpoint

| Method | Path                       | Purpose                          |
|--------|----------------------------|----------------------------------|
| POST   | `/api/public/partner-lead` | Submit a roofing lead            |
| OPTIONS| `/api/public/partner-lead` | CORS preflight (returns `204`)   |
| GET    | `/api/public/partner-lead` | Not allowed (returns `405`)      |

Requests are JSON (`Content-Type: application/json`). Responses are JSON with
`Cache-Control: no-store`.

## Authentication

Each approved publisher receives its own inbound key. The key is sent on every
request and is compared only as a **SHA-256 hash** in constant time — it is
never logged, echoed, or stored in plaintext. Only the hash is kept in the
publisher's configuration.

Send the key in either of two places (header is preferred):

- Header: `x-publisher-key: <your-key>`
- Body field: `publisher_key: "<your-key>"`

A missing or unrecognized key returns `401 bad_key`.

## Request fields

### Required fields

| Field                  | Type   | Rules                                                        |
|------------------------|--------|--------------------------------------------------------------|
| `first_name`           | string | 2–80 chars, must contain a letter                            |
| `last_name`            | string | optional, max 80 chars                                       |
| `phone`                | string | US/NANP, normalized to E.164 (`+1` + 10 digits)              |
| `email`                | string | valid email, max 254 chars                                   |
| `zip_code` (or `zip`)  | string | exactly 5 US digits                                          |
| `trustedform_cert_url` | string | `https://cert.trustedform.com/<40 hex>/`                     |
| `tcpa_text` (or `consent_text`) | string | min 20 chars — the consent language shown to the consumer |
| `ip_address`           | string | valid IPv4 or IPv6                                           |
| `home_owner`           | string | must be the string `Yes`                                     |
| `test`                 | flag   | `1` / `true` / `yes` for a test post                         |

### Optional pass-through fields

All optional fields accept **any string** and are retained verbatim through
the canonical lead and the final delivery. Wording never widens the pilot to
another service. A value longer than its limit returns `422 invalid_lead`
naming that field (values are never silently clipped).

| Field                | Max chars |
|----------------------|-----------|
| `project_type`       | 200       |
| `property_type`      | 200       |
| `roof_age`           | 200       |
| `time_frame`         | 200       |
| `contractor_factor`  | 200       |
| `roof_material`      | 200       |
| `stories`            | 200       |
| `home_size`          | 200       |
| `roof_condition`     | 200       |
| `roof_style`         | 200       |
| `address`            | 200       |
| `city`               | 120       |
| `state`              | 120       |
| `user_agent`         | 512       |
| `landing_page_url`   | 512       |
| `lead_submitted_at`  | 64        |
| `utm_source`         | 200       |
| `utm_medium`         | 200       |
| `utm_campaign`       | 200       |
| `utm_content`        | 200       |
| `utm_term`           | 200       |
| `utm_id`             | 200       |

`project_type` is **free-form publisher copy** (e.g. `Replace`, `Repair`,
`Install`, `Unsure`). The pilot service (roofing) is fixed by the approved
publisher's service configuration and is never inferred from this wording.

## Service

The only supported service is `roofing`. A publisher whose service
configuration does not include roofing is refused with `403 publisher_not_active`.

## Test vs. live mode

- `test=1` posts are **audit-only**: no canonical lead, no delivery, no billing,
  no CAPI, no notification, no cap consumption. Test posts are not sent to the
  phone-validation provider. Duplicate test identities within 30 days still
  return `409 duplicate`.
- Live posts (no `test` flag, on an activated publisher) run the full gate
  described below. While the publisher is not activated, a live post returns
  `403 publisher_not_active`.

## Live acceptance gate (activated publishers only)

A live accepted post runs, in order:

1. **Key + publisher check** — recognized key, publisher serves roofing.
2. **Field validation** — required and length rules above.
3. **Administrator qualifier rules** — optional per-publisher refusals
   (e.g. reject `own_or_rent = Rent`) evaluated before any cost is incurred.
   No rules configured = no-op.
4. **Real-time coverage check** — the submitted ZIP must be in an
   "accepting" coverage state.
5. **Phone validation** — the same Twilio Lookup v2 policy the live funnels
   use. Invalid / inactive / toll-free / VoIP-app numbers are rejected; an
   "Unreachable" number is a temporary hold; missing credentials, a provider
   error, or a timeout **fail closed** (`503 unavailable`). Only bounded
   evidence (line status, line type, carrier, checked-at) is stored — never a
   raw provider payload.
6. **Atomic accept** — inside one database transaction: a cross-source 30-day
   duplicate check, the live daily cap, the canonical lead insert, and the
   linked receipt. Either the caller gets `accepted` with a real lead, or
   nothing is written.

## Idempotency / duplicates

A 30-day duplicate check is scoped to the phone and email hashes of the
submission. A duplicate within the window returns `409 duplicate` and creates
nothing. A held ("Unreachable") phone is never stored, so a later post is
looked up afresh.

## Rate limiting

A distributed rate limit of **60 requests per 60 seconds** is keyed by the
hashed caller IP. Exceeding it returns `429 rate_limited`. The whole request
is bounded by a **10-second** server budget; a timeout fails closed with
`503 unavailable`.

## Responses

### Accepted (test or live)

```json
{
  "ok": true,
  "accepted": true,
  "lead_id": "<opaque receipt id>",
  "test": true
}
```

`lead_id` is an opaque receipt id, not an internal record id. `test` is `true`
for test posts and `false` for live posts.

### Errors

All error bodies share this shape:

```json
{ "ok": false, "error": "<code>", "fields": ["<field>", "..."] }
```

`fields` is present only for `invalid_lead` and lists the offending field
names (sorted, capped at 24). No field ever contains a submitted value.

| Status | `error`                | Meaning                                              |
|--------|------------------------|------------------------------------------------------|
| 401    | `bad_key`              | Missing or unrecognized publisher key                |
| 403    | `publisher_not_active` | Publisher not activated for live posting             |
| 405    | `method_not_allowed`   | Non-POST method                                       |
| 409    | `zip_not_covered`      | ZIP is outside the covered market                     |
| 409    | `zip_not_accepting`    | ZIP is covered but not currently accepting leads      |
| 409    | `duplicate`            | Phone/email already accepted within 30 days           |
| 422    | `invalid_lead`         | Required field missing/invalid, or over-length field  |
| 429    | `daily_cap_reached`    | Live daily cap reached (live posts only)              |
| 429    | `rate_limited`         | Too many requests from this IP                         |
| 503    | `unavailable`          | Upstream/infra failure; fail closed (never accepts)   |

## Consent / TCPA requirements

Every submission **must** carry:

- `trustedform_cert_url` — a valid TrustedForm certificate URL.
- `tcpa_text` (or `consent_text`) — the exact consent language the consumer
  saw, minimum 20 characters.
- `home_owner` = `Yes` — explicit homeowner confirmation.

Submissions missing any of these are rejected with `422 invalid_lead`. The
consent text and certificate URL are never echoed in responses, audit logs, or
error messages.

## Payload capture (administrator visibility)

Every submission (test or live, accepted or refused) is best-effort captured
into an admin-only audit store. Contact values (phone, email, address, IP,
consent text, certificate URL, user agent) are redacted to `[redacted]`, and
secret keys (`publisher_key`, `api_key`, `key`, `token`, `secret`) are dropped
entirely before storage. Capture failures never change the partner-facing
answer. Rows expire after 45 days and are readable by administrators only.

## Example — test submission

```bash
curl -X POST https://<your-site-origin>/api/public/partner-lead \
  -H "Content-Type: application/json" \
  -H "x-publisher-key: <your-publisher-key>" \
  -d '{
    "test": 1,
    "first_name": "Jordan",
    "last_name": "Sample",
    "phone": "5552021234",
    "email": "jordan@example.com",
    "zip_code": "22182",
    "trustedform_cert_url": "https://cert.trustedform.com/0123456789abcdef0123456789abcdef01234567",
    "tcpa_text": "By submitting, I consent to be contacted about my roofing project.",
    "ip_address": "203.0.113.10",
    "home_owner": "Yes",
    "project_type": "Replace",
    "property_type": "Single family",
    "roof_material": "Asphalt shingle"
  }'
```

Replace `<your-publisher-key>` with the key issued to your publisher account
and `<your-site-origin>` with the site origin provided during onboarding. All
example values above are placeholders.

## Onboarding / approval

No publishers ship pre-configured. To become an approved publisher, contact the
Pro Roofers USA partnerships team. Each approved publisher is provisioned with:

- An inbound publisher key (sent out-of-band; store it securely — it is never
  recoverable from the system).
- A pilot service scope (roofing only).
- Commercial terms: payout, daily cap, settlement cadence, and return window.

Activation to live posting is performed by an administrator and is off by
default. Until then, use `test=1` posts to validate your integration against
the contract above.
