# Powerlily API and Webhook Integration Brief

API version: 2026-08-01
Base URL: https://powerlily.io/api/v1
OpenAPI schema: https://powerlily.io/api/v1/openapi.yaml

This document is designed to be pasted into an LLM coding assistant or used directly by a developer. It contains no API credential or webhook secret.

## Instructions for an AI coding assistant

- Treat this brief and the linked OpenAPI schema as the authoritative integration contract.
- Build the integration in trusted server-side code. Never expose Powerlily credentials or webhook secrets to browsers, mobile clients, source control, URLs, or logs.
- Ask the developer to create one named Powerlily credential per integration and environment with the minimum necessary scopes.
- Authenticate with `Authorization: Bearer pl_live_...`.
- Send `Content-Type: application/json` for POST, PATCH, and PUT requests.
- Add a unique `Idempotency-Key` to every POST and preserve it when retrying the identical request.
- The OpenAPI schema lists supported routes/scopes, resource-specific request fields, nested responses and errors. Respect domain validations and do not invent writable fields; response schemas permit additive compatibility fields.
- Persist `X-Request-Id` from API responses for diagnostics.
- For webhooks, verify the signature against the unmodified raw body before parsing JSON, reject stale timestamps, require the signed body `id` to match `Webhook-Id`, deduplicate on that body ID, return 2xx after durable acceptance, and process asynchronously.
- Assume webhook delivery is at least once and unordered.

## Authentication

Create a named credential in Powerlily under Settings > Integrations & data > API access. Select permissions explicitly: `leads:create` for intake and optionally `leads:read` to inspect leads/forms. The token is shown once and starts with `pl_live_`.

```http
Authorization: Bearer $POWERLILY_API_KEY
Accept: application/json
```

Read smoke check (requires `leads:read`; an intake-only key can instead run the POST example):

```bash
curl "https://powerlily.io/api/v1/leads?per_page=1" \
  --header "Authorization: Bearer $POWERLILY_API_KEY" \
  --header "Accept: application/json"
```

## Credential scopes

### Workspace-wide

- `read` — Read every available resource.
- `write` — Create and change every writable resource.

### CRM and projects

- `clients:read` — Read customer accounts.
- `leads:read` — Read leads and lead form definitions.
- `leads:create` — Import leads without listing, changing, or deleting existing leads.
- `leads:write` — Create, change, and delete company leads.
- `projects:read` — Read projects, proposals, and designs.
- `projects:write` — Create and change projects.
- `workflows:read` — Read pipelines and stages.

### Operations

- `events:read` — Read calendar events.
- `events:write` — Create and change calendar events.
- `schedule:read` — Read crews and dispatch blocks.
- `schedule:write` — Create and change dispatch blocks.
- `tasks:read` — Read operational tasks.
- `tasks:write` — Create and change tasks.

### Billing and fleet

- `invoices:read` — Read invoices and recorded payments.
- `systems:read` — Read installed systems.

### Organization

- `branches:read` — Read offices and branch details.
- `catalog:read` — Read equipment catalog records.
- `team:read` — Read team members.

`leads:create` permits POST intake only. Existing `leads:write` and global `write` also permit lead creation; `leads:write` additionally permits updates/deletion. The global `read` and `write` scopes cover all corresponding resources. Prefer resource-specific scopes for production integrations.

## API conventions

- JSON response envelope: `{ "data": ..., "meta": ... }`.
- `meta` includes `request_id` and `api_version`; list responses also include `total_count`, `page`, `per_page`, `total_pages`, `next_page`, and `previous_page`.
- Pagination parameters: `page` and `per_page`. Default page size is 25; maximum is 100.
- Change filters accept ISO 8601 values: `updated_after`, `updated_before`, and `created_after`.
- Sort with `sort=field` for ascending or `sort=-field` for descending. Supported fields depend on the resource.
- Credential rate limit: 100 requests per minute. A broader IP safety limit also applies.
- On HTTP 429, wait for the number of seconds in `Retry-After` before retrying.
- Responses include `X-Request-Id` and `Powerlily-Version` headers.

### Idempotent POST requests

`Idempotency-Key` must contain 8 to 255 nonblank characters. For an authorized JSON POST, Powerlily retains the action's non-5xx response for 24 hours. Authentication, scope, media-type and key errors are checked before replay. Repeating the same POST path/query and raw body with the same credential and key returns the original response with `Idempotent-Replayed: true`. Reusing a key for a different request returns HTTP 409. A concurrent twin returns `409 idempotency_in_progress` and `Retry-After: 1`; retry unchanged. Expired keys establish a new replay window. Corrected validation failures need a fresh key.

```bash
curl "https://powerlily.io/api/v1/leads" \
  --request POST \
  --header "Authorization: Bearer $POWERLILY_API_KEY" \
  --header "Content-Type: application/json" \
  --header "Idempotency-Key: crm-lead-4242-v1" \
  --data '{"lead":{"name":"Avery Chen","email":"avery@example.com","external_provider":"crm","external_id":"4242"}}'
```

