Metadata-Version: 2.4
Name: nn-webhooks-sdk
Version: 0.5.2
Summary: Official Python SDK for NimbusNexus Webhooks — publish events + verify webhook signatures.
Project-URL: Homepage, https://github.com/NimbusNexus/Webhooks-sdks/tree/develop/python#readme
Project-URL: Repository, https://github.com/NimbusNexus/Webhooks-sdks
Project-URL: Issues, https://github.com/NimbusNexus/Webhooks-sdks/issues
Author: NimbusNexus
License: MIT
License-File: LICENSE
Keywords: hmac,nimbusnexus,sdk,signature,verify,webhook,webhooks
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.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.11
Requires-Dist: httpx>=0.27
Provides-Extra: dev
Requires-Dist: mypy; extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Requires-Dist: ruff; extra == 'dev'
Provides-Extra: postgres
Requires-Dist: psycopg[binary]>=3; extra == 'postgres'
Provides-Extra: redis
Requires-Dist: redis>=5; extra == 'redis'
Description-Content-Type: text/markdown

# nn-webhooks-sdk (Python)

Official Python SDK for **NimbusNexus Webhooks** — publish events, manage your endpoints / keys /
deliveries, and verify the webhooks you receive.

```sh
pip install nn-webhooks-sdk
```

## Verify an incoming webhook (subscribers)

When webhookd delivers a webhook it signs the body with your endpoint's signing secret. **Always
verify the signature** before trusting the payload — it proves the request really came from webhookd
and wasn't tampered with or replayed.

```python
from nn_webhooks import verify

# In your webhook handler — pass the RAW request body bytes (do not re-serialize the JSON):
ok = verify(
    secret=ENDPOINT_SIGNING_SECRET,
    raw_body=request.body,
    signature=request.headers["X-Webhook-Signature"],
    timestamp=request.headers["X-Webhook-Timestamp"],
)
if not ok:
    return Response(status_code=400)  # forged, tampered, or outside the 300s replay window
```

## Publish an event (producers)

```python
from nn_webhooks import Client, WebhooksAPIError

with Client("https://webhooks.example.com", api_key="whsk_…") as wh:
    try:
        event = wh.publish(
            "order.created",
            {"order_id": "ord_123", "total": 4200},
            idempotency_key="order-123",   # makes the publish safe to retry
        )
        print(event.event_uid, event.deliveries_created)
    except WebhooksAPIError as e:
        print(e.status_code, e.code, e.message)   # the stable {error:{code,message}} envelope
```

Transient failures (connection errors, `429`, `5xx`) are retried with backoff (a `429` honours
`Retry-After`); other `4xx` raise `WebhooksAPIError`.

### Targeting a project

A project is addressed by its **ID** (`"prj_3f9a…"`) — it has no slug or short name. Every
project-aware call takes an optional `project_id`; **leave it unset** to target your workspace's
default project, which is what a single-project workspace always wants:

```python
wh.publish("order.created", {...})                            # -> the workspace's default project
wh.publish("order.created", {...}, project_id="prj_3f9a…")    # -> that specific project
```

