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
- 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
pip install nusiiOr with your package manager of choice:
uv add nusii
poetry add nusii- 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-keyfrom 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.
account = nusii.account.me()
account["name"] # "Acme Corp"
account["subdomain"] # "acme"
account["currency"] # "USD"# 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)# 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 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 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)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 = nusii.templates.list()
template = nusii.templates.get(42)
# Nusii's public template gallery
nusii.templates.list(public_templates=True)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"...The team members of your account.
users = nusii.users.list()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 "", 204With 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.
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", ...}]nusii.integrations.create_one_page_crm_installation(api_key="...", api_secret="...")
nusii.integrations.create_less_annoying_crm_installation(token="...", user_code="...")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.
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.
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()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()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".
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})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.
- Python 3.10 or newer
- httpx 0.27 or newer (installed automatically)
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 themIntegration tests run against a real Nusii account. Copy .env.example to .env, add your API key, and run:
pytest -m integrationThey 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.
- 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
Released under the MIT License.
Made by Nusii, proposal software that helps you win more clients.