Metadata-Version: 2.5
Name: houseofapps
Version: 0.2.4
Summary: Official Python SDK for the House of Apps API
Project-URL: Homepage, https://github.com/houseofapps/house-of-apps-sdk-service
Project-URL: Documentation, https://docs.houseofapps.ai
Author: House of Apps
License-Expression: MIT
License-File: LICENSE
Keywords: api,crm,houseofapps,sdk
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: aiohttp<4,>=3.9
Requires-Dist: pydantic<3,>=2
Provides-Extra: dev
Requires-Dist: mypy>=1.11; extra == 'dev'
Requires-Dist: pre-commit>=3.7; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# House of Apps Python SDK

Official Python client for the **House of Apps** integrations API.

| | |
|---|---|
| **Package** | [`houseofapps`](https://pypi.org/project/houseofapps/) |
| **Version** | `0.2.4` |
| **Python** | 3.10+ |
| **HTTP** | `aiohttp` |
| **Models** | Pydantic v2 |
| **License** | MIT |

```bash
pip install houseofapps
```

This document is the complete SDK reference: authentication, client configuration, responses, errors, and every resource with method signatures and examples.

---

## Table of contents

1. [Concepts](#concepts)
2. [Install & imports](#install--imports)
3. [Quickstart](#quickstart)
4. [Authentication](#authentication)
5. [Client configuration](#client-configuration)
6. [Responses & pagination](#responses--pagination)
7. [Errors & retries](#errors--retries)
8. [Async client](#async-client)
9. [Typed request models](#typed-request-models)
10. [API reference](#api-reference)
11. [Version history](#version-history)

---

## Concepts

### Member-first integrations

The recommended mode passes **organization credentials** plus a **member personal access token** (`user_token`):

```python
HouseOfApps(
    license_key="...",
    app_secret="...",
    user_token="hoa_...",
)
```

| Mode | Credentials | Behavior |
|---|---|---|
| **Member (recommended)** | `license_key` + `app_secret` + `user_token` | Dual-mounted resources (leads, tasks) call **`/v2`**. Teams and profile require `user_token`. |
| **Organization (deprecated)** | `license_key` + `app_secret` only | Dual-mounted resources call **`/v1`** and emit `DeprecationWarning`. Will be removed in a future major release. |
| **Session JWT** | `access_token` (often also `license_key` + `app_secret` for integrations) | Bearer JWT for app APIs: `integration_user_tokens`, `organizations`, `sessions`, `audit_logs`, `dashboard`, `list_views`, `organization_list_views`, `verification_codes`, and `from_session`. |

### How URLs are built

- Resource paths are written without a version prefix (e.g. `/integrations/leads`).
- The SDK selects `/v1` or `/v2` and prepends it.
- Absolute paths already starting with `/v1/` or `/v2/` are preserved (no double prefix).
- Final URL: `{base_url}/v1/...` or `{base_url}/v2/...`.

Default production host: `https://api.houseofapps.ai`.

### Resource kinds

| Kind | Base | Auth | API version |
|---|---|---|---|
| Organization integrations | most CRM/IAM resources | `licenseKey` + `appSecret` (+ optional `userToken`) | always `/v1` |
| Versioned integrations | `leads`, `tasks` | keys; `user_token` recommended | `/v2` if present, else deprecated `/v1` |
| Member-only integrations | `teams`, profile methods | keys + **`user_token` required** | always `/v2` |
| Session JWT | `organizations`, `sessions`, `audit_logs`, `dashboard`, `list_views`, `organization_list_views`, `verification_codes`, `integration_user_tokens` | **`access_token` required** | `/v1` with `Authorization: Bearer` |

`AsyncHouseOfApps` mirrors the sync client 1:1 — every method is `async` and must be `await`ed.

---

## Install & imports

```bash
pip install houseofapps
# Requires Python 3.10+
```

```python
from houseofapps import (
    HouseOfApps,
    AsyncHouseOfApps,
    __version__,
)
from houseofapps.errors import (
    HouseOfAppsError,
    ConfigurationError,
    APIError,
    AuthenticationError,
    NotFoundError,
    RateLimitError,
)
```

---

## Quickstart

### Recommended: member client

```python
from houseofapps import HouseOfApps

with HouseOfApps(
    license_key="your-license-key",
    app_secret="your-app-secret",
    user_token="hoa_...",
) as client:
    leads = client.leads.list(page=1, page_size=20)
    print(f"{leads.total} leads")
    for lead in leads.items:
        print(lead["id"], lead.get("name"))

    profile = client.users.get_profile()
    teams = client.teams.list()
```

### Mint a `user_token` from a session JWT

Use the JWT from your login / select-org flow:

```python
from houseofapps import HouseOfApps

client = HouseOfApps.from_session(
    license_key="...",
    app_secret="...",
    access_token="<session jwt>",
    rotate=True,  # rotate existing PAT if one exists; set False to create only
)
print(client.user_token)  # hoa_<prefix>_<secret> — store securely; shown once
print(client.leads.list().total)
client.close()
```

### Environment variables

```bash
export HOUSEOFAPPS_LICENSE_KEY="..."
export HOUSEOFAPPS_APP_SECRET="..."
export HOUSEOFAPPS_USER_TOKEN="hoa_..."
```

```python
from houseofapps import HouseOfApps

with HouseOfApps() as client:
    print(client.contacts.list(page_size=5).items)
```

---

## Authentication

### Credentials

| Parameter | Env var | Sent as | Required when |
|---|---|---|---|
| `license_key` | `HOUSEOFAPPS_LICENSE_KEY` | header `licenseKey` | Most integration calls |
| `app_secret` | `HOUSEOFAPPS_APP_SECRET` | header `appSecret` | Most integration calls |
| `user_token` | `HOUSEOFAPPS_USER_TOKEN` | header `userToken` | Member mode; required for teams & profile |
| `access_token` | `HOUSEOFAPPS_ACCESS_TOKEN` | `Authorization: Bearer …` | PAT create/rotate/revoke / `from_session` |
| `house_of_apps_ai_key` | `HOUSEOFAPPS_AI_KEY` | header `House-Of-Apps-Ai-Key` | `email_templates.create` |

Always sent:

| Header | Value |
|---|---|
| `Accept` | `application/json` |
| `User-Agent` | `houseofapps-python/{version}` |

Missing required credentials raise `ConfigurationError` before the HTTP call.

### Choosing the right credential set

```python
# 1) Day-to-day API usage (member)
HouseOfApps(license_key="...", app_secret="...", user_token="hoa_...")

# 2) Issue or rotate a PAT, then use the returned client
HouseOfApps.from_session(license_key="...", app_secret="...", access_token="<jwt>")

# 3) Manage PATs explicitly
with HouseOfApps(license_key="...", app_secret="...", access_token="<jwt>") as client:
    token = client.integration_user_tokens.create()
    print(token["user_token"])
```

### `from_session` details

```python
HouseOfApps.from_session(
    *,
    license_key=None,
    app_secret=None,
    access_token=None,
    base_url=None,
    environment=None,
    rotate=True,   # True → rotate endpoint; False → create endpoint
    **kwargs,      # forwarded to constructor (timeout, max_retries, …)
) -> HouseOfApps
```

Async:

```python
client = await AsyncHouseOfApps.from_session(
    license_key="...",
    app_secret="...",
    access_token="<jwt>",
)
```

---

## Client configuration

### Constructor

```python
HouseOfApps(
    *,
    license_key=None,
    app_secret=None,
    base_url=None,
    environment=None,          # "production" | "prod" | "development" | "dev"
    house_of_apps_ai_key=None,
    user_token=None,
    access_token=None,
    timeout=60.0,              # float seconds or aiohttp.ClientTimeout
    max_retries=2,
    default_headers=None,
    http_client=None,          # optional aiohttp.ClientSession
)
```

`AsyncHouseOfApps` accepts the same keyword arguments.

### Environments / base URL

| Value | Host |
|---|---|
| `production` / `prod` (default) | `https://api.houseofapps.ai` |
| `development` / `dev` | `https://api-dev.houseofapps.ai` |

Resolution order:

1. `base_url=` argument  
2. `HOUSEOFAPPS_BASE_URL`  
3. `environment=` / `HOUSEOFAPPS_ENVIRONMENT`  
4. Production default  

Override `base_url` only for non-default deployments (for example a private gateway). Trailing `/` and trailing `/v1` are stripped.

```python
client = HouseOfApps(
    license_key="...",
    app_secret="...",
    user_token="hoa_...",
    environment="development",
)
```

### Timeouts, retries, headers

```python
import aiohttp
from houseofapps import HouseOfApps

client = HouseOfApps(
    license_key="...",
    app_secret="...",
    user_token="hoa_...",
    timeout=aiohttp.ClientTimeout(total=30, connect=5),
    max_retries=2,
    default_headers={"X-Correlation-Id": "batch-42"},
)
```

Retries (default `max_retries=2` → up to 3 attempts):

- HTTP **429** and **503**
- Idempotent methods (`GET` / `HEAD` / `OPTIONS`) on **5xx**
- Backoff uses `Retry-After` when present; otherwise exponential delay with jitter

### `copy` and `with_options`

```python
# Full clone with optional overrides (pins resolved host)
other = client.copy(user_token="hoa_other...", timeout=15.0)

# Transport-only clone
fast = client.with_options(timeout=10.0, max_retries=0)
fast.leads.list()
```

### Custom HTTP session

```python
import aiohttp
from houseofapps import HouseOfApps

session = aiohttp.ClientSession()
client = HouseOfApps(
    license_key="...",
    app_secret="...",
    user_token="hoa_...",
    http_client=session,
)
# client.close() does NOT close an injected session — you own it
client.close()
session.close()
```

### Lifecycle

```python
# Preferred
with HouseOfApps(...) as client:
    ...

# Manual
client = HouseOfApps(...)
try:
    ...
finally:
    client.close()
```

---

## Responses & pagination

### Success payloads

- **List helpers** (`list_response=True`) → `ListResult`
- **Other methods** → the API envelope’s `data` field (usually a `dict`), or the raw body if there is no `data` key

### `ListResult`

| Field | Type | Description |
|---|---|---|
| `items` | `list` | Page of records |
| `total` | `int` | Total matching records |
| `page` | `int` | Current page (1-based) |
| `page_size` | `int` | Page size |
| `total_pages` | `int` | Total pages |
| `message` | `str \| None` | Envelope message |
| `status_code` | `int \| None` | Envelope `statusCode` |
| `code` | `str \| None` | Business code |
| `raw` | `dict` | Full envelope |

```python
page = client.contacts.list(page=1, page_size=50)
print(page.total, page.page, page.page_size, page.total_pages)
for contact in page.items:
    print(contact["id"], contact.get("email"))
```

### Walking all pages

```python
def iter_all_contacts(client, *, page_size: int = 100):
    page = 1
    while True:
        result = client.contacts.list(page=page, page_size=page_size)
        yield from result.items
        if page >= result.total_pages or not result.items:
            break
        page += 1
```

There is no auto-iterator helper today — page explicitly with `page` / `page_size`.

---

## Errors & retries

### Hierarchy

```text
HouseOfAppsError
├── ConfigurationError          # missing/invalid client config
└── APIError                    # non-success HTTP / API envelope
    ├── AuthenticationError     # 401, 403
    ├── NotFoundError           # 404
    └── RateLimitError          # 429 (+ retry_after)
```

### `APIError` fields

| Attribute | Meaning |
|---|---|
| `message` | Human-readable error |
| `status_code` | HTTP status |
| `code` | API business code |
| `errors` | Field/detail list when present |
| `response` | Parsed body |
| `request_id` | From `X-Request-Id` / `x-request-id` |

`RateLimitError.retry_after` is seconds when provided by the server.

### Example

```python
from houseofapps import HouseOfApps
from houseofapps.errors import (
    AuthenticationError,
    NotFoundError,
    RateLimitError,
    APIError,
    ConfigurationError,
)

try:
    with HouseOfApps(license_key="...", app_secret="...", user_token="hoa_...") as client:
        lead = client.leads.get("missing-id")
except ConfigurationError as exc:
    print("config:", exc)
except AuthenticationError as exc:
    print("auth:", exc.status_code, exc.request_id)
except NotFoundError as exc:
    print("missing:", exc.message)
except RateLimitError as exc:
    print("rate limited; retry after", exc.retry_after)
except APIError as exc:
    print(exc.status_code, exc.code, exc.errors, exc.request_id)
```

The SDK already retries transient failures. Catch `RateLimitError` when retries are exhausted, or set `max_retries=0` to handle retries yourself.

---

## Async client

```python
import asyncio
from houseofapps import AsyncHouseOfApps

async def main() -> None:
    async with AsyncHouseOfApps(
        license_key="...",
        app_secret="...",
        user_token="hoa_...",
    ) as client:
        leads, profile, teams = await asyncio.gather(
            client.leads.list(page_size=10),
            client.users.get_profile(),
            client.teams.list(),
        )
        print(leads.total, profile, teams.total)

asyncio.run(main())
```

```python
client = await AsyncHouseOfApps.from_session(
    license_key="...",
    app_secret="...",
    access_token="<jwt>",
)
await client.aclose()
```

Do not call sync `HouseOfApps` methods from a running event loop — use `AsyncHouseOfApps` inside FastAPI / asyncio apps.

---

## Typed request models

Resource `body` arguments accept a **`dict`** or a **Pydantic v2 model** from `houseofapps.models.*`. Models are serialized with:

```text
model_dump(mode="json", by_alias=True, exclude_none=True)
```

```python
from houseofapps.models.contacts import CreateContactRequest
from houseofapps.models.companies import CreateCompanyRequest
from houseofapps.models.leads import CreateLeadRequest
from houseofapps.models.invites import InviteCreateRequest
from houseofapps.models.roles import CreateRoleRequest
from houseofapps.models.users import PatchUserRequest, UpdateUserEmailRequest

contact = client.contacts.create(
    CreateContactRequest(
        email="jane@acme.com",
        first_name="Jane",
        last_name="Doe",
        tags=["vip"],
    )
)

company = client.companies.create(
    CreateCompanyRequest(name="Acme Corp", industry="Software")
)

lead = client.leads.create(
    CreateLeadRequest(name="Acme expansion", lead_source="website", amount="25000", currency="USD")
)

invite = client.invites.create(
    InviteCreateRequest(
        email="newhire@acme.com",
        first_name="Alex",
        role_id="550e8400-e29b-41d4-a716-446655440000",
    )
)

client.users.patch(user_id, PatchUserRequest(role_id="..."))
client.users.update_email(user_id, UpdateUserEmailRequest(email="new@acme.com"))
```

### Common required fields

| Model | Required |
|---|---|
| `CreateContactRequest` | `email` |
| `CreateCompanyRequest` | `name` |
| `CreateLeadRequest` | `name` (if `amount` set, `currency` is also required) |
| `InviteCreateRequest` | `email`, `first_name`, `role_id` |
| `CreateRoleRequest` | `name`, `permission_ids` |
| `PatchUserRequest` | at least one of `role_id`, `custom_fields` |
| Contacts import body | `file_url`, `schema_version` (`file_type` defaults to `CSV`) |

Response bodies are returned as dicts / `ListResult` items today (not auto-cast to domain models).

---

## API reference

Unless noted, examples assume:

```python
from houseofapps import HouseOfApps

client = HouseOfApps(
    license_key="...",
    app_secret="...",
    user_token="hoa_...",
)
```

Paths below are logical resource paths. The SDK adds `/v1` or `/v2` as described in [Concepts](#concepts).

---

### Companies — `client.companies`

Organization companies (`/v1/integrations/clients/companies`).

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `list(*, search=None, status=None, page=1, page_size=20)` | GET | `/integrations/clients/companies` | `ListResult` |
| `create(body)` | POST | `/integrations/clients/companies` | data |
| `get(client_id)` | GET | `/integrations/clients/companies/{client_id}` | data |
| `update(client_id, body)` | PATCH | `/integrations/clients/companies/{client_id}` | data |
| `delete(client_id)` | DELETE | `/integrations/clients/companies/{client_id}` | data |
| `activity(company_id, *, page=1, page_size=20)` | GET | `/integrations/clients/companies/activity/{company_id}/` | `ListResult` |
| `search(query, *, status=None, page=1, page_size=20)` | GET | `/integrations/clients/companies/search` | `ListResult` |
| `enrich(company_id)` | POST | `/integrations/clients/companies/{company_id}/enrich` | data |

```python
page = client.companies.list(search="acme", page=1, page_size=20)
print(page.total, len(page.items))

company = client.companies.create(
    {
        "name": "Acme Corp",
        "industry": "Software",
        "email": "hello@acme.com",
        "tags": ["enterprise"],
        "description": "Example company",
    }
)
company_id = company["id"]

got = client.companies.get(company_id)
updated = client.companies.update(company_id, {"description": "Updated"})
hits = client.companies.search("Acme", page=1)
activity = client.companies.activity(company_id, page=1, page_size=20)
enriched = client.companies.enrich(company_id)
# client.companies.delete(company_id)
```

---

### Contacts — `client.contacts`

Person contacts (`/v1/integrations/clients/contacts`).

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `list(*, search=None, status=None, page=1, page_size=20)` | GET | `/integrations/clients/contacts` | `ListResult` |
| `create(body)` | POST | `/integrations/clients/contacts` | data |
| `get(client_id)` | GET | `/integrations/clients/contacts/{client_id}` | data |
| `update(client_id, body)` | PATCH | `/integrations/clients/contacts/{client_id}` | data |
| `delete(client_id)` | DELETE | `/integrations/clients/contacts/{client_id}` | data |
| `activity(contact_id, *, page=1, page_size=20)` | GET | `/integrations/clients/contacts/activity/{contact_id}/` | `ListResult` |
| `search(query, *, status=None, page=1, page_size=20)` | GET | `/integrations/clients/contacts/search` | `ListResult` |
| `enrich(contact_id)` | POST | `/integrations/clients/contacts/{contact_id}/enrich` | data |
| `by_email(email)` | GET | `/integrations/clients/contacts/by-email` | data |
| `by_phone(body)` | POST | `/integrations/clients/contacts/by-phone` | data |
| `lookup(body)` | POST | `/integrations/clients/contacts/lookup` | data |
| `create_import(body)` | POST | `/integrations/clients/contacts/imports` | data |
| `get_import(job_id)` | GET | `/integrations/clients/contacts/imports/{job_id}` | data |
| `get_import_errors(job_id, *, page=1, page_size=50)` | GET | `.../imports/{job_id}/errors` | `ListResult` |
| `retry_import(job_id)` | POST | `.../imports/{job_id}/retry` | data |

**Create body (minimum):** `{ "email": "…" }`  
Optional: `first_name`, `last_name`, `title`, `tags`, `phones`, `portal_access`, nested company/lead association, etc.

```python
contact = client.contacts.create(
    {
        "email": "jane@acme.com",
        "first_name": "Jane",
        "last_name": "Doe",
        "title": "VP Engineering",
        "tags": ["decision-maker"],
        "phones": [{"number": "+15551234567", "type": "mobile"}],
    }
)
contact_id = contact["id"]

found = client.contacts.by_email("jane@acme.com")
lookup = client.contacts.lookup({"email": "jane@acme.com"})
by_phone = client.contacts.by_phone({"phone": "+15551234567"})

page = client.contacts.list(search="jane", status=None, page=1, page_size=20)
client.contacts.update(contact_id, {"title": "CTO"})
activity = client.contacts.activity(contact_id)
hits = client.contacts.search("Doe")
# client.contacts.enrich(contact_id)
```

#### Contact CSV import

```python
job = client.contacts.create_import(
    {
        "file_url": "https://storage.example.com/contacts.csv",
        "file_type": "CSV",
        "schema_version": 1,
        "mapping": {
            "email": "Email",
            "first_name": "First Name",
            "last_name": "Last Name",
        },
    }
)
job_id = job["job_id"]
status = client.contacts.get_import(job_id)
errors = client.contacts.get_import_errors(job_id, page=1, page_size=50)
# client.contacts.retry_import(job_id)
```

---

### Variables — `client.variables`

Template / merge variables.

| Method | HTTP | Path |
|---|---|---|
| `list(*, entity_type="contact")` | GET | `/integrations/clients/variables` |

```python
contact_vars = client.variables.list(entity_type="contact")
lead_vars = client.variables.list(entity_type="lead")
```

---

### Leads — `client.leads`

**Versioned:** `/v2` with `user_token`, else deprecated `/v1`.

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `list(*, stage_id=None, search=None, page=1, page_size=20)` | GET | `/integrations/leads` | `ListResult` |
| `get(lead_id)` | GET | `/integrations/leads/{lead_id}` | data |
| `create(body)` | POST | `/integrations/leads` | data |
| `update(lead_id, body)` | PATCH | `/integrations/leads/{lead_id}` | data |
| `delete(lead_id)` | DELETE | `/integrations/leads/{lead_id}` | data |
| `activity(lead_id, *, page=1, page_size=20)` | GET | `/integrations/leads/activity/{lead_id}/` | `ListResult` |
| `search(query, *, stage_id=None, page=1, page_size=20)` | GET | `/integrations/leads/search` | `ListResult` |

**Create body (minimum):** `{ "name": "…" }`  
If you set `amount`, also set `currency` (ISO 4217). Optional: `stage_id`, `lead_source`, `description`, `owner_id`, nested contact/company create blocks.

```python
lead = client.leads.create(
    {
        "name": "Acme expansion deal",
        "lead_source": "website",
        "amount": "25000",
        "currency": "USD",
        "description": "Expansion opportunity",
    }
)
lead_id = lead["id"]

page = client.leads.list(page=1, page_size=20, search="Acme")
detail = client.leads.get(lead_id)
client.leads.update(lead_id, {"description": "Q3 priority"})
activity = client.leads.activity(lead_id, page=1)
hits = client.leads.search("expansion", page=1)
# client.leads.delete(lead_id)
```

---

### Lead stages — `client.lead_stages`

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `create(body)` | POST | `/integrations/lead-stages` | data |
| `list()` | GET | `/integrations/lead-stages` | `ListResult` |
| `get(stage_id)` | GET | `/integrations/lead-stages/{stage_id}` | data |
| `update(stage_id, body)` | PATCH | `/integrations/lead-stages/{stage_id}` | data |
| `delete(stage_id)` | DELETE | `/integrations/lead-stages/{stage_id}` | data |

```python
stages = client.lead_stages.list()
for stage in stages.items:
    print(stage.get("id"), stage.get("name"), stage.get("sort_order"))

created = client.lead_stages.create({"name": "Qualified", "sort_order": 2})
client.lead_stages.update(created["id"], {"name": "SQL"})
# client.lead_stages.delete(created["id"])
```

---

### Email templates — `client.email_templates`

| Method | HTTP | Path | Notes |
|---|---|---|---|
| `list(*, template_type=None, status=None)` | GET | `/integrations/email-templates` | `ListResult` |
| `get(template_id)` | GET | `/integrations/email-templates/{template_id}` | |
| `create(body)` | POST | `/integrations/email-templates` | **Requires AI key** |
| `update(template_id, body)` | PATCH | `/integrations/email-templates/{template_id}` | |
| `delete(template_id)` | DELETE | `/integrations/email-templates/{template_id}` | |
| `render(template_id, body)` | POST | `.../{template_id}/render` | |
| `send(template_id, body)` | POST | `.../{template_id}/send` | |
| `generate(body)` | POST | `/integrations/email-templates/generate` | AI assist |

```python
client = HouseOfApps(
    license_key="...",
    app_secret="...",
    user_token="hoa_...",
    house_of_apps_ai_key="...",  # required for create()
)

templates = client.email_templates.list(template_type="TRIGGER", status="DRAFT")

created = client.email_templates.create(
    {
        "name": "Welcome email",
        "template_type": "TRIGGER",
        "subject": "Welcome aboard",
        "html_content": "<p>Hello {{first_name}}</p>",
        "status": "DRAFT",
    }
)

rendered = client.email_templates.render(
    created["id"],
    {
        "variable_values": {"first_name": "Jane"},
        # "layout_id": "...",
    },
)

draft = client.email_templates.generate(
    {"query": "Write a short onboarding email for new customers"}
)

# client.email_templates.send(created["id"], {...})
# client.email_templates.update(created["id"], {"status": "ACTIVE"})
# client.email_templates.delete(created["id"])
```

---

### Lists — `client.lists`

Saved entity lists. `list()` requires `entity_type`.

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `create(body)` | POST | `/integrations/lists` | data |
| `list(*, entity_type, status=None, search=None, page=1, page_size=20)` | GET | `/integrations/lists` | `ListResult` |
| `get(list_id)` | GET | `/integrations/lists/{list_id}` | data |
| `update(list_id, body)` | PATCH | `/integrations/lists/{list_id}` | data |
| `delete(list_id)` | DELETE | `/integrations/lists/{list_id}` | data |

```python
lists = client.lists.list(entity_type="contact", page=1, page_size=20)
created = client.lists.create({"name": "VIP contacts", "entity_type": "contact"})
client.lists.update(created["id"], {"name": "VIP contacts (updated)"})
# client.lists.delete(created["id"])
```

---

### Custom fields — `client.custom_fields`

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `create(body)` | POST | `/integrations/custom-fields` | data |
| `list(*, entity_type)` | GET | `/integrations/custom-fields` | `ListResult` |
| `get(field_id)` | GET | `/integrations/custom-fields/{field_id}` | data |
| `update(field_id, body)` | PATCH | `/integrations/custom-fields/{field_id}` | data |
| `delete(field_id)` | DELETE | `/integrations/custom-fields/{field_id}` | data |

```python
fields = client.custom_fields.list(entity_type="lead")
for field in fields.items:
    print(field.get("id"), field.get("name"), field.get("field_type"))
```

---

### Filter fields — `client.filter_fields`

| Method | HTTP | Path |
|---|---|---|
| `list(*, entity_type)` | GET | `/integrations/filter-fields` |

```python
filters = client.filter_fields.list(entity_type="lead")
```

---

### Installed apps — `client.installed_apps`

| Method | HTTP | Path |
|---|---|---|
| `list()` | GET | `/integrations/installed-apps` |
| `batch_update(body)` | POST | `/integrations/installed-apps` |

```python
apps = client.installed_apps.list()
client.installed_apps.batch_update({"apps": []})
```

---

### Product interests — `client.product_interests`

| Method | HTTP | Path |
|---|---|---|
| `catalog(*, type=None, search=None)` | GET | `/integrations/product-interests/catalog` |
| `list(owner_type, owner_id, *, type=None)` | GET | `/integrations/product-interests/{owner_type}/{owner_id}` |
| `add(owner_type, owner_id, body)` | POST | same |
| `remove(owner_type, owner_id, body)` | DELETE | same (JSON body allowed) |

```python
catalog = client.product_interests.catalog(search="crm")
assigned = client.product_interests.list("contact", contact_id)
client.product_interests.add("contact", contact_id, {"product_interest_ids": ["..."]})
client.product_interests.remove("contact", contact_id, {"product_interest_ids": ["..."]})
```

---

### Users — `client.users`

Org member admin on `/v1`. Profile methods are **member-only** on `/v2` and require `user_token`.

| Method | HTTP | Path | Notes |
|---|---|---|---|
| `list(*, search=None, page=1, page_size=20, dropdown_filters=None)` | GET | `/integrations/users/list` | `ListResult` |
| `patch(user_id, body)` | PATCH | `/integrations/users/{user_id}` | role / custom fields |
| `update_email(user_id, body)` | PUT | `/integrations/users/{user_id}/email` | `{ "email": "…" }` |
| `ban(user_id)` | POST | `/integrations/users/ban/{user_id}` | |
| `unban(user_id)` | POST | `/integrations/users/unban/{user_id}` | |
| `get_profile()` | GET | `/v2/integrations/users/profile` | **requires `user_token`** |
| `update_profile(body)` | PATCH | `/v2/integrations/users/profile` | **requires `user_token`** |

```python
members = client.users.list(search="jane", page=1, page_size=20)
user_id = members.items[0]["id"]

client.users.patch(user_id, {"role_id": "550e8400-e29b-41d4-a716-446655440000"})
client.users.update_email(user_id, {"email": "jane.new@acme.com"})
# client.users.ban(user_id)
# client.users.unban(user_id)

profile = client.users.get_profile()
client.users.update_profile({"first_name": "Jane"})
```

---

### Invites — `client.invites`

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `create(body)` | POST | `/integrations/invites` | data |
| `list(*, page=1, page_size=20, status=None)` | GET | `/integrations/invites` | `ListResult` |
| `resend(invite_id)` | PUT | `/integrations/invites/{invite_id}/resend` | data |
| `patch(invite_id, body)` | PATCH | `/integrations/invites/{invite_id}` | data |
| `delete(invite_id)` | DELETE | `/integrations/invites/{invite_id}` | data |

**Create required:** `email`, `first_name`, `role_id`.

```python
invite = client.invites.create(
    {
        "email": "newhire@acme.com",
        "first_name": "Alex",
        "last_name": "Nguyen",
        "role_id": "550e8400-e29b-41d4-a716-446655440000",
    }
)
pending = client.invites.list(status="PENDING", page=1, page_size=20)
client.invites.resend(invite["id"])
client.invites.patch(invite["id"], {"role_id": "550e8400-e29b-41d4-a716-446655440000"})
# client.invites.delete(invite["id"])
```

---

### Roles — `client.roles`

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `list(*, search=None, page=1, page_size=20)` | GET | `/integrations/roles` | `ListResult` |
| `get(role_id)` | GET | `/integrations/roles/{role_id}` | data |
| `create(body)` | POST | `/integrations/roles` | data |
| `update(role_id, body)` | PUT | `/integrations/roles/{role_id}` | data |
| `delete(role_id)` | DELETE | `/integrations/roles/{role_id}` | data |

```python
role = client.roles.create(
    {
        "name": "Project Manager",
        "description": "Manages projects and team members",
        "permission_ids": ["6ba7b810-9dad-11d1-80b4-00c04fd430c8"],
    }
)
roles = client.roles.list(search="Manager")
detail = client.roles.get(role["id"])
client.roles.update(role["id"], {"description": "Updated"})
# client.roles.delete(role["id"])
```

---

### Permissions — `client.permissions`

| Method | HTTP | Path |
|---|---|---|
| `list()` | GET | `/integrations/permissions` |
| `get(permission_id)` | GET | `/integrations/permissions/{permission_id}` |

```python
catalog = client.permissions.list()
# shape is API data (often a list); inspect before indexing
one = client.permissions.get(permission_id)
```

---

### Teams — `client.teams`

**Member-only** (`user_token` required). Always `/v2`.

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `list(*, page=1, page_size=50, search=None)` | GET | `/integrations/teams` | `ListResult` |
| `create(body)` | POST | `/integrations/teams` | data |
| `get(team_id)` | GET | `/integrations/teams/{team_id}` | data |
| `update(team_id, body)` | PUT | `/integrations/teams/{team_id}` | data |
| `delete(team_id)` | DELETE | `/integrations/teams/{team_id}` | data |

```python
teams = client.teams.list(page=1, page_size=50, search="Sales")
team = client.teams.create(
    {
        "name": "Sales",
        "description": "Outbound sales",
        "member_ids": [],
    }
)
got = client.teams.get(team["id"])
client.teams.update(team["id"], {"description": "Updated"})
# client.teams.delete(team["id"])
```

Calling `client.teams.*` without `user_token` raises `ConfigurationError`.

---

### Tasks — `client.tasks`

**Versioned** Content Planner tasks (`/v2` with `user_token`).

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `list(*, board_id=None, assignee_id=None, overdue=None, completed=None, stage_id=None, priority=None, due_before=None, due_after=None, search=None)` | GET | `/integrations/tasks` | `ListResult` (`search` → query `q`) |
| `list_overdue(*, board_id=None, assignee_id=None)` | GET | `/integrations/tasks/overdue` | `ListResult` |
| `user_summary(user_id)` | GET | `/integrations/tasks/users/{user_id}/summary` | data |
| `get(task_id)` | GET | `/integrations/tasks/{task_id}` | data |
| `update(task_id, body)` | PATCH | `/integrations/tasks/{task_id}` | data |
| `delete(task_id)` | DELETE | `/integrations/tasks/{task_id}` | data |
| `list_notes(task_id)` | GET | `/integrations/tasks/{task_id}/notes` | `ListResult` |
| `add_note(task_id, body)` | POST | `/integrations/tasks/{task_id}/notes` | data |
| `add_end_date_change(task_id, body)` | POST | `/integrations/tasks/{task_id}/end-dates` | data |
| `add_attachment(task_id, body)` | POST | `/integrations/tasks/{task_id}/attachments` | data |
| `add_checklist_item(task_id, body)` | POST | `/integrations/tasks/{task_id}/checklist` | data |
| `remove_checklist_item(task_id, item_id)` | DELETE | `.../checklist/{item_id}` | data |
| `list_timeline(task_id)` | GET | `/integrations/tasks/{task_id}/timeline` | `ListResult` |

```python
tasks = client.tasks.list(board_id=board_id, search="blog", completed=False)
overdue = client.tasks.list_overdue(board_id=board_id)
summary = client.tasks.user_summary(user_id)

task = client.tasks.get(task_id)
client.tasks.update(task_id, {"title": "Draft blog post (v2)", "priority": "high"})
client.tasks.add_note(task_id, {"body": "Outline approved"})
client.tasks.add_checklist_item(task_id, {"title": "Write draft"})
timeline = client.tasks.list_timeline(task_id)
notes = client.tasks.list_notes(task_id)
# client.tasks.delete(task_id)
```

---

### Boards — `client.boards`

Content Planner boards and nested stages/tasks (`/v1`).

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `list(*, company_id=None, search=None, status=None)` | GET | `/integrations/boards` | `ListResult` (`search` → `q`) |
| `create(body)` | POST | `/integrations/boards` | data |
| `get(board_id, *, include_stages=True)` | GET | `/integrations/boards/{board_id}` | data |
| `summary(board_id)` | GET | `/integrations/boards/{board_id}/summary` | data |
| `update(board_id, body)` | PATCH | `/integrations/boards/{board_id}` | data |
| `delete(board_id)` | DELETE | `/integrations/boards/{board_id}` | data |
| `list_tasks(board_id, **params)` | GET | `/integrations/boards/{board_id}/tasks` | `ListResult` |
| `create_task(board_id, body)` | POST | `/integrations/boards/{board_id}/tasks` | data |
| `list_stages(board_id)` | GET | `/integrations/boards/{board_id}/stages` | `ListResult` |
| `create_stage(board_id, body)` | POST | `/integrations/boards/{board_id}/stages` | data |
| `replace_stages(board_id, body)` | PUT | `/integrations/boards/{board_id}/stages` | data |
| `update_stage(board_id, stage_id, body)` | PATCH | `.../stages/{stage_id}` | data |
| `delete_stage(board_id, stage_id)` | DELETE | `.../stages/{stage_id}` | data |

```python
boards = client.boards.list(search="Content", status="active")
board = client.boards.create({"name": "Q3 Content"})
board_id = board["id"]

detail = client.boards.get(board_id, include_stages=True)
summary = client.boards.summary(board_id)

stages = client.boards.list_stages(board_id)
stage = client.boards.create_stage(board_id, {"name": "Ideas", "sort_order": 1})

task = client.boards.create_task(
    board_id,
    {"title": "Draft blog post", "priority": "high"},
)
board_tasks = client.boards.list_tasks(board_id)

client.boards.update(board_id, {"name": "Q3 Content Calendar"})
# client.boards.delete_stage(board_id, stage["id"])
# client.boards.delete(board_id)
```

---

### Labels — `client.labels`

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `list()` | GET | `/integrations/labels` | `ListResult` |
| `create(body)` | POST | `/integrations/labels` | data |
| `get(label_id)` | GET | `/integrations/labels/{label_id}` | data |
| `update(label_id, body)` | PATCH | `/integrations/labels/{label_id}` | data |
| `delete(label_id)` | DELETE | `/integrations/labels/{label_id}` | data |

```python
labels = client.labels.list()
label = client.labels.create({"name": "Urgent", "color": "#FF0000"})
client.labels.update(label["id"], {"name": "Critical"})
# client.labels.delete(label["id"])
```

---

### Templates — `client.templates`

Content Planner task templates.

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `list()` | GET | `/integrations/templates` | `ListResult` |
| `create(body)` | POST | `/integrations/templates` | data |
| `get(template_id)` | GET | `/integrations/templates/{template_id}` | data |
| `update(template_id, body)` | PATCH | `/integrations/templates/{template_id}` | data |
| `delete(template_id)` | DELETE | `/integrations/templates/{template_id}` | data |

```python
templates = client.templates.list()
created = client.templates.create({"name": "Blog workflow"})
got = client.templates.get(created["id"])
```

---

### Triggers — `client.triggers`

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `list()` | GET | `/integrations/triggers` | `ListResult` |
| `create(body)` | POST | `/integrations/triggers` | data |
| `get(trigger_id)` | GET | `/integrations/triggers/{trigger_id}` | data |
| `update(trigger_id, body)` | PATCH | `/integrations/triggers/{trigger_id}` | data |
| `delete(trigger_id)` | DELETE | `/integrations/triggers/{trigger_id}` | data |

```python
triggers = client.triggers.list()
created = client.triggers.create({"name": "On task complete"})
```

---

### Integration user tokens — `client.integration_user_tokens`

Manage member PATs. **Requires `access_token` (session JWT)** on the client. Does not use `userToken` for these calls.

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `create()` | POST | `/integration-user-tokens` | data (includes plaintext `user_token` **once**) |
| `rotate()` | POST | `/integration-user-tokens/rotate` | data (new plaintext `user_token`) |
| `revoke()` | DELETE | `/integration-user-tokens` | data |
| `list()` | GET | `/integration-user-tokens` | `ListResult` (metadata only; secret never returned) |

```python
with HouseOfApps(
    license_key="...",
    app_secret="...",
    access_token="<session jwt>",
) as client:
    created = client.integration_user_tokens.create()
    user_token = created["user_token"]  # store securely

    meta = client.integration_user_tokens.list()
    rotated = client.integration_user_tokens.rotate()
    # client.integration_user_tokens.revoke()

# Prefer the helper for “mint PAT + ready member client”:
member = HouseOfApps.from_session(
    license_key="...",
    app_secret="...",
    access_token="<session jwt>",
)
member.leads.list()
member.close()
```

---

### Organizations — `client.organizations`

Session JWT organization APIs (`/v1/organization`). **Requires `access_token`.**

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `list(...)` | GET | `/organization/list` | `ListResult` |
| `get(organization_id)` | GET | `/organization/{id}` | data |
| `create(body)` | POST | `/organization/` | data |
| `update(organization_id, body)` | PUT | `/organization/{id}` | data |
| `delete(organization_id)` | DELETE | `/organization/{id}` | data |
| `verify_name(name, ...)` | GET | `/organization/verify-name` | data |
| `get_ai_overview_settings()` | GET | `/organization/ai-overview-settings` | data |
| `refetch_ai_overview_settings(body)` | POST | `/organization/ai-overview-settings/refetch` | data |
| `list_delete_requests(...)` | GET | `/organization/delete-request-list` | data |
| `request_delete(organization_id)` | POST | `/organization/request-to-delete/{id}` | data |
| `process_delete_request(request_id, body)` | PATCH | `/organization/delete-request/{id}` | data |
| `delete_member(member_user_id)` | DELETE | `/organization/member/{id}` | data |
| `query_memory(body)` | POST | `/organization/memory/query` | data |

```python
with HouseOfApps(access_token="<session jwt>") as client:
    orgs = client.organizations.list(page=1, page_size=20)
    org = client.organizations.get(orgs.items[0]["id"])
```

---

### Sessions — `client.sessions`

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `list(...)` | GET | `/sessions` | `ListResult` (current user) |
| `list_all(...)` | GET | `/sessions/all` | `ListResult` (organization) |
| `delete(session_id)` | DELETE | `/sessions/{id}` | data |

```python
sessions = client.sessions.list(page=1, page_size=20)
# client.sessions.delete(sessions.items[0]["id"])
```

---

### Audit logs — `client.audit_logs`

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `list(...)` | GET | `/audit-logs` | `ListResult` |
| `get(audit_log_id)` | GET | `/audit-logs/{id}` | data |
| `delete_all()` | DELETE | `/audit-logs` | data |

```python
logs = client.audit_logs.list(page=1, page_size=50)
```

---

### Dashboard — `client.dashboard`

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `get(leads_start_date=..., leads_end_date=...)` | GET | `/dashboard` | data |

```python
summary = client.dashboard.get()
```

---

### List views — `client.list_views`

Per-user list view overrides. `entity_type` ∈ `company | contact | lead | cp_board | cp_task`.

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `get(entity_type)` | GET | `/list-views/{entity_type}` | data |
| `update(entity_type, body)` | PUT | `/list-views/{entity_type}` | data |
| `delete(entity_type)` | DELETE | `/list-views/{entity_type}` | data |

```python
view = client.list_views.get("lead")
```

---

### Organization list views — `client.organization_list_views`

Organization-wide defaults (same `entity_type` values).

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `get(entity_type)` | GET | `/organization/list-views/{entity_type}` | data |
| `update(entity_type, body)` | PUT | `/organization/list-views/{entity_type}` | data |
| `delete(entity_type)` | DELETE | `/organization/list-views/{entity_type}` | data |

---

### Verification codes — `client.verification_codes`

| Method | HTTP | Path | Returns |
|---|---|---|---|
| `send(body)` | POST | `/verification-code/send` | data |
| `verify(body)` | POST | `/verification-code/verify` | data |

```python
sent = client.verification_codes.send(
    {"type": "EMAIL", "email": "user@example.com"}
)
client.verification_codes.verify(
    {
        "type": "EMAIL",
        "email": "user@example.com",
        "verification_id": sent["verification_id"],
        "verification_code": "123456",
    }
)
```

---

## End-to-end example

```python
from houseofapps import HouseOfApps
from houseofapps.errors import APIError, NotFoundError

def main() -> None:
    # 1) Exchange session JWT for a member client
    client = HouseOfApps.from_session(
        license_key="...",
        app_secret="...",
        access_token="<session jwt>",
    )
    try:
        # 2) CRM
        company = client.companies.create({"name": "Acme Corp", "industry": "Software"})
        contact = client.contacts.create(
            {
                "email": "jane@acme.com",
                "first_name": "Jane",
                "last_name": "Doe",
            }
        )
        lead = client.leads.create(
            {
                "name": "Acme expansion",
                "lead_source": "sdk-example",
                "amount": "10000",
                "currency": "USD",
            }
        )

        # 3) Member surfaces
        profile = client.users.get_profile()
        teams = client.teams.list()

        # 4) Content planner
        board = client.boards.create({"name": "SDK Demo Board"})
        task = client.boards.create_task(board["id"], {"title": "Follow up with Jane"})
        client.tasks.add_note(task["id"], {"body": "Created via houseofapps SDK"})

        print("company", company["id"])
        print("contact", contact["id"])
        print("lead", lead["id"])
        print("profile", profile)
        print("teams", teams.total)
        print("task", task["id"])

        # cleanup (optional)
        try:
            client.leads.delete(lead["id"])
        except NotFoundError:
            pass
    except APIError as exc:
        print("API error", exc.status_code, exc.message, exc.request_id)
        raise
    finally:
        client.close()

if __name__ == "__main__":
    main()
```

---

## Version history

### 0.2.4

- Session JWT resources: organizations, sessions, audit logs, dashboard,
  list views, organization list views, verification codes

### 0.2.3

- Expanded PyPI README into a full SDK reference (concepts, auth, method tables, examples)

### 0.2.2

- Expanded README with per-resource examples

### 0.2.1

- Self-contained README (removed broken external doc links)

### 0.2.0

- Member-first client: `user_token` / `HOUSEOFAPPS_USER_TOKEN`
- `from_session` / `access_token` for PAT minting
- `client.teams`, profile methods, Content Planner resources, `integration_user_tokens`
- `with_options` / `copy`
- **Breaking:** removed `client.v2` namespace
- Keys-only organization mode deprecated

### 0.1.2

- IAM: `users`, `invites`, `roles`, `permissions`

### 0.1.1

- AI key rename to `house_of_apps_ai_key` / `HOUSEOFAPPS_AI_KEY`
- HTTP transport via `aiohttp`

### 0.1.0

- Initial public release (sync + async CRM resources, Pydantic models)

---

## License

MIT © House of Apps
