Metadata-Version: 2.4
Name: onefirstflock-donations-embed
Version: 1.2.4
Summary: Server-side Python SDK for the 1st Flock Donations Embed API — typed client, webhook signature verification, and event models.
Project-URL: Homepage, https://gitlab.com/1st-flock/donations
Project-URL: Documentation, https://docs.1stflock.com/donations-embed/python
Project-URL: Repository, https://gitlab.com/1st-flock/donations
Project-URL: Source, https://gitlab.com/1st-flock/donations/-/tree/main/python/donations-embed
Project-URL: Issues, https://gitlab.com/1st-flock/donations/-/issues
Author-email: 1st Flock <developers@1stflock.com>
License-Expression: MIT
License-File: LICENSE
Keywords: 1stflock,donations,fundraising,sdk,webhooks
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
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: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2.5
Requires-Dist: tenacity>=8.0
Provides-Extra: dev
Requires-Dist: mypy>=1.13; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.8; extra == 'dev'
Description-Content-Type: text/markdown

# onefirstflock-donations-embed

Server-side Python SDK for the 1st Flock Donations Embed API. Async-first client built on `httpx`, typed Pydantic v2 models for every payload, and a constant-time HMAC-SHA256 webhook verifier. FastAPI / Starlette / Django friendly.

[![PyPI version](https://img.shields.io/pypi/v/onefirstflock-donations-embed.svg)](https://pypi.org/project/onefirstflock-donations-embed/)
[![Python versions](https://img.shields.io/pypi/pyversions/onefirstflock-donations-embed.svg)](https://pypi.org/project/onefirstflock-donations-embed/)
[![License](https://img.shields.io/pypi/l/onefirstflock-donations-embed.svg)](./LICENSE)

## Install

```bash
pip install onefirstflock-donations-embed
# or
uv add onefirstflock-donations-embed
```

The package name on PyPI is `onefirstflock-donations-embed`; the import name is `donations_embed`. Requires Python 3.10 or newer.

> **v1.0.0 install bug — install from source until v1.0.1 ships.** The published `onefirstflock-donations-embed==1.0.0` wheel on PyPI omits one production module (`donations_embed/models/webhook_test.py`) and raises `ModuleNotFoundError: No module named 'donations_embed.models.webhook_test'` at import time. Until v1.0.1 is published, install from the tagged source:
>
> ```bash
> pip install "git+https://gitlab.com/1st-flock/donations@v1.0.0#subdirectory=python/donations-embed"
> # or
> uv add "git+https://gitlab.com/1st-flock/donations@v1.0.0#subdirectory=python/donations-embed"
> ```
>
> The git-source install pulls every file under `src/donations_embed/` and is byte-for-byte identical to what v1.0.1 will publish to PyPI.

## Quickstart

```python
import asyncio
import os
from donations_embed import Client


async def main() -> None:
    async with Client(api_key=os.environ["FLOCK_SECRET_KEY"]) as client:
        page = await client.list_donations(limit=25)
        for donation in page.donations:
            print(donation.id, donation.gross_amount_cents, donation.status)


asyncio.run(main())
```

Run this against a sandbox key (`sk_test_…`) and any donation made through the embed (e.g. with sandbox card `4111 1111 1111 1111`, exp `10/29`, CVV `123`) shows up here within a few seconds.

## Configuration

Generate keys in the 1st Flock account portal under **Donations Setup → Embed Keys** ([full guide](https://1stflock.com/developers/donations-embed/)). The server SDK requires a **secret** key:

- **Secret keys** (`sk_live_…`, `sk_test_…`) — server-only. Never ship a secret key to a browser. Store in environment variables, your secrets manager, or a settings library.
- **Webhook signing secret** — issued separately when you create or rotate a webhook endpoint. Used by `verify_webhook` to authenticate inbound deliveries. Store alongside the secret key.

The constructor rejects publishable keys (`pk_…`) at construction time with a clear `ValueError` so misconfiguration surfaces synchronously rather than as a confusing 401.

```python
from donations_embed import Client

client = Client(
    api_key=os.environ["FLOCK_SECRET_KEY"],
    # Optional overrides:
    base_url="https://api.sandbox.1stflock.com",  # also auto-resolved by sk_test_ prefix
    max_retries=3,    # retries on 5xx + transient network errors
    timeout=30.0,     # per-request timeout in seconds
)
```

For long-lived applications (FastAPI lifespan, Django async middleware), construct once at startup and call `await client.aclose()` on shutdown. For one-off scripts use the async-context-manager form (`async with Client(...) as client:`) so connection pools are cleaned up automatically.

## Test mode

Pass a sandbox key (`sk_test_…`) — the SDK routes to the sandbox cluster automatically based on the `_test_` prefix. No real money moves. Same code path, same wire format, same webhook events. Donations show up in the hub with a **TEST** badge and never appear in the production ledger.

## Common tasks

### List + paginate donations

```python
async def stream_all_donations() -> None:
    async with Client(api_key=os.environ["FLOCK_SECRET_KEY"]) as client:
        cursor: str | None = None
        while True:
            page = await client.list_donations(cursor=cursor, limit=200)
            for donation in page.donations:
                await sync_to_ledger(donation)
            if page.next_cursor is None:
                break
            cursor = page.next_cursor
```

Filter by fund or donor email:

```python
building_only = await client.list_donations(fund_id="general")
sams_history = await client.list_donations(donor_email="sam@example.com")
```

### Fetch a single donation

```python
donation = await client.get_donation("8a1b9c4d-1234-4321-9abc-def012345678")
print(donation.confirmation_id, donation.gross_amount_cents)
```

Cross-organization access deliberately collapses to `404` (`NotFoundError`) so you cannot use the API to confirm whether a UUID belongs to another tenant.

### Refund a donation

```python
from donations_embed import Client, idempotency_key

# Full refund:
await client.refund_donation(donation_id)

# Partial refund with an idempotency key (safe to retry on network blip):
refund = await client.refund_donation(
    donation_id,
    amount_cents=1000,
    reason="Donor adjustment",
    idempotency_key=idempotency_key("refund"),
)
print(refund.processor_refund_id, refund.refund_amount_cents)
```

`idempotency_key("refund")` returns a tagged UUID like `"refund:1f2dab36-2a55-4f11-bdc8-2a2a4a95b001"`. Replays of the same key return the original response without re-contacting the gateway.

### List + cancel recurring schedules

```python
page = await client.list_recurring(status="active", limit=100)
for recurring in page.recurring:
    if donor_opted_out(recurring.donor_email):
        await client.cancel_recurring(
            recurring.id,
            idempotency_key=idempotency_key("cancel-recurring"),
        )
```

### Fire a synthetic webhook (integration test)

```python
result = await client.test_webhook("donation.completed")
print("test event id:", result.event_id)
```

The synthetic event's `data.object.synthetic` flag is set so downstream code can branch on it.

## Webhook verification

Verify every inbound delivery with `verify_webhook` — constant-time HMAC-SHA256 comparison plus a 5-minute timestamp tolerance to defeat replays.

```python
import os
from fastapi import FastAPI, Header, HTTPException, Request
from donations_embed import (
    DonationCompletedEvent,
    DonationRefundedEvent,
    KeyFrozenEvent,
    WebhookError,
    verify_webhook,
)

app = FastAPI()
WEBHOOK_SECRET = os.environ["FLOCK_WEBHOOK_SECRET"]


@app.post("/webhooks/donations")
async def receive_webhook(
    request: Request,
    flock_signature: str = Header(..., alias="Flock-Signature"),
) -> dict[str, str]:
    body = await request.body()
    try:
        event = verify_webhook(body, flock_signature, WEBHOOK_SECRET)
    except WebhookError:
        # Always 401 — never confirm which check failed.
        raise HTTPException(status_code=401, detail="invalid signature") from None

    match event:
        case DonationCompletedEvent():
            await enqueue_receipt(event.data.object)
        case DonationRefundedEvent():
            await reverse_receipt(event.data.object)
        case KeyFrozenEvent():
            await alert_on_call(event.data.object)
        case _:
            pass  # Ignore unknown event types.

    return {"status": "ok"}
```

Pass the **exact body bytes** the platform delivered. Re-serializing the JSON before verifying will invalidate the signature.

`verify_webhook` raises typed exceptions on failure (`WebhookFormatError`, `WebhookTimestampError`, `WebhookSignatureError` — all inherit from `WebhookError`) so you can log distinct failure modes even though the HTTP response is the same `401` in every case. An async wrapper, `verify_webhook_async`, is also provided for `await`-based middleware.

### Dedup by `Flock-Event-Id`

Webhook delivery is at-least-once. Persist `event.id` (also exposed as the `Flock-Event-Id` HTTP header) and short-circuit re-deliveries.

## Errors

Every API call raises a typed exception on a non-2xx response.

| HTTP status | Exception |
| --- | --- |
| 400 / 422 | `ValidationError` |
| 401 | `AuthError` |
| 403 | `ForbiddenError` |
| 404 | `NotFoundError` |
| 409 | `ConflictError` |
| 410 | `GoneError` |
| 429 | `RateLimitError` (carries `retry_after`) |
| 502 | `BadGatewayError` |
| 503 | `ServiceUnavailableError` |
| Other | `ApiError` |

All inherit from `ApiError` and expose `status_code`, `code`, `message`, `details`, and `request_id`.

```python
from donations_embed import ApiError, ConflictError, NotFoundError, RateLimitError

try:
    refund = await client.refund_donation(donation_id, amount_cents=1000)
except RateLimitError as exc:
    await asyncio.sleep(exc.retry_after or 1)
    return await retry()
except ConflictError:
    log.warning("Donation %s already refunded", donation_id)
except NotFoundError:
    log.warning("Donation %s not found", donation_id)
except ApiError as exc:
    log.error("API error %s %s: %s", exc.status_code, exc.code, exc.message)
    raise
```

## Full documentation

The full reference — every endpoint, every error code, the webhook event catalogue with payload schemas, and operations guidance — lives at [1stflock.com/developers/donations-embed/](https://1stflock.com/developers/donations-embed/).

## Vendor neutrality

This SDK never names the underlying payment processor, bank-link service, or any other third-party vendor in customer-facing strings. Donations carry vendor-neutral identifiers (`processor_transaction_id`, `processor_refund_id`, `processor_subscription_id`); errors route through 1st Flock's own taxonomy. The underlying integrations are an implementation detail and may change without notice — your code never has to.

## License

MIT — see [LICENSE](./LICENSE).

## Reporting issues

File issues at [gitlab.com/1st-flock/donations/-/issues](https://gitlab.com/1st-flock/donations/-/issues) with the label `donations-embed-python`.