## API resources

| Path | Purpose | Operations | Scope |
| --- | --- | --- | --- |
| `/company` | Authenticated company | GET | `read` |
| `/projects` | Projects and proposals | GET · POST · PATCH · PUT · DELETE | `projects` |
| `/leads` | CRM leads | GET · POST · PATCH · PUT · DELETE | `leads:read / leads:create / leads:write` |
| `/lead_forms` | Intake form definitions | GET | `leads:read` |
| `/clients` | Customer accounts | GET | `clients:read` |
| `/workflows` | Project pipelines | GET | `workflows:read` |
| `/lead_stages` | Lead pipeline stages | GET | `workflows:read` |
| `/invoices` | Invoices | GET | `invoices:read` |
| `/payments` | Recorded payments | GET | `invoices:read` |
| `/tasks` | Operational tasks | GET · POST · PATCH · PUT · DELETE | `tasks` |
| `/events` | Calendar events | GET · POST · PATCH · PUT · DELETE | `events` |
| `/schedule_blocks` | Crew dispatch blocks | GET · POST · PATCH · PUT · DELETE | `schedule` |
| `/crews` | Dispatch crews | GET | `schedule:read` |
| `/systems` | Installed systems | GET | `systems:read` |
| `/branches` | Offices | GET | `branches:read` |
| `/team` | Team members | GET | `team:read` |
| `/catalog/{type}` | Panels, inverters, batteries, equipment | GET | `catalog:read` |

Collection paths support GET. Resources marked with POST, PATCH, PUT, or DELETE expose writes on the collection or `/{id}` path as appropriate. Use OpenAPI for supported routes, scopes, resource-specific payloads and errors. Writable fields are explicit; nested quote is the canonical project request key even on /projects. PATCH and PUT both apply partial updates.

## Lead intake

- Form-less intake requires name and an email address or valid phone.
- Supply paired `lead.external_provider` and `lead.external_id` for durable company-scoped deduplication. Provider is trimmed/lowercased (max 80 characters); ID is trimmed (max 255).
- New leads return HTTP 201 and `meta.duplicate: false`. An existing identity returns HTTP 200 and `meta.duplicate: true`, preserving the original lead without repeating creation side effects.
- `external_provider`/`external_id` cannot change after creation. PATCH accepts an unchanged echo; changes return 422.
- `lead.lead_form_id` on creation selects an active form, its visible required builtin/custom answers, snapshot, and routing. Omit assignment to let form routing choose. Unknown custom keys are discarded for form-linked intake.
- Discover forms with `GET /lead_forms` (`leads:read`) and stages with `GET /lead_stages` (`workflows:read`).
- Message is returned as plain text. Top-level attribution is stored/read with channel `api`; PATCH merges nonblank whitelisted tracking fields.
- Credentials act on the company’s real data; separate keys do not create an isolated sandbox.

## Error contract

API errors use this shape:

```json
{
  "error": "Human-readable summary",
  "errors": [{ "code": "stable_machine_code", "message": "Explanation", "details": {} }],
  "meta": { "request_id": "...", "api_version": "2026-08-01" }
}
```

Common statuses:

- `400`: invalid parameters or idempotency key.
- `401`: missing, expired, revoked, or invalid Bearer credential.
- `402`: API access requires Powerlily Pro.
- `403`: insufficient scope. Read `meta.required_scope`.
- `404`: resource not found within the authenticated company.
- `409`: idempotency key reused for a different request, or a matching request is in progress. Honor `Retry-After` for in-progress requests.
- `415`: write request is not `application/json`.
- `422`: validation or deletion failure. Inspect `errors[0].details`.
- `429`: rate limit exceeded. Honor `Retry-After`.

## Webhook endpoint contract

Configure one or more endpoints in Settings > Integrations & data > API access. Each endpoint has its own URL, event subscriptions, enabled state, and `whsec_...` signing secret.

- Powerlily sends HTTPS POST requests with `Content-Type: application/json`.
- Connect, read, and write each have a separate 10-second timeout. DNS resolution runs separately; these are not a total request deadline.
- Any 2xx response marks the delivery successful.
- Timeouts, network failures, and non-2xx responses are retried up to eight times over approximately four days.
- Delivery records are persisted with the resource transaction. A recovery dispatcher scans every minute for failed enqueue, due retries, and expired worker leases. Publication and delivery require Pro.
- HTTP 410 Gone disables the endpoint. Exhausted failures can pause it and alert company-settings managers.
- Inspect searchable delivery history at `/api/webhook_deliveries`, including payload, response/error, immutable attempts, destination, and next retry.
- Automatic retries keep the captured destination. Manual replay uses the current endpoint URL, retains event ID/payload, and starts a fresh retry window. Paused endpoints must be re-enabled first.
- Delivery is at least once and ordering is not guaranteed.
- After verifying the raw-body signature, require body `id` to match `Webhook-Id` and deduplicate using that signed body ID. The header is not independently signed. Manual replay preserves the original event ID.
- Secret rotation is immediate and has no overlap window.
- The Send test action uses the same transport and signature path as production and sends a `test` event.

