Nusii for Python
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
- Getting an API key
- Quick start
- Usage
- Pagination
- Error handling
- Retries and timeouts
- Configuration
- OAuth
- Calling any endpoint
- Type hints
- Requirements
- Development
- Contributing
- License
Installation
pip install nusii
Or with your package manager of choice:
uv add nusii
poetry add nusii
Getting an API key
- Log in to Nusii.
- Go to Settings → API (app.nusii.com/settings/api).
- 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
- Fork it (https://github.com/Nusii/nusii-python/fork)
- Create your feature branch (
git checkout -b improving-something) - Commit your changes and add tests (
pytest,ruff check .,mypyandpyrightmust pass) - Push to the branch (
git push origin improving-something) - 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)
| File | Size | Uploaded | |
|---|---|---|---|
| nusii-0.1.0.tar.gz | 38.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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