Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 82 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ print(f"Experiment: {result.experiment_url}")

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

### Verify webhooks

Pass a `webhook_url` when you create an experiment and Foundry POSTs status updates
to it instead of making you poll. Every delivery is signed, so check the signature
before trusting the payload:

```python
from adaptyv.exceptions import WebhookPayloadError, WebhookVerificationError
from adaptyv.webhooks import verify

try:
# raw_body must be the bytes that arrived, not a parsed or re-serialized payload
event = verify(raw_body, request_headers, WEBHOOK_SECRET)
except WebhookVerificationError:
... # not from Foundry, or damaged in transit: reject it
except WebhookPayloadError:
... # signed by Foundry, but the envelope did not parse: log it, let it retry

print(event.event) # "experiment_update"
print(event.delivery_id) # "019b8da3-4a91-16c6-fa94-619212bee6a6"
print(event.payload["data"]["experiment_code"])
```

`verify` never returns a boolean. It raises, and which exception it raises is what
you branch on:

| Exception | Meaning | Answer with |
| --- | --- | --- |
| `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 |
| `WebhookPayloadError` | The signature checked out, but the envelope was not readable JSON carrying an `event` and a `delivery_id`. | 5xx, or accept and log |

The split matters because 4xx is permanent in the retry model. Answering 4xx to a
genuinely signed delivery whose shape you did not expect throws away a real event and
tells Foundry never to send it again. The two are siblings rather than parent and
child, so catching one cannot swallow the other by accident.

`event.payload` is the whole envelope exactly as it arrived, so fields the SDK does
not know about pass through untouched.

With FastAPI:

```python
import logging
import os

from fastapi import FastAPI, Request, Response

from adaptyv.exceptions import WebhookPayloadError, WebhookVerificationError
from adaptyv.webhooks import verify

app = FastAPI()
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"]


@app.post("/foundry-hook")
async def foundry_hook(request: Request) -> Response:
try:
event = verify(await request.body(), request.headers, WEBHOOK_SECRET)
except WebhookVerificationError:
return Response(status_code=400) # permanent, and correctly so
except WebhookPayloadError:
logging.exception("unreadable webhook envelope")
return Response(status_code=500) # genuinely ours, so let it come back