Only the server can resolve "the default project" — the id is opaque and per-workspace, so there is no
client-side name for it. Omitting the field is the way to ask for it; passing a made-up string
(`"default"`, a project's display name) is a `404`. The id you need is on any response —
`event.project_id`, `endpoint["project_id"]` — or from `GET /v1/projects`.

## Outbox / durable buffering (producers)

`publish()` calls webhookd synchronously — if webhookd is unreachable it raises and the event is
lost. The **write-first outbox** decouples the two: `enqueue()` durably persists the event to a
pluggable `Store` and returns IMMEDIATELY (no network); `drain()` (or a background drainer) ships the
buffered events later. Every send carries `Idempotency-Key = record.id`, so a re-drain after a crash
or a lost response never double-publishes — webhookd dedupes. Delivery is **at-least-once**: nothing
is lost while webhookd is down.

```python
from nn_webhooks import Client, SQLiteStore

# 1. Configure a durable store (survives process restarts).
store = SQLiteStore("outbox.db")

with Client("https://webhooks.example.com", api_key="whsk_…", store=store) as wh:
    # 2. enqueue() instead of publish() — writes to the store and returns at once, NO network call.
    record_id = wh.enqueue("order.created", {"order_id": "ord_123", "total": 4200})

    # 3a. Drain on demand (returns {"sent", "failed", "remaining"}):
    wh.drain()

    # 3b. …or run a background drainer that calls drain() every 5s until the client closes.
    wh.start_drainer(interval_seconds=5)
    # ... your app keeps enqueuing; the drainer ships in the background ...
    wh.stop_drainer()   # also called automatically by Client.close()/__exit__
```

**Idempotency guarantee.** `record_id` is the `idempotency_key` you pass (or a generated UUID v4) and
becomes the `Idempotency-Key` header on every delivery attempt for that record. If the process
crashes after a send but before the response is recorded, the next `drain()` re-sends with the *same*
key and webhookd returns the original event without re-fanning-out. A record that keeps failing is
retried with capped exponential backoff up to `max_attempts` (default 10), then flagged **dead**
(never retried again) and handed to the optional `on_dead` callback.

**Built-in stores** — pick one for `Client(..., store=...)`:

| Store | Durable? | Extra needed |
| --- | --- | --- |
| `MemoryStore` | No (in-process) | — (stdlib) |
| `FileStore(dir)` | Yes (per-record JSON files) | — (stdlib) |
| `SQLiteStore(path)` | Yes (transactional) | — (stdlib `sqlite3`) |
| `RedisStore(url)` | Yes | `pip install 'nn-webhooks-sdk[redis]'` |
| `PostgresStore(dsn)` | Yes | `pip install 'nn-webhooks-sdk[postgres]'` |

The core SDK's only runtime dependency is `httpx`; `RedisStore` / `PostgresStore` lazily import their driver only
when you construct them.

> **Schema change.** The SQL stores' `project` column (a slug) is now `project_id`, nullable (null =
> the workspace's default project). There is no migration step — drain an outbox written by an older
> SDK before upgrading, or drop the `webhookd_outbox` table.

## Manage endpoints, keys & deliveries (operators)

The same `Client` wraps the control-plane API — register receivers, mint keys, and drain the
dead-letter queue from code (needs an **admin**-scoped key). Management methods return the raw JSON as
`dict`s (snake_case, exactly as the API sends); list methods return a page —
`{"items": [...], "next_offset": int | None}`; delete/revoke return `None` (a `204`).

```python
from nn_webhooks import Client

wh = Client("https://webhooks.example.com", api_key="whsk_admin_…")

# --- Endpoints ----------------------------------------------------------------
# Create a receiver — its signing secret is in the response exactly once, so persist it now.
ep = wh.create_endpoint(
    "https://your-app.example/webhooks",
    subscriptions=[{"match_kind": "prefix", "pattern": "order."}],
    description="orders service",
)
endpoint_id, signing_secret = ep["id"], ep["secret"]

wh.list_endpoints()                          # {"items": [...], "next_offset": ...} — default project
wh.list_endpoints(project_id="prj_3f9a…")    # …or scope the listing to one project by id
wh.get_endpoint(endpoint_id)

# PATCH — send only the keys you want to change (omitted = unchanged, None = cleared):
wh.update_endpoint(endpoint_id, {"max_attempts": 10, "status": "disabled"})

wh.rotate_endpoint_secret(endpoint_id)       # returns the new secret, once
wh.enable_endpoint(endpoint_id)              # recover an auto-disabled endpoint
wh.delete_endpoint(endpoint_id)              # -> None (204)

# --- API keys -----------------------------------------------------------------
key = wh.create_api_key("ci-publisher", scope="publish", expires_in_days=90)
print(key["key"])                            # shown once
wh.revoke_api_key(key["id"])                 # -> None (204)

# --- Deliveries / dead-letter recovery ----------------------------------------
for d in wh.list_deliveries(status="dead")["items"]:
    wh.redeliver(d["id"])
```

## Develop

```sh
pip install -e '.[dev]'
pytest && ruff check . && mypy nn_webhooks
```
