Metadata-Version: 2.5
Name: nusii
Version: 0.1.0
Summary: Official Python client for the Nusii proposal software API
Project-URL: Homepage, https://nusii.com
Project-URL: Documentation, https://github.com/Nusii/nusii-python#readme
Project-URL: API reference, https://developer.nusii.com
Project-URL: Source, https://github.com/Nusii/nusii-python
Project-URL: Issues, https://github.com/Nusii/nusii-python/issues
Project-URL: Changelog, https://github.com/Nusii/nusii-python/blob/main/CHANGELOG.md
Author-email: Nusii <support@nusii.com>
License-Expression: MIT
License-File: LICENSE
Keywords: api,client,nusii,proposal software,proposals,sdk
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Office/Business
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx<1,>=0.27
Requires-Dist: typing-extensions>=4.7
Description-Content-Type: text/markdown

# Nusii for Python

[![PyPI version](https://img.shields.io/pypi/v/nusii.svg)](https://pypi.org/project/nusii/)
[![Python versions](https://img.shields.io/pypi/pyversions/nusii.svg)](https://pypi.org/project/nusii/)
[![CI](https://github.com/Nusii/nusii-python/actions/workflows/ci.yml/badge.svg)](https://github.com/Nusii/nusii-python/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

The official Python client for the [Nusii](https://nusii.com) proposal software API.

Create clients, build and send proposals, track when they are viewed and accepted, and react to webhooks, all from Python scripts, Django, Flask, FastAPI or any other Python app.

- **Fully typed**: every response and parameter has type hints, so your editor autocompletes keys, statuses, cost types and webhook events, and mypy or Pyright catch typos.
- **Pythonic**: keyword arguments in, plain dictionaries out. Attribute names match the API exactly.
- **Batteries included**: automatic pagination, retries with backoff, timeouts and typed exceptions.
- **One dependency**: [httpx](https://www.python-httpx.org).

Full API reference: [developer.nusii.com](https://developer.nusii.com)

## Table of contents

- [Installation](#installation)
- [Getting an API key](#getting-an-api-key)
- [Quick start](#quick-start)
- [Usage](#usage)
  - [Account](#account)
  - [Clients](#clients)
  - [Proposals](#proposals)
  - [Sections](#sections)
  - [Line items](#line-items)
  - [Taxes and discounts](#taxes-and-discounts)
  - [Templates](#templates)
  - [Proposal activities](#proposal-activities)
  - [Users](#users)
  - [Webhooks](#webhooks)
  - [Themes, currencies, locales and PDF page sizes](#themes-currencies-locales-and-pdf-page-sizes)
  - [CRM integrations](#crm-integrations)
- [Pagination](#pagination)
- [Error handling](#error-handling)
- [Retries and timeouts](#retries-and-timeouts)
- [Configuration](#configuration)
- [OAuth](#oauth)
- [Calling any endpoint](#calling-any-endpoint)
- [Type hints](#type-hints)
- [Requirements](#requirements)
- [Development](#development)
- [Contributing](#contributing)
- [License](#license)

## Installation

```bash
pip install nusii
```

Or with your package manager of choice:

```bash
uv add nusii
poetry add nusii
```

## Getting an API key

1. Log in to [Nusii](https://app.nusii.com).
2. Go to **Settings → API** ([app.nusii.com/settings/api](https://app.nusii.com/settings/api)).
3. Under **Personal Tokens**, create a token and copy it.

Treat the key like a password: it gives full access to your Nusii account. Keep it out of your code and out of git.

The usual way is an environment variable, which the client reads automatically:

```bash
export NUSII_API_KEY=your-api-key
```

## Quick start

```python
from nusii import Nusii

# Reads NUSII_API_KEY from the environment.
nusii = Nusii()

# Or pass the key explicitly:
# nusii = Nusii(api_key="your-api-key")

account = nusii.account.me()
print(f"Connected to {account['name']}")

client = nusii.clients.create(
    name="Jane",
    surname="Doe",
    email="jane@example.com",
    business="Acme Inc",
)

proposal = nusii.proposals.create(
    title="Website redesign",
    client_id=client["id"],
)

nusii.proposals.send(
    proposal["id"],
    recipients=[{"name": "Jane Doe", "email": "jane@example.com"}],
    subject="Your proposal for the website redesign",
)
```

Attribute names match the API exactly (`client_id`, `expires_at`...), so everything in the [API documentation](https://developer.nusii.com) applies one to one. Responses are plain dictionaries with a numeric `id`.

## Usage

### Account

```python
account = nusii.account.me()

account["name"]  # "Acme Corp"
account["subdomain"]  # "acme"
account["currency"]  # "USD"
```

### Clients

```python
# List (25 per page by default, newest first)
page = nusii.clients.list(page=1, per_page=50)
page.data  # list of clients
page.total_count  # 132

# Search
nusii.clients.list(query="acme")
nusii.clients.list(email="jane@example.com")
nusii.clients.list(emails=["jane@example.com", "john@example.com"])

# Get, create, update, delete
client = nusii.clients.get(123)

created = nusii.clients.create(
    name="Jane",  # required
    email="jane@example.com",  # required
    surname="Doe",
    business="Acme Inc",
    telephone="+1 555 1234",
    address="123 Main St",
    city="New York",
    postcode="10001",
    state="NY",
    country="US",
    web="https://acme.com",
    currency="USD",
    locale="en",
)

nusii.clients.update(123, business="Acme Corporation")
nusii.clients.delete(123)
```

### Proposals

```python
# List with filters
nusii.proposals.list(status="pending")
nusii.proposals.list(statuses=["accepted", "rejected"])
nusii.proposals.list(archived=True)  # only archived ones
nusii.proposals.list(query="website")  # title, number, client...
nusii.proposals.list(recipient_email="jane@example.com")
nusii.proposals.list(no_activity_client_view_proposal=True)  # never viewed
nusii.proposals.list(
    sent_at_after="2026-01-01",  # dates are YYYY-MM-DD, inclusive
    sent_at_before="2026-03-31",
)

proposal = nusii.proposals.get(456)
proposal["status"]  # "draft", "pending", "accepted", "rejected" or "clarification"
proposal["pdf_url"]

# Create from scratch...
nusii.proposals.create(
    title="Website redesign",
    client_id=123,
    expires_at="2026-12-31",
    theme="clean",
)

# ...or from a template, copying its sections and line items
nusii.proposals.create(title="Website redesign", client_id=123, template_id=42)

nusii.proposals.update(456, title="Website redesign v2")
nusii.proposals.archive(456)
nusii.proposals.delete(456)
```

Send a proposal by email to one or more recipients:

```python
result = nusii.proposals.send(
    456,
    recipients=[
        {"name": "Jane Doe", "email": "jane@example.com"},
        {"name": "John Doe", "email": "john@example.com", "eligible_to_sign": False},
    ],
    cc="manager@example.com",
    bcc="archive@example.com",
    subject="Your proposal",
    message="<p>Hi Jane, here is the proposal we discussed.</p>",
    sender_email="sales@yourcompany.com",  # send as a team member (default: account owner)
)

result["status"]  # "pending"
result["sent_at"]
```

To send to the proposal's client only, pass `email="jane@example.com"` instead of `recipients`.

A proposal's status follows what happens to it (sent, accepted, rejected), so it cannot be changed with `update`.

### Sections

Sections are the blocks of a proposal or template: text sections and cost sections (which contain line items).

```python
# Sections of a proposal or template, with their line items
sections = nusii.sections.list(proposal_id=456)
sections[0].get("line_items")  # list of line items on cost sections

nusii.sections.list(template_id=42)

# Without a proposal or template: the reusable sections of your content library
nusii.sections.list()

section = nusii.sections.create(
    proposal_id=456,
    title="Project scope",
    body="<p>What we will deliver.</p>",
    section_type="text",  # or "cost"
    position=1,
)

# Copy a reusable section (with its line items) into a proposal
nusii.sections.copy(reusable_section_id, proposal_id=456, position=2)

nusii.sections.update(section["id"], title="Scope of work")
nusii.sections.delete(section["id"])
```

### Line items

Line items belong to cost sections. Amounts are in cents.

```python
items = nusii.line_items.list(section_id)

# A fixed price of $1,500.00
nusii.line_items.create(section_id, name="Logo design", cost_type="fixed", amount=150000)

# Per unit: 10 hours at $75
nusii.line_items.create(
    section_id,
    name="Development",
    cost_type="per",
    per_type="hour",
    quantity=10,
    amount=7500,
)

# Recurring
nusii.line_items.create(
    section_id, name="Hosting", cost_type="recurring", recurring_type="monthly", amount=2500
)

# A price range: "$5,000 – $8,000" (requires price ranges on your account)
price_range = nusii.line_items.create(
    section_id,
    name="Discovery & UX research",
    cost_type="range",
    amount=500000,
    maximum_amount=800000,
)
price_range["maximum_amount_formatted"]  # "$8,000.00"

# Let the client pick optional items
nusii.line_items.create(section_id, name="Extra revision", choice_type="checkbox")

nusii.line_items.get(item_id)
nusii.line_items.update(item_id, quantity=12)
nusii.line_items.delete(item_id)
```

### Taxes and discounts

```python
nusii.taxations.create(proposal_id, name="VAT", percentage=21)
nusii.taxations.create(proposal_id, name="Loyalty discount", percentage=-10)

taxes = nusii.taxations.list(proposal_id)
nusii.taxations.update(proposal_id, tax_id, percentage=19)
nusii.taxations.delete(proposal_id, tax_id)
```

### Templates

```python
templates = nusii.templates.list()
template = nusii.templates.get(42)

# Nusii's public template gallery
nusii.templates.list(public_templates=True)
```

### Proposal activities

Every view, send, acceptance, email open and bounce, newest first.

```python
nusii.proposal_activities.list()
nusii.proposal_activities.list(proposal_id=456)
nusii.proposal_activities.list(client_id=123)

activity = nusii.proposal_activities.get(789)
activity["activity_type"]  # "client_view_proposal", "client_accepted_proposal"...
```

### Users

The team members of your account.

```python
users = nusii.users.list()
```

### Webhooks

Ask Nusii to POST events to your server:

```python
endpoint = nusii.webhook_endpoints.create(
    target_url="https://example.com/webhooks/nusii/a-long-random-secret",
    events=["proposal_accepted", "proposal_rejected", "proposal_activity_client_viewed_proposal"],
)

nusii.webhook_endpoints.list()
nusii.webhook_endpoints.get(endpoint["id"])
nusii.webhook_endpoints.delete(endpoint["id"])
```

Then receive them. `parse_webhook_event` takes the raw request body and returns a typed event you can narrow by `event_name`:

```python
from nusii import parse_webhook_event


def handle(body: bytes) -> None:
    event = parse_webhook_event(body)

    if event["event_name"] == "proposal_accepted":
        proposal = event["proposal"]
        print(f"{proposal['title']} accepted for {proposal['accepted_total_formatted']}")
    elif event["event_name"] == "proposal_activity_client_viewed_proposal":
        print(f"{event['proposal_activity']['client_email']} is reading your proposal")
    elif event["event_name"] == "client_created":
        print(f"New client: {event['client']['email']}")
```

With Flask:

```python
@app.post("/webhooks/nusii/<secret>")
def nusii_webhook(secret):
    if not hmac.compare_digest(secret, os.environ["NUSII_WEBHOOK_SECRET"]):
        abort(404)
    handle(request.get_data())
    return "", 204
```

With Django, pass `request.body`; with FastAPI, `await request.body()`.

Nusii does not sign webhook deliveries, so put a long random secret in the endpoint URL and check it, and fetch the record from the API when you need to be sure of its current state. Responding with HTTP `410 Gone` removes the endpoint.

| Event | When |
| --- | --- |
| `proposal_created` | A proposal was created |
| `proposal_updated` | A proposal was updated |
| `proposal_destroyed` | A proposal was deleted |
| `proposal_sent` | A proposal was sent |
| `proposal_accepted` | The client accepted a proposal |
| `proposal_rejected` | The client rejected a proposal |
| `client_created` | A client was created |
| `client_updated` | A client was updated |
| `client_destroyed` | A client was deleted |
| `proposal_activity_client_viewed` | A client viewed something |
| `proposal_activity_client_viewed_proposal` | A client viewed a proposal |

The full list is available as `nusii.WEBHOOK_EVENTS`.

### Themes, currencies, locales and PDF page sizes

The allowed values for `theme`, `currency`, `locale` and `pdf_page_size`:

```python
nusii.themes.list()  # [{"id": "clean", "name": "Modern Theme"}, {"id": "classic", ...}]
nusii.currencies.list()  # [{"id": "USD", "iso_code": "USD", "name": "United States Dollar $"}, ...]
nusii.locales.list()  # [{"id": "en", "code": "en", "name": "English"}, ...]
nusii.pdf_page_sizes.list()  # [{"id": "A4", "name": "A4"}, {"id": "US-Letter", ...}]
```

### CRM integrations

```python
nusii.integrations.create_one_page_crm_installation(api_key="...", api_secret="...")
nusii.integrations.create_less_annoying_crm_installation(token="...", user_code="...")
```

## Pagination

List methods return one `Page` at a time. A page behaves like a list of the items on it:

```python
page = nusii.proposals.list(per_page=50)

for proposal in page:
    print(proposal["title"])

len(page)  # items on this page
page[0]  # first proposal
page.data  # the items as a list
page.current_page  # 1
page.next_page  # 2, or None on the last page
page.prev_page  # None on the first page
page.total_pages  # 4
page.total_count  # 180

if page.has_next_page():
    next_page = page.get_next_page()
```

To walk through everything, use `list_all()`. It fetches the next page only when you reach it, and stops when you `break`:

```python
for proposal in nusii.proposals.list_all(status="pending"):
    print(proposal["title"])

# Or collect everything into a list
clients = list(nusii.clients.list_all())
```

From a page you already have, `page.iter_all()` continues through the following pages the same way.

## Error handling

Failed requests raise an exception you can catch by class:

```python
from nusii import NotFoundError, NusiiError, RateLimitError, UnprocessableEntityError

try:
    nusii.clients.create(name="", email="not-an-email")
except UnprocessableEntityError as error:
    print(error)  # "422 name can't be blank; email is invalid"
    error.errors  # [{"source": {"pointer": "/data/attributes/name"}, "detail": "can't be blank"}, ...]
except NotFoundError:
    ...
except RateLimitError as error:
    error.retry_after  # seconds
except NusiiError:
    ...  # any other error from this library
```

| Exception | When |
| --- | --- |
| `BadRequestError` | 400: malformed request |
| `AuthenticationError` | 401: invalid or missing API key |
| `PaymentRequiredError` | 402: plan limit reached, e.g. active proposals |
| `ForbiddenError` | 403: not allowed, e.g. missing OAuth scope or unconfirmed sender email |
| `NotFoundError` | 404: record does not exist |
| `MethodNotAllowedError` | 405 |
| `NotAcceptableError` | 406 |
| `GoneError` | 410: record already deleted |
| `UnprocessableEntityError` | 422: validation failed, or the proposal is locked |
| `RateLimitError` | 429: too many requests |
| `ServerError` | 5xx |
| `ServiceUnavailableError` | 503 (a `ServerError`) |
| `APIConnectionError` | No response: network down, DNS failure... |
| `APITimeoutError` | The request took longer than `timeout` (an `APIConnectionError`) |

All HTTP errors extend `APIError` and expose `status`, `headers`, the parsed `body`, the field `errors`, and a `code` when the API sends one (for example `proposal_locked`). Everything extends `NusiiError`.

## Retries and timeouts

The Nusii API allows 100 requests per 30 seconds. The client handles the limit for you: a rate limited request is retried after the `Retry-After` delay the API asks for. Connection errors and 5xx responses are retried with exponential backoff, but only for reads (GET), so a proposal is never sent twice. By default a request is retried twice.

```python
# For every request
nusii = Nusii(max_retries=5, timeout=10)  # timeout in seconds

# For some requests
nusii.with_options(max_retries=0, timeout=5).proposals.list()
```

## Configuration

```python
nusii = Nusii(
    api_key="your-api-key",  # default: NUSII_API_KEY environment variable
    access_token="oauth-token",  # instead of api_key, default: NUSII_ACCESS_TOKEN
    base_url="https://app.nusii.com",  # default: NUSII_BASE_URL or https://app.nusii.com
    timeout=30,  # seconds
    max_retries=2,
    headers={"X-Request-Source": "my-app"},  # added to every request
    http_client=httpx.Client(proxy="http://proxy:8080"),  # e.g. for a proxy
)
```

The client keeps connections open between requests. Close them when you are done, or use it as a context manager:

```python
with Nusii() as nusii:
    nusii.proposals.list()
```

## OAuth

Apps that act on behalf of other Nusii accounts use OAuth 2.0 access tokens instead of API keys:

```python
nusii = Nusii(access_token=user_access_token)
```

Tokens with only the `read` scope can call GET endpoints; anything else raises a `ForbiddenError` with `code == "insufficient_scope"`.

## Calling any endpoint

`nusii.request()` calls any API v2 endpoint with the same authentication, retries and error handling, and returns the JSON body as is:

```python
body = nusii.request("GET", "/proposals", query={"status": "draft", "per": 10})
```

## Type hints

Responses are plain dictionaries described by `TypedDict` classes in `nusii.types`, so editors autocomplete their keys and type checkers flag typos:

```python
from nusii.types import Proposal, ProposalStatus


def is_open(proposal: Proposal) -> bool:
    return proposal["status"] in ("draft", "pending")
```

Keyword arguments are typed too: `nusii.line_items.create(section_id, cost_type="hourly")` is flagged by mypy and Pyright, because `"hourly"` is not a valid cost type.

## Requirements

- Python 3.10 or newer
- [httpx](https://www.python-httpx.org) 0.27 or newer (installed automatically)

## Development

```bash
git clone https://github.com/Nusii/nusii-python.git
cd nusii-python
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -e . --group dev

pytest             # unit tests
ruff check .       # lint
ruff format .      # format
mypy               # types (strict)
pyright            # types, as your editor sees them
```

Integration tests run against a real Nusii account. Copy `.env.example` to `.env`, add your API key, and run:

```bash
pytest -m integration
```

They only read data unless you set `NUSII_INTEGRATION_WRITE=true`, in which case they create clients, proposals, sections, line items, taxes and a webhook endpoint, and delete them afterwards. To test against a local Nusii server, set `NUSII_BASE_URL=http://localhost:3000` in `.env`.

Releases are published to PyPI by GitHub Actions, see [RELEASING.md](RELEASING.md).

## Contributing

1. Fork it (https://github.com/Nusii/nusii-python/fork)
2. Create your feature branch (`git checkout -b improving-something`)
3. Commit your changes and add tests (`pytest`, `ruff check .`, `mypy` and `pyright` must pass)
4. Push to the branch (`git push origin improving-something`)
5. Create a new Pull Request

## License

Released under the [MIT License](LICENSE).

---

Made by [Nusii](https://nusii.com), proposal software that helps you win more clients.
