Skip to main content

Nusii for Python

PyPI version Python versions CI License: MIT

The official Python client for the Nusii 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.

Full API reference: developer.nusii.com

Table of contents

Installation

pip install nusii

Or with your package manager of choice:

uv add nusii
poetry add nusii

Getting an API key

  1. Log in to Nusii.
  2. Go to Settings → API (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:

export NUSII_API_KEY=your-api-key

Quick start

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 applies one to one. Responses are plain dictionaries with a numeric id.

Usage

Account

account = nusii.account.me()

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

Clients

# 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

# 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:

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).

# 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.

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

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

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.

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.

users = nusii.users.list()

Webhooks

Ask Nusii to POST events to your server:

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:

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:

@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:

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

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:

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:

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:

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.

# 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

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:

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:

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:

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:

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 0.27 or newer (installed automatically)

Development

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:

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.

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.


Made by Nusii, proposal software that helps you win more clients.

Metadata

Release files for nusii 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for nusii 0.1.0
File Size Uploaded
nusii-0.1.0.tar.gz 38.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for nusii 0.1.0
File Interpreter ABI Platform
nusii-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 71.8 kB

Release files / nusii-0.1.0.tar.gz

Download URL nusii-0.1.0.tar.gz
Size 38.0 kB
Tags Source
SHA-256 checksum
How to use checksums
157b4e6ab95284bd03980c6ab79f7f16182fc2ada0760abed85b0e1ba859895e
BLAKE2b-256 checksum
How to use checksums
f854c5b4c737fe3a29b97b4c5154e3e06589de22af1638af56f65cbf164428bf
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.

Transparency log

Release files / nusii-0.1.0-py3-none-any.whl

Download URL nusii-0.1.0-py3-none-any.whl
Size 33.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
29dba5d96bb1152d34f96788eb4b8443e180cb52fbc458c6c3a3ef9a027fcfad
BLAKE2b-256 checksum
How to use checksums
ff8c99d54a78a6331b00a05530f39952c041c5a5db81a992816c489fefe5ace6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page