Python client for the SupDesk API.
Server-side SDK with two clients sharing one transport core: SupDesk (sync,
httpx.Client) and AsyncSupDesk (async, httpx.AsyncClient). Requires Python
3.9+. The only runtime dependency is httpx.
Warning
Server-side only. Never ship your API key to a browser.
A SupDesk API key authenticates as your entire project. Anything that reaches a browser is public — bundlers inline it, DevTools shows it, and users can read it straight out of the network tab. Use this SDK from a backend you control and let your frontend talk to that.
pip install supdeskfrom supdesk import SupDesk
supdesk = SupDesk() # api_key=... or $SUPDESK_API_KEY
# Auto-pages: iterating walks every page for you.
for submission in supdesk.submissions.list(status="open"):
print(submission.title)
supdesk.submissions.create(
type="bug",
title="Export button does nothing",
email="user@example.com",
body="Clicking Export on the reports page has no effect.",
)Async is the same shape, one await at a time:
import asyncio
from supdesk import AsyncSupDesk
async def main() -> None:
supdesk = AsyncSupDesk()
async for submission in await supdesk.submissions.list(status="open"):
print(submission.title)
async with supdesk:
await supdesk.submissions.create(
type="bug",
title="Export button does nothing",
email="user@example.com",
)
asyncio.run(main())API keys come from Workspace Settings → API Keys in the SupDesk console and are
scoped to a single project. Reads work on every plan; writes (POST/PATCH/DELETE)
require a paid plan and otherwise raise a ForbiddenError.
Read the key from a server-side environment variable — $SUPDESK_API_KEY, or any
SECRET_* your platform provides
The API key is a server-side secret. It is project-scoped, and on a paid plan it can create, edit and delete submissions, feedback, changelog entries, help center articles, message threads, waitlist signups and beta programs — and read every end-user email address in your project. It is not a publishable key, and SupDesk has no browser-safe equivalent.
# In a frontend you ship to users:
SupDesk(api_key="sd_live_…")Never do this. The constructor has no browser guard because Python has no DOM to detect — but that is no invitation: if you ever find a key in a repository, a build log, or a client bundle, rotate it in Workspace Settings → API Keys.
Two habits worth keeping: give each environment its own key so one can be revoked without downtime elsewhere, and store the webhook signing secret server-side too, since it is what proves a delivery actually came from SupDesk.
from supdesk import SupDesk
supdesk = SupDesk(
api_key="sd_live_…", # or $SUPDESK_API_KEY
base_url="https://api.supdesk.app/v1", # default
timeout=30.0, # seconds; 0 or None disables
max_retries=2, # retries after the first attempt
retry_unsafe_methods=False,
default_headers={"x-app": "my-service"},
http_client=None, # inject an httpx.Client / httpx.AsyncClient
)AsyncSupDesk takes the same arguments. Every method also accepts a final
request_options={"timeout": ..., "headers": ...} for per-call overrides; authorization
cannot be overridden per call. timeout is in seconds here (the Python idiom) rather
than the JavaScript client's milliseconds.
| Accessor | Methods |
|---|---|
submissions |
list get create |
feedback |
list get create |
changelog |
list get create update delete |
messages |
list get create update delete add_message |
waitlist |
list get create update delete |
beta.programs |
list get create update delete |
beta.testers |
list get create delete |
articles |
list search get create update delete |
article_categories |
list get create update delete |
list() returns a Page, which is both the current page and an iterable over
everything after it.
page = supdesk.articles.list(status="published")
page.data # just this page
page.pagination # PaginationMeta(limit=20, offset=0, has_more=...)
page.has_next_page()
page.get_next_page()
for article in page: # every page
print(article.title)
page.to_list() # everything, in memoryarticles.search() is the exception — it returns a plain ranked list, not a page.
Every failure is a subclass of SupDeskError, so one except covers the lot while the
class hierarchy still narrows to the specific case.
from supdesk import ForbiddenError, LimitReachedError, NotFoundError, SupDeskError
try:
supdesk.articles.create(title="How to export")
except ForbiddenError:
# Valid key, but writes need a paid plan.
pass
except LimitReachedError:
# Monthly submission quota exhausted.
pass
except SupDeskError as error:
print(error)| Class | Status | Code |
|---|---|---|
InvalidRequestError |
400 | invalid_request |
UnauthorizedError |
401 | unauthorized |
ForbiddenError |
403 | forbidden |
NotFoundError |
404 | not_found |
RateLimitedError |
429 | rate_limited |
LimitReachedError |
429 | limit_reached |
InternalServerError |
5xx | internal_error |
Plus SupDeskConnectionError, SupDeskTimeoutError, RequestTooLargeError (the API
caps requests at 1 MB, checked before sending), SupDeskConfigurationError and
SupDeskSignatureVerificationError. SupDeskAPIError carries status, code,
headers, body and request_id.
The client retries with exponential backoff and jitter (500 ms base, 8 s cap), honouring
Retry-After when a proxy supplies one. Two behaviours are worth knowing about:
limit_reachedis never retried. It shares HTTP 429 withrate_limited, but a monthly quota will not clear inside a backoff window — retrying just burns more of your requests-per-minute budget. The two are told apart bycode, not status.POSTis not replayed on network errors or 5xx by default. SupDesk has no idempotency key, andsubmissions.create/feedback.createare metered, so a request that failed after the server accepted it would double-charge your quota and file the end user's ticket twice. A 429rate_limitedis still retried on any method, because the server states it did not process the request. Opt in withretry_unsafe_methods=True.
Verification is synchronous: hmac + hashlib, with a constant-time
hmac.compare_digest. Pass the raw body plus the X-SupDesk-Signature header.
from supdesk import construct_event_from_headers
def on_webhook(payload: bytes, headers: dict) -> None:
event = construct_event_from_headers(payload, headers, secret)
if event.event == "waitlist_signup.joined":
print(event.data["email"])Pass the raw body. The signature covers the exact bytes SupDesk sent. Frameworks that parse JSON for you (Flask's
request.get_json(), FastAPI'sawait request.json(), Django'sJsonRequestParser) break verification, because a re-serialized dict will not reproduce the original whitespace and key order. Capture the raw bytes first:
- Flask:
request.get_data()- FastAPI / Starlette:
await request.body()- Django:
request.body
Lower-level helpers: verify_webhook_signature(payload, signature, secret) returns a
boolean (never raises on a malformed header), construct_event throws on mismatch,
compute_webhook_signature builds fixtures, and Webhooks(secret) binds all of them to
one secret. payload accepts str or bytes.
Framework-specific, runnable integrations (FastAPI, Django, Flask, plus a
no-framework script) live in examples/ with their own
requirements.txt. They are not part of the published package.
python3 -m venv .venv && ./.venv/bin/pip install -e ".[dev]"
./.venv/bin/ruff check . && ./.venv/bin/ruff format --check .
./.venv/bin/mypy src
./.venv/bin/python -m pytest --cov=supdesk --cov-report=term-missing --cov-fail-under=80
./.venv/bin/python -m build && ./.venv/bin/twine check dist/*The suite runs entirely against httpx.MockTransport — no network — and is parametrized
so each test body covers both the sync and async clients.
MIT