Metadata-Version: 2.5
Name: supdesk
Version: 0.2.0
Summary: Server-side Python client for the SupDesk API, with sync and async clients.
Project-URL: Homepage, https://github.com/RabinApps/supdesk-python
Project-URL: Documentation, https://docs.supdesk.app/en/api/authentication
Project-URL: Repository, https://github.com/RabinApps/supdesk-python
Project-URL: Issues, https://github.com/RabinApps/supdesk-python/issues
Project-URL: Changelog, https://github.com/RabinApps/supdesk-python/blob/main/CHANGELOG.md
Author: Rabin Apps LLC
License: MIT
License-File: LICENSE
Keywords: api,async,changelog,client,feedback,helpdesk,sdk,supdesk,waitlist
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
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 :: Implementation :: CPython
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx>=0.27
Requires-Dist: typing-extensions>=4.5; python_version < '3.11'
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: mypy>=1.13; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.8; extra == 'dev'
Requires-Dist: twine>=5.1; extra == 'dev'
Description-Content-Type: text/markdown

# supdesk

Python client for the [SupDesk API](https://docs.supdesk.app/en/api/authentication).

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.

```bash
pip install supdesk
```

## Quick start

```python
from 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:

```python
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 and writes both work on every plan** — Free
included. Creating submissions and feedback is metered against your monthly submission
quota, which raises a `LimitReachedError` at the cap.

Posts created through the API run the same spam assessment as portal submissions, so
check `moderation_status` on the result — `published`, or `pending` / `spam` when held.
A held post triggers no notifications until someone clears it in the console.

Read the key from a server-side environment variable — `$SUPDESK_API_KEY`, or any
`SECRET_*` your platform provides

## Security

**The API key is a server-side secret.** It is project-scoped, and 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.

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

## Client options

```python
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.

## Resources

| 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`               |

## Pagination

`list()` returns a `Page`, which is both the current page and an iterable over
everything after it.

```python
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 memory
```

`articles.search()` is the exception — it returns a plain ranked `list`, not a page.

## Errors

Every failure is a subclass of `SupDeskError`, so one `except` covers the lot while the
class hierarchy still narrows to the specific case.

```python
from supdesk import LimitReachedError, NotFoundError, SupDeskError

try:
    supdesk.articles.create(title="How to export")
except LimitReachedError:
    # Monthly submission quota exhausted.
    pass
except NotFoundError:
    # No such resource in this project.
    pass
except SupDeskError as error:
    print(error)
```

| Class                 | Status | Code              |
| --------------------- | ------ | ----------------- |
| `InvalidRequestError` | 400    | `invalid_request` |
| `UnauthorizedError`   | 401    | `unauthorized`    |
| `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`.

## Retries

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_reached` is never retried.** It shares HTTP 429 with `rate_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 by `code`, not status.
- **`POST` is not replayed** on network errors or 5xx by default. SupDesk has no
  idempotency key, and `submissions.create` / `feedback.create` are 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 429 `rate_limited` is still retried on any method, because
  the server states it did not process the request. Opt in with
  `retry_unsafe_methods=True`.

## Webhooks

Verification is synchronous: `hmac` + `hashlib`, with a constant-time
`hmac.compare_digest`. Pass the raw body plus the `X-SupDesk-Signature` header.

```python
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's `await request.json()`,
> Django's `JsonRequestParser`) 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`.

## Examples

Framework-specific, runnable integrations (FastAPI, Django, Flask, plus a
no-framework script) live in [`examples/`](examples/) with their own
`requirements.txt`. They are not part of the published package.

## Contributing

```bash
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.

## License

MIT
