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
« 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.
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"""
11import hmac
12from hashlib import sha256
13from typing import Optional, Union
15_SIGNATURE_PREFIX = "sha256="
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}"
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.
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)