Skip to content

feat: add webhook signature verification helper - #4

Open
EsraaKamel11 wants to merge 1 commit into
adaptyvbio:mainfrom
EsraaKamel11:feat/webhook-verification
Open

feat: add webhook signature verification helper#4
EsraaKamel11 wants to merge 1 commit into
adaptyvbio:mainfrom
EsraaKamel11:feat/webhook-verification

Conversation

@EsraaKamel11

@EsraaKamel11 EsraaKamel11 commented Aug 5, 2026

Copy link
Copy Markdown

Closes #3.

What

Adds src/adaptyv/webhooks.py with a single entry point:

from adaptyv.webhooks import verify

event = verify(raw_body, request.headers, WEBHOOK_SECRET)

It returns a frozen WebhookEvent (event name, delivery id, parsed payload) or raises.
Stdlib only (hmac, hashlib, json): no framework imports, no new runtime
dependencies.

Why

The SDK already accepts webhook_url on experiment creation but has nothing for the
receiving side, so everyone integrating webhooks hand-writes the HMAC check. The snippet
in the API docs is correct; the code people build around it is where the quiet failures
live. Each of those is a guard here with a test that dies when the guard is removed.

Design notes

  • Raw bytes only. body must be bytes. A str or a parsed dict is refused with
    a message saying why, so the re-serialization mistake is not expressible through the
    API rather than merely documented against.
  • Refusals, never a boolean. A boolean gets assigned to a variable and then never
    checked, which is the failure this helper exists to prevent.
  • Two exceptions, kept as siblings. WebhookVerificationError means the delivery
    never proved it came from Foundry, and 4xx is the right answer: resending would not
    make it verify. WebhookPayloadError is raised only after the signature has checked
    out, so the delivery is provably yours and only its shape is the problem. That one
    must not be answered 4xx, because 4xx is permanent in the retry model and would
    discard a real event while telling the API never to resend it. Siblings rather than
    parent and child so that except WebhookVerificationError cannot swallow the payload
    case and quietly reintroduce the permanent rejection.
  • An empty secret is refused before any HMAC is computed. hmac.new(b"", body, sha256) produces a perfectly valid signature, so an unset secret makes verification
    pass for every caller instead of failing closed.
  • Event metadata comes from the signed body. The signature covers the body alone,
    which leaves X-Adaptyv-Event and X-Adaptyv-Delivery-Id unauthenticated.
    WebhookEvent.event and .delivery_id therefore read the envelope inside the body,
    so a handler deduping on event.delivery_id is keyed on something the sender cannot
    forge.
  • The body is parsed only after the signature checks out, so unverified bytes never
    reach the JSON parser.
  • The envelope passes through unchanged on event.payload, so fields the SDK does
    not model are available rather than dropped, and a shape change is not a breaking
    change.
  • No dedupe store. Retries make duplicates certain, so the README states the
    idempotency requirement and the delivery id is exposed. A store inside the SDK would
    need a database and a lifecycle the library has no business owning.
  • Header lookup is case-insensitive, because dict(request.headers) lowercases
    names in several frameworks.

Tests

tests/test_webhooks.py: 30 tests, no API key, no network, no recorded fixtures.
Signatures are constructed in the test with the same secret, so the file is
deterministic for anyone who clones the repo.

The two comparison tests stub hmac.compare_digest and assert on what its answer does,
rather than counting calls to it. Counting would only show that the primitive ran, and
code that calls it and then branches on its own == would pass such a check.

Each guard was then mutation-checked by removing it and confirming named tests go red:

Guard Removed Tests that go red
Raw body must be bytes the isinstance(body, bytes) refusal test_rejects_str_body, test_rejects_parsed_dict_body, both then leaking a bare TypeError
Constant-time comparison hmac.compare_digest replaced by == test_rejects_a_valid_delivery_when_the_comparison_returns_false, test_accepts_a_mismatched_delivery_when_the_comparison_returns_true, test_calls_hmac_compare_digest_once
Non-empty secret the secret refusal test_rejects_empty_secret_whose_signature_matches and the whitespace case both stop raising, meaning verification passes; plus the None and non-str cases
Signature header parsing the validation in _parse_signature all seven TestSignatureHeader cases
Delivery id exposed delivery_id no longer read from the payload test_exposes_delivery_id_and_event_from_the_signed_body and three others
Payload error is a sibling WebhookPayloadError made a subclass of WebhookVerificationError test_rejects_envelope_without_event, test_rejects_envelope_without_delivery_id

Public surface

WebhookVerificationError and WebhookPayloadError are exported from adaptyv
alongside the other exceptions. verify and WebhookEvent are reachable as
adaptyv.webhooks.verify and adaptyv.webhooks.WebhookEvent, and are deliberately not
added to the top-level __all__, since a bare verify there reads ambiguously next to
lab and FoundryClient. Happy to lift them up if you would rather.

Housekeeping

  • src/adaptyv/types/generated.py is untouched.
  • The single deleted line in README.md is a trailing blank line that the repo's own
    end-of-file-fixer pre-commit hook strips. It is what running the configured hooks
    produces, not a stray edit.

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.
@EsraaKamel11
EsraaKamel11 force-pushed the feat/webhook-verification branch from 2667652 to 2e411d0 Compare August 5, 2026 13:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add webhook signature verification helper

1 participant