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.
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/onefirstflock-donations-embed.svg)](https://pypi.org/project/onefirstflock-donations-embed/)
41
+ [![Python versions](https://img.shields.io/pypi/pyversions/onefirstflock-donations-embed.svg)](https://pypi.org/project/onefirstflock-donations-embed/)
42
+ [![License](https://img.shields.io/pypi/l/onefirstflock-donations-embed.svg)](./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
+ [![PyPI version](https://img.shields.io/pypi/v/onefirstflock-donations-embed.svg)](https://pypi.org/project/onefirstflock-donations-embed/)
6
+ [![Python versions](https://img.shields.io/pypi/pyversions/onefirstflock-donations-embed.svg)](https://pypi.org/project/onefirstflock-donations-embed/)
7
+ [![License](https://img.shields.io/pypi/l/onefirstflock-donations-embed.svg)](./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`.