Coverage for notion_client/webhooks.py: 100%

14 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-30 15:22 +0000

1"""Helpers for receiving and verifying Notion webhook deliveries. 

2 

3Notion signs each webhook request with HMAC-SHA256 over the raw HTTP body 

4using the subscription's verification token as the key, and sends the 

5resulting hex digest in the `X-Notion-Signature` header as `sha256=<hex>`. 

6To verify a delivery, callers must pass the body **exactly as it arrived 

7over the wire** — any JSON re-serialization will change the bytes and 

8invalidate the signature. 

9""" 

10 

11import hmac 

12from hashlib import sha256 

13from typing import Optional, Union 

14 

15_SIGNATURE_PREFIX = "sha256=" 

16 

17 

18def sign_webhook_payload(body: Union[str, bytes], verification_token: str) -> str: 

19 """Compute the value Notion would send in `X-Notion-Signature` for a given 

20 body and verification token. Useful for unit-testing webhook handlers without 

21 standing up a real subscription. 

22 """ 

23 if isinstance(body, str): 

24 body = body.encode("utf-8") 

25 digest = hmac.new(verification_token.encode("utf-8"), body, sha256).hexdigest() 

26 return f"{_SIGNATURE_PREFIX}{digest}" 

27 

28 

29def verify_webhook_signature( 

30 body: Union[str, bytes], 

31 signature: Optional[str], 

32 verification_token: str, 

33) -> bool: 

34 """Verify that a webhook delivery came from Notion and has not been tampered 

35 with. 

36 

37 Performs a constant-time comparison; returns ``True`` only if the supplied 

38 ``signature`` matches HMAC-SHA256 of ``body`` keyed by ``verification_token``. 

39 Returns ``False`` (rather than raising) for any malformed input so the 

40 caller can respond with a single 401/403 path. 

41 """ 

42 if not isinstance(signature, str) or not signature.startswith(_SIGNATURE_PREFIX): 

43 return False 

44 expected = sign_webhook_payload(body, verification_token) 

45 return hmac.compare_digest(signature, expected)