Skip to content

Commit 2e411d0

Browse files
committed
feat: add webhook signature verification helper
The SDK accepts webhook_url on experiment creation but has nothing for verifying the deliveries that arrive, so every integrator hand-writes the HMAC check from the docs snippet. That snippet is correct; the code built around it is where the quiet failures live. Adds adaptyv.webhooks.verify, which takes the raw body bytes, the request headers, and the webhook secret, and returns a frozen WebhookEvent or raises. Stdlib only, no framework imports, no new runtime dependencies. Two new leaves on AdaptyvError, deliberately siblings so that catching one cannot swallow the other. WebhookVerificationError means the delivery never proved it came from Foundry and should be answered 4xx. WebhookPayloadError is raised only after the signature checks out, so the delivery is genuine and only its shape is at issue; answering that 4xx would discard a real event, since 4xx is permanent in the retry model. Event name and delivery id are read from the signed body rather than the X-Adaptyv-Event and X-Adaptyv-Delivery-Id headers, which the signature does not cover.
1 parent cdf2078 commit 2e411d0

5 files changed

Lines changed: 664 additions & 2 deletions

File tree

README.md

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ print(f"Experiment: {result.experiment_url}")
5858

5959
- Picks up `ADAPTYV_API_KEY` and `ADAPTYV_API_URL` from environment
6060
- Retries on failure with exponential backoff
61+
- Signature verification for incoming webhooks
6162
- Type hints throughout
6263
- Context managers for cleanup
6364
- Requires Python 3.11+
@@ -156,6 +157,87 @@ for result in results.items:
156157
print(f"{result.title}: {result.result_type}")
157158
```
158159

160+
### Verify webhooks
161+
162+
Pass a `webhook_url` when you create an experiment and Foundry POSTs status updates
163+
to it instead of making you poll. Every delivery is signed, so check the signature
164+
before trusting the payload:
165+
166+
```python
167+
from adaptyv.exceptions import WebhookPayloadError, WebhookVerificationError
168+
from adaptyv.webhooks import verify
169+
170+
try:
171+
# raw_body must be the bytes that arrived, not a parsed or re-serialized payload
172+
event = verify(raw_body, request_headers, WEBHOOK_SECRET)
173+
except WebhookVerificationError:
174+
... # not from Foundry, or damaged in transit: reject it
175+
except WebhookPayloadError:
176+
... # signed by Foundry, but the envelope did not parse: log it, let it retry
177+
178+
print(event.event) # "experiment_update"
179+
print(event.delivery_id) # "019b8da3-4a91-16c6-fa94-619212bee6a6"
180+
print(event.payload["data"]["experiment_code"])
181+
```
182+
183+
`verify` never returns a boolean. It raises, and which exception it raises is what
184+
you branch on:
185+
186+
| Exception | Meaning | Answer with |
187+
| --- | --- | --- |
188+
| `WebhookVerificationError` | A missing or malformed `X-Adaptyv-Signature`, a signature that does not match the body, an empty secret, or a body that is not raw bytes. The delivery never proved it came from Foundry. | 4xx |
189+
| `WebhookPayloadError` | The signature checked out, but the envelope was not readable JSON carrying an `event` and a `delivery_id`. | 5xx, or accept and log |
190+
191+
The split matters because 4xx is permanent in the retry model. Answering 4xx to a
192+
genuinely signed delivery whose shape you did not expect throws away a real event and
193+
tells Foundry never to send it again. The two are siblings rather than parent and
194+
child, so catching one cannot swallow the other by accident.
195+
196+
`event.payload` is the whole envelope exactly as it arrived, so fields the SDK does
197+
not know about pass through untouched.
198+
199+
With FastAPI:
200+
201+
```python
202+
import logging
203+
import os
204+
205+
from fastapi import FastAPI, Request, Response
206+
207+
from adaptyv.exceptions import WebhookPayloadError, WebhookVerificationError
208+
from adaptyv.webhooks import verify
209+
210+
app = FastAPI()
211+
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"]
212+
213+
214+
@app.post("/foundry-hook")
215+
async def foundry_hook(request: Request) -> Response:
216+
try:
217+
event = verify(await request.body(), request.headers, WEBHOOK_SECRET)
218+
except WebhookVerificationError:
219+
return Response(status_code=400) # permanent, and correctly so
220+
except WebhookPayloadError:
221+
logging.exception("unreadable webhook envelope")
222+
return Response(status_code=500) # genuinely ours, so let it come back
223+
224+
handle(event) # your code, keyed on event.delivery_id
225+
return Response(status_code=200)
226+
```
227+
228+
`await request.body()` hands back the raw bytes. Reading `await request.json()` and
229+
re-serializing it produces different bytes than the ones that were signed, so the
230+
signature would never match. The equivalent accessor is `request.get_data()` on
231+
Flask and `request.body` on Django.
232+
233+
**Handlers have to be idempotent.** A delivery is retried up to three times with
234+
exponential backoff on network errors and 5xx responses, so a handler that fails
235+
once is guaranteed to see the same event again. `event.delivery_id` is stable across
236+
those attempts, which makes it the key to deduplicate on in whatever store you
237+
already have. The SDK deliberately does not keep one for you. Return 2xx to
238+
acknowledge a delivery; a 4xx tells Foundry the failure is permanent and stops the
239+
retries.
240+
159241
---
160242

161243
## Examples
@@ -225,4 +307,3 @@ The SDK is pegged to the deployed OpenAPI spec
225307
so an API change surfaces as a failed build instead of silent drift. When it
226308
fires: run `mise run gen:types`, bump `FOUNDRY_SPEC_VERSION`, add any new
227309
client method, and commit.
228-

src/adaptyv/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
PermissionDeniedError,
1313
RateLimitError,
1414
ValidationError,
15+
WebhookPayloadError,
16+
WebhookVerificationError,
1517
)
1618

1719
__all__ = [
@@ -47,6 +49,8 @@
4749
"PermissionDeniedError",
4850
"RateLimitError",
4951
"ValidationError",
52+
"WebhookPayloadError",
53+
"WebhookVerificationError",
5054
]
5155

5256
# Lazy import mapping: name -> (module_path, attribute_name)

src/adaptyv/exceptions.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
│ ├── NotFoundError (404, resource not found)
88
│ └── RateLimitError (429, rate limit exceeded with retry_after)
99
├── ValidationError (invalid input parameters)
10-
└── PermissionDeniedError (API key lacks create_experiment permission)
10+
├── PermissionDeniedError (API key lacks create_experiment permission)
11+
├── WebhookVerificationError (webhook delivery could not be trusted)
12+
└── WebhookPayloadError (webhook delivery was trusted but unreadable)
1113
"""
1214

1315
from __future__ import annotations
@@ -144,3 +146,28 @@ def __init__(
144146
request_id=request_id,
145147
request_path=request_path,
146148
)
149+
150+
151+
class WebhookVerificationError(AdaptyvError):
152+
"""Webhook delivery could not be trusted.
153+
154+
Raised by adaptyv.webhooks.verify when a delivery fails to prove it came
155+
from Foundry: a bad, missing, or malformed signature, an empty secret, or a
156+
body that is not raw bytes. Handlers should answer 4xx, which the API
157+
treats as permanent and does not retry, because nothing about resending an
158+
unverifiable request would make it verify.
159+
"""
160+
161+
162+
class WebhookPayloadError(AdaptyvError):
163+
"""Webhook delivery was authentic but its envelope could not be read.
164+
165+
Raised by adaptyv.webhooks.verify only after the signature has checked out,
166+
so the delivery provably came from Foundry and only its shape is at issue.
167+
Handlers should answer 5xx or accept and log, never 4xx: a permanent
168+
rejection discards a real event and tells the API never to send it again.
169+
170+
Deliberately a sibling of WebhookVerificationError rather than a subclass,
171+
so that a handler catching the verification error to answer 4xx cannot
172+
swallow this one by accident.
173+
"""

src/adaptyv/webhooks.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""Webhook signature verification for Foundry deliveries.
2+
3+
Foundry POSTs signed events to the webhook_url registered on an experiment.
4+
Each delivery carries X-Adaptyv-Signature, which is "sha256=<hex>" over the
5+
HMAC-SHA256 of the raw request body, alongside X-Adaptyv-Event and
6+
X-Adaptyv-Delivery-Id. The contract is documented at
7+
https://docs.adaptyvbio.com/api-reference/api-introduction.
8+
9+
verify() is framework-agnostic on purpose: hand it the raw body bytes, the
10+
request headers, and the webhook secret, and it either returns a WebhookEvent
11+
or raises WebhookVerificationError. The README has FastAPI and framework-free
12+
handlers built on it.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import hashlib
18+
import hmac
19+
import json
20+
from collections.abc import Mapping
21+
from dataclasses import dataclass
22+
from typing import Any
23+
24+
from adaptyv.exceptions import WebhookPayloadError, WebhookVerificationError
25+
26+
SIGNATURE_HEADER = "X-Adaptyv-Signature"
27+
28+
_SIGNATURE_PREFIX = "sha256="
29+
_DIGEST_SIZE = hashlib.sha256().digest_size
30+
31+
32+
@dataclass(frozen=True)
33+
class WebhookEvent:
34+
"""A webhook delivery whose signature has been verified.
35+
36+
Attributes:
37+
event: Event slug, for example "experiment_update".
38+
delivery_id: Unique id for this delivery attempt. Foundry retries a
39+
failed delivery up to three times, so a handler that errors once
40+
will see this id again and should use it as its dedupe key.
41+
payload: The whole parsed envelope, including timestamp, api_version,
42+
and the nested data object.
43+
"""
44+
45+
event: str
46+
delivery_id: str
47+
payload: dict[str, Any]
48+
49+
50+
def _get_header(headers: Mapping[str, str], name: str) -> str | None:
51+
"""Look up a header without depending on the caller's capitalization.
52+
53+
Frameworks disagree here: httpx and Starlette hand back case-insensitive
54+
mappings, while dict(request.headers) hands back whatever casing arrived on
55+
the wire. HTTP header names are case-insensitive, so this treats them so.
56+
"""
57+
value = headers.get(name)
58+
if value is not None:
59+
return value
60+
61+
wanted = name.lower()
62+
for key, candidate in headers.items():
63+
if key.lower() == wanted:
64+
return candidate
65+
return None
66+
67+
68+
def _parse_signature(headers: Mapping[str, str]) -> bytes:
69+
"""Decode the digest carried by the signature header.
70+
71+
Returns the digest decoded to bytes rather than the hex text it arrived as,
72+
so the comparison cannot turn on the casing the sender chose.
73+
74+
Raises:
75+
WebhookVerificationError: The header is absent or cannot be parsed.
76+
"""
77+
header = _get_header(headers, SIGNATURE_HEADER)
78+
if header is None:
79+
raise WebhookVerificationError(f"{SIGNATURE_HEADER} header is missing from the delivery")
80+
81+
if not header.startswith(_SIGNATURE_PREFIX):
82+
raise WebhookVerificationError(
83+
f"{SIGNATURE_HEADER} must be formatted as 'sha256=<hex>', got {header!r}"
84+
)
85+
86+
hex_digest = header[len(_SIGNATURE_PREFIX) :]
87+
try:
88+
digest = bytes.fromhex(hex_digest)
89+
except ValueError as exc:
90+
raise WebhookVerificationError(
91+
f"{SIGNATURE_HEADER} digest is not valid hex: {hex_digest!r}"
92+
) from exc
93+
94+
if len(digest) != _DIGEST_SIZE:
95+
raise WebhookVerificationError(
96+
f"{SIGNATURE_HEADER} digest must be {_DIGEST_SIZE} bytes, got {len(digest)}"
97+
)
98+
return digest
99+
100+
101+
def _parse_payload(body: bytes) -> dict[str, Any]:
102+
"""Parse an already verified body into an event envelope.
103+
104+
Raises:
105+
WebhookPayloadError: The body is not a JSON object. Not a verification
106+
failure: the signature already proved where these bytes came from.
107+
"""
108+
try:
109+
payload = json.loads(body)
110+
except ValueError as exc:
111+
raise WebhookPayloadError(f"Webhook body is not valid JSON: {exc}") from exc
112+
113+
if not isinstance(payload, dict):
114+
raise WebhookPayloadError(
115+
f"Webhook body must be a JSON object, got {type(payload).__name__}"
116+
)
117+
return payload
118+
119+
120+
def _required_str(payload: dict[str, Any], key: str) -> str:
121+
"""Read an envelope field that WebhookEvent types as str.
122+
123+
Raises:
124+
WebhookPayloadError: The field is absent or is not a string.
125+
"""
126+
value = payload.get(key)
127+
if not isinstance(value, str):
128+
raise WebhookPayloadError(f"Webhook payload has no string {key!r} field")
129+
return value
130+
131+
132+
def verify(body: bytes, headers: Mapping[str, str], secret: str) -> WebhookEvent:
133+
"""Verify a webhook delivery and return the event it carries.
134+
135+
Args:
136+
body: The raw request body, exactly as it arrived. A str or an already
137+
parsed payload is refused, because re-serializing a payload changes
138+
the bytes the signature was computed over.
139+
headers: The request headers, looked up case-insensitively.
140+
secret: The webhook secret for this endpoint.
141+
142+
Returns:
143+
The verified event. Its event name and delivery id come from the signed
144+
body, not from the X-Adaptyv-Event and X-Adaptyv-Delivery-Id headers,
145+
which the signature does not cover. The whole envelope is exposed
146+
unchanged on payload, so fields this SDK does not know about pass
147+
through rather than being dropped.
148+
149+
Raises:
150+
WebhookVerificationError: The delivery did not prove it came from
151+
Foundry. The body is not bytes, the secret is empty, the signature
152+
header is missing or malformed, or the signature does not match.
153+
Answer 4xx: resending would not help.
154+
WebhookPayloadError: The signature checked out but the envelope could
155+
not be read. Answer 5xx or accept and log, never 4xx, or a genuine
156+
event is discarded and the API is told not to retry it.
157+
"""
158+
if not isinstance(body, bytes):
159+
raise WebhookVerificationError(
160+
"Webhook body must be the raw request body as bytes, got "
161+
f"{type(body).__name__}. Re-serializing a parsed payload produces "
162+
"different bytes than the ones that were signed."
163+
)
164+
165+
if not isinstance(secret, str) or not secret.strip():
166+
raise WebhookVerificationError(
167+
"Webhook secret must be a non-empty string. An unset secret still "
168+
"produces a valid HMAC, so verification would pass for every request "
169+
"instead of failing closed."
170+
)
171+
172+
provided = _parse_signature(headers)
173+
expected = hmac.new(secret.encode(), body, hashlib.sha256).digest()
174+
if not hmac.compare_digest(expected, provided):
175+
raise WebhookVerificationError("Webhook signature does not match the request body")
176+
177+
# Parsed only after the signature checks out, so an unverified body never
178+
# reaches the JSON parser.
179+
payload = _parse_payload(body)
180+
return WebhookEvent(
181+
event=_required_str(payload, "event"),
182+
delivery_id=_required_str(payload, "delivery_id"),
183+
payload=payload,
184+
)

0 commit comments

Comments
 (0)