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.
@@ -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
+ ```