feat: add webhook signature verification helper - #4
Open
EsraaKamel11 wants to merge 1 commit into
Open
Conversation
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
force-pushed
the
feat/webhook-verification
branch
from
August 5, 2026 13:32
2667652 to
2e411d0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3.
What
Adds
src/adaptyv/webhooks.pywith a single entry point:It returns a frozen
WebhookEvent(event name, delivery id, parsed payload) or raises.Stdlib only (
hmac,hashlib,json): no framework imports, no new runtimedependencies.
Why
The SDK already accepts
webhook_urlon experiment creation but has nothing for thereceiving 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
bodymust bebytes. Astror a parseddictis refused witha message saying why, so the re-serialization mistake is not expressible through the
API rather than merely documented against.
checked, which is the failure this helper exists to prevent.
WebhookVerificationErrormeans the deliverynever proved it came from Foundry, and 4xx is the right answer: resending would not
make it verify.
WebhookPayloadErroris raised only after the signature has checkedout, 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 WebhookVerificationErrorcannot swallow the payloadcase and quietly reintroduce the permanent rejection.
hmac.new(b"", body, sha256)produces a perfectly valid signature, so an unset secret makes verificationpass for every caller instead of failing closed.
which leaves
X-Adaptyv-EventandX-Adaptyv-Delivery-Idunauthenticated.WebhookEvent.eventand.delivery_idtherefore read the envelope inside the body,so a handler deduping on
event.delivery_idis keyed on something the sender cannotforge.
reach the JSON parser.
event.payload, so fields the SDK doesnot model are available rather than dropped, and a shape change is not a breaking
change.
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.
dict(request.headers)lowercasesnames 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_digestand 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:
isinstance(body, bytes)refusaltest_rejects_str_body,test_rejects_parsed_dict_body, both then leaking a bareTypeErrorhmac.compare_digestreplaced 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_oncetest_rejects_empty_secret_whose_signature_matchesand the whitespace case both stop raising, meaning verification passes; plus theNoneand non-strcases_parse_signatureTestSignatureHeadercasesdelivery_idno longer read from the payloadtest_exposes_delivery_id_and_event_from_the_signed_bodyand three othersWebhookPayloadErrormade a subclass ofWebhookVerificationErrortest_rejects_envelope_without_event,test_rejects_envelope_without_delivery_idPublic surface
WebhookVerificationErrorandWebhookPayloadErrorare exported fromadaptyvalongside the other exceptions.
verifyandWebhookEventare reachable asadaptyv.webhooks.verifyandadaptyv.webhooks.WebhookEvent, and are deliberately notadded to the top-level
__all__, since a bareverifythere reads ambiguously next tolabandFoundryClient. Happy to lift them up if you would rather.Housekeeping
src/adaptyv/types/generated.pyis untouched.README.mdis a trailing blank line that the repo's ownend-of-file-fixerpre-commit hook strips. It is what running the configured hooksproduces, not a stray edit.