onefirstflock-donations-embed 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- onefirstflock_donations_embed-1.0.0/.gitignore +71 -0
- onefirstflock_donations_embed-1.0.0/.gitkeep +0 -0
- onefirstflock_donations_embed-1.0.0/LICENSE +21 -0
- onefirstflock_donations_embed-1.0.0/PKG-INFO +277 -0
- onefirstflock_donations_embed-1.0.0/README.md +242 -0
- onefirstflock_donations_embed-1.0.0/examples/refund_donation.py +95 -0
- onefirstflock_donations_embed-1.0.0/examples/stream_recurring.py +105 -0
- onefirstflock_donations_embed-1.0.0/examples/verify_webhook.py +157 -0
- onefirstflock_donations_embed-1.0.0/pyproject.toml +105 -0
- onefirstflock_donations_embed-1.0.0/sandbox/validate.py +194 -0
- onefirstflock_donations_embed-1.0.0/src/donations_embed/__init__.py +157 -0
- onefirstflock_donations_embed-1.0.0/src/donations_embed/_http.py +318 -0
- onefirstflock_donations_embed-1.0.0/src/donations_embed/_idempotency.py +53 -0
- onefirstflock_donations_embed-1.0.0/src/donations_embed/_version.py +9 -0
- onefirstflock_donations_embed-1.0.0/src/donations_embed/client.py +282 -0
- onefirstflock_donations_embed-1.0.0/src/donations_embed/errors.py +180 -0
- onefirstflock_donations_embed-1.0.0/src/donations_embed/models/__init__.py +93 -0
- onefirstflock_donations_embed-1.0.0/src/donations_embed/models/donations.py +96 -0
- onefirstflock_donations_embed-1.0.0/src/donations_embed/models/events.py +345 -0
- onefirstflock_donations_embed-1.0.0/src/donations_embed/models/recurring.py +61 -0
- onefirstflock_donations_embed-1.0.0/src/donations_embed/webhooks.py +189 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# --- Node ---
|
|
2
|
+
**/node_modules/
|
|
3
|
+
.pnpm-store/
|
|
4
|
+
|
|
5
|
+
# --- Build output ---
|
|
6
|
+
**/dist/
|
|
7
|
+
*.tsbuildinfo
|
|
8
|
+
|
|
9
|
+
# --- Logs ---
|
|
10
|
+
logs/
|
|
11
|
+
*.log
|
|
12
|
+
npm-debug.log*
|
|
13
|
+
pnpm-debug.log*
|
|
14
|
+
yarn-debug.log*
|
|
15
|
+
yarn-error.log*
|
|
16
|
+
|
|
17
|
+
# --- Environment files ---
|
|
18
|
+
.env
|
|
19
|
+
.env.local
|
|
20
|
+
.env.*.local
|
|
21
|
+
|
|
22
|
+
# --- OS files ---
|
|
23
|
+
.DS_Store
|
|
24
|
+
Thumbs.db
|
|
25
|
+
|
|
26
|
+
# --- IDE ---
|
|
27
|
+
.idea/
|
|
28
|
+
.vscode/
|
|
29
|
+
.claude/
|
|
30
|
+
|
|
31
|
+
# --- npm/pnpm publish ---
|
|
32
|
+
.npmrc
|
|
33
|
+
|
|
34
|
+
# --- Test coverage ---
|
|
35
|
+
coverage/
|
|
36
|
+
|
|
37
|
+
# --- Turbo ---
|
|
38
|
+
.turbo/
|
|
39
|
+
|
|
40
|
+
# --- Cache ---
|
|
41
|
+
.cache/
|
|
42
|
+
|
|
43
|
+
# --- Temporary files ---
|
|
44
|
+
tmp/
|
|
45
|
+
temp/
|
|
46
|
+
*.tmp
|
|
47
|
+
|
|
48
|
+
# --- Playwright artifacts ---
|
|
49
|
+
**/test-results/
|
|
50
|
+
**/playwright-report/
|
|
51
|
+
**/playwright/.cache/
|
|
52
|
+
node/donations-embed/tests/e2e/screenshots/
|
|
53
|
+
|
|
54
|
+
# --- OpenAPI codegen output ---
|
|
55
|
+
codegen-output/
|
|
56
|
+
|
|
57
|
+
# --- Python ---
|
|
58
|
+
**/__pycache__/
|
|
59
|
+
**/.pytest_cache/
|
|
60
|
+
**/.ruff_cache/
|
|
61
|
+
**/.mypy_cache/
|
|
62
|
+
**/.venv/
|
|
63
|
+
**/*.egg-info/
|
|
64
|
+
**/htmlcov/
|
|
65
|
+
.coverage
|
|
66
|
+
.tox/
|
|
67
|
+
|
|
68
|
+
# --- Go ---
|
|
69
|
+
**/*.exe
|
|
70
|
+
**/*.test
|
|
71
|
+
**/*.out
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 1st Flock, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: onefirstflock-donations-embed
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Server-side Python SDK for the 1st Flock Donations Embed API — typed client, webhook signature verification, and event models.
|
|
5
|
+
Project-URL: Homepage, https://gitlab.com/1st-flock/donations
|
|
6
|
+
Project-URL: Documentation, https://docs.1stflock.com/donations-embed/python
|
|
7
|
+
Project-URL: Repository, https://gitlab.com/1st-flock/donations
|
|
8
|
+
Project-URL: Source, https://gitlab.com/1st-flock/donations/-/tree/main/python/donations-embed
|
|
9
|
+
Project-URL: Issues, https://gitlab.com/1st-flock/donations/-/issues
|
|
10
|
+
Author-email: 1st Flock <developers@1stflock.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: 1stflock,donations,fundraising,sdk,webhooks
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: httpx>=0.27
|
|
26
|
+
Requires-Dist: pydantic>=2.5
|
|
27
|
+
Requires-Dist: tenacity>=8.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: mypy>=1.13; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# onefirstflock-donations-embed
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
[](https://pypi.org/project/onefirstflock-donations-embed/)
|
|
41
|
+
[](https://pypi.org/project/onefirstflock-donations-embed/)
|
|
42
|
+
[](./LICENSE)
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install onefirstflock-donations-embed
|
|
48
|
+
# or
|
|
49
|
+
uv add onefirstflock-donations-embed
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The package name on PyPI is `onefirstflock-donations-embed`; the import name is `donations_embed`. Requires Python 3.10 or newer.
|
|
53
|
+
|
|
54
|
+
## Quickstart
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import asyncio
|
|
58
|
+
import os
|
|
59
|
+
from donations_embed import Client
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def main() -> None:
|
|
63
|
+
async with Client(api_key=os.environ["FLOCK_SECRET_KEY"]) as client:
|
|
64
|
+
page = await client.list_donations(limit=25)
|
|
65
|
+
for donation in page.donations:
|
|
66
|
+
print(donation.id, donation.gross_amount_cents, donation.status)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
asyncio.run(main())
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
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.
|
|
73
|
+
|
|
74
|
+
## Configuration
|
|
75
|
+
|
|
76
|
+
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:
|
|
77
|
+
|
|
78
|
+
- **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.
|
|
79
|
+
- **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.
|
|
80
|
+
|
|
81
|
+
The constructor rejects publishable keys (`pk_…`) at construction time with a clear `ValueError` so misconfiguration surfaces synchronously rather than as a confusing 401.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from donations_embed import Client
|
|
85
|
+
|
|
86
|
+
client = Client(
|
|
87
|
+
api_key=os.environ["FLOCK_SECRET_KEY"],
|
|
88
|
+
# Optional overrides:
|
|
89
|
+
base_url="https://api.sandbox.1stflock.com", # also auto-resolved by sk_test_ prefix
|
|
90
|
+
max_retries=3, # retries on 5xx + transient network errors
|
|
91
|
+
timeout=30.0, # per-request timeout in seconds
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
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.
|
|
96
|
+
|
|
97
|
+
## Test mode
|
|
98
|
+
|
|
99
|
+
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.
|
|
100
|
+
|
|
101
|
+
## Common tasks
|
|
102
|
+
|
|
103
|
+
### List + paginate donations
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
async def stream_all_donations() -> None:
|
|
107
|
+
async with Client(api_key=os.environ["FLOCK_SECRET_KEY"]) as client:
|
|
108
|
+
cursor: str | None = None
|
|
109
|
+
while True:
|
|
110
|
+
page = await client.list_donations(cursor=cursor, limit=200)
|
|
111
|
+
for donation in page.donations:
|
|
112
|
+
await sync_to_ledger(donation)
|
|
113
|
+
if page.next_cursor is None:
|
|
114
|
+
break
|
|
115
|
+
cursor = page.next_cursor
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Filter by fund or donor email:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
building_only = await client.list_donations(fund_id="general")
|
|
122
|
+
sams_history = await client.list_donations(donor_email="sam@example.com")
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Fetch a single donation
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
donation = await client.get_donation("8a1b9c4d-1234-4321-9abc-def012345678")
|
|
129
|
+
print(donation.confirmation_id, donation.gross_amount_cents)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Cross-organization access deliberately collapses to `404` (`NotFoundError`) so you cannot use the API to confirm whether a UUID belongs to another tenant.
|
|
133
|
+
|
|
134
|
+
### Refund a donation
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from donations_embed import Client, idempotency_key
|
|
138
|
+
|
|
139
|
+
# Full refund:
|
|
140
|
+
await client.refund_donation(donation_id)
|
|
141
|
+
|
|
142
|
+
# Partial refund with an idempotency key (safe to retry on network blip):
|
|
143
|
+
refund = await client.refund_donation(
|
|
144
|
+
donation_id,
|
|
145
|
+
amount_cents=1000,
|
|
146
|
+
reason="Donor adjustment",
|
|
147
|
+
idempotency_key=idempotency_key("refund"),
|
|
148
|
+
)
|
|
149
|
+
print(refund.processor_refund_id, refund.refund_amount_cents)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`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.
|
|
153
|
+
|
|
154
|
+
### List + cancel recurring schedules
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
page = await client.list_recurring(status="active", limit=100)
|
|
158
|
+
for recurring in page.recurring:
|
|
159
|
+
if donor_opted_out(recurring.donor_email):
|
|
160
|
+
await client.cancel_recurring(
|
|
161
|
+
recurring.id,
|
|
162
|
+
idempotency_key=idempotency_key("cancel-recurring"),
|
|
163
|
+
)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Fire a synthetic webhook (integration test)
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
result = await client.test_webhook("donation.completed")
|
|
170
|
+
print("test event id:", result.event_id)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The synthetic event's `data.object.synthetic` flag is set so downstream code can branch on it.
|
|
174
|
+
|
|
175
|
+
## Webhook verification
|
|
176
|
+
|
|
177
|
+
Verify every inbound delivery with `verify_webhook` — constant-time HMAC-SHA256 comparison plus a 5-minute timestamp tolerance to defeat replays.
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
import os
|
|
181
|
+
from fastapi import FastAPI, Header, HTTPException, Request
|
|
182
|
+
from donations_embed import (
|
|
183
|
+
DonationCompletedEvent,
|
|
184
|
+
DonationRefundedEvent,
|
|
185
|
+
KeyFrozenEvent,
|
|
186
|
+
WebhookError,
|
|
187
|
+
verify_webhook,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
app = FastAPI()
|
|
191
|
+
WEBHOOK_SECRET = os.environ["FLOCK_WEBHOOK_SECRET"]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@app.post("/webhooks/donations")
|
|
195
|
+
async def receive_webhook(
|
|
196
|
+
request: Request,
|
|
197
|
+
flock_signature: str = Header(..., alias="Flock-Signature"),
|
|
198
|
+
) -> dict[str, str]:
|
|
199
|
+
body = await request.body()
|
|
200
|
+
try:
|
|
201
|
+
event = verify_webhook(body, flock_signature, WEBHOOK_SECRET)
|
|
202
|
+
except WebhookError:
|
|
203
|
+
# Always 401 — never confirm which check failed.
|
|
204
|
+
raise HTTPException(status_code=401, detail="invalid signature") from None
|
|
205
|
+
|
|
206
|
+
match event:
|
|
207
|
+
case DonationCompletedEvent():
|
|
208
|
+
await enqueue_receipt(event.data.object)
|
|
209
|
+
case DonationRefundedEvent():
|
|
210
|
+
await reverse_receipt(event.data.object)
|
|
211
|
+
case KeyFrozenEvent():
|
|
212
|
+
await alert_on_call(event.data.object)
|
|
213
|
+
case _:
|
|
214
|
+
pass # Ignore unknown event types.
|
|
215
|
+
|
|
216
|
+
return {"status": "ok"}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Pass the **exact body bytes** the platform delivered. Re-serializing the JSON before verifying will invalidate the signature.
|
|
220
|
+
|
|
221
|
+
`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.
|
|
222
|
+
|
|
223
|
+
### Dedup by `Flock-Event-Id`
|
|
224
|
+
|
|
225
|
+
Webhook delivery is at-least-once. Persist `event.id` (also exposed as the `Flock-Event-Id` HTTP header) and short-circuit re-deliveries.
|
|
226
|
+
|
|
227
|
+
## Errors
|
|
228
|
+
|
|
229
|
+
Every API call raises a typed exception on a non-2xx response.
|
|
230
|
+
|
|
231
|
+
| HTTP status | Exception |
|
|
232
|
+
| --- | --- |
|
|
233
|
+
| 400 / 422 | `ValidationError` |
|
|
234
|
+
| 401 | `AuthError` |
|
|
235
|
+
| 403 | `ForbiddenError` |
|
|
236
|
+
| 404 | `NotFoundError` |
|
|
237
|
+
| 409 | `ConflictError` |
|
|
238
|
+
| 410 | `GoneError` |
|
|
239
|
+
| 429 | `RateLimitError` (carries `retry_after`) |
|
|
240
|
+
| 502 | `BadGatewayError` |
|
|
241
|
+
| 503 | `ServiceUnavailableError` |
|
|
242
|
+
| Other | `ApiError` |
|
|
243
|
+
|
|
244
|
+
All inherit from `ApiError` and expose `status_code`, `code`, `message`, `details`, and `request_id`.
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
from donations_embed import ApiError, ConflictError, NotFoundError, RateLimitError
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
refund = await client.refund_donation(donation_id, amount_cents=1000)
|
|
251
|
+
except RateLimitError as exc:
|
|
252
|
+
await asyncio.sleep(exc.retry_after or 1)
|
|
253
|
+
return await retry()
|
|
254
|
+
except ConflictError:
|
|
255
|
+
log.warning("Donation %s already refunded", donation_id)
|
|
256
|
+
except NotFoundError:
|
|
257
|
+
log.warning("Donation %s not found", donation_id)
|
|
258
|
+
except ApiError as exc:
|
|
259
|
+
log.error("API error %s %s: %s", exc.status_code, exc.code, exc.message)
|
|
260
|
+
raise
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Full documentation
|
|
264
|
+
|
|
265
|
+
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/).
|
|
266
|
+
|
|
267
|
+
## Vendor neutrality
|
|
268
|
+
|
|
269
|
+
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.
|
|
270
|
+
|
|
271
|
+
## License
|
|
272
|
+
|
|
273
|
+
MIT — see [LICENSE](./LICENSE).
|
|
274
|
+
|
|
275
|
+
## Reporting issues
|
|
276
|
+
|
|
277
|
+
File issues at [gitlab.com/1st-flock/donations/-/issues](https://gitlab.com/1st-flock/donations/-/issues) with the label `donations-embed-python`.
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# onefirstflock-donations-embed
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/onefirstflock-donations-embed/)
|
|
6
|
+
[](https://pypi.org/project/onefirstflock-donations-embed/)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install onefirstflock-donations-embed
|
|
13
|
+
# or
|
|
14
|
+
uv add onefirstflock-donations-embed
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The package name on PyPI is `onefirstflock-donations-embed`; the import name is `donations_embed`. Requires Python 3.10 or newer.
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import asyncio
|
|
23
|
+
import os
|
|
24
|
+
from donations_embed import Client
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def main() -> None:
|
|
28
|
+
async with Client(api_key=os.environ["FLOCK_SECRET_KEY"]) as client:
|
|
29
|
+
page = await client.list_donations(limit=25)
|
|
30
|
+
for donation in page.donations:
|
|
31
|
+
print(donation.id, donation.gross_amount_cents, donation.status)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
asyncio.run(main())
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
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.
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
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:
|
|
42
|
+
|
|
43
|
+
- **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.
|
|
44
|
+
- **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.
|
|
45
|
+
|
|
46
|
+
The constructor rejects publishable keys (`pk_…`) at construction time with a clear `ValueError` so misconfiguration surfaces synchronously rather than as a confusing 401.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from donations_embed import Client
|
|
50
|
+
|
|
51
|
+
client = Client(
|
|
52
|
+
api_key=os.environ["FLOCK_SECRET_KEY"],
|
|
53
|
+
# Optional overrides:
|
|
54
|
+
base_url="https://api.sandbox.1stflock.com", # also auto-resolved by sk_test_ prefix
|
|
55
|
+
max_retries=3, # retries on 5xx + transient network errors
|
|
56
|
+
timeout=30.0, # per-request timeout in seconds
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
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.
|
|
61
|
+
|
|
62
|
+
## Test mode
|
|
63
|
+
|
|
64
|
+
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.
|
|
65
|
+
|
|
66
|
+
## Common tasks
|
|
67
|
+
|
|
68
|
+
### List + paginate donations
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
async def stream_all_donations() -> None:
|
|
72
|
+
async with Client(api_key=os.environ["FLOCK_SECRET_KEY"]) as client:
|
|
73
|
+
cursor: str | None = None
|
|
74
|
+
while True:
|
|
75
|
+
page = await client.list_donations(cursor=cursor, limit=200)
|
|
76
|
+
for donation in page.donations:
|
|
77
|
+
await sync_to_ledger(donation)
|
|
78
|
+
if page.next_cursor is None:
|
|
79
|
+
break
|
|
80
|
+
cursor = page.next_cursor
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Filter by fund or donor email:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
building_only = await client.list_donations(fund_id="general")
|
|
87
|
+
sams_history = await client.list_donations(donor_email="sam@example.com")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Fetch a single donation
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
donation = await client.get_donation("8a1b9c4d-1234-4321-9abc-def012345678")
|
|
94
|
+
print(donation.confirmation_id, donation.gross_amount_cents)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Cross-organization access deliberately collapses to `404` (`NotFoundError`) so you cannot use the API to confirm whether a UUID belongs to another tenant.
|
|
98
|
+
|
|
99
|
+
### Refund a donation
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from donations_embed import Client, idempotency_key
|
|
103
|
+
|
|
104
|
+
# Full refund:
|
|
105
|
+
await client.refund_donation(donation_id)
|
|
106
|
+
|
|
107
|
+
# Partial refund with an idempotency key (safe to retry on network blip):
|
|
108
|
+
refund = await client.refund_donation(
|
|
109
|
+
donation_id,
|
|
110
|
+
amount_cents=1000,
|
|
111
|
+
reason="Donor adjustment",
|
|
112
|
+
idempotency_key=idempotency_key("refund"),
|
|
113
|
+
)
|
|
114
|
+
print(refund.processor_refund_id, refund.refund_amount_cents)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`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.
|
|
118
|
+
|
|
119
|
+
### List + cancel recurring schedules
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
page = await client.list_recurring(status="active", limit=100)
|
|
123
|
+
for recurring in page.recurring:
|
|
124
|
+
if donor_opted_out(recurring.donor_email):
|
|
125
|
+
await client.cancel_recurring(
|
|
126
|
+
recurring.id,
|
|
127
|
+
idempotency_key=idempotency_key("cancel-recurring"),
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Fire a synthetic webhook (integration test)
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
result = await client.test_webhook("donation.completed")
|
|
135
|
+
print("test event id:", result.event_id)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The synthetic event's `data.object.synthetic` flag is set so downstream code can branch on it.
|
|
139
|
+
|
|
140
|
+
## Webhook verification
|
|
141
|
+
|
|
142
|
+
Verify every inbound delivery with `verify_webhook` — constant-time HMAC-SHA256 comparison plus a 5-minute timestamp tolerance to defeat replays.
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
import os
|
|
146
|
+
from fastapi import FastAPI, Header, HTTPException, Request
|
|
147
|
+
from donations_embed import (
|
|
148
|
+
DonationCompletedEvent,
|
|
149
|
+
DonationRefundedEvent,
|
|
150
|
+
KeyFrozenEvent,
|
|
151
|
+
WebhookError,
|
|
152
|
+
verify_webhook,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
app = FastAPI()
|
|
156
|
+
WEBHOOK_SECRET = os.environ["FLOCK_WEBHOOK_SECRET"]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@app.post("/webhooks/donations")
|
|
160
|
+
async def receive_webhook(
|
|
161
|
+
request: Request,
|
|
162
|
+
flock_signature: str = Header(..., alias="Flock-Signature"),
|
|
163
|
+
) -> dict[str, str]:
|
|
164
|
+
body = await request.body()
|
|
165
|
+
try:
|
|
166
|
+
event = verify_webhook(body, flock_signature, WEBHOOK_SECRET)
|
|
167
|
+
except WebhookError:
|
|
168
|
+
# Always 401 — never confirm which check failed.
|
|
169
|
+
raise HTTPException(status_code=401, detail="invalid signature") from None
|
|
170
|
+
|
|
171
|
+
match event:
|
|
172
|
+
case DonationCompletedEvent():
|
|
173
|
+
await enqueue_receipt(event.data.object)
|
|
174
|
+
case DonationRefundedEvent():
|
|
175
|
+
await reverse_receipt(event.data.object)
|
|
176
|
+
case KeyFrozenEvent():
|
|
177
|
+
await alert_on_call(event.data.object)
|
|
178
|
+
case _:
|
|
179
|
+
pass # Ignore unknown event types.
|
|
180
|
+
|
|
181
|
+
return {"status": "ok"}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Pass the **exact body bytes** the platform delivered. Re-serializing the JSON before verifying will invalidate the signature.
|
|
185
|
+
|
|
186
|
+
`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.
|
|
187
|
+
|
|
188
|
+
### Dedup by `Flock-Event-Id`
|
|
189
|
+
|
|
190
|
+
Webhook delivery is at-least-once. Persist `event.id` (also exposed as the `Flock-Event-Id` HTTP header) and short-circuit re-deliveries.
|
|
191
|
+
|
|
192
|
+
## Errors
|
|
193
|
+
|
|
194
|
+
Every API call raises a typed exception on a non-2xx response.
|
|
195
|
+
|
|
196
|
+
| HTTP status | Exception |
|
|
197
|
+
| --- | --- |
|
|
198
|
+
| 400 / 422 | `ValidationError` |
|
|
199
|
+
| 401 | `AuthError` |
|
|
200
|
+
| 403 | `ForbiddenError` |
|
|
201
|
+
| 404 | `NotFoundError` |
|
|
202
|
+
| 409 | `ConflictError` |
|
|
203
|
+
| 410 | `GoneError` |
|
|
204
|
+
| 429 | `RateLimitError` (carries `retry_after`) |
|
|
205
|
+
| 502 | `BadGatewayError` |
|
|
206
|
+
| 503 | `ServiceUnavailableError` |
|
|
207
|
+
| Other | `ApiError` |
|
|
208
|
+
|
|
209
|
+
All inherit from `ApiError` and expose `status_code`, `code`, `message`, `details`, and `request_id`.
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
from donations_embed import ApiError, ConflictError, NotFoundError, RateLimitError
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
refund = await client.refund_donation(donation_id, amount_cents=1000)
|
|
216
|
+
except RateLimitError as exc:
|
|
217
|
+
await asyncio.sleep(exc.retry_after or 1)
|
|
218
|
+
return await retry()
|
|
219
|
+
except ConflictError:
|
|
220
|
+
log.warning("Donation %s already refunded", donation_id)
|
|
221
|
+
except NotFoundError:
|
|
222
|
+
log.warning("Donation %s not found", donation_id)
|
|
223
|
+
except ApiError as exc:
|
|
224
|
+
log.error("API error %s %s: %s", exc.status_code, exc.code, exc.message)
|
|
225
|
+
raise
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Full documentation
|
|
229
|
+
|
|
230
|
+
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/).
|
|
231
|
+
|
|
232
|
+
## Vendor neutrality
|
|
233
|
+
|
|
234
|
+
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.
|
|
235
|
+
|
|
236
|
+
## License
|
|
237
|
+
|
|
238
|
+
MIT — see [LICENSE](./LICENSE).
|
|
239
|
+
|
|
240
|
+
## Reporting issues
|
|
241
|
+
|
|
242
|
+
File issues at [gitlab.com/1st-flock/donations/-/issues](https://gitlab.com/1st-flock/donations/-/issues) with the label `donations-embed-python`.
|