Request headers:

```http
Content-Type: application/json
User-Agent: Powerlily-Webhook/2.0
Webhook-Id: evt_...
Webhook-Timestamp: 1786248000
Powerlily-Signature: t=1786248000,v1=hex_hmac_sha256
```

Event envelope:

```json
{
  "id": "evt_abc123",
  "object": "event",
  "api_version": "2026-08-01",
  "event": "lead.created",
  "created_at": "2026-08-09T20:48:30.000Z",
  "company_id": "company_uuid",
  "data": { "id": "resource_uuid", "object": "lead" }
}
```

Custom automation event names can be explicitly subscribed to on an endpoint; lead and project automation steps share this delivery path. No matching destination is recorded as skipped.

`data` contains a serialized snapshot of the affected resource. Transition events can add context such as a previous stage ID.

## Webhook signature verification

1. Capture the exact raw request body before JSON parsing.
2. Parse `t` and `v1` from `Powerlily-Signature`.
3. Reject the request if `abs(current_unix_time - t) > 300` seconds.
4. Compute the hexadecimal HMAC-SHA256 of the UTF-8 string `t.raw_body` using the complete `whsec_...` secret.
5. Compare the received and expected hexadecimal signatures in constant time.
6. Parse the verified body; reject a body `id` that differs from `Webhook-Id`. Persist that signed body ID for deduplication and enqueue business processing before returning 2xx.

Python reference implementation:

```python
import hashlib, hmac, json, os, time

raw_body = request.get_data()
parts = dict(part.split("=", 1) for part in request.headers["Powerlily-Signature"].split(","))
if abs(int(time.time()) - int(parts["t"])) > 300:
    raise ValueError("Stale webhook")

message = parts["t"].encode() + b"." + raw_body
expected = hmac.new(
    os.environ["POWERLILY_WEBHOOK_SECRET"].encode(), message, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(parts["v1"], expected):
    raise ValueError("Invalid signature")

event = json.loads(raw_body)
if not event.get("id") or event["id"] != request.headers.get("Webhook-Id"):
    raise ValueError("Webhook ID mismatch")
# Durably deduplicate event["id"] before enqueueing business processing.
```

## Webhook events

### Leads

- `lead.created` — Sent when a lead is created.
- `lead.updated` — Sent when a lead is changed.
- `lead.deleted` — Sent when a lead is deleted.
- `lead.stage_changed` — Sent when a lead moves to another stage.

### Projects

- `quote.created` — Sent when a project is created.
- `quote.updated` — Sent when a project is changed.
- `quote.deleted` — Sent when a project is deleted.
- `quote.signed` — Sent when a project is signed.
- `quote.stage_changed` — Sent when a project moves to another stage.

### Clients

- `client.created` — Sent when a client is created.
- `client.updated` — Sent when a client is changed.
- `client.deleted` — Sent when a client is deleted.

### Billing

- `invoice.created` — Sent when a invoice is created.
- `invoice.updated` — Sent when a invoice is changed.
- `invoice.deleted` — Sent when a invoice is deleted.
- `invoice.paid` — Sent when a invoice is marked paid.
- `payment.created` — Sent when a payment is created.
- `payment.updated` — Sent when a payment is changed.
- `payment.deleted` — Sent when a payment is deleted.

### Tasks and calendar

- `task.created` — Sent when a task is created.
- `task.updated` — Sent when a task is changed.
- `task.deleted` — Sent when a task is deleted.
- `task.completed` — Sent when a task is completed.
- `event.created` — Sent when a calendar event is created.
- `event.updated` — Sent when a calendar event is changed.
- `event.deleted` — Sent when a calendar event is deleted.

### Scheduling

- `schedule_block.created` — Sent when a schedule block is created.
- `schedule_block.updated` — Sent when a schedule block is changed.
- `schedule_block.deleted` — Sent when a schedule block is deleted.

### Installed systems

- `system.created` — Sent when a installed system is created.
- `system.updated` — Sent when a installed system is changed.
- `system.deleted` — Sent when a installed system is deleted.
- `system.status_changed` — Sent when a installed system changes status.

## Implementation checklist

- [ ] Create separate least-privilege credentials for development and production.
- [ ] Store the Bearer credential and webhook secret in a secret manager.
- [ ] Implement API timeouts, bounded retries, 429 backoff, and request ID logging.
- [ ] Add idempotency keys to every POST.
- [ ] Verify webhooks from the raw body with a five-minute timestamp tolerance.
- [ ] Require signed body `id` to match `Webhook-Id`; persist that body ID to prevent duplicate side effects.
- [ ] Return 2xx promptly after durable event acceptance and process asynchronously.
- [ ] Test every endpoint from Powerlily before enabling production traffic.
- [ ] Import https://powerlily.io/api/v1/openapi.yaml for resource-specific request/response schemas and route/scope definitions; honor the documented domain validations.
