# ShedCloud Partner API

The Partner API is a public, **company-scoped** HTTP API that lets a company's own
developers and trusted tools read that company's data programmatically. Every
request is authenticated with a credential the company creates for itself, and
every credential is permanently bound to exactly one company — there is no way
for a credential issued by Company A to read Company B's data.

- **Base URL:** your ShedCloud API host (e.g. `https://api.shedcloud.com`)
- **Reference guide:** [`/partner/reference`](/partner/reference) (hand-crafted, Stripe-style docs)
- **Interactive reference:** [`/partner/docs`](/partner/docs) (Swagger UI)
- **Changelog:** [`PARTNER_API_CHANGELOG.md`](./PARTNER_API_CHANGELOG.md), also rendered at [`/partner/reference#changelog`](/partner/reference#changelog)
- **Machine/AI-readable docs:** [`/llms.txt`](/llms.txt) (also `/partner/llms.txt`) —
  llms.txt index for AI agents; [`/partner/reference.md`](/partner/reference.md) —
  this document served as Markdown; [`/partner/openapi.json`](/partner/openapi.json) /
  [`/partner/openapi.yaml`](/partner/openapi.yaml) — raw OpenAPI spec;
  [`/partner/changelog.md`](/partner/changelog.md) — the changelog as Markdown
- **Namespace:** all resource endpoints live under `/partner/v1/{resource}`
- **Versioning:** `/partner/v1` is **additive-only** — new endpoints/fields may
  appear at any time, existing fields are never renamed, retyped, or removed.
  Breaking changes would ship as `/partner/v2`. SDK semver moves in lockstep
  with API additions.

### Environments

| Environment | Host | Purpose |
|-------------|------|---------|
| Production | `https://go.shedcloud.com` | Live company data |
| Sandbox | `https://api.shedcloudtest.com` | Test environment with the same API surface |

Both environments expose the identical `/partner/v1` contract. Credentials are
**environment-specific** — a production API key or OAuth app does not work
against the sandbox and vice versa. Create sandbox credentials the same way as
production ones: log into the sandbox portal with your test account and go to
**Settings → Company → Developer API**. If your company does not yet have a
sandbox account/fixtures, request one through your ShedCloud contact — build and
verify your integration against the sandbox before pointing it at production.

The official SDKs ([`@shedcloud/partner-api`](https://www.npmjs.com/package/@shedcloud/partner-api)
for TypeScript/JavaScript,
[`shedcloud-gomod/partnerapi`](https://github.com/Corland-Partners-LLC/shedcloud-gomod)
for Go,
[`shedcloud-partner-api`](https://pypi.org/project/shedcloud-partner-api/) for Python,
[`shedcloud/partner-api`](https://github.com/Corland-Partners-LLC/shedcloud-php) for PHP, and
[`shedcloud-partner_api`](https://github.com/Corland-Partners-LLC/shedcloud-gem) for Ruby)
ship both hosts built in — select them with `environment: 'production' | 'sandbox'`
(Python/PHP/Ruby use the same option names; see each package README).

---

## 1. Authentication

There are two ways to authenticate. Both resolve to the same `{company, scopes}`
identity downstream, so any endpoint behaves identically regardless of which you
use — pick whichever fits your integration.

| | **API key** | **OAuth2 app (client credentials)** |
|---|---|---|
| Best for | A single server-side script or cron job you control | Integrations/tooling that expect the standard OAuth2 token exchange |
| Credential | One long-lived secret (`sc_live_…`) | A `client_id` + `client_secret`, exchanged for short-lived access tokens |
| Sent as | `Authorization: Bearer sc_live_…` | `Authorization: Bearer <access_token>` |
| Rotation | Revoke + create a new key | Rotate the secret, or revoke the app |
| Expiry | Never (until revoked) | Access token expires after 1 hour (default) |

Both credential types are created and managed from the ShedCloud portal under
**Settings → Company → Developer API** (visible to users with the
`partner-api.manage` permission — typically Owner/Admin). The full secret is
shown **once**, at creation time, and only a hash is stored server-side. If you
lose it, revoke the credential and create a new one.

### 1a. API key

Send the key directly as a bearer token:

```bash
curl "https://api.shedcloud.com/partner/v1/lot-stock?limit=50" \
  -H "Authorization: Bearer sc_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

### 1b. OAuth2 client credentials

First exchange your `client_id` / `client_secret` for an access token, then use
that token as a bearer token on subsequent requests.

### 1c. `GET /partner/v1/me` — verify your credential

Returns who the calling credential belongs to and what it may do. Requires any
valid credential — no specific scope — so it's the right first call to smoke-test
a new integration or discover granted scopes without probing endpoints for 403s.

```bash
curl "https://api.shedcloud.com/partner/v1/me" \
  -H "Authorization: Bearer <api_key_or_access_token>"
```

**Response `200 OK`:**

```json
{
  "companyId": "6529b409e7d0f84e18e2a001",
  "companyName": "Leland's Sheds",
  "credentialType": "api_key",
  "scopes": ["partner-api.lot-stock.read", "partner-api.quotes.write"],
  "rateLimit": { "requestsPerSecond": 20, "burst": 40 }
}
```

`credentialType` is `api_key` or `oauth_client`. `rateLimit` reports the
per-credential throttle configuration (see
[§21](#21-rate-limits-token-lifetime--revocation)).

---

## 2. `POST /oauth/token`

Exchanges OAuth2 client credentials for a short-lived bearer access token
(`client_credentials` grant, [RFC 6749 §4.4](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4)).

**Content-Type:** `application/x-www-form-urlencoded`

Credentials may be sent either via HTTP Basic auth (recommended) **or** as form
fields — both are supported for compatibility with generic OAuth2 client
libraries.

| Parameter | In | Required | Description |
|-----------|----|----------|-------------|
| `grant_type` | form | yes | Must be `client_credentials` |
| `client_id` | form | conditionally | Omit if using HTTP Basic auth |
| `client_secret` | form | conditionally | Omit if using HTTP Basic auth |

**Request (HTTP Basic auth):**

```bash
curl -X POST "https://api.shedcloud.com/oauth/token" \
  -u "sc_client_xxxxxxxxxxxxxxxxxxxx:sc_secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -d "grant_type=client_credentials"
```

**Request (form body):**

```bash
curl -X POST "https://api.shedcloud.com/oauth/token" \
  -d "grant_type=client_credentials" \
  -d "client_id=sc_client_xxxxxxxxxxxxxxxxxxxx" \
  -d "client_secret=sc_secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

**Response `200 OK`:**

```json
{
  "access_token": "sc_at_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "partner-api.lot-stock.read"
}
```

The token carries the scopes granted to the OAuth app. There are **no refresh
tokens** for this grant — when the token expires, simply request a new one.

**Error responses** follow the OAuth2 error shape:

```json
{ "error": "invalid_client", "error_description": "unknown or revoked client" }
```

| Status | `error` | Meaning |
|--------|---------|---------|
| 400 | `unsupported_grant_type` | `grant_type` was not `client_credentials` |
| 400 | `invalid_request` | Missing/malformed credentials |
| 401 | `invalid_client` | Unknown, revoked, or wrong-secret client |

---

## 3. `GET /partner/v1/lot-stock`

Returns the authenticated company's ready-to-sell physical inventory (on-lot
units, rental returns, and immediate-sale units).

**Auth:** requires the `partner-api.lot-stock.read` scope.

**Query parameters:**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page` | int | `1` | Page number (1-based) |
| `limit` | int | `50` | Page size, max `100` |
| `purchaseType` | string | `ALL` | Filter: `ALL`, `Lot Stock`, `Rental Return`, or `Immediate Sale`. Case-insensitive; hyphens/underscores accepted (`lot-stock`, `immediate_sale`). Unrecognized values fall back to `ALL`. |
| `location` | string | — | Filter by location **id** or **slug** |
| `region` | string | — | Filter by the locations' exact region label (see [§6](#6-locations)); combines with `location` (both must match) |
| `search` | string | — | Case-insensitive substring match on serial number **or** title |
| `sort` | string | `createdAt` | Sort field: `serialNumber`, `title`, `price`, or `createdAt` |
| `order` | string | `desc` | Sort direction: `asc` or `desc` |

Only sellable units are returned — units with a processed linked sales order are
excluded, and results are collapsed to one row per work order (mirroring the
internal stock report).

**Request:**

```bash
curl "https://api.shedcloud.com/partner/v1/lot-stock?limit=50&purchaseType=Lot%20Stock&search=00123&sort=price&order=asc" \
  -H "Authorization: Bearer <api_key_or_access_token>"
```

**Response `200 OK`:**

```json
{
  "data": [
    {
      "id": "6529b409e7d0f84e18e2a108",
      "workOrderId": "6529b409e7d0f84e18e2a222",
      "serialNumber": "SC-2024-00123",
      "title": "10x16 Lofted Barn",
      "purchaseType": "Lot Stock",
      "basePrice": 8200.00,
      "price": 8995.00,
      "locationId": "66c00443c2d8aa83c5757dcf",
      "locationName": "Dallas Lot",
      "locationSlug": "dallas-lot",
      "images": [
        "https://cdn.shedcloud.com/.../front.jpg"
      ],
      "attributes": {
        "siding": "LP Smart Side Siding & Trim",
        "sidingColor": "Ivory",
        "trimColor": "Polar White",
        "roofMaterial": "Metal Roof",
        "roofColor": "Barn Red Metal"
      },
      "sold": false
    }
  ],
  "page": 1,
  "limit": 50,
  "total": 137
}
```

| Field | Type | Description |
|-------|------|-------------|
| `data[].id` | string | Inventory record id |
| `data[].workOrderId` | string | Linked work order id |
| `data[].serialNumber` | string | Unit serial number |
| `data[].title` | string | Human-readable product title |
| `data[].purchaseType` | string | `Lot Stock` / `Rental Return` / `Immediate Sale` |
| `data[].basePrice` | number | Base price |
| `data[].price` | number | Selling price |
| `data[].locationId` | string | Location id (omitted if unset) |
| `data[].locationName` | string | Location display name (omitted if unset) |
| `data[].locationSlug` | string | Location slug (omitted if unset) |
| `data[].images` | string[] | Image URLs |
| `data[].attributes` | object | Exterior configuration — the same values shown on the work-order detail page. Omitted when the unit has no configurator. |
| `data[].attributes.siding` | string | Siding material (e.g. `LP Smart Side Siding & Trim`) |
| `data[].attributes.sidingColor` | string | Siding color; custom colors surface as `Custom Color - #HEX` |
| `data[].attributes.trimColor` | string | Trim color; custom colors surface as `Custom Color - #HEX` |
| `data[].attributes.roofMaterial` | string | `Metal Roof` or `Shingle Roof` |
| `data[].attributes.roofColor` | string | Roof color display name |
| `data[].sold` | bool | Whether the unit has an active linked order |
| `page` | int | Echoed page number |
| `limit` | int | Echoed page size |
| `total` | int | Total matching records across all pages |

**Error responses:**

| Status | Body | Meaning |
|--------|------|---------|
| 401 | `{ "error": "invalid or expired credential" }` | Missing/invalid/revoked/expired credential |
| 403 | `{ "error": "credential is not authorized for this scope" }` | Credential lacks the `partner-api.lot-stock.read` scope |
| 429 | `{ "error": "rate limit exceeded" }` | Per-credential rate limit exceeded |

All partner error bodies use the shape `{ "error": "<message>" }`.

### 3a. `GET /partner/v1/stock-templates` — buildable catalog designs

Same scope (`partner-api.lot-stock.read`). Stock templates are **buildable
catalog designs** (template work orders), not physical inventory — they are
deliberately excluded from `/lot-stock` and rejected by quote-create. Use them
to show a "design your own" catalog; each entry deep-links the interactive 3D
configurator via `structuraUrl`.

| List | Get one |
|------|---------|
| `GET /partner/v1/stock-templates` | `GET /partner/v1/stock-templates/{id}` |

**Query parameters (list):**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page` | int | `1` | Page number (1-based) |
| `limit` | int | `50` | Page size, max `60` |
| `search` | string | — | Case-insensitive match on work-order number, template name, or product name |
| `tags` | string | — | Comma-separated public tags; templates must carry **all** of them |

**Response `200 OK`:**

```json
{
  "data": [
    {
      "id": "6529b409e7d0f84e18e2a555",
      "workOrderNumber": "T-1042",
      "templateName": "12x24 Side Lofted Barn",
      "productName": "Side Lofted Barn",
      "description": "Our most popular 12-wide layout…",
      "descriptionHtml": "<p>Our most popular 12-wide layout…</p>",
      "tags": ["barn", "lofted", "12-wide"],
      "images": ["https://cdn.shedcloud.com/.../hero.jpg"],
      "basePrice": 9200.00,
      "upgradesPrice": 850.00,
      "removedPrice": 0,
      "configuratorId": "6529b409e7d0f84e18e2a777",
      "structuraUrl": "https://3d.shedcloud.com/?...",
      "productId": "6529b409e7d0f84e18e2a888",
      "sizeId": "6529b409e7d0f84e18e2a999"
    }
  ],
  "page": 1,
  "limit": 50,
  "total": 12
}
```

`tags` are the template's **public** tags only (internal tags are never
exposed). Prices mirror the public gallery: configured base price, upgrades
total, and the total of removed standard features. Get-one returns the same
item shape without the envelope; a non-template work order id (or another
company's id) returns `404`.

---

## 4. Leads, quotes & sales orders

Leads, quotes, and sales orders share one underlying sales entity, split into
three resources:

| Resource | List | Get one | Read scope |
|----------|------|---------|------------|
| Leads | `GET /partner/v1/leads` | `GET /partner/v1/leads/{id}` | `partner-api.leads.read` |
| Quotes | `GET /partner/v1/quotes` | `GET /partner/v1/quotes/{id}` | `partner-api.quotes.read` |
| Sales orders | `GET /partner/v1/orders` | `GET /partner/v1/orders/{id}` | `partner-api.orders.read` |

**Common query parameters (all three lists):**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page` | int | `1` | Page number (1-based) |
| `limit` | int | `50` | Page size, max `100` |
| `search` | string | — | Case-insensitive substring match on customer name, order number, email, or phone |
| `sort` | string | `createdAt` | `orderNumber`, `customerName`, `status`, `total`, `createdAt`, or `updatedAt` |
| `order` | string | `desc` | `asc` or `desc` |
| `status` | string | — | Filter by status (case-insensitive), e.g. `Open`, `Active`, `Sold`, `Lost`, `Retired`, `Unprocessed`, `On hold` |
| `location` | string | — | Filter by location **id** or **slug** |
| `customerEmail` | string | — | Exact-ish match on customer email |
| `customerPhone` | string | — | Match on customer phone |
| `orderNumber` | string | — | Match on order/quote/lead number |
| `salesperson` | string | — | Match on salesperson name |
| `createdFrom` / `createdTo` | date | — | Created-at range (RFC 3339 or `YYYY-MM-DD`) |
| `updatedFrom` / `updatedTo` | date | — | Updated-at range (RFC 3339 or `YYYY-MM-DD`) |
| `externalRef` | string | — | `key:value` exact match on your own [external references](#10d-external-references) (also on work-order and customer lists) |

**Per-resource extras:**

| Resource | Parameter | Description |
|----------|-----------|-------------|
| Quotes | `converted` | `true` = only quotes converted to an order, `false` = only unconverted |
| Orders | `paymentType` | `rto` or `cash` |
| Orders | `serialNumber` | Match on the unit serial number |

**Request:**

```bash
curl "https://api.shedcloud.com/partner/v1/orders?limit=25&status=Unprocessed&sort=total&order=desc" \
  -H "Authorization: Bearer <api_key_or_access_token>"
```

**Response `200 OK`** (order shown; leads omit `pricing`/`serialNumber`,
quotes add `converted` fields):

```json
{
  "data": [
    {
      "id": "665f0a1b2c3d4e5f60718293",
      "orderNumber": 13847,
      "status": "Unprocessed",
      "statusChangedAt": "2026-07-01T18:22:04Z",
      "customer": { "name": "Jane Doe", "email": "jane@example.com", "phone": "555-0100" },
      "salesperson": { "id": "66a0aa0bb1cc2dd3ee4ff556", "name": "John Rep", "email": "john@dealer.com" },
      "location": { "id": "66c00443c2d8aa83c5757dcf", "name": "Dallas Lot", "slug": "dallas-lot" },
      "pricing": { "subtotal": 8200.00, "total": 8995.00, "monthlyPayment": 415.32, "paymentType": "rto", "changeOrderFee": 150.00 },
      "rto": {
        "termMonths": 36,
        "monthlyPayment": 415.32,
        "rent": 373.79,
        "securityDeposit": 415.32,
        "downPayment": 899.50,
        "damageWaiver": 12.50,
        "totalDueToday": 1314.82,
        "balance": 8095.50,
        "agreementId": "665f0a1b2c3d4e5f60718293",
        "providerName": "AFG Rentals, LLC"
      },
      "deposits": {
        "downPayment": 899.50,
        "securityDeposit": 415.32,
        "totalDueToday": 1314.82,
        "totalPaid": 1314.82,
        "balance": 7680.18
      },
      "serialNumber": "SC-2024-00123",
      "workOrderId": "665f0a1b2c3d4e5f60718294",
      "expectedDeliveryDate": "2026-07-15T00:00:00Z",
      "externalReferences": { "crmDealId": "D-99812" },
      "version": 4,
      "createdAt": "2026-06-28T15:03:11Z",
      "updatedAt": "2026-07-01T18:22:04Z"
    }
  ],
  "page": 1,
  "limit": 25,
  "total": 42
}
```

- `salesperson.id` is the assigned user's id, resolvable against
  [`GET /partner/v1/users`](#11-users--salespeople).
- `pricing.changeOrderFee` is present when a change-order fee was applied.
  Change orders are a fee on the order (plus payment-ledger entries), not a
  separate order entity.
- `rto` appears on quotes and orders when `pricing.paymentType` is `rto` —
  the customer's selected term, monthly payment/rent, deposits, damage
  waiver, and remaining balance. `agreementId` links the sales order to the
  B2B rate-program agreement; `providerName` (the financing/RTO company)
  is resolved on **get-by-id only**, never on lists. There is no financing
  *application* lifecycle (approve/decline) in ShedCloud — the RTO provider
  handles applications externally.
- `deposits` appears on orders — money collected/owed regardless of payment
  type.
- Quotes carry an optional `validUntil` (RFC 3339) — settable via
  [PATCH](#10-updating-resources) or the portal. When it passes while the
  quote is still Open/Active, a `quote.expired` [event](#17-events-feed) is
  emitted (the quote's status is **not** changed automatically).
- Orders carry `expectedDeliveryDate` and, once delivered, `deliveredAt`.
- There is no quote **viewed** tracking — customer opens of the shared quote
  page are not recorded. `quote.sent` is emitted when the quote is shared or
  emailed to the customer.
- `externalReferences` and `version` are described in
  [§10d](#10d-external-references) and [§10e](#10e-optimistic-concurrency-etag--if-match).

Get-one endpoints return the same item shape without the envelope (plus an
`ETag` header carrying `version`). A resource belonging to another company
returns `404` (never `403`), so ids can't be probed cross-tenant.

**Read-only sub-resources** (same read scope as the parent):

| Endpoint | Returns |
|----------|---------|
| `GET /partner/v1/{leads\|quotes\|orders}/{id}/status-history` | Status timeline — see [§12](#12-status-history) |
| `GET /partner/v1/{quotes\|orders}/{id}/line-items` | Curated line items + building configuration — see [§13](#13-line-items--configuration) |
| `GET /partner/v1/orders/{id}/contract` | Contract signature summary (scope `contracts.read`) — see [§14](#14-contract-summary) |
| `GET /partner/v1/orders/{id}/payments` | Payments on the order (scope `payments.read`) — see [§15](#15-payments) |

**Write sub-resources**: `POST /partner/v1/orders/{id}/payments` (record a
manual payment) and `POST /partner/v1/orders/{id}/payment-links` (Stripe
Checkout link) — scope `payments.write`, see [§15a](#15a-post-partnerv1ordersidpayments--record-a-manual-payment)
and [§15b](#15b-post-partnerv1ordersidpayment-links--stripe-checkout-link).

### 4a. `POST /partner/v1/quotes` — create a quote from an in-stock unit

Requires `partner-api.quotes.write`. Points at an on-lot building (by
`serialNumber` or `workOrderId`, both discoverable via
`GET /partner/v1/lot-stock`) and creates a quote for it, running the same
pipeline ShedCloud's own website embed uses:

1. The customer is matched by email (or created) in the company's customer list.
2. The next quote number is issued.
3. The unit's **work order is linked to the quote** — serial number, product
   lines, and pricing are copied onto the quote, and the unit shows as taken.
4. The **sales location is assigned** (explicit `locationId`, or the unit's own
   location), and if that location has **lead routing** configured a
   salesperson is auto-assigned and notified by email.
5. The customer receives a "we received your quote" confirmation email when an
   email address was provided, and newly created customers are synced to any
   connected CRM (same as `POST /partner/v1/customers`).

**Body fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `serialNumber` | string | one of these two | Serial number of the in-stock unit |
| `workOrderId` | string | one of these two | Work order id of the unit (takes precedence when both are sent) |
| `customer` | object | yes | `{ "name", "email", "phone" }` — an email, or a name + phone, is required |
| `deliveryAddress` | object | no | `{ "address", "city", "state", "zipCode", "latitude", "longitude" }` |
| `locationId` | string | no | Sales location id; defaults to the unit's own location |
| `purchaseType` | string | no | `Lot Stock`, `Rental Return`, or `Immediate Sale`; inferred from the unit when omitted |
| `price` | number | no | Sell price; when omitted the unit's own pricing is used |
| `note` | string | no | Free-form note stored on the quote |

```bash
curl -X POST "https://api.shedcloud.com/partner/v1/quotes" \
  -H "Authorization: Bearer <credential>" \
  -H "Content-Type: application/json" \
  -d '{
    "serialNumber": "SC-2024-00123",
    "customer": { "name": "Jane Doe", "email": "jane@example.com", "phone": "555-0100" },
    "deliveryAddress": { "address": "42 Oak Ave", "city": "Dallas", "state": "TX", "zipCode": "75201" }
  }'
```

Returns `201` with the created quote in the same shape as
`GET /partner/v1/quotes/{id}` (including the linked `workOrderId` and
`serialNumber`).

**Create error responses:**

| Status | Meaning |
|--------|---------|
| 400 | Missing unit reference, missing customer contact, invalid field, or a `serialNumber` that doesn't match the given `workOrderId` |
| 404 | No work order matches the serial number / id in your company, or `locationId` isn't one of your locations |
| 409 | The unit already has an active quote or order, is a stock template, or is cancelled |

### 4b. `POST /partner/v1/leads` — create a lead

Requires `partner-api.leads.write`. Creates a lead at a sales location with
the customer's contact information — the same flow ShedCloud's own lead-create
endpoint runs (lead number, status log, audit trail).

When `salespersonName`/`salespersonEmail` are omitted, the location's **lead
routing** strategy (round-robin, availability, skill-based) auto-assigns a
salesperson — the same routing in-stock quote creation uses.

**Body fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `locationId` | string | yes | Sales location id (must be one of your locations) |
| `customer` | object | yes | `{ "name", "email", "phone" }` — at least one of the three is required |
| `salespersonName` | string | no | Explicit salesperson; skips lead routing |
| `salespersonEmail` | string | no | Explicit salesperson email; skips lead routing |

```bash
curl -X POST "https://api.shedcloud.com/partner/v1/leads" \
  -H "Authorization: Bearer <credential>" \
  -H "Content-Type: application/json" \
  -d '{
    "locationId": "66c00443c2d8aa83c5757dcf",
    "customer": { "name": "Jane Doe", "email": "jane@example.com", "phone": "555-0100" }
  }'
```

Returns `201` with the created lead in the same shape as
`GET /partner/v1/leads/{id}`. A `locationId` outside your company returns
`404`; a missing customer contact returns `400`.

### 4c. `POST /partner/v1/quotes/{id}/convert` — convert a quote to a sales order

Requires **`partner-api.orders.write`** (not the quotes scope — the endpoint
creates a sales order). Runs the exact same conversion the portal's
"Place Order" button runs:

1. The next **order number** is issued and a new sales order is created from
   the quote (customer, salesperson, location, pricing, delivery, payment type).
2. The quote's **product lines and configurator are cloned** onto the new order.
3. The source quote is marked **Sold** with a back-reference to the new order
   (`convertedOrderId` / `convertedOrderNumber` on the quote).
4. A **linked work order** moves with the conversion — it is re-linked to the
   new sales order.
5. The new order starts in the **`Unsubmitted`** status; submit it with
   `POST /partner/v1/orders/{id}/status` → `Unprocessed` when ready.

**Body (optional):** `{ "salespersonName": "...", "salespersonEmail": "..." }`
— overrides the salesperson copied from the quote.

```bash
curl -X POST "https://api.shedcloud.com/partner/v1/quotes/665f0a1b2c3d4e5f60718293/convert" \
  -H "Authorization: Bearer <credential>" \
  -H "Content-Type: application/json" \
  -d '{}'
```

Returns `201` with the created order in the same shape as
`GET /partner/v1/orders/{id}` (including `sourceQuoteId` /
`sourceQuoteNumber`).

**Convert error responses:**

| Status | Meaning |
|--------|---------|
| 400 | The id is not a quote |
| 404 | Quote not found (or belongs to another company) |
| 409 | Quote is already Sold / Cancelled / Deleted, or an active order already exists for it |

### 4d. `POST /partner/v1/orders` — create a sales order

Requires `partner-api.orders.write`. Creates a **full sales order** directly
(no quote needed): customer, location, base product, upgrades, pricing, and
an optional configurator payload. Runs the same pipeline the portal's order
create runs — order number from the accumulator, master + localized documents
in one transaction, product detail lines, linked configurator, status log,
ledger snapshot, audit trail, and CRM sync. Supports the
[`Idempotency-Key` header](#10c-idempotency-keys).

**Body fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `customerId` | string | one of these two | Existing customer id |
| `customer` | object | one of these two | `{ "name", "email", "phone" }` — matched by email or created (same as quote create) |
| `locationId` | string | yes | Sales location id (must be one of your locations) |
| `productId` | string | yes | Base catalog product (a finished product from `GET /partner/v1/products`) |
| `sizeId` | string | no | Size child actually being sold; becomes the base line's product side |
| `salespersonId` | string | no | Explicit salesperson (a user id from `GET /partner/v1/users`); when omitted the location's lead routing assigns one |
| `basePrice` / `subtotal` / `total` | number | no | Pricing header fields (never negative) |
| `paymentType` | string | no | `cash` or `rto` |
| `upgrades` | array | no | `[{ "productId", "quantity", "price", "lineKey" }]` — upgrade lines added as product detail rows |
| `configuration` | object | no | Configurator payload, see below |
| `deliveryAddress` | object | no | `{ "address", "city", "state", "zipCode", "latitude", "longitude" }` |
| `note` | string | no | Free-form note stored on the order |
| `externalReferences` | object | no | Your own correlation ids — see [§10d](#10d-external-references) |

**`configuration`:** either pass the portal-shaped `selectedCategories` JSON
array verbatim (full fidelity — the same shape `GET .../line-items` derives
its `configuration` block from), or the flat display attributes, which are
converted into category entries server-side:

```json
{
  "model": "12x24 Lofted Barn",
  "siding": "LP Smart Side",
  "sidingColor": "Barn Red",
  "trimColor": "White",
  "roofMaterial": "Metal Roof",
  "roofColor": "Charcoal"
}
```

```bash
curl -X POST "https://api.shedcloud.com/partner/v1/orders" \
  -H "Authorization: Bearer <credential>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "customer": { "name": "Jane Doe", "email": "jane@example.com" },
    "locationId": "66c00443c2d8aa83c5757dcf",
    "productId": "6659f3ab8e5a2c001f9b1c11",
    "sizeId": "6659f3ab8e5a2c001f9b1c22",
    "basePrice": 8995,
    "paymentType": "cash",
    "upgrades": [{ "productId": "6659f3ab8e5a2c001f9b1c33", "quantity": 2 }],
    "configuration": { "sidingColor": "Barn Red", "roofMaterial": "Metal Roof", "roofColor": "Charcoal" }
  }'
```

Returns `201` with the created order in the same shape as
`GET /partner/v1/orders/{id}`. The new order starts in **`Unsubmitted`**;
submitting to `Unprocessed` still requires a serial number, same as the
portal (documented in [§10b](#10-updating-resources)).

**Create error responses:**

| Status | Meaning |
|--------|---------|
| 400 | Missing customer/location/product, negative pricing, unknown field, or a malformed `configuration` payload |
| 404 | `customerId`, `locationId`, `productId`, `sizeId`, an upgrade product, or `salespersonId` isn't found in your company |

### 4e. Line-item writes — `POST` / `DELETE` `.../line-items`

Requires the parent's write scope (`partner-api.quotes.write` /
`partner-api.orders.write`). Adds or removes **upgrade lines** on an existing
quote or order — the same add/remove-upgrade semantics the portal uses. The
**base product line is protected** (deleting it returns `409`).

| Endpoint | Effect |
|----------|--------|
| `POST /partner/v1/{quotes\|orders}/{id}/line-items` | Add one upgrade line |
| `DELETE /partner/v1/{quotes\|orders}/{id}/line-items/{lineId}` | Remove one upgrade line |

**POST body:** `{ "productId", "quantity", "price", "lineKey" }` — only
`productId` is required. The add is **idempotent on `lineKey`**: re-sending
the same key returns the existing line (`200`, `"created": false`) instead of
duplicating it. `lineId` in the delete path is the line's key (from the add
response or from `GET .../line-items`).

```bash
curl -X POST "https://api.shedcloud.com/partner/v1/orders/665f.../line-items" \
  -H "Authorization: Bearer <credential>" \
  -H "Content-Type: application/json" \
  -d '{ "productId": "6659f3ab8e5a2c001f9b1c44", "quantity": 1, "lineKey": "crm-line-42" }'
```

**Response `201 Created`:**

```json
{ "lineId": "crm-line-42", "productId": "6659f3ab8e5a2c001f9b1c44", "quantity": 1, "created": true }
```

`DELETE` returns `204 No Content`.

> **Pricing note:** header pricing (`subtotal` / `total`) is **not**
> recalculated when lines change — the portal recalculates client-side and
> saves; API callers should PATCH the entity's pricing fields after changing
> lines.

---

## 5. Work orders

| List | Get one | Create | Scopes |
|------|---------|--------|--------|
| `GET /partner/v1/work-orders` | `GET /partner/v1/work-orders/{id}` | [`POST /partner/v1/work-orders`](#5a-post-partnerv1work-orders--create-a-work-order) | `partner-api.work-orders.read` / `.write` |

**Query parameters:**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page`, `limit`, `order` | | | Same as the sales lists |
| `search` | string | — | Substring match on serial number, title, or work order number |
| `sort` | string | `createdAt` | `workOrderNumber`, `serialNumber`, `status`, `createdAt`, or `updatedAt` |
| `status` | string | — | e.g. `Open build`, `Build in process`, `Finished good`, `Delivered` |
| `serialNumber` | string | — | Match on serial number |
| `orderNumber` | string | — | Match on the linked sales order number |
| `linkedOrderId` | string | — | Exact linked sales order id |
| `location` | string | — | Building location id or slug |
| `createdFrom/To`, `updatedFrom/To` | date | — | Date ranges (RFC 3339 or `YYYY-MM-DD`) |

**Response item:**

```json
{
  "id": "665f0a1b2c3d4e5f60718294",
  "workOrderNumber": 5012,
  "serialNumber": "SC-2024-00123",
  "title": "10x16 Lofted Barn",
  "status": "Build in process",
  "statusChangedAt": "2026-07-02T10:15:00Z",
  "orderId": "665f0a1b2c3d4e5f60718293",
  "orderNumber": 13847,
  "location": { "id": "66c00443c2d8aa83c5757dcf", "name": "Dallas Lot", "slug": "dallas-lot" },
  "basePrice": 8200.00,
  "promisedDate": "2026-08-15T00:00:00Z",
  "createdAt": "2026-06-28T15:03:11Z",
  "updatedAt": "2026-07-02T10:15:00Z"
}
```

**Delivery block (get-by-id only).** `GET /partner/v1/work-orders/{id}` adds a
`delivery` object resolved from the unit's transportation-run assignment:

```json
{
  "delivery": {
    "scheduledDate": "2026-07-14T00:00:00Z",
    "runNumber": 87,
    "runStatus": "Scheduled",
    "driverName": "Mike Driver",
    "deliveredAt": ""
  }
}
```

`runStatus` is one of `Open`, `Scheduled`, `En Route`, `Delivered`.
`deliveredAt` is set from the work order's own status transition to
`Delivered`. The block is omitted when the unit was never assigned to a run
and isn't delivered. Delivery milestones are also emitted as `delivery.*`
[events](#17-events-feed).

### 5a. `POST /partner/v1/work-orders` — create a work order

Scope: **`partner-api.work-orders.write`**. Supports the
[`Idempotency-Key` header](#16-idempotency-keys).

Creates a manufacturing work order the same way the portal's building wizard
does: the summary starts in status **`Customer Care`**, the work order number
is allocated automatically from the company accumulator, and the friendly
type enums below map onto the internal mutually-exclusive build-type flags.

**Body fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `locationId` | string | yes | Building location id (24-char hex) **or slug** |
| `purchaseType` | string | no | `new-build` · `existing-physical-inventory` · `general` · `stock` |
| `workOrderType` | string | no | `made-to-order` · `lot-stock` · `rental-return` · `immediate-sale` · `template` |
| `deliveryType` | string | no | `delivery-from-factory` · `built-on-site` · `delivered-from-dealer` · `delivered-from-lot-stock` |
| `serialNumber` | string | no | Uniqueness enforced per company — duplicates return `409` |
| `title` | string | no | Display title |
| `sizeId` | string | no | Product size to attach (same `WORKORDER_TO_PRODUCT` link the portal writes) |
| `promisedDate` | string | no | RFC 3339 or `YYYY-MM-DD` |
| `externalReferences` | object | no | Your own ids, merged like the PATCH semantics |

Notes:

- When `sizeId` is provided, attaching the product also creates the
  manufacturing-batch BOM — **unless** `purchaseType` is
  `existing-physical-inventory`, which marks the unit as pre-existing
  physical inventory and skips BOM creation (portal parity).
- `workOrderType: template` creates a stock template: it receives the
  internal-only `Template` status so it stays off the production Kanban and
  shows up under `GET /partner/v1/stock-templates` once published.
- Each type enum is optional; when provided, its whole flag group is set
  (one true, siblings false), when omitted the group is left untouched.

```bash
curl -X POST "https://api.shedcloud.com/partner/v1/work-orders" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7" \
  -d '{
    "locationId": "dallas-lot",
    "purchaseType": "new-build",
    "workOrderType": "made-to-order",
    "deliveryType": "delivery-from-factory",
    "serialNumber": "SC-2026-00999",
    "title": "12x24 Lofted Barn",
    "sizeId": "6659f3ab8e5a2c001f9b1c22",
    "promisedDate": "2026-09-01"
  }'
```

Returns `201` with the created work order (same shape as the list item).
Errors: `400` invalid enum/field, `404` unknown location or size, `409`
duplicate serial number.

---

## 6. Locations

| List | Get one | Create | Update | Scopes |
|------|---------|--------|--------|--------|
| `GET /partner/v1/locations` | `GET /partner/v1/locations/{id}` | `POST /partner/v1/locations` | `PATCH /partner/v1/locations/{id}` | `partner-api.locations.read` / `.write` |

**List query parameters:**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page`, `limit`, `order` | | | Same as the sales lists |
| `search` | string | — | Substring match on name, code, or city |
| `active` | bool | — | Filter by active flag |
| `salesLot` | bool | — | Filter by sales-lot flag |
| `plant` | bool | — | Filter by plant flag |
| `region` | string | — | Filter by exact region label (dealer-defined; set in the portal location edit view) |
| `sort` | string | `createdAt` | `name`, `code`, `city`, `createdAt`, or `updatedAt` |

**Response item:**

```json
{
  "id": "66c00443c2d8aa83c5757dcf",
  "name": "Dallas Lot",
  "slug": "dallas-lot",
  "code": "DAL01",
  "address": "123 Main St",
  "city": "Dallas",
  "state": "TX",
  "zipCode": "75201",
  "phone": "555-0100",
  "contactPerson": "Jane Manager",
  "contactEmail": "dallas@dealer.com",
  "latitude": 32.7767,
  "longitude": -96.7970,
  "active": true,
  "salesLot": true,
  "plant": false,
  "region": "Southeast",
  "timezone": "America/Chicago",
  "storeHours": {
    "mon": { "enabled": true, "from": "08:00", "to": "17:00" },
    "tue": { "enabled": true, "from": "08:00", "to": "17:00" },
    "wed": { "enabled": true, "from": "08:00", "to": "17:00" },
    "thu": { "enabled": true, "from": "08:00", "to": "17:00" },
    "fri": { "enabled": true, "from": "08:00", "to": "17:00" },
    "sat": { "enabled": true, "from": "09:00", "to": "13:00" },
    "sun": { "enabled": false }
  },
  "createdAt": "2026-06-28T15:03:11Z",
  "updatedAt": "2026-07-02T10:15:00Z"
}
```

`storeHours` is the weekly store schedule keyed by day (`mon`..`sun`), with
24-hour `"HH:MM"` times in the location's local time. It's omitted when the
location has no schedule configured. Disabled days may still carry their last
`from`/`to` values.

`timezone` is the company-level IANA timezone (locations don't carry their
own yet) — interpret `storeHours` in this zone. `region` is a free-form,
dealer-defined grouping label edited in the portal's location edit view;
quotes and orders derive their region through their sales location
(`location.id` → `GET /partner/v1/locations/{id}`).

> **Hidden locations.** Locations flagged "Hide from Partner API" in the
> ShedCloud portal never appear in Partner API responses: they are excluded
> from the list, return `404` on get/patch, are omitted from location
> enrichment on other resources, and inventory sitting at them is excluded
> from `GET /partner/v1/lot-stock`. Creating a lead or quote against a hidden
> location returns `404 location not found`.

### 6a. `POST /partner/v1/locations` — create

`name` is required, plus **either** an `address` **or** a `latitude`/`longitude`
pair (coordinates must be provided together). `code` must be unique within the
company (`409` on duplicates). Optional fields: `slug` (derived from the name
when omitted), `city`, `state`, `zipCode`, `phone`, `contactPerson`,
`contactEmail`, `active`, `salesLot`, `plant`. Returns `201` with the created
location.

```bash
curl -X POST "https://api.shedcloud.com/partner/v1/locations" \
  -H "Authorization: Bearer <credential>" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Fort Worth Lot", "code": "FTW01", "address": "500 Elm St", "city": "Fort Worth", "state": "TX", "zipCode": "76102", "salesLot": true }'
```

### 6b. `PATCH /partner/v1/locations/{id}` — update

Accepts the same field set as create (all optional). Unknown fields are
rejected with `400`.

---

## 7. Customers

| List | Get one | Create | Update | Scopes |
|------|---------|--------|--------|--------|
| `GET /partner/v1/customers` | `GET /partner/v1/customers/{id}` | `POST /partner/v1/customers` | `PATCH /partner/v1/customers/{id}` | `partner-api.customers.read` / `.write` |

**List query parameters:**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page`, `limit`, `order` | | | Same as the sales lists |
| `search` | string | — | Substring match on name, email, or phone |
| `email` | string | — | Filter by email (substring) |
| `phone` | string | — | Filter by phone (substring) |
| `includeMerged` | bool | `false` | Include customers that were [merged](#7c-post-partnerv1customersidmerge--merge-a-duplicate) into another record |
| `createdFrom/To`, `updatedFrom/To` | date | — | Date ranges (RFC 3339 or `YYYY-MM-DD`) |
| `sort` | string | `createdAt` | `name`, `email`, `createdAt`, or `updatedAt` |

**Response item:**

```json
{
  "id": "665f0a1b2c3d4e5f60718293",
  "name": "Jane Doe",
  "contactName": "Jane Doe",
  "contactPerson": "Jane Doe",
  "email": "jane@example.com",
  "phone": "555-0100",
  "address": "42 Oak Ave",
  "city": "Dallas",
  "state": "TX",
  "zipCode": "75201",
  "code": "A1B2C",
  "active": true,
  "createdAt": "2026-06-28T15:03:11Z",
  "updatedAt": "2026-07-02T10:15:00Z"
}
```

### 7a. `POST /partner/v1/customers` — create

`email` is required and must be unique within the company (`409` on
duplicates). Optional fields: `name`, `contactName`, `contactPerson`, `phone`,
`address`, `city`, `state`, `zipCode`, `code` (random 5-char code assigned
when omitted). New customers are synced to any connected CRM, same as
portal-created customers. Returns `201` with the created customer.

```bash
curl -X POST "https://api.shedcloud.com/partner/v1/customers" \
  -H "Authorization: Bearer <credential>" \
  -H "Content-Type: application/json" \
  -d '{ "email": "jane@example.com", "name": "Jane Doe", "phone": "555-0100" }'
```

### 7b. `PATCH /partner/v1/customers/{id}` — update

Allowed fields: `name`, `contactName`, `contactPerson`, `email`, `phone`,
`address`, `city`, `state`, `zipCode`, `code`, `active` (bool). Unknown fields
are rejected with `400`. Changing `email` to one that already belongs to
another customer returns `409`. Patching a merged customer returns `409`
pointing at the survivor.

### 7c. `POST /partner/v1/customers/{id}/merge` — merge a duplicate

Requires `partner-api.customers.write`. Folds the duplicate customer in the
path (the "loser") into a surviving record — use it to dedupe after your CRM
identifies two records as the same person. Supports the
[`Idempotency-Key`](#10c-idempotency-keys) header.

**Body:** `{ "survivorId": "<customer id that absorbs the duplicate>" }`

```bash
curl -X POST "https://api.shedcloud.com/partner/v1/customers/665f.../merge" \
  -H "Authorization: Bearer <credential>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: merge-665f-2026-07-11" \
  -d '{ "survivorId": "66a0aa0bb1cc2dd3ee4ff556" }'
```

**Response `200 OK`:**

```json
{
  "survivor": { "id": "66a0aa0bb1cc2dd3ee4ff556", "name": "Jane Doe", "...": "..." },
  "mergedCustomerId": "665f0a1b2c3d4e5f60718293",
  "relinkedSalesEntities": 3
}
```

What happens:

- Every lead/quote/order pointing at the loser is **relinked to the survivor**
  (`relinkedSalesEntities` counts them).
- The loser's record is kept, flagged with merge lineage — its id stays
  resolvable forever: `GET /partner/v1/customers/{loserId}` returns it with
  `"merged": true`, `"mergedInto": "<survivorId>"`, and `"mergedAt"`.
- The loser disappears from lists (pass `includeMerged=true` to include it)
  and rejects writes with `409` pointing at the survivor.
- A `customer.merged` [event](#17-events-feed) is emitted with both ids.

**Merge error responses:**

| Status | Meaning |
|--------|---------|
| 400 | Invalid ids, or `survivorId` equals the path id |
| 404 | Either customer doesn't exist in your company |
| 409 | The customer is already merged, or the survivor is itself merged (merge into *its* survivor instead) |

---

## 8. Products (catalog)

These are the company's **finished catalog products** — the same list the
portal shows under *Inventory → Products → Products*. Raw materials and kits
are never returned (a raw/kit id on the get-one endpoint returns `404`). For
serialized on-lot units, use `GET /partner/v1/lot-stock` instead.

> **Hidden products.** Products flagged "Hide from Partner API" in the
> ShedCloud portal's product edit dialog never appear in Partner API
> responses: they are excluded from the list and return `404` on the get-one
> endpoint.

| List | Get one | Scope |
|------|---------|-------|
| `GET /partner/v1/products` | `GET /partner/v1/products/{id}` | `partner-api.products.read` |

| Create | Update | Add size | Scope |
|--------|--------|----------|-------|
| `POST /partner/v1/products` | `PATCH /partner/v1/products/{id}` | `POST /partner/v1/products/{id}/sizes` | `partner-api.products.write` |

**Query parameters:**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page`, `limit`, `order` | | | Same as the sales lists |
| `search` | string | — | Substring match on name, SKU, or description |
| `sku` | string | — | Filter by SKU (substring) |
| `active` | bool | — | Filter by active flag |
| `createdFrom/To`, `updatedFrom/To` | date | — | Date ranges (RFC 3339 or `YYYY-MM-DD`) |
| `sort` | string | `createdAt` | `name`, `sku`, `price`, `createdAt`, or `updatedAt` |

**Response item:**

```json
{
  "id": "6529b409e7d0f84e18e2a108",
  "name": "10x16 Lofted Barn",
  "description": "Lofted barn with double doors",
  "sku": "LB-1016",
  "price": 8200.00,
  "width": 10,
  "length": 16,
  "active": true,
  "images": [
    "https://cdn.shedcloud.com/public/products/front.jpg",
    "https://cdn.shedcloud.com/public/products/interior.jpg"
  ],
  "createdAt": "2026-06-28T15:03:11Z",
  "updatedAt": "2026-07-02T10:15:00Z"
}
```

`images` contains ready-to-use public URLs: the product's uploaded gallery (in
upload order) first, then any legacy image slots stored on the product record,
deduplicated. Empty array when the product has no images.

### 8a. `POST /partner/v1/products` — create

Creates a finished catalog product (partner-created products are always
sellable catalog products — raw materials and kits stay portal-only).
Supports [idempotency keys](#10c-idempotency-keys).

```json
{
  "name": "10x16 Lofted Barn",        // required
  "description": "Lofted barn with double doors",
  "sku": "LB-1016",
  "price": 8200.00,
  "width": 10,
  "length": 16,
  "active": true,                     // default true
  "lineId": "6529b409e7d0f84e18e2a100" // optional: link under a product line
}
```

- `lineId` links the new product under an existing product line (parent
  product) using the same relationship the portal's catalog uses; the id must
  belong to your company or the request fails with `404`.
- Returns `201` with the standard product item.
- For configurable models, put real pricing and dimensions on **size
  children** (below) rather than the parent — that matches how the portal's
  configurator and price lists resolve them.

### 8b. `PATCH /partner/v1/products/{id}` — update

Allowlisted fields: `name`, `description`, `sku`, `price`, `width`, `length`,
`active`. Unknown fields are rejected. Raw materials, kits, and hidden
products return `404`. Updates propagate to the master document, every
localized copy, and the denormalized relationship rows the portal reads —
partner updates are indistinguishable from portal edits.

### 8c. `POST /partner/v1/products/{id}/sizes` — add a size

Creates a size child under a catalog product and links it with the portal's
finished-good relationship. Size children carry the model's real pricing and
dimensions; they never appear in the products list themselves. Supports
[idempotency keys](#10c-idempotency-keys).

```json
{
  "name": "10x16",     // optional, defaults to "<width>x<length>"
  "sku": "LB-1016-S",  // optional
  "width": 10,         // required, > 0
  "length": 16,        // required, > 0
  "price": 8200.00,    // required, >= 0
  "active": true       // default true
}
```

Returns `201`:

```json
{
  "id": "6529b409e7d0f84e18e2a201",
  "productId": "6529b409e7d0f84e18e2a108",
  "name": "10x16",
  "sku": "LB-1016-S",
  "width": 10,
  "length": 16,
  "price": 8200.00,
  "active": true
}
```

---

## 9. Domains (white-label storefronts)

Read-only. A **domain** is a white-label storefront subdomain configured in
the portal (*Settings → Integrations → DNS*), carrying per-location
assignments: which locations sell on that domain, whether prices are hidden,
which products (and sizes) are offered, and whether the domain is the
location's **Default For Store** — its primary storefront. A location has at
most one default domain; the flag is managed from the location's *Domains*
tab in the portal.

| List | Per location | Scope |
|------|--------------|-------|
| `GET /partner/v1/domains` | `GET /partner/v1/locations/{id}/domains` | `partner-api.domains.read` |

**Query parameters (both endpoints):**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page`, `limit` | | `1` / `50` | Pagination (in-memory; domains are a small set) |
| `locationId` | string | — | *(list only)* Narrow to domains assigned to this location |
| `defaultForStore` | bool | — | `true` → only domains flagged Default For Store for their location(s) |

**Response item:**

```json
{
  "integrationId": "6613f2ab9cd0e21a34b7c001",
  "subdomain": "shop.lelandsheds.com",
  "apexDomain": "lelandsheds.com",
  "verified": true,
  "locations": [
    {
      "locationId": "66194eb4757eda220e773709",
      "name": "Leland Main Lot",
      "code": "LML",
      "hidePrices": false,
      "defaultForStore": true,
      "products": [
        {
          "productId": "6529b409e7d0f84e18e2a108",
          "sizes": ["6529b409e7d0f84e18e2a201"],
          "excludedUpgrades": ["6529b409e7d0f84e18e2a305"]
        }
      ]
    }
  ]
}
```

- One item per subdomain; a DNS integration with several subdomains produces
  several items sharing the same `integrationId`.
- `verified` reflects DNS verification (per-subdomain when present, falling
  back to the integration-level flag).
- `products` is the per-location catalog mapping for that domain: empty
  `sizes` means all sizes are offered; `excludedUpgrades` lists upgrade ids
  hidden on that storefront.
- On the per-location endpoint (and when filtering by `locationId` /
  `defaultForStore`), `locations` is narrowed to the matching entries.

---

## 9a. B2B agreements

Read-only view of the company's **B2B partnership agreements** (dealer ↔ RTO /
ERP / DMAN). Invitations, acceptance, rate proposals, and revocation stay on
the portal — the Partner API exposes only the sanitized agreement record and
per-state RTO legal configuration.

| Endpoint | Scope |
|----------|-------|
| `GET /partner/v1/agreements` | `partner-api.agreements.read` |
| `GET /partner/v1/agreements/{id}` | `partner-api.agreements.read` |
| `GET /partner/v1/agreements/active` | `partner-api.agreements.read` |
| `GET /partner/v1/agreements/{id}/state-legal` | `partner-api.agreements.read` |
| `GET /partner/v1/agreements/{id}/state-legal/{state}` | `partner-api.agreements.read` |

**Query parameters (`GET /partner/v1/agreements`):**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page`, `limit` | | `1` / `50` | Pagination |
| `status` | string | — | Filter by status (`active`, `pending`, `revoked`, …) |
| `search` | string | — | Substring match on counterparty name |

**Response item (sanitized):**

```json
{
  "id": "665f0a1b2c3d4e5f60718293",
  "direction": "DMAN_RTO",
  "status": "active",
  "from": { "companyId": "66194eb4757eda220e773709", "companyType": "DEALER", "name": "Leland Sheds" },
  "to": { "companyId": "6613f2ab9cd0e21a34b7c001", "companyType": "RTO", "name": "AFG Rentals, LLC" },
  "rateConfig": {
    "states": [{ "stateCode": "TX", "termRates": [{ "months": 36, "rate": 0.0899 }] }],
    "securityDeposit": { "type": "other", "otherPercent": 7.5 }
  },
  "rateConfigVersion": 3,
  "pendingUpdate": { "status": "pending_counterparty_acceptance", "requestedAt": "2026-06-01T12:00:00Z" },
  "orderCount": 42,
  "hasOrders": true,
  "createdAt": "2025-11-10T09:00:00Z",
  "updatedAt": "2026-06-28T15:03:11Z"
}
```

- Acceptance evidence (signatures, IP, signer PII) and internal actor ids are
  **never** included.
- `GET /partner/v1/agreements/active` returns the most recently updated active
  `DMAN_RTO` / `ERP_RTO` / `DMAN_ERP` agreement for the caller's company, or
  `404` when none exists.
- Cross-tenant agreement ids return **404** (not 403).
- RTO quotes/orders include `rto.agreementId` (the linked B2B agreement id
  stored on the sales order). `rto.providerName` still resolves on get-by-id only.

---

## 10. Updating resources

Two write shapes per resource, both requiring the resource's `.write` scope.
Every write invalidates the server-side list cache for that resource, is
attributed to your credential in the audit trail, and returns the updated
item.

### 10a. `PATCH /partner/v1/{resource}/{id}` — field updates

The body is a JSON object of partner field names. Only allowlisted fields are
accepted — an unknown or forbidden key rejects the whole request with `400`
and the list of allowed fields.

| Resource | Allowed fields |
|----------|----------------|
| Leads | `salesLocation` (location id), `salespersonName`, `salespersonEmail` |
| Quotes / Orders | `customerName`, `customerEmail`, `customerPhone`, `salespersonName`, `salespersonEmail`, `salesLocation`, `deliveryAddress`, `deliveryCity`, `deliveryState`, `deliveryZipCode` |
| Quotes only | `validUntil` (RFC 3339 or `YYYY-MM-DD`; `null` clears) — see [§4](#4-leads-quotes--sales-orders) |
| Work orders | `title`, `description`, `buildingLocation` (location id), `promisedDate` (RFC 3339 or `YYYY-MM-DD`) |

No pricing, serial number, or work-order-linking fields are writable in v1.

```bash
curl -X PATCH "https://api.shedcloud.com/partner/v1/orders/665f0a1b2c3d4e5f60718293" \
  -H "Authorization: Bearer <credential>" \
  -H "Content-Type: application/json" \
  -d '{ "customerPhone": "555-0199", "salespersonEmail": "newrep@dealer.com" }'
```

### 10b. `POST /partner/v1/{resource}/{id}/status` — status transitions

Body: `{ "status": "...", "actionDescription": "optional note" }`. Only the
transitions below are allowed through the Partner API; anything else returns
`409 Conflict`. In particular, **`Processed` can never be set via the Partner
API** — processing an order creates work orders and fires tax/CRM/email side
effects, so it stays internal-only in v1.

| Resource | Allowed transitions |
|----------|---------------------|
| Leads | `Open → Lost / Retired` · `Lost → Open` · `Retired → Open` |
| Quotes | `Open → Active / Lost / Retired` · `Active → Lost / Retired` · `Lost → Active` · `Retired → Active` |
| Orders | `Unsubmitted → Unprocessed / On hold` · `Unprocessed → On hold` · `On hold → Unprocessed` |
| Work orders | `Open build → Build in process` · `Build in process → Finished good` · `Finished good → Delivered` |

```bash
curl -X POST "https://api.shedcloud.com/partner/v1/work-orders/665f0a1b2c3d4e5f60718294/status" \
  -H "Authorization: Bearer <credential>" \
  -H "Content-Type: application/json" \
  -d '{ "status": "Finished good", "actionDescription": "Completed by build crew A" }'
```

**Write error responses:**

| Status | Meaning |
|--------|---------|
| 400 | Unknown/forbidden field, invalid value, or unknown status |
| 404 | Resource not found (or belongs to another company) |
| 409 | Status transition not allowed through the Partner API |
| 423 | Record is locked (e.g. signed contract lock) |

### 10c. Idempotency keys (all `POST` create endpoints)

Every create endpoint (`POST /partner/v1/quotes`, `/quotes/{id}/convert`,
`/leads`, `/locations`, `/customers`) accepts an optional **`Idempotency-Key`**
header — an opaque string of your choosing (a UUID is typical), max 255
characters.

When a request with a key succeeds, the response is stored for **24 hours**.
Retrying the same key within that window (e.g. after a network timeout where
you never saw the response) **replays the stored response** instead of
creating a duplicate — replayed responses carry an
`Idempotency-Replayed: true` header.

```bash
curl -X POST "https://api.shedcloud.com/partner/v1/quotes" \
  -H "Authorization: Bearer <credential>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 0d3bd9b2-6a5e-4c1f-9f6a-1c2d3e4f5a6b" \
  -d '{ ... }'
```

Rules:

| Situation | Result |
|-----------|--------|
| Same key, same body, original completed | Stored response replayed (`Idempotency-Replayed: true`) |
| Same key, **different body** | `422 Unprocessable Entity` |
| Same key while the original request is still executing | `409 Conflict` — retry shortly |
| Original request failed with a `5xx` | Key is released; the retry executes normally |
| No header | Normal, non-idempotent behavior |

Keys are scoped per company **and per endpoint**, so the same key value on two
different endpoints never collides.

### 10d. External references

Attach your own correlation ids (CRM deal id, ERP order id, …) to leads,
quotes, orders, work orders, and customers. They're stored server-side,
echoed in every DTO and every event, and filterable on lists — so you never
need a local id-mapping table.

- **Set at create**: every create body accepts an `externalReferences` object.
- **Set/merge via PATCH**: include `externalReferences` in any PATCH body.
  Keys are **merged** into the existing map; a `null` value **deletes** that
  key.
- **Filter lists**: `?externalRef=key:value` (exact match).
- **Limits**: max 10 keys per record; keys match `[A-Za-z0-9_-]{1,64}`;
  values are 1–128 characters.

```bash
curl -X PATCH "https://api.shedcloud.com/partner/v1/orders/665f0a1b2c3d4e5f60718293" \
  -H "Authorization: Bearer <credential>" \
  -H "Content-Type: application/json" \
  -d '{ "externalReferences": { "crmDealId": "D-99812", "oldKey": null } }'
```

### 10e. Optimistic concurrency (ETag / If-Match)

Every sales entity, work order, and customer carries an integer `version`,
incremented on each write (from any source — portal, internal, or partner).
Get-one responses mirror it into a strong `ETag` header (`ETag: "4"`).

PATCH requests may send `If-Match: "<version>"`:

- **Match** → the write proceeds and the response carries the new `ETag`.
- **Mismatch** → `409 Conflict` with the current version in the `ETag`
  header and an error body — re-read, re-apply, retry.
- **No `If-Match`** → last-write-wins, exactly as before (fully backward
  compatible).

```bash
curl -X PATCH "https://api.shedcloud.com/partner/v1/orders/665f0a1b2c3d4e5f60718293" \
  -H "Authorization: Bearer <credential>" \
  -H 'If-Match: "4"' \
  -H "Content-Type: application/json" \
  -d '{ "customerPhone": "555-0199" }'
```

---

## 11. Users / salespeople

Directory of the company's users, for resolving `salesperson.id` on sales
entities and understanding lead-routing membership — plus full user
create/update.

| Endpoint | Scope |
|----------|-------|
| `GET /partner/v1/users` | `partner-api.users.read` |
| `GET /partner/v1/users/{id}` | `partner-api.users.read` |
| `GET /partner/v1/roles` | `partner-api.users.read` |
| `POST /partner/v1/users` | `partner-api.users.write` |
| `PATCH /partner/v1/users/{id}` | `partner-api.users.write` |

**Query parameters:** `page`, `limit` plus optional `search` (name/email
substring) and `active` (bool).

**Response item:**

```json
{
  "id": "66a0aa0bb1cc2dd3ee4ff556",
  "name": "John Rep",
  "email": "john@dealer.com",
  "phone": "555-0100",
  "active": true,
  "locationIds": ["66c00443c2d8aa83c5757dcf"],
  "allLocations": false,
  "inLeadRoutingPool": true,
  "createdAt": "2025-04-11T09:00:00Z"
}
```

`allLocations: true` means the user isn't restricted to specific locations
(`locationIds` is empty in that case). `inLeadRoutingPool` reflects whether
the user participates in automatic lead assignment.

### 11a. `POST /partner/v1/users` — create a user

Creates a company user through the portal's own pipeline: the user
documents, the RBAC role assignment, the legacy role metadata, location
permissions, and the invitation email. Two important behaviors:

- **Existing accounts are linked, not duplicated.** When the email belongs
  to an existing ShedCloud account, that account is added to your company
  (with the role/locations you specify). If it's already a member, `409`.
- **No Cognito login is created here.** The user's login is created at
  their **first sign-in** through the invitation — send the invite (default)
  or the user can't log in.

**Body fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `firstName` / `lastName` | string | yes | Profile name |
| `email` | string | yes | Also used as the username |
| `phone` | string | no | Phone number |
| `roleId` | string | yes | Company RBAC role — discover via `GET /partner/v1/roles` |
| `locationIds` | string[] | no | Restrict to specific locations |
| `allLocations` | bool | no | Unrestricted location access (mutually exclusive with `locationIds`) |
| `invite` | bool | no | Send the invitation email (default `true`) |

Supports the [`Idempotency-Key` header](#10c-idempotency-keys).

```bash
curl -X POST "https://go.shedcloud.com/partner/v1/users" \
  -H "Authorization: Bearer sc_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "firstName": "Alex", "lastName": "Rep", "email": "alex@example.com",
    "roleId": "665f0a1b2c3d4e5f60718293",
    "locationIds": ["66c00443c2d8aa83c5757dcf"]
  }'
```

Returns `201 Created` with the standard user item. Errors: `400` invalid
body / unknown `roleId`, `409` email already belongs to a member.

### 11b. `PATCH /partner/v1/users/{id}` — update a user

Partial update; empty/omitted fields are left untouched.

| Field | Behavior |
|-------|----------|
| `firstName` / `lastName` / `email` / `phone` | Profile updates (propagated to the denormalized directory rows) |
| `roleId` | Reassigns the RBAC role (replaces the previous one) |
| `locationIds` | **Replaces** the user's location assignments |
| `allLocations` | `true` grants the unrestricted assignment |
| `active` | Enables/disables the user's membership in your company. The company owner cannot be disabled (`400`) |

Returns `200 OK` with the updated user item. Errors: `400` invalid
body / unknown `roleId` / owner-protected, `404` user not found.

### 11c. `GET /partner/v1/roles` — list assignable roles

Returns the company's RBAC roles so you can discover valid `roleId` values.
Requires `partner-api.users.read`.

```json
{
  "data": [
    {
      "id": "665f0a1b2c3d4e5f60718293",
      "name": "Salesperson",
      "key": "salesperson",
      "description": "Sales staff with quote/order access",
      "isSystem": true
    }
  ]
}
```

---

## 12. Status history

The full status timeline of a record, oldest state changes to newest —
written by every flow (portal, internal automation, and Partner API writes).

| Endpoint | Scope |
|----------|-------|
| `GET /partner/v1/leads/{id}/status-history` | `partner-api.leads.read` |
| `GET /partner/v1/quotes/{id}/status-history` | `partner-api.quotes.read` |
| `GET /partner/v1/orders/{id}/status-history` | `partner-api.orders.read` |
| `GET /partner/v1/work-orders/{id}/status-history` | `partner-api.work-orders.read` |

**Response:**

```json
{
  "data": [
    {
      "status": "Unprocessed",
      "previousStatus": "Unsubmitted",
      "description": "Submitted by partner integration",
      "changedAt": "2026-07-01T18:22:04Z",
      "actor": { "name": "John Rep", "email": "john@dealer.com" }
    }
  ],
  "page": 1,
  "limit": 50,
  "total": 3
}
```

---

## 13. Line items & configuration

The curated line items on a quote or order (standard features, added
upgrades, removed options) plus a summary of the configured building.

| Endpoint | Scope |
|----------|-------|
| `GET /partner/v1/quotes/{id}/line-items` | `partner-api.quotes.read` |
| `GET /partner/v1/orders/{id}/line-items` | `partner-api.orders.read` |

**Response:**

```json
{
  "data": [
    {
      "id": "667788990011223344556677",
      "productId": "6529b409e7d0f84e18e2a108",
      "name": "9-Lite Steel Door",
      "colorName": "Polar White",
      "quantity": 1,
      "amount": 385.00,
      "status": "added",
      "isStandardFeature": false,
      "category": "Doors",
      "side": "Front"
    }
  ],
  "totals": { "included": 12, "added": 4, "removed": 1 },
  "configuration": {
    "model": "10x16 Lofted Barn",
    "siding": "LP Smart Side Siding & Trim",
    "sidingColor": "Ivory",
    "trimColor": "Polar White",
    "roofMaterial": "Metal Roof",
    "roofColor": "Barn Red Metal"
  }
}
```

`status` is `included` (standard feature), `added`, or `removed`.
`configuration` is omitted when the record has no linked configurator.

---

## 14. Contract summary

Derived, read-only signature status of an order's sales contract. No
signature images and no customer-data-sheet PII are ever exposed.

| Endpoint | Scope |
|----------|-------|
| `GET /partner/v1/orders/{id}/contract` | `partner-api.contracts.read` |

**Response:**

```json
{
  "orderId": "665f0a1b2c3d4e5f60718293",
  "status": "completed",
  "contractVersion": "K3F9Q",
  "contractNumber": "13847",
  "customerSigned": true,
  "customerSignedAt": "2026-07-02T16:40:00Z",
  "salespersonSigned": true,
  "salespersonSignedAt": "2026-07-02T16:45:00Z",
  "signedPdfDocumentId": "66aa11bb22cc33dd44ee55ff"
}
```

`status` is one of `draft`, `out_for_signature`, `partially_signed`,
`completed`. When present, `signedPdfDocumentId` can be handed to
`GET /partner/v1/documents/{id}/download` (scope `documents.read`) to fetch
the signed PDF.

---

## 15. Payments

Payment records (card/ACH via Stripe checkout, plus manually recorded
payments). Stripe payloads are redacted — only the opaque
`providerReference` is exposed.

| Endpoint | Scope |
|----------|-------|
| `GET /partner/v1/payments` | `partner-api.payments.read` |
| `GET /partner/v1/payments/{id}` | `partner-api.payments.read` |
| `GET /partner/v1/orders/{id}/payments` | `partner-api.payments.read` |
| `POST /partner/v1/orders/{id}/payments` | `partner-api.payments.write` |
| `POST /partner/v1/orders/{id}/payment-links` | `partner-api.payments.write` |

**List query parameters:** `page`, `limit`, `orderId`, `status`
(case-insensitive exact match, e.g. `paid`, `Refunded`),
`createdFrom` / `createdTo` (RFC 3339 or `YYYY-MM-DD`).

**Response item:**

```json
{
  "id": "66bb22cc33dd44ee55ff6677",
  "orderId": "665f0a1b2c3d4e5f60718293",
  "customerId": "665f0a1b2c3d4e5f60718200",
  "amount": 1314.82,
  "method": "card",
  "status": "paid",
  "description": "Down payment + security deposit",
  "providerReference": "cs_live_a1B2c3...",
  "createdAt": "2026-07-01T18:25:11Z"
}
```

`method` is `card`, `ach`, `cash`, `check`, `financed`, or `manual`.
Refunded payments additionally carry `refundedAmount` and `refundedAt`.

### 15a. `POST /partner/v1/orders/{id}/payments` — record a manual payment

Records a payment against the order through the same pipeline the portal
uses: the payment record is inserted, the order's balance ledger is
recalculated, and an audit entry is written. The payment's status is
always `paid`. A `payment.created` event fires automatically.

**Manual methods only** — `method` must be `cash`, `check`, `financed`,
or `manual`. `card` and `ach` are rejected with `400` because those
records are created by the Stripe pipeline; use payment links instead.

**Body fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `method` | string | yes | `cash`, `check`, `financed`, or `manual` |
| `amount` | number | yes | Payment amount (> 0) |
| `description` | string | no | Defaults to `"<Method> payment"` |
| `checkNumber` | string | no | Method `check` only |
| `bankName` | string | no | Method `financed` only |

Supports the [`Idempotency-Key` header](#10c-idempotency-keys).

```bash
curl -X POST "https://go.shedcloud.com/partner/v1/orders/665f0a1b2c3d4e5f60718293/payments" \
  -H "Authorization: Bearer sc_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 3e0c1f9d-7c1a-4b0e-9f3c-2a6d8e5b4f10" \
  -d '{"method": "check", "amount": 500, "checkNumber": "1042"}'
```

Returns `201 Created` with the standard payment item (see above).
Errors: `400` invalid body/method, `404` order not found, `409`
duplicate in-flight idempotency key.

### 15b. `POST /partner/v1/orders/{id}/payment-links` — Stripe Checkout link

Creates a Stripe Checkout session for the order and returns the hosted
payment URL. Requires the company's Stripe integration to be connected
with payments enabled (`503` otherwise). When the customer completes the
checkout, the existing webhook pipeline records the payment and emits
`payment.*` events — you don't create a payment record yourself.

**Body fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `amount` | number | yes | Amount to collect (minimum 1) |
| `name` | string | no | Checkout line-item label (defaults to `"Order #<n>"`) |
| `email` | string | no | Recipient override; defaults to the order's customer email |
| `sendEmail` | bool | no | Send the payment-link email to the customer (default `true`) |
| `currency` | string | no | Defaults to `usd` |

```bash
curl -X POST "https://go.shedcloud.com/partner/v1/orders/665f0a1b2c3d4e5f60718293/payment-links" \
  -H "Authorization: Bearer sc_live_..." \
  -H "Content-Type: application/json" \
  -d '{"amount": 250, "sendEmail": true}'
```

**Response (`201 Created`):**

```json
{
  "url": "https://checkout.stripe.com/c/pay/cs_live_a1B2c3...",
  "sessionId": "cs_live_a1B2c3...",
  "expiresAt": "2026-07-14T15:00:00Z",
  "emailSent": true
}
```

Errors: `400` invalid amount / missing recipient email when
`sendEmail=true`, `404` order not found, `503` Stripe integration not
connected or payments disabled.

---

## 16. Documents

File metadata for a sales entity or work order (contract PDFs, work-order
previews, verification photos), plus short-lived download links. Internal
storage paths are never exposed.

| Endpoint | Scope |
|----------|-------|
| `GET /partner/v1/documents?entityType=...&entityId=...` | `partner-api.documents.read` |
| `GET /partner/v1/documents/{id}/download` | `partner-api.documents.read` |

**List query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `entityType` | string | yes | `order`, `quote`, or `workOrder` |
| `entityId` | string | yes | The entity's id (24-char hex) |
| `page`, `limit` | int | no | Pagination |

**List response item:**

```json
{
  "id": "66aa11bb22cc33dd44ee55ff",
  "name": "Signed contract",
  "fileName": "contract-13847.pdf",
  "type": "Contract",
  "mimeType": "application/pdf",
  "sizeBytes": 482113,
  "createdAt": "2026-07-02T16:46:02Z"
}
```

**Download response** (`GET .../{id}/download`):

```json
{
  "downloadUrl": "https://...presigned...",
  "fileName": "contract-13847.pdf",
  "expiresAt": "2026-07-02T17:00:00Z"
}
```

The URL is presigned and expires after ~10 minutes — fetch it promptly and
don't store it. Request a fresh link any time.

---

## 17. Events feed

A lossless, cursor-based change feed of everything that happens to your
company's data — from **any** source (portal users, internal automation, and
Partner API writes alike). Use it for reconciliation, or as a fallback/audit
alongside webhooks. Events are retained ~90 days.

| Endpoint | Scope |
|----------|-------|
| `GET /partner/v1/events` | `partner-api.events.read` |
| `POST /partner/v1/events/{id}/redeliver` | `partner-api.events.read` |
| `GET /partner/v1/webhook-deliveries` | `partner-api.events.read` |

**Query parameters:**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `cursor` | string | — | The last event id you processed; omit to start from the oldest retained event |
| `types` | string | — | Comma-separated event types to include (e.g. `order.status_changed,payment.paid`) |
| `limit` | int | `100` | Max events per page (max 200) |

**Response:**

```json
{
  "data": [
    {
      "id": "66cc33dd44ee55ff66778899",
      "type": "order.status_changed",
      "occurredAt": "2026-07-01T18:22:04Z",
      "resourceType": "order",
      "resourceId": "665f0a1b2c3d4e5f60718293",
      "resourceVersion": 4,
      "resourceUrl": "/partner/v1/orders/665f0a1b2c3d4e5f60718293",
      "externalReferences": { "crmDealId": "D-99812" },
      "data": { "status": "Unprocessed", "previousStatus": "Unsubmitted" }
    }
  ],
  "nextCursor": "66cc33dd44ee55ff66778899",
  "hasMore": false
}
```

Poll with `cursor=nextCursor` until `hasMore` is `false`; store the cursor
durably and resume from it after downtime — no events are lost between
polls. Deletes surface as `*.deleted` tombstone events carrying only the
resource type/id.

**Event types:**

| Family | Types |
|--------|-------|
| Sales entities | `lead\|quote\|order .created / .updated / .status_changed / .deleted` |
| Quote lifecycle | `quote.sent` (quote shared/emailed to the customer), `quote.expired` (its `validUntil` passed while still Open/Active — daily sweep; the status is not changed) |
| Order lifecycle | `order.cancelled` (emitted alongside `order.status_changed` when the status becomes `Cancelled`) |
| Work orders | `work_order.created / .updated / .status_changed / .deleted` |
| Deliveries | `delivery.scheduled / .dispatched / .delivered` — transportation-run milestones, carrying the linked `workOrderId`/`orderId` in `data` |
| Customers | `customer.created / .updated / .deleted / .merged` |
| Contracts | `contract.sent / .signed / .completed / .voided` |
| Payments | `payment.created / .paid / .failed / .refunded / .updated` |
| Documents | `document.created` |

`data` is a compact snapshot (never raw internal fields); fetch
`resourceUrl` for the full DTO when you need it.

> **No `inventory.*` events.** Inventory availability is derivable:
> `quote.created` / `order.created` take a unit off the lot,
> `order.cancelled` / `quote.status_changed` (Lost/Retired) can release it,
> and `delivery.delivered` removes it physically. Re-query
> `GET /partner/v1/lot-stock` on those triggers instead of expecting a
> dedicated inventory feed. There is also no quote **viewed** tracking.

`POST /partner/v1/events/{id}/redeliver` re-enqueues one event to every
matching active webhook subscription (returns `202` with the enqueue count) —
use it after fixing a broken endpoint. `GET /partner/v1/webhook-deliveries`
lists delivery attempts (`eventId`, `url`, `attempt`, `statusCode`, `ok`,
`durationMs`), filterable by `eventId` or `subscriptionId`, retained ~30
days.

---

## 18. Webhooks

Push delivery of the same events as the feed. Endpoints are managed in the
portal under **Settings → Company → Developer API → Webhooks** (RBAC
`partner-api.manage`): create an endpoint URL, optionally filter event
types, and store the **signing secret** shown once at create/rotate time.

**Delivery contract:**

- `POST` to your URL with the event JSON (same shape as the feed item) as
  the body, `Content-Type: application/json`.
- Headers on every delivery:
  - `X-ShedCloud-Signature: t=<unix seconds>,v1=<hex hmac>`
  - `X-ShedCloud-Event-Id: <event id>`
  - `X-ShedCloud-Event-Type: <event type>`
- Respond with any `2xx` within **10 seconds**. Anything else (or a timeout)
  is retried with backoff for roughly 24 hours, then dead-lettered.
- Delivery is **at-least-once** — dedupe by `X-ShedCloud-Event-Id`.
- Ordering is not guaranteed across events; use `occurredAt` /
  `resourceVersion` when sequence matters, or reconcile via the events feed.

**Signature verification** — compute HMAC-SHA256 over the string
`"<t>.<raw body>"` with your signing secret and compare (constant-time)
against `v1`; reject timestamps older than ~5 minutes:

```js
import crypto from "node:crypto";

function verify(secret, header, rawBody) {
  const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
  const expected = crypto.createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
```

Both official SDKs ship this as `verifyWebhookSignature` — prefer those over
hand-rolling. Python: `verify_webhook_signature`; PHP: `Webhooks::verifySignature`;
Ruby: `ShedCloud::PartnerApi::Webhooks.verify_signature`.

---

## 19. Configurator sessions

Hand your customer a **single-use link** into ShedCloud's Structura 3D
configurator — pre-attached to a customer, an existing quote's design, or an
in-stock unit — without exposing any PII or internal ids in the URL.

| Endpoint | Scope |
|----------|-------|
| `POST /partner/v1/configurator-sessions` | `partner-api.configurator-sessions.write` |

Supports the `Idempotency-Key` header.

**Body fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `customerId` | string | no | Attach an existing customer (create one first via `POST /partner/v1/customers`) |
| `locationId` | string | no | Sales location context |
| `quoteId` | string | no | Reopen this quote's saved design |
| `workOrderId` / `serialNumber` | string | no | Open an in-stock building's design (at most one of `quoteId` / `workOrderId` / `serialNumber`) |
| `returnUrl` | string | no | Where to send the shopper after saving — its **origin must be on your launch allowlist** (Settings → Developer API → Configurator Launch Allowlist) |
| `ttlSeconds` | int | no | Link lifetime, 60–3600 (default 900) |

**Response `201`:**

```json
{
  "sessionId": "66dd44ee55ff667788990011",
  "launchUrl": "https://api.shedcloud.com/api/external/partner/launch?token=plt_...",
  "expiresAt": "2026-07-11T22:33:00Z",
  "customerId": "665f0a1b2c3d4e5f60718200",
  "quoteId": "665f0a1b2c3d4e5f60718293"
}
```

**Flow:**

1. Give `launchUrl` to the shopper (button, email link, iframe redirect…).
2. Opening it **consumes the token** (one-time; expired/reused links get
   `410 Gone`) and 302-redirects into the right configurator context. The
   browser URL carries only opaque ids — never customer PII.
3. The shopper designs and saves. The authoritative signal is the
   **`quote.created` / `quote.updated` event** on your webhook/feed, carrying
   your `externalReferences` when the session was created with a customer you
   stamped.
4. If you passed `returnUrl`, the configurator offers a "return" action that
   sends the shopper back to it with only `psid` (the session id) and
   `quoteId` appended.

**Errors:** `400` invalid ids / disallowed `returnUrl` / bad `ttlSeconds`,
`404` customer/quote/work order not found in your company, `410` on the
public launch link when invalid/expired/used.

---

## 20. Scopes

Scopes are granted per credential when you create it. A request is only allowed
if its credential holds the scope the endpoint requires.

| Scope | Grants |
|-------|--------|
| `partner-api.lot-stock.read` | List on-lot inventory via `GET /partner/v1/lot-stock` |
| `partner-api.leads.read` | List and read leads |
| `partner-api.leads.write` | Create leads, update lead fields and status |
| `partner-api.quotes.read` | List and read quotes |
| `partner-api.quotes.write` | Create in-stock quotes, update quote fields and status |
| `partner-api.orders.read` | List and read sales orders |
| `partner-api.orders.write` | Update sales order fields and status, convert quotes to orders |
| `partner-api.work-orders.read` | List and read work orders |
| `partner-api.work-orders.write` | Create work orders, update fields and status |
| `partner-api.locations.read` | List and read locations |
| `partner-api.locations.write` | Create and update locations |
| `partner-api.customers.read` | List and read customers |
| `partner-api.customers.write` | Create and update customers |
| `partner-api.products.read` | List and read catalog products |
| `partner-api.products.write` | Create and update catalog products and their sizes |
| `partner-api.domains.read` | List white-label storefront domains and their location/product mappings |
| `partner-api.agreements.read` | List and read B2B partnership agreements and state legal configuration |
| `partner-api.users.read` | List and read users/salespeople, list roles |
| `partner-api.users.write` | Create and update users (profile, role, locations, enable/disable) |
| `partner-api.contracts.read` | Read contract signature summaries |
| `partner-api.payments.read` | List and read payments |
| `partner-api.payments.write` | Record manual payments and create Stripe payment links |
| `partner-api.documents.read` | List documents, generate download links |
| `partner-api.events.read` | Read the events feed and webhook delivery log, redeliver events |
| `partner-api.configurator-sessions.write` | Create configurator launch sessions |

New scopes are added as more `/partner/v1` resources ship.

---

## 21. Rate limits, token lifetime & revocation

- **Rate limiting** is per credential (per API key / per OAuth token), not per
  IP. Exceeding it returns `429 Too Many Requests` with a `Retry-After`
  header (seconds) plus `X-RateLimit-Limit` and `X-RateLimit-Remaining` so
  clients can back off precisely. Defaults: 20 req/s with a
  burst of 40 (configurable server-side via `PARTNER_API_RATE_LIMIT_RPS` /
  `PARTNER_API_RATE_LIMIT_BURST`).
- **Request correlation**: every partner response carries an `X-Request-Id`
  header (yours is echoed back when you send one; otherwise the server
  generates it). Include it when reporting issues — it links directly to the
  server-side request log.
- **Response caching**: every `GET /partner/v1/*` list response is cached
  server-side for 30 seconds per company + query combination (configurable per
  resource via `PARTNER_LOT_STOCK_CACHE_TTL_SECONDS`,
  `PARTNER_LEADS_CACHE_TTL_SECONDS`, `PARTNER_QUOTES_CACHE_TTL_SECONDS`,
  `PARTNER_ORDERS_CACHE_TTL_SECONDS`,
  `PARTNER_WORK_ORDERS_CACHE_TTL_SECONDS`,
  `PARTNER_LOCATIONS_CACHE_TTL_SECONDS`,
  `PARTNER_CUSTOMERS_CACHE_TTL_SECONDS`,
  `PARTNER_PRODUCTS_CACHE_TTL_SECONDS`; `0` disables). Writes through the
  Partner API invalidate the cache for that resource immediately. The response
  carries
  `Cache-Control`, an `X-Cache: HIT|MISS` header, and a `Server-Timing` header
  breaking down where the latency went (`db` = database work, `total` =
  end-to-end handler time in milliseconds).
- **Access token lifetime** defaults to 1 hour
  (`PARTNER_API_TOKEN_TTL_SECONDS`). Expired tokens are rejected with `401` and
  garbage-collected automatically.
- **Revocation is immediate.** Revoking an API key or OAuth app (or rotating an
  app's secret) invalidates it on the very next request. Already-issued OAuth
  access tokens continue to work until they expire — revoke the app *and* wait
  out the ≤1 hour TTL if you need to fully cut off access.

---

## 22. Operational policy

### Versioning & deprecation

- The API is versioned in the path (`/partner/v1`). Additive changes — new
  endpoints, new optional request parameters, new response fields, new event
  types — ship **without notice** and are never considered breaking. Build your
  client to ignore unknown response fields and unknown event types.
- **Breaking changes** (removing/renaming a field, changing a type, tightening
  validation, removing an endpoint) are only shipped under a new version prefix
  (`/partner/v2`). When a version is deprecated we commit to a minimum
  **6-month overlap window** during which both versions are served, announced
  through the changelog and to the technical contacts on file for each company
  with active credentials.
- Native status vocabularies (order/work-order statuses, event types) may gain
  new values over time — treat them as open enums and preserve unknown values
  rather than coercing them.

### Limits & timeouts

| Limit | Value |
|-------|-------|
| Request body size | 512 KB maximum on write endpoints |
| List page size | 100 rows maximum (`limit` is clamped) |
| Server-side request timeout | 30 seconds — design clients for a 30–60 s client timeout |
| Rate limit | 20 req/s, burst 40, per credential (429 + `Retry-After` beyond it) |
| Events feed page size | 200 events maximum |
| Webhook payload | ≤ 256 KB per event delivery |

### Data retention

- **Change events** (`GET /partner/v1/events`): retained ~**60 days**, then
  aged out. Consume the cursor feed at least weekly to stay comfortably inside
  the window; a full re-poll of the resource lists recovers anything older.
- **Webhook delivery attempts** (`GET /partner/v1/webhook-deliveries`):
  retained ~**30 days**.
- **Idempotency keys**: replay window of **24 hours** per key.
- Business records (leads, quotes, orders, work orders, customers, documents)
  are retained indefinitely under the company's own data-retention settings.

### Support & incidents

- Include the `X-Request-Id` response header with every support request — it
  links directly to the server-side request log.
- Report integration issues through your ShedCloud account contact or the
  support channel in the portal (**Help → Support**). Escalations for
  production-blocking API incidents are handled with priority over feature
  requests.
- Planned maintenance windows and incident notices are announced to the
  technical contacts on file for companies with active Partner API credentials.

---

## 23. Security notes

- Secrets are transmitted only over HTTPS and are **never logged**. Only the
  non-secret `key_prefix` / `client_id` appear in audit logs and the portal UI.
- Store secrets in a secrets manager, not in source control.
- Treat the create-response secret as the only copy — it cannot be retrieved
  again.

---

## 24. Regenerating the OpenAPI spec (maintainers)

The Swagger UI at `/partner/docs` is generated from `@`-annotations on the
handlers in [`internal/handler/partnerapi/`](../internal/handler/partnerapi/).
After changing any annotation, regenerate the spec:

```bash
swag init -g internal/handler/partnerapi/doc.go --output docs/partner-api --parseInternal
```

Commit the regenerated `docs/partner-api/` output alongside the annotation change.
