feat(python-sdk): add webhook verification and event handling (#344)
* feat(python-sdk): add webhook verification and event handling Add webhook support to the Python SDK matching the JS SDK implementation: - Add Webhooks class with verify() and construct_event() methods - Implement HMAC-SHA256 signature verification with timing-safe comparison - Add timestamp validation with configurable tolerance (default 5 minutes) - Add comprehensive webhook event types (18 events: email, contact, domain, test) - Add WebhookVerificationError with typed error codes - Export webhook constants (headers) and types * fix(python-sdk): harden webhook parsing and typing Normalize invalid UTF-8 webhook payloads to INVALID_BODY errors so verify() safely returns false, and narrow base email webhook event types to avoid discriminated-union overlap. Add regression tests for both paths. * chore(python-sdk): bump package version to 0.2.9 * feat(python-sdk): add local webhook test example project Add a runnable Flask receiver and signed webhook sender under packages/python-sdk/example, and link it from the Python SDK README for local verification. --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,26 @@
|
||||
from .usesend import UseSend, UseSendHTTPError
|
||||
from .domains import Domains # type: ignore
|
||||
from .campaigns import Campaigns # type: ignore
|
||||
from .webhooks import (
|
||||
Webhooks,
|
||||
WebhookVerificationError,
|
||||
WEBHOOK_SIGNATURE_HEADER,
|
||||
WEBHOOK_TIMESTAMP_HEADER,
|
||||
WEBHOOK_EVENT_HEADER,
|
||||
WEBHOOK_CALL_HEADER,
|
||||
)
|
||||
from . import types
|
||||
|
||||
__all__ = ["UseSend", "UseSendHTTPError", "types", "Domains", "Campaigns"]
|
||||
__all__ = [
|
||||
"UseSend",
|
||||
"UseSendHTTPError",
|
||||
"types",
|
||||
"Domains",
|
||||
"Campaigns",
|
||||
"Webhooks",
|
||||
"WebhookVerificationError",
|
||||
"WEBHOOK_SIGNATURE_HEADER",
|
||||
"WEBHOOK_TIMESTAMP_HEADER",
|
||||
"WEBHOOK_EVENT_HEADER",
|
||||
"WEBHOOK_CALL_HEADER",
|
||||
]
|
||||
|
||||
@@ -449,3 +449,418 @@ class CampaignActionResponse(TypedDict, total=False):
|
||||
class APIError(TypedDict):
|
||||
code: str
|
||||
message: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Webhook Events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Event type literals
|
||||
ContactWebhookEventType = Literal[
|
||||
"contact.created",
|
||||
"contact.updated",
|
||||
"contact.deleted",
|
||||
]
|
||||
|
||||
DomainWebhookEventType = Literal[
|
||||
"domain.created",
|
||||
"domain.verified",
|
||||
"domain.updated",
|
||||
"domain.deleted",
|
||||
]
|
||||
|
||||
EmailWebhookEventType = Literal[
|
||||
"email.queued",
|
||||
"email.sent",
|
||||
"email.delivery_delayed",
|
||||
"email.delivered",
|
||||
"email.bounced",
|
||||
"email.rejected",
|
||||
"email.rendering_failure",
|
||||
"email.complained",
|
||||
"email.failed",
|
||||
"email.cancelled",
|
||||
"email.suppressed",
|
||||
"email.opened",
|
||||
"email.clicked",
|
||||
]
|
||||
|
||||
EmailBaseWebhookEventType = Literal[
|
||||
"email.queued",
|
||||
"email.sent",
|
||||
"email.delivery_delayed",
|
||||
"email.delivered",
|
||||
"email.rejected",
|
||||
"email.rendering_failure",
|
||||
"email.complained",
|
||||
"email.cancelled",
|
||||
]
|
||||
|
||||
WebhookTestEventType = Literal["webhook.test"]
|
||||
|
||||
WebhookEventType = Literal[
|
||||
# Contact events
|
||||
"contact.created",
|
||||
"contact.updated",
|
||||
"contact.deleted",
|
||||
# Domain events
|
||||
"domain.created",
|
||||
"domain.verified",
|
||||
"domain.updated",
|
||||
"domain.deleted",
|
||||
# Email events
|
||||
"email.queued",
|
||||
"email.sent",
|
||||
"email.delivery_delayed",
|
||||
"email.delivered",
|
||||
"email.bounced",
|
||||
"email.rejected",
|
||||
"email.rendering_failure",
|
||||
"email.complained",
|
||||
"email.failed",
|
||||
"email.cancelled",
|
||||
"email.suppressed",
|
||||
"email.opened",
|
||||
"email.clicked",
|
||||
# Test event
|
||||
"webhook.test",
|
||||
]
|
||||
|
||||
# Email status for webhook payloads
|
||||
WebhookEmailStatus = Literal[
|
||||
"QUEUED",
|
||||
"SENT",
|
||||
"DELIVERY_DELAYED",
|
||||
"DELIVERED",
|
||||
"BOUNCED",
|
||||
"REJECTED",
|
||||
"RENDERING_FAILURE",
|
||||
"COMPLAINED",
|
||||
"FAILED",
|
||||
"CANCELLED",
|
||||
"SUPPRESSED",
|
||||
"OPENED",
|
||||
"CLICKED",
|
||||
"SCHEDULED",
|
||||
]
|
||||
|
||||
|
||||
# Webhook payload types
|
||||
class EmailBasePayload(TypedDict, total=False):
|
||||
"""Base payload for email webhook events."""
|
||||
|
||||
id: str
|
||||
status: WebhookEmailStatus
|
||||
# Note: 'from' is a reserved keyword, using alternative access
|
||||
to: List[str]
|
||||
occurredAt: str
|
||||
campaignId: Optional[str]
|
||||
contactId: Optional[str]
|
||||
domainId: Optional[int]
|
||||
subject: str
|
||||
templateId: str
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
|
||||
# Using functional syntax for 'from' field
|
||||
EmailBasePayloadFull = TypedDict(
|
||||
"EmailBasePayloadFull",
|
||||
{
|
||||
"id": str,
|
||||
"status": WebhookEmailStatus,
|
||||
"from": str,
|
||||
"to": List[str],
|
||||
"occurredAt": str,
|
||||
"campaignId": NotRequired[Optional[str]],
|
||||
"contactId": NotRequired[Optional[str]],
|
||||
"domainId": NotRequired[Optional[int]],
|
||||
"subject": NotRequired[str],
|
||||
"templateId": NotRequired[str],
|
||||
"metadata": NotRequired[Dict[str, Any]],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ContactWebhookPayload(TypedDict, total=False):
|
||||
"""Payload for contact webhook events."""
|
||||
|
||||
id: str
|
||||
email: str
|
||||
contactBookId: str
|
||||
subscribed: bool
|
||||
properties: Dict[str, Any]
|
||||
firstName: Optional[str]
|
||||
lastName: Optional[str]
|
||||
createdAt: str
|
||||
updatedAt: str
|
||||
|
||||
|
||||
class DomainWebhookPayload(TypedDict, total=False):
|
||||
"""Payload for domain webhook events."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
status: str
|
||||
region: str
|
||||
createdAt: str
|
||||
updatedAt: str
|
||||
clickTracking: bool
|
||||
openTracking: bool
|
||||
subdomain: Optional[str]
|
||||
sesTenantId: Optional[str]
|
||||
dkimStatus: Optional[str]
|
||||
spfDetails: Optional[str]
|
||||
dmarcAdded: Optional[bool]
|
||||
|
||||
|
||||
BounceType = Literal["Transient", "Permanent", "Undetermined"]
|
||||
BounceSubType = Literal[
|
||||
"General",
|
||||
"NoEmail",
|
||||
"Suppressed",
|
||||
"OnAccountSuppressionList",
|
||||
"MailboxFull",
|
||||
"MessageTooLarge",
|
||||
"ContentRejected",
|
||||
"AttachmentRejected",
|
||||
]
|
||||
|
||||
|
||||
class BounceDetails(TypedDict, total=False):
|
||||
"""Bounce details for email.bounced events."""
|
||||
|
||||
type: BounceType
|
||||
subType: BounceSubType
|
||||
message: str
|
||||
|
||||
|
||||
class FailureDetails(TypedDict):
|
||||
"""Failure details for email.failed events."""
|
||||
|
||||
reason: str
|
||||
|
||||
|
||||
SuppressionType = Literal["Bounce", "Complaint", "Manual"]
|
||||
|
||||
|
||||
class SuppressionDetails(TypedDict, total=False):
|
||||
"""Suppression details for email.suppressed events."""
|
||||
|
||||
type: SuppressionType
|
||||
reason: str
|
||||
source: str
|
||||
|
||||
|
||||
class OpenDetails(TypedDict, total=False):
|
||||
"""Open tracking details for email.opened events."""
|
||||
|
||||
timestamp: str
|
||||
userAgent: str
|
||||
ip: str
|
||||
platform: str
|
||||
|
||||
|
||||
class ClickDetails(TypedDict, total=False):
|
||||
"""Click tracking details for email.clicked events."""
|
||||
|
||||
timestamp: str
|
||||
url: str
|
||||
userAgent: str
|
||||
ip: str
|
||||
platform: str
|
||||
|
||||
|
||||
# Extended email payloads with additional details
|
||||
EmailBouncedPayload = TypedDict(
|
||||
"EmailBouncedPayload",
|
||||
{
|
||||
"id": str,
|
||||
"status": WebhookEmailStatus,
|
||||
"from": str,
|
||||
"to": List[str],
|
||||
"occurredAt": str,
|
||||
"campaignId": NotRequired[Optional[str]],
|
||||
"contactId": NotRequired[Optional[str]],
|
||||
"domainId": NotRequired[Optional[int]],
|
||||
"subject": NotRequired[str],
|
||||
"templateId": NotRequired[str],
|
||||
"metadata": NotRequired[Dict[str, Any]],
|
||||
"bounce": BounceDetails,
|
||||
},
|
||||
)
|
||||
|
||||
EmailFailedPayload = TypedDict(
|
||||
"EmailFailedPayload",
|
||||
{
|
||||
"id": str,
|
||||
"status": WebhookEmailStatus,
|
||||
"from": str,
|
||||
"to": List[str],
|
||||
"occurredAt": str,
|
||||
"campaignId": NotRequired[Optional[str]],
|
||||
"contactId": NotRequired[Optional[str]],
|
||||
"domainId": NotRequired[Optional[int]],
|
||||
"subject": NotRequired[str],
|
||||
"templateId": NotRequired[str],
|
||||
"metadata": NotRequired[Dict[str, Any]],
|
||||
"failed": FailureDetails,
|
||||
},
|
||||
)
|
||||
|
||||
EmailSuppressedPayload = TypedDict(
|
||||
"EmailSuppressedPayload",
|
||||
{
|
||||
"id": str,
|
||||
"status": WebhookEmailStatus,
|
||||
"from": str,
|
||||
"to": List[str],
|
||||
"occurredAt": str,
|
||||
"campaignId": NotRequired[Optional[str]],
|
||||
"contactId": NotRequired[Optional[str]],
|
||||
"domainId": NotRequired[Optional[int]],
|
||||
"subject": NotRequired[str],
|
||||
"templateId": NotRequired[str],
|
||||
"metadata": NotRequired[Dict[str, Any]],
|
||||
"suppression": SuppressionDetails,
|
||||
},
|
||||
)
|
||||
|
||||
EmailOpenedPayload = TypedDict(
|
||||
"EmailOpenedPayload",
|
||||
{
|
||||
"id": str,
|
||||
"status": WebhookEmailStatus,
|
||||
"from": str,
|
||||
"to": List[str],
|
||||
"occurredAt": str,
|
||||
"campaignId": NotRequired[Optional[str]],
|
||||
"contactId": NotRequired[Optional[str]],
|
||||
"domainId": NotRequired[Optional[int]],
|
||||
"subject": NotRequired[str],
|
||||
"templateId": NotRequired[str],
|
||||
"metadata": NotRequired[Dict[str, Any]],
|
||||
"open": OpenDetails,
|
||||
},
|
||||
)
|
||||
|
||||
EmailClickedPayload = TypedDict(
|
||||
"EmailClickedPayload",
|
||||
{
|
||||
"id": str,
|
||||
"status": WebhookEmailStatus,
|
||||
"from": str,
|
||||
"to": List[str],
|
||||
"occurredAt": str,
|
||||
"campaignId": NotRequired[Optional[str]],
|
||||
"contactId": NotRequired[Optional[str]],
|
||||
"domainId": NotRequired[Optional[int]],
|
||||
"subject": NotRequired[str],
|
||||
"templateId": NotRequired[str],
|
||||
"metadata": NotRequired[Dict[str, Any]],
|
||||
"click": ClickDetails,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class WebhookTestPayload(TypedDict):
|
||||
"""Payload for webhook.test events."""
|
||||
|
||||
test: bool
|
||||
webhookId: str
|
||||
sentAt: str
|
||||
|
||||
|
||||
# Webhook event structures
|
||||
class EmailWebhookEvent(TypedDict):
|
||||
"""Structure for email webhook events."""
|
||||
|
||||
id: str
|
||||
type: EmailBaseWebhookEventType
|
||||
createdAt: str
|
||||
data: EmailBasePayloadFull
|
||||
|
||||
|
||||
class EmailBouncedEvent(TypedDict):
|
||||
"""Structure for email.bounced webhook events."""
|
||||
|
||||
id: str
|
||||
type: Literal["email.bounced"]
|
||||
createdAt: str
|
||||
data: EmailBouncedPayload
|
||||
|
||||
|
||||
class EmailFailedEvent(TypedDict):
|
||||
"""Structure for email.failed webhook events."""
|
||||
|
||||
id: str
|
||||
type: Literal["email.failed"]
|
||||
createdAt: str
|
||||
data: EmailFailedPayload
|
||||
|
||||
|
||||
class EmailSuppressedEvent(TypedDict):
|
||||
"""Structure for email.suppressed webhook events."""
|
||||
|
||||
id: str
|
||||
type: Literal["email.suppressed"]
|
||||
createdAt: str
|
||||
data: EmailSuppressedPayload
|
||||
|
||||
|
||||
class EmailOpenedEvent(TypedDict):
|
||||
"""Structure for email.opened webhook events."""
|
||||
|
||||
id: str
|
||||
type: Literal["email.opened"]
|
||||
createdAt: str
|
||||
data: EmailOpenedPayload
|
||||
|
||||
|
||||
class EmailClickedEvent(TypedDict):
|
||||
"""Structure for email.clicked webhook events."""
|
||||
|
||||
id: str
|
||||
type: Literal["email.clicked"]
|
||||
createdAt: str
|
||||
data: EmailClickedPayload
|
||||
|
||||
|
||||
class ContactWebhookEvent(TypedDict):
|
||||
"""Structure for contact webhook events."""
|
||||
|
||||
id: str
|
||||
type: ContactWebhookEventType
|
||||
createdAt: str
|
||||
data: ContactWebhookPayload
|
||||
|
||||
|
||||
class DomainWebhookEvent(TypedDict):
|
||||
"""Structure for domain webhook events."""
|
||||
|
||||
id: str
|
||||
type: DomainWebhookEventType
|
||||
createdAt: str
|
||||
data: DomainWebhookPayload
|
||||
|
||||
|
||||
class WebhookTestEvent(TypedDict):
|
||||
"""Structure for webhook.test events."""
|
||||
|
||||
id: str
|
||||
type: Literal["webhook.test"]
|
||||
createdAt: str
|
||||
data: WebhookTestPayload
|
||||
|
||||
|
||||
# Union type for all webhook events
|
||||
WebhookEventData = Union[
|
||||
EmailWebhookEvent,
|
||||
EmailBouncedEvent,
|
||||
EmailFailedEvent,
|
||||
EmailSuppressedEvent,
|
||||
EmailOpenedEvent,
|
||||
EmailClickedEvent,
|
||||
ContactWebhookEvent,
|
||||
DomainWebhookEvent,
|
||||
WebhookTestEvent,
|
||||
]
|
||||
|
||||
@@ -74,6 +74,34 @@ class UseSend:
|
||||
self.domains = Domains(self)
|
||||
self.campaigns = Campaigns(self)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Webhooks
|
||||
# ------------------------------------------------------------------
|
||||
def webhooks(self, secret: str) -> "Webhooks":
|
||||
"""Create a Webhooks instance for verifying webhook signatures.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
secret:
|
||||
The webhook signing secret (starts with 'whsec_').
|
||||
|
||||
Returns
|
||||
-------
|
||||
Webhooks
|
||||
A Webhooks instance for verifying signatures and constructing events.
|
||||
|
||||
Example
|
||||
-------
|
||||
```python
|
||||
usesend = UseSend("us_12345")
|
||||
webhooks = usesend.webhooks("whsec_xxx")
|
||||
|
||||
# In your webhook handler
|
||||
event = webhooks.construct_event(body, headers=request.headers)
|
||||
```
|
||||
"""
|
||||
return Webhooks(secret)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal request helper
|
||||
# ------------------------------------------------------------------
|
||||
@@ -160,3 +188,4 @@ from .emails import Emails # noqa: E402 pylint: disable=wrong-import-position
|
||||
from .contacts import Contacts # noqa: E402 pylint: disable=wrong-import-position
|
||||
from .domains import Domains # type: ignore # noqa: E402
|
||||
from .campaigns import Campaigns # type: ignore # noqa: E402
|
||||
from .webhooks import Webhooks # noqa: E402 pylint: disable=wrong-import-position
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Webhook verification and event construction for UseSend webhooks.
|
||||
|
||||
This module provides secure webhook signature verification using HMAC-SHA256,
|
||||
timestamp validation, and type-safe event parsing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict, Literal, Mapping, Optional, Union
|
||||
|
||||
from .types import WebhookEventData
|
||||
|
||||
|
||||
# Webhook header names
|
||||
WEBHOOK_SIGNATURE_HEADER = "X-UseSend-Signature"
|
||||
WEBHOOK_TIMESTAMP_HEADER = "X-UseSend-Timestamp"
|
||||
WEBHOOK_EVENT_HEADER = "X-UseSend-Event"
|
||||
WEBHOOK_CALL_HEADER = "X-UseSend-Call"
|
||||
|
||||
# Signature format
|
||||
SIGNATURE_PREFIX = "v1="
|
||||
|
||||
# Default tolerance: 5 minutes in milliseconds
|
||||
DEFAULT_TOLERANCE_MS = 5 * 60 * 1000
|
||||
|
||||
|
||||
WebhookVerificationErrorCode = Literal[
|
||||
"MISSING_SIGNATURE",
|
||||
"MISSING_TIMESTAMP",
|
||||
"INVALID_SIGNATURE_FORMAT",
|
||||
"INVALID_TIMESTAMP",
|
||||
"TIMESTAMP_OUT_OF_RANGE",
|
||||
"SIGNATURE_MISMATCH",
|
||||
"INVALID_BODY",
|
||||
"INVALID_JSON",
|
||||
]
|
||||
|
||||
|
||||
class WebhookVerificationError(Exception):
|
||||
"""Error raised when webhook verification fails.
|
||||
|
||||
Attributes:
|
||||
code: The error code indicating the type of verification failure.
|
||||
message: A human-readable description of the error.
|
||||
"""
|
||||
|
||||
def __init__(self, code: WebhookVerificationErrorCode, message: str) -> None:
|
||||
self.code = code
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"[{self.code}] {self.message}"
|
||||
|
||||
|
||||
class Webhooks:
|
||||
"""Webhook verification and event construction.
|
||||
|
||||
This class provides methods to verify webhook signatures and parse
|
||||
webhook events with type safety.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
secret:
|
||||
The webhook signing secret (starts with 'whsec_').
|
||||
|
||||
Example
|
||||
-------
|
||||
```python
|
||||
from usesend import UseSend
|
||||
|
||||
usesend = UseSend("us_12345")
|
||||
webhooks = usesend.webhooks("whsec_xxx")
|
||||
|
||||
# Flask example
|
||||
@app.route("/webhook", methods=["POST"])
|
||||
def handle_webhook():
|
||||
try:
|
||||
event = webhooks.construct_event(
|
||||
request.data,
|
||||
headers=request.headers
|
||||
)
|
||||
|
||||
if event["type"] == "email.delivered":
|
||||
print(f"Email delivered to {event['data']['to']}")
|
||||
|
||||
return "OK", 200
|
||||
except WebhookVerificationError as e:
|
||||
return str(e), 400
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, secret: str) -> None:
|
||||
self._secret = secret
|
||||
|
||||
def verify(
|
||||
self,
|
||||
body: Union[str, bytes],
|
||||
*,
|
||||
headers: Mapping[str, Any],
|
||||
secret: Optional[str] = None,
|
||||
tolerance: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Verify webhook signature without parsing the event.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
body:
|
||||
Raw webhook body (string or bytes).
|
||||
headers:
|
||||
Request headers (dict-like object).
|
||||
secret:
|
||||
Optional override for the webhook secret.
|
||||
tolerance:
|
||||
Optional tolerance in milliseconds for timestamp validation.
|
||||
Defaults to 5 minutes. Set to -1 to disable timestamp validation.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if signature is valid, False otherwise.
|
||||
|
||||
Example
|
||||
-------
|
||||
```python
|
||||
is_valid = webhooks.verify(body, headers=request.headers)
|
||||
|
||||
if not is_valid:
|
||||
return "Invalid signature", 401
|
||||
```
|
||||
"""
|
||||
try:
|
||||
self._verify_internal(body, headers=headers, secret=secret, tolerance=tolerance)
|
||||
return True
|
||||
except WebhookVerificationError:
|
||||
return False
|
||||
|
||||
def construct_event(
|
||||
self,
|
||||
body: Union[str, bytes],
|
||||
*,
|
||||
headers: Mapping[str, Any],
|
||||
secret: Optional[str] = None,
|
||||
tolerance: Optional[int] = None,
|
||||
) -> WebhookEventData:
|
||||
"""Verify and parse a webhook event.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
body:
|
||||
Raw webhook body (string or bytes).
|
||||
headers:
|
||||
Request headers (dict-like object).
|
||||
secret:
|
||||
Optional override for the webhook secret.
|
||||
tolerance:
|
||||
Optional tolerance in milliseconds for timestamp validation.
|
||||
Defaults to 5 minutes. Set to -1 to disable timestamp validation.
|
||||
|
||||
Returns
|
||||
-------
|
||||
WebhookEventData
|
||||
Verified and typed webhook event.
|
||||
|
||||
Raises
|
||||
------
|
||||
WebhookVerificationError
|
||||
If the webhook signature is invalid or the payload cannot be parsed.
|
||||
|
||||
Example
|
||||
-------
|
||||
```python
|
||||
# Flask
|
||||
event = webhooks.construct_event(
|
||||
request.data,
|
||||
headers=request.headers
|
||||
)
|
||||
|
||||
# Django
|
||||
event = webhooks.construct_event(
|
||||
request.body,
|
||||
headers=request.headers
|
||||
)
|
||||
|
||||
# FastAPI
|
||||
event = webhooks.construct_event(
|
||||
await request.body(),
|
||||
headers=dict(request.headers)
|
||||
)
|
||||
|
||||
# Type-safe event handling
|
||||
if event["type"] == "email.delivered":
|
||||
print(event["data"]["to"])
|
||||
elif event["type"] == "email.bounced":
|
||||
print(event["data"]["bounce"]["type"])
|
||||
```
|
||||
"""
|
||||
self._verify_internal(body, headers=headers, secret=secret, tolerance=tolerance)
|
||||
|
||||
body_string = _to_string(body)
|
||||
try:
|
||||
return json.loads(body_string)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
raise WebhookVerificationError(
|
||||
"INVALID_JSON",
|
||||
f"Webhook payload is not valid JSON: {e}",
|
||||
) from e
|
||||
|
||||
def _verify_internal(
|
||||
self,
|
||||
body: Union[str, bytes],
|
||||
*,
|
||||
headers: Mapping[str, Any],
|
||||
secret: Optional[str] = None,
|
||||
tolerance: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Internal verification logic."""
|
||||
webhook_secret = secret if secret is not None else self._secret
|
||||
signature = _get_header(headers, WEBHOOK_SIGNATURE_HEADER)
|
||||
timestamp = _get_header(headers, WEBHOOK_TIMESTAMP_HEADER)
|
||||
|
||||
if not signature:
|
||||
raise WebhookVerificationError(
|
||||
"MISSING_SIGNATURE",
|
||||
f"Missing {WEBHOOK_SIGNATURE_HEADER} header",
|
||||
)
|
||||
|
||||
if not timestamp:
|
||||
raise WebhookVerificationError(
|
||||
"MISSING_TIMESTAMP",
|
||||
f"Missing {WEBHOOK_TIMESTAMP_HEADER} header",
|
||||
)
|
||||
|
||||
if not signature.startswith(SIGNATURE_PREFIX):
|
||||
raise WebhookVerificationError(
|
||||
"INVALID_SIGNATURE_FORMAT",
|
||||
"Signature header must start with v1=",
|
||||
)
|
||||
|
||||
try:
|
||||
timestamp_num = int(timestamp)
|
||||
except ValueError as e:
|
||||
raise WebhookVerificationError(
|
||||
"INVALID_TIMESTAMP",
|
||||
"Timestamp header must be a number (milliseconds since epoch)",
|
||||
) from e
|
||||
|
||||
tolerance_ms = tolerance if tolerance is not None else DEFAULT_TOLERANCE_MS
|
||||
now = int(time.time() * 1000)
|
||||
|
||||
if tolerance_ms >= 0 and abs(now - timestamp_num) > tolerance_ms:
|
||||
raise WebhookVerificationError(
|
||||
"TIMESTAMP_OUT_OF_RANGE",
|
||||
"Webhook timestamp is outside the allowed tolerance",
|
||||
)
|
||||
|
||||
body_string = _to_string(body)
|
||||
expected = _compute_signature(webhook_secret, timestamp, body_string)
|
||||
|
||||
if not _safe_compare(expected, signature):
|
||||
raise WebhookVerificationError(
|
||||
"SIGNATURE_MISMATCH",
|
||||
"Webhook signature does not match",
|
||||
)
|
||||
|
||||
|
||||
def _compute_signature(secret: str, timestamp: str, body: str) -> str:
|
||||
"""Compute the HMAC-SHA256 signature for webhook verification."""
|
||||
message = f"{timestamp}.{body}"
|
||||
signature = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
message.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return f"{SIGNATURE_PREFIX}{signature}"
|
||||
|
||||
|
||||
def _to_string(body: Union[str, bytes]) -> str:
|
||||
"""Convert body to UTF-8 string."""
|
||||
if isinstance(body, str):
|
||||
return body
|
||||
if isinstance(body, bytes):
|
||||
try:
|
||||
return body.decode("utf-8")
|
||||
except UnicodeDecodeError as e:
|
||||
raise WebhookVerificationError(
|
||||
"INVALID_BODY",
|
||||
"Webhook body must be valid UTF-8.",
|
||||
) from e
|
||||
raise WebhookVerificationError(
|
||||
"INVALID_BODY",
|
||||
f"Unsupported body type: {type(body).__name__}. Expected str or bytes.",
|
||||
)
|
||||
|
||||
|
||||
def _get_header(headers: Mapping[str, Any], name: str) -> Optional[str]:
|
||||
"""Get header value in a case-insensitive manner."""
|
||||
if headers is None:
|
||||
return None
|
||||
|
||||
# Try direct access first
|
||||
if name in headers:
|
||||
value = headers[name]
|
||||
if isinstance(value, list):
|
||||
return value[0] if value else None
|
||||
return str(value) if value is not None else None
|
||||
|
||||
# Case-insensitive lookup
|
||||
lower_name = name.lower()
|
||||
for key in headers:
|
||||
if key.lower() == lower_name:
|
||||
value = headers[key]
|
||||
if isinstance(value, list):
|
||||
return value[0] if value else None
|
||||
return str(value) if value is not None else None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _safe_compare(a: str, b: str) -> bool:
|
||||
"""Timing-safe string comparison to prevent timing attacks."""
|
||||
return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
|
||||
Reference in New Issue
Block a user