handle(event) # your code, keyed on event.delivery_id
return Response(status_code=200)
```

`await request.body()` hands back the raw bytes. Reading `await request.json()` and
re-serializing it produces different bytes than the ones that were signed, so the
signature would never match. The equivalent accessor is `request.get_data()` on
Flask and `request.body` on Django.

**Handlers have to be idempotent.** A delivery is retried up to three times with
exponential backoff on network errors and 5xx responses, so a handler that fails
once is guaranteed to see the same event again. `event.delivery_id` is stable across
those attempts, which makes it the key to deduplicate on in whatever store you
already have. The SDK deliberately does not keep one for you. Return 2xx to
acknowledge a delivery; a 4xx tells Foundry the failure is permanent and stops the
retries.

---

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

4 changes: 4 additions & 0 deletions src/adaptyv/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
PermissionDeniedError,
RateLimitError,
ValidationError,
WebhookPayloadError,
WebhookVerificationError,
)

__all__ = [
Expand Down Expand Up @@ -47,6 +49,8 @@
"PermissionDeniedError",
"RateLimitError",
"ValidationError",
"WebhookPayloadError",
"WebhookVerificationError",
]

# Lazy import mapping: name -> (module_path, attribute_name)
Expand Down
29 changes: 28 additions & 1 deletion src/adaptyv/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
│ ├── NotFoundError (404, resource not found)
│ └── RateLimitError (429, rate limit exceeded with retry_after)
├── ValidationError (invalid input parameters)
└── PermissionDeniedError (API key lacks create_experiment permission)
├── PermissionDeniedError (API key lacks create_experiment permission)
├── WebhookVerificationError (webhook delivery could not be trusted)
└── WebhookPayloadError (webhook delivery was trusted but unreadable)
"""

from __future__ import annotations
Expand Down Expand Up @@ -144,3 +146,28 @@ def __init__(
request_id=request_id,
request_path=request_path,
)


class WebhookVerificationError(AdaptyvError):
"""Webhook delivery could not be trusted.

Raised by adaptyv.webhooks.verify when a delivery fails to prove it came
from Foundry: a bad, missing, or malformed signature, an empty secret, or a
body that is not raw bytes. Handlers should answer 4xx, which the API
treats as permanent and does not retry, because nothing about resending an
unverifiable request would make it verify.
"""


class WebhookPayloadError(AdaptyvError):
"""Webhook delivery was authentic but its envelope could not be read.

Raised by adaptyv.webhooks.verify only after the signature has checked out,
so the delivery provably came from Foundry and only its shape is at issue.
Handlers should answer 5xx or accept and log, never 4xx: a permanent
rejection discards a real event and tells the API never to send it again.

Deliberately a sibling of WebhookVerificationError rather than a subclass,
so that a handler catching the verification error to answer 4xx cannot
swallow this one by accident.
"""
184 changes: 184 additions & 0 deletions src/adaptyv/webhooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""Webhook signature verification for Foundry deliveries.

Foundry POSTs signed events to the webhook_url registered on an experiment.
Each delivery carries X-Adaptyv-Signature, which is "sha256=<hex>" over the
HMAC-SHA256 of the raw request body, alongside X-Adaptyv-Event and
X-Adaptyv-Delivery-Id. The contract is documented at
https://docs.adaptyvbio.com/api-reference/api-introduction.

verify() is framework-agnostic on purpose: hand it the raw body bytes, the
request headers, and the webhook secret, and it either returns a WebhookEvent
or raises WebhookVerificationError. The README has FastAPI and framework-free
handlers built on it.
"""

from __future__ import annotations

import hashlib
import hmac
import json
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any

from adaptyv.exceptions import WebhookPayloadError, WebhookVerificationError

SIGNATURE_HEADER = "X-Adaptyv-Signature"

_SIGNATURE_PREFIX = "sha256="
_DIGEST_SIZE = hashlib.sha256().digest_size


@dataclass(frozen=True)
class WebhookEvent:
"""A webhook delivery whose signature has been verified.

Attributes:
event: Event slug, for example "experiment_update".
delivery_id: Unique id for this delivery attempt. Foundry retries a
failed delivery up to three times, so a handler that errors once
will see this id again and should use it as its dedupe key.
payload: The whole parsed envelope, including timestamp, api_version,
and the nested data object.
"""

event: str
delivery_id: str
payload: dict[str, Any]


def _get_header(headers: Mapping[str, str], name: str) -> str | None:
"""Look up a header without depending on the caller's capitalization.

Frameworks disagree here: httpx and Starlette hand back case-insensitive
mappings, while dict(request.headers) hands back whatever casing arrived on
the wire. HTTP header names are case-insensitive, so this treats them so.
"""
value = headers.get(name)
if value is not None:
return value

wanted = name.lower()
for key, candidate in headers.items():
if key.lower() == wanted:
return candidate
return None


def _parse_signature(headers: Mapping[str, str]) -> bytes:
"""Decode the digest carried by the signature header.

Returns the digest decoded to bytes rather than the hex text it arrived as,
so the comparison cannot turn on the casing the sender chose.

Raises:
WebhookVerificationError: The header is absent or cannot be parsed.
"""
header = _get_header(headers, SIGNATURE_HEADER)
if header is None:
raise WebhookVerificationError(f"{SIGNATURE_HEADER} header is missing from the delivery")

if not header.startswith(_SIGNATURE_PREFIX):
raise WebhookVerificationError(
f"{SIGNATURE_HEADER} must be formatted as 'sha256=<hex>', got {header!r}"
)

hex_digest = header[len(_SIGNATURE_PREFIX) :]
try:
digest = bytes.fromhex(hex_digest)
except ValueError as exc:
raise WebhookVerificationError(
f"{SIGNATURE_HEADER} digest is not valid hex: {hex_digest!r}"
) from exc

if len(digest) != _DIGEST_SIZE:
raise WebhookVerificationError(
f"{SIGNATURE_HEADER} digest must be {_DIGEST_SIZE} bytes, got {len(digest)}"
)
return digest


def _parse_payload(body: bytes) -> dict[str, Any]:
"""Parse an already verified body into an event envelope.

Raises:
WebhookPayloadError: The body is not a JSON object. Not a verification
failure: the signature already proved where these bytes came from.
"""
try:
payload = json.loads(body)
except ValueError as exc:
raise WebhookPayloadError(f"Webhook body is not valid JSON: {exc}") from exc

if not isinstance(payload, dict):
raise WebhookPayloadError(
f"Webhook body must be a JSON object, got {type(payload).__name__}"
)
return payload


def _required_str(payload: dict[str, Any], key: str) -> str:
"""Read an envelope field that WebhookEvent types as str.

Raises:
WebhookPayloadError: The field is absent or is not a string.
"""
value = payload.get(key)
if not isinstance(value, str):
raise WebhookPayloadError(f"Webhook payload has no string {key!r} field")
return value


def verify(body: bytes, headers: Mapping[str, str], secret: str) -> WebhookEvent:
"""Verify a webhook delivery and return the event it carries.

Args:
body: The raw request body, exactly as it arrived. A str or an already
parsed payload is refused, because re-serializing a payload changes
the bytes the signature was computed over.
headers: The request headers, looked up case-insensitively.
secret: The webhook secret for this endpoint.

Returns:
The verified event. Its event name and delivery id come from the signed
body, not from the X-Adaptyv-Event and X-Adaptyv-Delivery-Id headers,
which the signature does not cover. The whole envelope is exposed
unchanged on payload, so fields this SDK does not know about pass
through rather than being dropped.

Raises:
WebhookVerificationError: The delivery did not prove it came from
Foundry. The body is not bytes, the secret is empty, the signature
header is missing or malformed, or the signature does not match.
Answer 4xx: resending would not help.
WebhookPayloadError: The signature checked out but the envelope could
not be read. Answer 5xx or accept and log, never 4xx, or a genuine
event is discarded and the API is told not to retry it.
"""
if not isinstance(body, bytes):
raise WebhookVerificationError(
"Webhook body must be the raw request body as bytes, got "
f"{type(body).__name__}. Re-serializing a parsed payload produces "
"different bytes than the ones that were signed."
)

if not isinstance(secret, str) or not secret.strip():
raise WebhookVerificationError(
"Webhook secret must be a non-empty string. An unset secret still "
"produces a valid HMAC, so verification would pass for every request "
"instead of failing closed."
)

provided = _parse_signature(headers)
expected = hmac.new(secret.encode(), body, hashlib.sha256).digest()
if not hmac.compare_digest(expected, provided):
raise WebhookVerificationError("Webhook signature does not match the request body")

# Parsed only after the signature checks out, so an unverified body never
# reaches the JSON parser.
payload = _parse_payload(body)
return WebhookEvent(
event=_required_str(payload, "event"),
delivery_id=_required_str(payload, "delivery_id"),
payload=payload,
)
Loading