Metadata-Version: 2.4
Name: kajabi-py
Version: 0.1
Summary: Python client for the Kajabi API
Author-email: "Joshua \"jag\" Ginsberg" <jag@flowtheory.net>
License-Expression: Apache-2.0
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.28.1
Requires-Dist: pydantic>=2.13.3
Dynamic: license-file

# kajabi-py

Python client for the [Kajabi API](https://developers.kajabi.com). Provides both sync and async interfaces with full type hints, Pydantic models, and automatic OAuth2 token management.

## Installation

```bash
pip install kajabi-py
```

Requires Python 3.11+.

## Quick start

```python
from kajabi import Client

client = Client(client_id="your_id", client_secret="your_secret")

# List resources — returns a lazy collection, no request until accessed
contacts = client.contacts.list()
print(contacts.count)        # total across all pages (triggers first page fetch)
print(contacts[0].name)      # indexed access (fetches page if not cached)
for contact in contacts:     # iterates all pages lazily
    print(contact.name, contact.email)

# Get a single resource
post = client.blog_posts.get("42")

# Create, update, delete
contact = client.contacts.create(
    name="Jane Doe",
    email="jane@example.com",
    site_id="your_site_id",
)
contact = contact.update(name="Jane Smith")
contact.delete()
```

### Async

```python
from kajabi import AsyncClient

async with AsyncClient(client_id="your_id", client_secret="your_secret") as client:
    contacts = client.contacts.list()          # no await — returns collection immediately
    count = await contacts.acount()            # total across all pages
    first = await contacts.aget(0)             # indexed access
    async for contact in contacts:             # iterates all pages lazily
        print(contact.name)
    contact = await client.contacts.get("42")
    contact = await contact.aupdate(name="Updated Name")
    await contact.adelete()
```

## Authentication

Pass `client_id`/`client_secret` or `username`/`password`. Authentication is lazy -- the first API call triggers the token exchange. Tokens are refreshed automatically.

```python
# OAuth2 client credentials
client = Client(client_id="...", client_secret="...")

# Resource owner password
client = Client(username="user@example.com", password="...")
```

## Resources

All resources are Pydantic models returned from manager methods on the client. Read-only resources support `get()` and `list()`. Writable resources also support `create()`, `update()`/`aupdate()`, and `delete()`/`adelete()`.

| Manager | Model | Writable |
|---------|-------|----------|
| `blog_posts` | `BlogPost` | No |
| `contacts` | `Contact` | Yes |
| `contact_notes` | `ContactNote` | Yes |
| `contact_tags` | `ContactTag` | No |
| `courses` | `Course` | No |
| `custom_fields` | `CustomField` | No |
| `customers` | `Customer` | No |
| `forms` | `Form` | No |
| `form_submissions` | `FormSubmission` | No |
| `landing_pages` | `LandingPage` | No |
| `offers` | `Offer` | No |
| `orders` | `Order` | No |
| `order_items` | `OrderItem` | No |
| `payouts` | `Payout` | No |
| `podcasts` | `Podcast` | No |
| `products` | `Product` | No |
| `purchases` | `Purchase` | No |
| `sites` | `Site` | No |
| `site_pages` | `SitePage` | No |
| `transactions` | `Transaction` | No |
| `webhooks` | `Webhook` | Yes (create/delete) |

### Filtering, sorting, and sparse fields

```python
contacts = client.contacts.list(
    filters={"site_id": "123"},
    sort="-created_at",
    fields=["name", "email"],
)
```

The default page size is 20. Pass `page_size` when constructing the client to change it globally:

```python
client = Client(client_id="...", client_secret="...", page_size=50)
```

### Lazy-loading related resources

Relationship ID fields (e.g., `site_id`, `product_ids`) have corresponding properties that lazily load the full resource on first access. Results are cached for the lifetime of the instance.

```python
customer = client.customers.get("123")

# Single relation: customer.site_id -> customer.site
site = customer.site  # fetches Site on first access, cached thereafter

# Plural relation: customer.product_ids -> customer.products
products = customer.products  # fetches each Product, cached thereafter
```

Returns `None` for single relations when the ID is `None`, and an empty list for plural relations with no IDs. Accessing a relation descriptor on an async client raises `KajabiError` -- use explicit async calls instead.

### Relationships

Some resources expose relationship operations:

```python
# Contact tags
contact.list_tags()
contact.add_tags(["tag-id-1", "tag-id-2"])
contact.remove_tags(["tag-id-1"])

# Contact/Customer offers
contact.grant_offers(["offer-id-1"])
contact.revoke_offers(["offer-id-1"])

# Form submissions
form.submit(email="user@example.com", name="User")

# Purchase actions
purchase.reactivate()
purchase.deactivate()
purchase.cancel_subscription()
```

### Convenience endpoints

```python
me = client.me()        # current user profile
ver = client.version()  # API version info
```

## Error handling

All errors inherit from `KajabiError`. HTTP errors are mapped to specific exception types:

| Status | Exception |
|--------|-----------|
| 400 | `BadRequestError` |
| 401 | `AuthenticationError` |
| 403 | `AuthorizationError` |
| 404 | `NotFoundError` |
| 405 | `MethodNotAllowed` |
| 422 | `ValidationError` |
| 429 | `RateLimitError` |
| 5xx | `ServerError` |

```python
from kajabi import Client, NotFoundError

try:
    client.contacts.get("nonexistent")
except NotFoundError as e:
    print(e)
```

## Development

```bash
uv sync
uv run pytest
uv run ruff check .
uv run ty check
```

Pre-commit hooks are managed with [prek](https://github.com/j178/prek):

```bash
uv run prek install
uv run prek run --all-files
```

## License

Apache 2.0
