cmp-consent 0.1.1__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.
- cmp_consent-0.1.1/.gitignore +46 -0
- cmp_consent-0.1.1/INTEGRATION.md +628 -0
- cmp_consent-0.1.1/PKG-INFO +129 -0
- cmp_consent-0.1.1/README.md +104 -0
- cmp_consent-0.1.1/cmp_consent/__init__.py +36 -0
- cmp_consent-0.1.1/cmp_consent/aio.py +118 -0
- cmp_consent-0.1.1/cmp_consent/client.py +166 -0
- cmp_consent-0.1.1/cmp_consent/errors.py +32 -0
- cmp_consent-0.1.1/cmp_consent/models.py +78 -0
- cmp_consent-0.1.1/cmp_consent/webhooks.py +48 -0
- cmp_consent-0.1.1/examples/e2e_officer_flow.py +146 -0
- cmp_consent-0.1.1/pyproject.toml +45 -0
- cmp_consent-0.1.1/tests/README.md +60 -0
- cmp_consent-0.1.1/tests/conftest.py +49 -0
- cmp_consent-0.1.1/tests/test_client.py +224 -0
- cmp_consent-0.1.1/tests/test_webhooks.py +108 -0
- cmp_consent-0.1.1/uv.lock +478 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Secrets — NEVER commit
|
|
2
|
+
.env
|
|
3
|
+
*.env
|
|
4
|
+
!.env.example
|
|
5
|
+
|
|
6
|
+
# Python
|
|
7
|
+
__pycache__/
|
|
8
|
+
*.py[cod]
|
|
9
|
+
*.egg-info/
|
|
10
|
+
|
|
11
|
+
# Build artifacts (python-sdk wheels/sdists)
|
|
12
|
+
# dist/
|
|
13
|
+
build/
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.ruff_cache/
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
|
|
18
|
+
# Virtual env / uv
|
|
19
|
+
.venv/
|
|
20
|
+
venv/
|
|
21
|
+
.venv-*/
|
|
22
|
+
|
|
23
|
+
# OS / editor
|
|
24
|
+
.DS_Store
|
|
25
|
+
Thumbs.db
|
|
26
|
+
.idea/
|
|
27
|
+
.vscode/
|
|
28
|
+
|
|
29
|
+
# Logs
|
|
30
|
+
*.log
|
|
31
|
+
|
|
32
|
+
# web
|
|
33
|
+
pnpm-lock.yaml
|
|
34
|
+
pnpm-workspace.yaml
|
|
35
|
+
|
|
36
|
+
# Claude
|
|
37
|
+
.claude/
|
|
38
|
+
.superpowers/
|
|
39
|
+
docs/
|
|
40
|
+
CLAUDE.md
|
|
41
|
+
package-lock.json
|
|
42
|
+
# package lock (per request)
|
|
43
|
+
web/package-lock.json
|
|
44
|
+
.agents/
|
|
45
|
+
plans/
|
|
46
|
+
skills-lock.json
|
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
# cmp-consent — Developer Integration Guide
|
|
2
|
+
|
|
3
|
+
A hands-on guide to integrating the **CMP Python SDK** into your website's backend.
|
|
4
|
+
Written for a developer who has never seen this SDK before. By the end you will
|
|
5
|
+
have installed it, raised a consent request, received the outcome, and gated your
|
|
6
|
+
own code on it.
|
|
7
|
+
|
|
8
|
+
> **Scope.** This is the **server-to-server** SDK. It runs inside *your backend*
|
|
9
|
+
> and holds your API key. It is **not** for the browser — never ship the key or
|
|
10
|
+
> call these endpoints from frontend JavaScript. (The browser cookie-banner is a
|
|
11
|
+
> different SDK, `sdk/cmp.js`.)
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Contents
|
|
16
|
+
|
|
17
|
+
1. [The one-paragraph mental model](#1-the-one-paragraph-mental-model)
|
|
18
|
+
2. [What you need before you start](#2-what-you-need-before-you-start)
|
|
19
|
+
3. [Install (local, for now)](#3-install-local-for-now)
|
|
20
|
+
4. [Configure the client](#4-configure-the-client)
|
|
21
|
+
5. [The full API surface](#5-the-full-api-surface)
|
|
22
|
+
6. [The integration, step by step](#6-the-integration-step-by-step)
|
|
23
|
+
7. [Receiving the outcome — webhooks](#7-receiving-the-outcome--webhooks)
|
|
24
|
+
8. [Error handling — the exception model](#8-error-handling--the-exception-model)
|
|
25
|
+
9. [Async version](#9-async-version)
|
|
26
|
+
10. [Framework snippets (Flask / FastAPI / Django)](#10-framework-snippets)
|
|
27
|
+
11. [Testing without a live person](#11-testing-without-a-live-person)
|
|
28
|
+
12. [Field & value reference](#12-field--value-reference)
|
|
29
|
+
13. [FAQ / troubleshooting](#13-faq--troubleshooting)
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 1. The one-paragraph mental model
|
|
34
|
+
|
|
35
|
+
Your staff member (an **officer**) submits a person's email. You call the SDK to
|
|
36
|
+
**raise a consent request** — CMP emails that person a secure link and gives you
|
|
37
|
+
back a `consent_id` immediately. You store that `consent_id` against your record.
|
|
38
|
+
Later the person clicks the link and **accepts or declines** on a CMP-hosted page.
|
|
39
|
+
CMP tells you the outcome via a **webhook**. From then on, every single time your
|
|
40
|
+
code is about to *use* that person's data, you call **`verify()`** — "am I still
|
|
41
|
+
allowed?" — and only proceed if the answer is yes. `verify()` is **fail-closed**:
|
|
42
|
+
if anything goes wrong (network, timeout, unknown consent) it says *no*, so you
|
|
43
|
+
never process data on an uncertain answer.
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
officer submits ──► create_consent_request() ──► store consent_id (t=0)
|
|
47
|
+
│
|
|
48
|
+
person clicks email link ──► accepts/declines ──► webhook ──► verify_webhook()
|
|
49
|
+
│
|
|
50
|
+
before ANY use of the data ──► verify(consent_id, purpose) ──► proceed only if allowed
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 2. What you need before you start
|
|
56
|
+
|
|
57
|
+
From your CMP admin console (or from whoever operates the CMP), collect:
|
|
58
|
+
|
|
59
|
+
| Thing | Looks like | Where used |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| **API base URL** | `https://cmp-api.yourdomain` (or `http://localhost:8000` locally) | `CMPClient(api_base=…)` |
|
|
62
|
+
| **Application API key** | `cmpk_live_…` | `CMPClient(api_key=…)` — sent as `X-API-Key` |
|
|
63
|
+
| **Notice key** | `kyc-notice` | passed to `create_consent_request(notice=…)` |
|
|
64
|
+
| **Purpose code(s)** | `bureau_pull` | passed to `verify(purpose=…)` |
|
|
65
|
+
| **Webhook secret** | `whsec_…` | `CMPClient(webhook_secret=…)` — verifies inbound webhooks |
|
|
66
|
+
|
|
67
|
+
The API key and webhook secret are **secrets** — keep them in environment
|
|
68
|
+
variables / your secrets manager, never in code or git.
|
|
69
|
+
|
|
70
|
+
You also need the **CMP API running and reachable** from your backend, and its
|
|
71
|
+
background **worker running** (that's what sends the email — if it's down, you'll
|
|
72
|
+
get a `consent_id` but the person never receives a link).
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 3. Install (local, for now)
|
|
77
|
+
|
|
78
|
+
The package is not on PyPI yet, so install the pre-built wheel straight from disk.
|
|
79
|
+
The wheel lives in `python-sdk/dist/`:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# from your website's project, with its virtualenv active
|
|
83
|
+
pip install "D:/consent-management-platform/python-sdk/dist/cmp_consent-0.1.0-py3-none-any.whl"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Using `uv`:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
uv pip install "D:/consent-management-platform/python-sdk/dist/cmp_consent-0.1.0-py3-none-any.whl"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
> **If the wheel isn't there**, build it first:
|
|
93
|
+
> ```bash
|
|
94
|
+
> cd D:/consent-management-platform/python-sdk
|
|
95
|
+
> rm -rf dist/ && uv build # produces dist/cmp_consent-0.1.0-py3-none-any.whl
|
|
96
|
+
> ```
|
|
97
|
+
|
|
98
|
+
**Developing the SDK at the same time?** Use an editable install instead — your
|
|
99
|
+
edits to the SDK source are picked up with no rebuild:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
uv pip install -e "D:/consent-management-platform/python-sdk"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Verify it imported:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
python -c "import cmp_consent; print(cmp_consent.__version__)" # → 0.1.0
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Requirements: **Python 3.9+**. `httpx` and `pydantic` install automatically.
|
|
112
|
+
|
|
113
|
+
> Package name vs import name: you install **`cmp-consent`** (hyphen) but import
|
|
114
|
+
> **`cmp_consent`** (underscore). That's normal Python.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## 4. Configure the client
|
|
119
|
+
|
|
120
|
+
Create **one** client at startup and reuse it — it holds a pooled HTTP connection.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
import os
|
|
124
|
+
from cmp_consent import CMPClient
|
|
125
|
+
|
|
126
|
+
cmp = CMPClient(
|
|
127
|
+
api_base=os.environ["CMP_API_BASE"], # e.g. https://cmp-api.yourdomain
|
|
128
|
+
api_key=os.environ["CMP_API_KEY"], # cmpk_live_…
|
|
129
|
+
webhook_secret=os.environ["CMP_WEBHOOK_SECRET"], # whsec_… (only needed for webhooks)
|
|
130
|
+
timeout=10.0, # seconds per request (default 10)
|
|
131
|
+
retries=2, # transport-level retries on connection errors (default 2)
|
|
132
|
+
)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
| Argument | Required | Default | Notes |
|
|
136
|
+
|---|---|---|---|
|
|
137
|
+
| `api_base` | ✅ | — | Trailing slash is stripped for you |
|
|
138
|
+
| `api_key` | ✅ | — | Sent as `X-API-Key` on every call |
|
|
139
|
+
| `webhook_secret` | only for `verify_webhook()` | `None` | Omit if you never receive webhooks |
|
|
140
|
+
| `timeout` | — | `10.0` | Per-request, seconds |
|
|
141
|
+
| `retries` | — | `2` | Connection-level only (not on 4xx/5xx) |
|
|
142
|
+
|
|
143
|
+
The client is a context manager if you want deterministic cleanup:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
with CMPClient(api_base=…, api_key=…) as cmp:
|
|
147
|
+
...
|
|
148
|
+
# or call cmp.close() yourself
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## 5. The full API surface
|
|
154
|
+
|
|
155
|
+
Everything the SDK exposes. Six methods, five models, five exceptions.
|
|
156
|
+
|
|
157
|
+
### `CMPClient` methods
|
|
158
|
+
|
|
159
|
+
| Method | Does | Returns | Raises |
|
|
160
|
+
|---|---|---|---|
|
|
161
|
+
| `create_consent_request(*, email, notice, requested_by, idempotency_key=None, link_expire_seconds=None)` | Raise a PENDING request; CMP emails the link | `ConsentRequest` | `CMPError` on HTTP/network failure |
|
|
162
|
+
| `get_request(request_id)` | Poll a request's status | `RequestStatus` | `CMPError` |
|
|
163
|
+
| `cancel(request_id)` | Cancel a pending request (idempotent) | `dict` | `CMPError` |
|
|
164
|
+
| `resend(request_id)` | Re-mint a fresh magic link | `dict` | `CMPError` |
|
|
165
|
+
| `verify(*, consent_id, purpose)` | **The gate.** May I process this now? | `VerifyResult` | **never raises** (fail-closed → `allowed=False`) |
|
|
166
|
+
| `require_consent(*, consent_id, purpose)` | Like `verify()` but raises when not allowed | `VerifyResult` | `ConsentPending` / `ConsentDeclined` / `ConsentExpired` |
|
|
167
|
+
| `verify_webhook(headers, body)` | Validate an inbound webhook signature | `WebhookEvent` | `SignatureError`, `CMPError` |
|
|
168
|
+
|
|
169
|
+
All keyword arguments marked `*,` are **keyword-only** — you must name them
|
|
170
|
+
(`create_consent_request(email=…, notice=…)`, not positionally).
|
|
171
|
+
|
|
172
|
+
### Models (all Pydantic v2)
|
|
173
|
+
|
|
174
|
+
- **`ConsentRequest`** — `consent_id`, `request_id`, `status`, `request_ref`, `magic_link` (usually `None`).
|
|
175
|
+
- **`RequestStatus`** — `request_id`, `consent_id`, `status`.
|
|
176
|
+
- **`VerifyResult`** — `allowed` (bool — the only thing to branch on), `status`, `purpose`, `purpose_state`, `reason`, `consent_record_id`, `integrity_hash`, `consent_seq`, `needs_reconsent`, `expires_at`.
|
|
177
|
+
- **`WebhookEvent`** — `event_id`, `event_type`, `application_id`, `occurred_at`, `data`; plus convenience properties: `.type`, `.consent_id`, `.request_id`, `.consent_seq`, `.decision`, `.granted_purposes`, `.denied_purposes`.
|
|
178
|
+
|
|
179
|
+
Value tables (what each `status`/`reason` can be) are in [§12](#12-field--value-reference).
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## 6. The integration, step by step
|
|
184
|
+
|
|
185
|
+
### Step 1 — Officer raises the request
|
|
186
|
+
|
|
187
|
+
When your officer submits the person's details:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
req = cmp.create_consent_request(
|
|
191
|
+
email="user@example.com",
|
|
192
|
+
notice="kyc-notice", # exactly one notice key
|
|
193
|
+
requested_by="officer-42", # your officer's id — recorded for audit
|
|
194
|
+
idempotency_key="loan-9931", # optional but recommended (see below)
|
|
195
|
+
link_expire_seconds=1800, # optional; overrides the default link lifetime
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# STORE consent_id NOW — before the person has decided anything.
|
|
199
|
+
save_to_your_db(record_id, consent_id=req.consent_id, request_id=req.request_id)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
What comes back (`ConsentRequest`):
|
|
203
|
+
|
|
204
|
+
| Field | Use |
|
|
205
|
+
|---|---|
|
|
206
|
+
| `consent_id` | **The durable key.** Store it against your record forever — it's how you tie your data to the eventual decision and how you call `verify()` later. |
|
|
207
|
+
| `request_id` | This attempt — use it to `get_request()`, `cancel()`, `resend()`. |
|
|
208
|
+
| `status` | `PENDING` on a fresh request. |
|
|
209
|
+
| `request_ref` | Human-readable reference for support/audit. |
|
|
210
|
+
| `magic_link` | Usually `None`. Only populated if the application is configured in "return link" mode (you send the email yourself). |
|
|
211
|
+
|
|
212
|
+
**Idempotency.** Pass a stable `idempotency_key` (your loan/application id is
|
|
213
|
+
perfect). If a network blip makes you retry, the same key returns the **same**
|
|
214
|
+
request instead of emailing the person twice.
|
|
215
|
+
|
|
216
|
+
**If your own save fails** after raising the request, call `cmp.cancel(req.request_id)`
|
|
217
|
+
so no orphaned request stays pending.
|
|
218
|
+
|
|
219
|
+
### Step 2 — The person decides (nothing to build)
|
|
220
|
+
|
|
221
|
+
CMP hosts this entirely. The person gets a branded email, opens the link, sees the
|
|
222
|
+
purposes and a per-purpose toggle, and clicks **Accept** or **Decline**. You don't
|
|
223
|
+
build any of it. Two timers matter:
|
|
224
|
+
|
|
225
|
+
- **Link lifetime** — default 30 min (configurable 5 min–24 h, or per-request via `link_expire_seconds`). If it expires but the request window is still open, `resend()` a fresh link.
|
|
226
|
+
- **Request window** — the request stays `PENDING` for days until decided or expired.
|
|
227
|
+
|
|
228
|
+
### Step 3 — Learn the outcome (webhook)
|
|
229
|
+
|
|
230
|
+
The person may decide seconds or days later, so you're notified via a webhook — see
|
|
231
|
+
[§7](#7-receiving-the-outcome--webhooks). (You can also poll `get_request()` as a
|
|
232
|
+
fallback.)
|
|
233
|
+
|
|
234
|
+
### Step 4 — Gate every use of the data
|
|
235
|
+
|
|
236
|
+
This is the part that actually enforces consent. **Before any code path that uses
|
|
237
|
+
the person's data**, gate it:
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
res = cmp.verify(consent_id=stored_consent_id, purpose="bureau_pull")
|
|
241
|
+
if res.allowed:
|
|
242
|
+
pull_credit_bureau(...) # safe — consent-backed
|
|
243
|
+
log_audit(proof=res.integrity_hash) # keep the proof
|
|
244
|
+
else:
|
|
245
|
+
# res.reason tells you WHY (CONSENT_PENDING, PURPOSE_OPTED_OUT, …)
|
|
246
|
+
skip_and_notify(res.reason)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`verify()` **never raises** — on any error it returns `allowed=False` with
|
|
250
|
+
`reason="VERIFY_UNAVAILABLE"`. That's deliberate: a network outage must never
|
|
251
|
+
accidentally permit processing.
|
|
252
|
+
|
|
253
|
+
If you'd rather have unconsented processing be *impossible* (raise instead of
|
|
254
|
+
branch), use `require_consent()`:
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
from cmp_consent import ConsentPending, ConsentDeclined, ConsentExpired
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
cmp.require_consent(consent_id=stored_consent_id, purpose="bureau_pull")
|
|
261
|
+
pull_credit_bureau(...) # only reached when allowed
|
|
262
|
+
except ConsentPending:
|
|
263
|
+
... # not decided yet — try later
|
|
264
|
+
except ConsentDeclined:
|
|
265
|
+
... # declined/withdrawn — stop, don't retry
|
|
266
|
+
except ConsentExpired:
|
|
267
|
+
... # needs a fresh request / re-consent
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Managing an in-flight request:
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
cmp.get_request(req.request_id) # → RequestStatus(status="PENDING" | "GRANTED" | …)
|
|
274
|
+
cmp.resend(req.request_id) # re-mint an expired link
|
|
275
|
+
cmp.cancel(req.request_id) # cancel a pending request
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## 7. Receiving the outcome — webhooks
|
|
281
|
+
|
|
282
|
+
CMP POSTs a JSON event to an endpoint you host. You must **verify the signature over
|
|
283
|
+
the raw request body** and then act on it.
|
|
284
|
+
|
|
285
|
+
### The delivered event
|
|
286
|
+
|
|
287
|
+
Body (verified against the server envelope):
|
|
288
|
+
|
|
289
|
+
```json
|
|
290
|
+
{
|
|
291
|
+
"event_id": "b2c8…",
|
|
292
|
+
"event_type": "CONSENT_GRANTED",
|
|
293
|
+
"application_id": "a1…",
|
|
294
|
+
"occurred_at": "2026-07-24T10:15:00+00:00",
|
|
295
|
+
"data": {
|
|
296
|
+
"consent_id": "c9…",
|
|
297
|
+
"request_id": "r7…",
|
|
298
|
+
"decision": "FULL",
|
|
299
|
+
"granted_purposes": ["bureau_pull", "kyc_store"],
|
|
300
|
+
"denied_purposes": [],
|
|
301
|
+
"purposes": [ { "purpose_code": "bureau_pull", "state": "OPT_IN", … } ],
|
|
302
|
+
"consent_seq": 3,
|
|
303
|
+
"integrity_hash": "…"
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Also sent as headers: `X-CMP-Signature`, `X-CMP-Event-Id`, `X-CMP-Event-Type`.
|
|
309
|
+
|
|
310
|
+
### Event types
|
|
311
|
+
|
|
312
|
+
| `event_type` | Fired when |
|
|
313
|
+
|---|---|
|
|
314
|
+
| `CONSENT_REQUEST_CREATED` | You raised a request |
|
|
315
|
+
| `CONSENT_GRANTED` | Accepted (fully or partially — see `data.decision`) |
|
|
316
|
+
| `CONSENT_DECLINED` | Declined |
|
|
317
|
+
| `CONSENT_REQUEST_EXPIRED` | The request window elapsed undecided |
|
|
318
|
+
| `CONSENT_WITHDRAWN` | A previously granted consent was withdrawn |
|
|
319
|
+
|
|
320
|
+
Subscribe to the ones you need in the CMP console.
|
|
321
|
+
|
|
322
|
+
### Handling it correctly
|
|
323
|
+
|
|
324
|
+
```python
|
|
325
|
+
from cmp_consent import SignatureError
|
|
326
|
+
|
|
327
|
+
def handle_cmp_webhook(request):
|
|
328
|
+
try:
|
|
329
|
+
# PASS THE RAW BODY BYTES — not a parsed/re-serialized dict.
|
|
330
|
+
event = cmp.verify_webhook(request.headers, request.get_data()) # Flask: get_data()
|
|
331
|
+
except SignatureError:
|
|
332
|
+
return ("bad signature", 400) # forged or replayed — reject
|
|
333
|
+
|
|
334
|
+
# De-dupe: webhooks are at-least-once. Skip if you've seen this event_id.
|
|
335
|
+
if already_processed(event.event_id):
|
|
336
|
+
return ("", 200)
|
|
337
|
+
|
|
338
|
+
if event.type == "CONSENT_GRANTED":
|
|
339
|
+
# order updates by consent_seq if you track state transitions
|
|
340
|
+
mark_consent_granted(event.consent_id,
|
|
341
|
+
granted=event.granted_purposes,
|
|
342
|
+
seq=event.consent_seq)
|
|
343
|
+
elif event.type == "CONSENT_DECLINED":
|
|
344
|
+
mark_consent_declined(event.consent_id)
|
|
345
|
+
|
|
346
|
+
remember(event.event_id)
|
|
347
|
+
return ("", 200) # 2xx = delivered; anything else = retried
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Three rules that matter:
|
|
351
|
+
|
|
352
|
+
1. **Verify over the raw bytes.** The signature covers the exact bytes CMP sent. If your framework parsed the body into a dict and you re-serialize it, the signature won't match. Grab the raw body (`request.get_data()` in Flask, `await request.body()` in FastAPI/Starlette, `request.body` in Django).
|
|
353
|
+
2. **De-dupe by `event.event_id`.** Delivery is at-least-once; the same event can arrive twice.
|
|
354
|
+
3. **Treat `verify()` as the source of truth.** Webhooks can arrive out of order or be missed. For any real decision, `verify()` is authoritative — the webhook is just the nudge to re-check.
|
|
355
|
+
|
|
356
|
+
Return **2xx** to acknowledge. Any other status (or a timeout) makes CMP retry.
|
|
357
|
+
|
|
358
|
+
---
|
|
359
|
+
|
|
360
|
+
## 8. Error handling — the exception model
|
|
361
|
+
|
|
362
|
+
| Exception | Meaning | Base |
|
|
363
|
+
|---|---|---|
|
|
364
|
+
| `CMPError` | Any HTTP 4xx/5xx or network/timeout failure. Carries `.status` (HTTP) and `.code` (envelope code) when available. | `Exception` |
|
|
365
|
+
| `ConsentPending` | Requested but not yet granted — wait. | `CMPError` |
|
|
366
|
+
| `ConsentDeclined` | Declined or withdrawn — stop. | `CMPError` |
|
|
367
|
+
| `ConsentExpired` | Consent/request expired — needs re-consent. | `CMPError` |
|
|
368
|
+
| `SignatureError` | Inbound webhook signature invalid (forged/replayed). | `CMPError` |
|
|
369
|
+
|
|
370
|
+
Which methods raise what:
|
|
371
|
+
|
|
372
|
+
- **`create_consent_request`, `get_request`, `cancel`, `resend`** raise `CMPError` on failure. Wrap them:
|
|
373
|
+
```python
|
|
374
|
+
from cmp_consent import CMPError
|
|
375
|
+
try:
|
|
376
|
+
req = cmp.create_consent_request(email=e, notice="kyc-notice", requested_by=o)
|
|
377
|
+
except CMPError as err:
|
|
378
|
+
log.error("CMP request failed: %s (status=%s code=%s)", err, err.status, err.code)
|
|
379
|
+
```
|
|
380
|
+
- **`verify`** never raises — check `res.allowed`.
|
|
381
|
+
- **`require_consent`** raises `ConsentPending` / `ConsentDeclined` / `ConsentExpired` (all subclasses of `CMPError`, so a bare `except CMPError` catches them all).
|
|
382
|
+
- **`verify_webhook`** raises `SignatureError` (bad/missing signature) or `CMPError` (no `webhook_secret` configured).
|
|
383
|
+
|
|
384
|
+
---
|
|
385
|
+
|
|
386
|
+
## 9. Async version
|
|
387
|
+
|
|
388
|
+
If your website is async (FastAPI, Starlette, aiohttp), use `AsyncCMPClient` — same
|
|
389
|
+
surface, awaitable, except `verify_webhook` which stays sync (it does no I/O):
|
|
390
|
+
|
|
391
|
+
```python
|
|
392
|
+
from cmp_consent import AsyncCMPClient
|
|
393
|
+
|
|
394
|
+
cmp = AsyncCMPClient(api_base=…, api_key=…, webhook_secret=…)
|
|
395
|
+
|
|
396
|
+
async def raise_and_gate():
|
|
397
|
+
req = await cmp.create_consent_request(
|
|
398
|
+
email="user@example.com", notice="kyc-notice", requested_by="officer-42")
|
|
399
|
+
res = await cmp.verify(consent_id=req.consent_id, purpose="bureau_pull")
|
|
400
|
+
return res.allowed
|
|
401
|
+
|
|
402
|
+
# or scoped:
|
|
403
|
+
async with AsyncCMPClient(api_base=…, api_key=…) as cmp:
|
|
404
|
+
...
|
|
405
|
+
# verify_webhook is NOT awaited — it's synchronous:
|
|
406
|
+
event = cmp.verify_webhook(headers, raw_body)
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Create it inside the event loop (e.g. FastAPI startup), and `await cmp.aclose()`
|
|
410
|
+
on shutdown.
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
## 10. Framework snippets
|
|
415
|
+
|
|
416
|
+
The SDK is framework-agnostic. The only framework-specific part is **getting the
|
|
417
|
+
raw webhook body** and **reading headers**.
|
|
418
|
+
|
|
419
|
+
### Flask
|
|
420
|
+
|
|
421
|
+
```python
|
|
422
|
+
from flask import Flask, request
|
|
423
|
+
from cmp_consent import CMPClient, SignatureError
|
|
424
|
+
|
|
425
|
+
app = Flask(__name__)
|
|
426
|
+
cmp = CMPClient(api_base=…, api_key=…, webhook_secret=…)
|
|
427
|
+
|
|
428
|
+
@app.post("/webhooks/cmp")
|
|
429
|
+
def cmp_webhook():
|
|
430
|
+
try:
|
|
431
|
+
event = cmp.verify_webhook(dict(request.headers), request.get_data())
|
|
432
|
+
except SignatureError:
|
|
433
|
+
return "", 400
|
|
434
|
+
# …dedupe + handle…
|
|
435
|
+
return "", 200
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### FastAPI (async)
|
|
439
|
+
|
|
440
|
+
```python
|
|
441
|
+
from fastapi import FastAPI, Request
|
|
442
|
+
from cmp_consent import AsyncCMPClient, SignatureError
|
|
443
|
+
|
|
444
|
+
app = FastAPI()
|
|
445
|
+
cmp = AsyncCMPClient(api_base=…, api_key=…, webhook_secret=…)
|
|
446
|
+
|
|
447
|
+
@app.post("/webhooks/cmp")
|
|
448
|
+
async def cmp_webhook(request: Request):
|
|
449
|
+
raw = await request.body() # RAW bytes — essential
|
|
450
|
+
try:
|
|
451
|
+
event = cmp.verify_webhook(request.headers, raw) # sync, don't await
|
|
452
|
+
except SignatureError:
|
|
453
|
+
return Response(status_code=400)
|
|
454
|
+
# …dedupe + handle…
|
|
455
|
+
return Response(status_code=200)
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
### Django
|
|
459
|
+
|
|
460
|
+
```python
|
|
461
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
462
|
+
from django.http import HttpResponse
|
|
463
|
+
from cmp_consent import CMPClient, SignatureError
|
|
464
|
+
|
|
465
|
+
cmp = CMPClient(api_base=…, api_key=…, webhook_secret=…)
|
|
466
|
+
|
|
467
|
+
@csrf_exempt
|
|
468
|
+
def cmp_webhook(request):
|
|
469
|
+
try:
|
|
470
|
+
event = cmp.verify_webhook(request.headers, request.body) # request.body = raw bytes
|
|
471
|
+
except SignatureError:
|
|
472
|
+
return HttpResponse(status=400)
|
|
473
|
+
# …dedupe + handle…
|
|
474
|
+
return HttpResponse(status=200)
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
(Exempt the webhook route from CSRF — it's an inbound machine call, authenticated
|
|
478
|
+
by the signature, not a browser form.)
|
|
479
|
+
|
|
480
|
+
---
|
|
481
|
+
|
|
482
|
+
## 11. Testing without a live person
|
|
483
|
+
|
|
484
|
+
You don't need to actually receive an email to test most of this.
|
|
485
|
+
|
|
486
|
+
**Webhook verification is pure local crypto — no server needed.** You can forge a
|
|
487
|
+
correctly-signed event and prove your handler works:
|
|
488
|
+
|
|
489
|
+
```python
|
|
490
|
+
import hmac, hashlib, time, json
|
|
491
|
+
from cmp_consent import CMPClient
|
|
492
|
+
|
|
493
|
+
cmp = CMPClient(api_base="http://localhost:8000", api_key="x", webhook_secret="whsec_test")
|
|
494
|
+
|
|
495
|
+
body = json.dumps({
|
|
496
|
+
"event_id": "e1", "event_type": "CONSENT_GRANTED",
|
|
497
|
+
"application_id": "a1", "occurred_at": "2026-07-24T10:00:00+00:00",
|
|
498
|
+
"data": {"consent_id": "c1", "decision": "FULL",
|
|
499
|
+
"granted_purposes": ["bureau_pull"], "consent_seq": 3},
|
|
500
|
+
}).encode()
|
|
501
|
+
|
|
502
|
+
ts = int(time.time())
|
|
503
|
+
mac = hmac.new(b"whsec_test", f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
|
|
504
|
+
sig = f"t={ts},v1={mac}"
|
|
505
|
+
|
|
506
|
+
event = cmp.verify_webhook({"X-CMP-Signature": sig}, body)
|
|
507
|
+
assert event.type == "CONSENT_GRANTED"
|
|
508
|
+
assert event.consent_id == "c1"
|
|
509
|
+
assert "bureau_pull" in event.granted_purposes
|
|
510
|
+
print("webhook handling OK")
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
**For the request/verify calls** you need the CMP API running (point `api_base` at
|
|
514
|
+
`http://localhost:8000`) and a real application API key. Then a full round-trip is:
|
|
515
|
+
|
|
516
|
+
```python
|
|
517
|
+
req = cmp.create_consent_request(email="you@example.com",
|
|
518
|
+
notice="kyc-notice", requested_by="dev")
|
|
519
|
+
print(req.consent_id, req.status) # → … PENDING
|
|
520
|
+
# (accept via the emailed/hosted link, or simulate the decision server-side)
|
|
521
|
+
res = cmp.verify(consent_id=req.consent_id, purpose="bureau_pull")
|
|
522
|
+
print(res.allowed, res.status, res.reason) # → before decision: False PENDING CONSENT_PENDING
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
A good local smoke test: raise a request, `verify()` **before** deciding (expect
|
|
526
|
+
`allowed=False`, `reason="CONSENT_PENDING"`), then accept and `verify()` again
|
|
527
|
+
(expect `allowed=True`, `reason="OK"`).
|
|
528
|
+
|
|
529
|
+
---
|
|
530
|
+
|
|
531
|
+
## 12. Field & value reference
|
|
532
|
+
|
|
533
|
+
### `VerifyResult.status`
|
|
534
|
+
|
|
535
|
+
`GRANTED` · `PARTIALLY_GRANTED` · `PENDING` · `DECLINED` · `EXPIRED` · `CANCELLED` ·
|
|
536
|
+
`WITHDRAWN` · `CONSENT_EXPIRED` · `NOT_FOUND` · `UNKNOWN` (SDK-side, on a failed call).
|
|
537
|
+
|
|
538
|
+
### `VerifyResult.reason` (machine code — safe to branch on)
|
|
539
|
+
|
|
540
|
+
| `reason` | `allowed` | Meaning |
|
|
541
|
+
|---|---|---|
|
|
542
|
+
| `OK` | ✅ true | Granted for this purpose |
|
|
543
|
+
| `CONSENT_PENDING` | false | Requested, not yet decided |
|
|
544
|
+
| `CONSENT_DECLINED` | false | Person declined |
|
|
545
|
+
| `CONSENT_WITHDRAWN` | false | Previously granted, now withdrawn |
|
|
546
|
+
| `CONSENT_EXPIRED` | false | Consent expired (`needs_reconsent=True`) |
|
|
547
|
+
| `REQUEST_EXPIRED` | false | Request window elapsed |
|
|
548
|
+
| `REQUEST_CANCELLED` | false | Request cancelled/superseded |
|
|
549
|
+
| `PURPOSE_OPTED_OUT` | false | Consent exists but this purpose is opted out |
|
|
550
|
+
| `PURPOSE_NOT_PRESENTED` | false | This purpose wasn't part of the notice |
|
|
551
|
+
| `NOT_FOUND` | false | Unknown `consent_id` |
|
|
552
|
+
| `VERIFY_UNAVAILABLE` | false | The call to CMP failed (network/timeout) — **fail-closed** |
|
|
553
|
+
|
|
554
|
+
### `VerifyResult.purpose_state`
|
|
555
|
+
|
|
556
|
+
`OPT_IN` · `OPT_OUT` · `NOT_PRESENTED`.
|
|
557
|
+
|
|
558
|
+
### `RequestStatus.status`
|
|
559
|
+
|
|
560
|
+
`PENDING` · `GRANTED` · `PARTIALLY_GRANTED` · `DECLINED` · `EXPIRED` · `CANCELLED` · `SUPERSEDED`.
|
|
561
|
+
|
|
562
|
+
### Fields set only when `allowed=True`
|
|
563
|
+
|
|
564
|
+
`consent_record_id` and `integrity_hash` — keep the `integrity_hash` as your
|
|
565
|
+
audit proof that processing was consent-backed at that moment.
|
|
566
|
+
|
|
567
|
+
---
|
|
568
|
+
|
|
569
|
+
## 13. FAQ / troubleshooting
|
|
570
|
+
|
|
571
|
+
**`ModuleNotFoundError: No module named 'cmp_consent'`**
|
|
572
|
+
You installed the wrong name or into a different venv. Install `cmp-consent`
|
|
573
|
+
(hyphen), import `cmp_consent` (underscore), and make sure it's the same
|
|
574
|
+
interpreter running your app.
|
|
575
|
+
|
|
576
|
+
**`verify()` always returns `allowed=False`, `reason="VERIFY_UNAVAILABLE"`**
|
|
577
|
+
The call never reached CMP. Check `api_base` (right host/port/scheme), the API key,
|
|
578
|
+
and network/TLS. This is fail-closed doing its job — it's not a consent problem.
|
|
579
|
+
|
|
580
|
+
**Webhook always fails with `SignatureError`**
|
|
581
|
+
Almost always you passed a parsed/re-serialized body instead of the **raw bytes**.
|
|
582
|
+
Use `request.get_data()` (Flask) / `await request.body()` (FastAPI) / `request.body`
|
|
583
|
+
(Django). Second most common: clock skew > 5 min on your server — fix NTP.
|
|
584
|
+
|
|
585
|
+
**`CMPError: verify_webhook requires webhook_secret at construction`**
|
|
586
|
+
You called `verify_webhook` but built the client without `webhook_secret=…`. Add it.
|
|
587
|
+
|
|
588
|
+
**The person never got the email**
|
|
589
|
+
The request was created (you got a `consent_id`) but the CMP **worker** that sends
|
|
590
|
+
email is down, or email mode is `return_link` (then `req.magic_link` is populated
|
|
591
|
+
and *you* send it). Check with the CMP operator.
|
|
592
|
+
|
|
593
|
+
**Should I create a `CMPClient` per request?**
|
|
594
|
+
No. Create one at startup and reuse it — it pools connections. Create per-request
|
|
595
|
+
only if you have a strong reason.
|
|
596
|
+
|
|
597
|
+
**Can I call this from my frontend / browser?**
|
|
598
|
+
No. It holds your API key. All calls are server-to-server. The browser has its own
|
|
599
|
+
separate SDK.
|
|
600
|
+
|
|
601
|
+
---
|
|
602
|
+
|
|
603
|
+
## Appendix — copy-paste starter
|
|
604
|
+
|
|
605
|
+
```python
|
|
606
|
+
import os
|
|
607
|
+
from cmp_consent import CMPClient, CMPError, ConsentPending, ConsentDeclined, ConsentExpired
|
|
608
|
+
|
|
609
|
+
cmp = CMPClient(
|
|
610
|
+
api_base=os.environ["CMP_API_BASE"],
|
|
611
|
+
api_key=os.environ["CMP_API_KEY"],
|
|
612
|
+
webhook_secret=os.environ.get("CMP_WEBHOOK_SECRET"),
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
def start_consent(record_id: str, email: str, officer_id: str) -> str:
|
|
616
|
+
req = cmp.create_consent_request(
|
|
617
|
+
email=email, notice="kyc-notice", requested_by=officer_id,
|
|
618
|
+
idempotency_key=record_id,
|
|
619
|
+
)
|
|
620
|
+
save_consent_id(record_id, req.consent_id) # your DB
|
|
621
|
+
return req.consent_id
|
|
622
|
+
|
|
623
|
+
def use_the_data(consent_id: str):
|
|
624
|
+
res = cmp.verify(consent_id=consent_id, purpose="bureau_pull")
|
|
625
|
+
if not res.allowed:
|
|
626
|
+
raise PermissionError(f"consent not granted: {res.reason}")
|
|
627
|
+
do_the_processing(proof=res.integrity_hash)
|
|
628
|
+
```
|