Verify incoming Liveblocks webhook requests. Usage:: handler = WebhookHandler("whsec_...") event = handler.verify_request(headers=request.headers, raw_body=request.body)
| 404 | |
| 405 | |
| 406 | class WebhookHandler: |
| 407 | """Verify incoming Liveblocks webhook requests. |
| 408 | |
| 409 | Usage:: |
| 410 | |
| 411 | handler = WebhookHandler("whsec_...") |
| 412 | event = handler.verify_request(headers=request.headers, raw_body=request.body) |
| 413 | """ |
| 414 | |
| 415 | def __init__(self, secret: str) -> None: |
| 416 | if not secret or not isinstance(secret, str): |
| 417 | raise ValueError("Secret is required and must be a non-empty string") |
| 418 | |
| 419 | if not secret.startswith(_SECRET_PREFIX): |
| 420 | raise ValueError("Invalid secret, must start with whsec_") |
| 421 | |
| 422 | secret_key = secret[len(_SECRET_PREFIX) :] |
| 423 | try: |
| 424 | self._secret_bytes = base64.b64decode(secret_key) |
| 425 | except Exception: |
| 426 | raise ValueError( |
| 427 | "Webhook secret contains invalid base64 after the 'whsec_' prefix. " |
| 428 | "Please copy the full secret from your Liveblocks dashboard." |
| 429 | ) from None |
| 430 | |
| 431 | def verify_request(self, *, headers: dict[str, str], raw_body: str) -> WebhookEvent: |
| 432 | """Verify a webhook request and return the parsed event. |
| 433 | |
| 434 | Args: |
| 435 | headers: The HTTP headers as a string-to-string mapping. |
| 436 | raw_body: The raw request body as a string (do **not** parse it first). |
| 437 | |
| 438 | Returns: |
| 439 | The parsed webhook event dictionary. |
| 440 | |
| 441 | Raises: |
| 442 | ValueError: If the request cannot be verified. |
| 443 | """ |
| 444 | webhook_id, timestamp, raw_signatures = self._verify_headers(headers) |
| 445 | |
| 446 | if not isinstance(raw_body, str): |
| 447 | raise ValueError( |
| 448 | f"Invalid raw_body, must be a string, got {type(raw_body).__name__!r} instead. " |
| 449 | "Make sure you pass the raw request body string, not a parsed object." |
| 450 | ) |
| 451 | |
| 452 | self._verify_timestamp(timestamp) |
| 453 | |
| 454 | signature = self._sign(f"{webhook_id}.{timestamp}.{raw_body}") |
| 455 | |
| 456 | expected_signatures = [ |
| 457 | parts[1] for raw_sig in raw_signatures.split(" ") if len(parts := raw_sig.split(",")) > 1 |
| 458 | ] |
| 459 | |
| 460 | if not any(hmac.compare_digest(signature, s) for s in expected_signatures): |
| 461 | raise ValueError( |
| 462 | f"Invalid signature for webhook {webhook_id}. " |
| 463 | "Make sure you are using the correct webhook secret " |
no outgoing calls